feat(auth): add remembered local login to the frontend
This commit is contained in:
@@ -1,6 +1,20 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { server } from "../test/msw";
|
||||
import { apiFetch } from "./client";
|
||||
import { apiErrorMessage, apiFetch, ApiError, assertSameOriginRequestUrl } from "./client";
|
||||
import { clearAuthState, setAuthState } from "../auth/authState";
|
||||
|
||||
const authenticated = {
|
||||
issuer: "local",
|
||||
subject: "user-1",
|
||||
roles: ["user"] as const,
|
||||
permissions: ["session.use"],
|
||||
isAdmin: false,
|
||||
csrfToken: "c".repeat(43),
|
||||
session: null,
|
||||
};
|
||||
|
||||
beforeEach(() => setAuthState(authenticated));
|
||||
afterEach(() => clearAuthState());
|
||||
|
||||
test("body-less POST omits content-type (avoids Fastify empty-body 400)", async () => {
|
||||
let contentType: string | null = "unset";
|
||||
@@ -25,3 +39,258 @@ test("POST with a body sends application/json content-type", async () => {
|
||||
await apiFetch("/sessions/s1/steer", { method: "POST", body: JSON.stringify({ text: "hi" }) });
|
||||
expect(contentType).toContain("application/json");
|
||||
});
|
||||
|
||||
test("same-origin requests include credentials and overwrite the CSRF header from memory", async () => {
|
||||
let observed: { credentials: string | null; csrf: string | null } | undefined;
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
server.use(
|
||||
http.post("/api/sessions/s1/steer", ({ request }) => {
|
||||
observed = {
|
||||
credentials: request.headers.get("credentials"),
|
||||
csrf: request.headers.get("x-thothii-csrf"),
|
||||
};
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
);
|
||||
|
||||
await apiFetch("/sessions/s1/steer", {
|
||||
method: "POST",
|
||||
headers: { "X-ThothII-CSRF": "attacker-supplied" },
|
||||
body: JSON.stringify({ text: "hi" }),
|
||||
});
|
||||
|
||||
expect(observed?.csrf).toBe("c".repeat(43));
|
||||
expect(observed?.credentials).toBeNull();
|
||||
expect(fetchSpy.mock.calls.at(-1)?.[1]).toMatchObject({ credentials: "same-origin" });
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("a 401 clears the in-memory auth state and advances its generation", async () => {
|
||||
const before = (await import("../auth/authState")).getAuthGeneration();
|
||||
server.use(http.get("/api/private", () => new HttpResponse(null, { status: 401 })));
|
||||
|
||||
await expect(apiFetch("/private")).rejects.toMatchObject({ status: 401 });
|
||||
|
||||
const state = await import("../auth/authState");
|
||||
expect(state.getAuthState()).toBeNull();
|
||||
expect(state.getAuthGeneration()).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
test("a delayed 401 from user A cannot clear user B after a new login", async () => {
|
||||
let release!: () => void;
|
||||
const delayed = new Promise<void>((resolve) => { release = resolve; });
|
||||
server.use(http.get("/api/stale-request", async () => {
|
||||
await delayed;
|
||||
return new HttpResponse(null, { status: 401 });
|
||||
}));
|
||||
|
||||
const request = apiFetch("/stale-request");
|
||||
const userB = { ...authenticated, subject: "user-b", csrfToken: "b".repeat(43) };
|
||||
setAuthState(userB);
|
||||
release();
|
||||
|
||||
await expect(request).rejects.toMatchObject({ status: 401 });
|
||||
expect((await import("../auth/authState")).getAuthState()).toMatchObject({ subject: "user-b" });
|
||||
});
|
||||
|
||||
test("bounds streamed error bodies and never exposes raw HTML or secrets", async () => {
|
||||
const secret = "TOP-SECRET-token-123";
|
||||
const hugeBody = `<html>${secret}${"x".repeat(20_000)}</html>`;
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(hugeBody.slice(0, 4_000)));
|
||||
controller.enqueue(new TextEncoder().encode(hugeBody.slice(4_000)));
|
||||
controller.close();
|
||||
},
|
||||
}), { status: 500, headers: { "content-type": "text/html", "content-length": "1" } }),
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await apiFetch<unknown>("/oversized").catch((error: unknown) => error);
|
||||
expect(result).toBeInstanceOf(ApiError);
|
||||
const failure = result as ApiError;
|
||||
expect(failure.status).toBe(500);
|
||||
expect(failure.message).not.toContain(secret);
|
||||
expect(failure.message).not.toContain("<html>");
|
||||
expect(failure.bodyText).not.toContain(secret);
|
||||
expect(failure.payload).toBeUndefined();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps only a known safe bounded JSON error payload", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({
|
||||
code: "dwh_unreachable",
|
||||
error: "The data warehouse is unreachable.",
|
||||
}), { status: 503, headers: { "content-type": "application/json" } }),
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await apiFetch<unknown>("/known-error").catch((error: unknown) => error);
|
||||
const failure = result as ApiError;
|
||||
expect(failure).toMatchObject({
|
||||
status: 503,
|
||||
code: "dwh_unreachable",
|
||||
payload: { code: "dwh_unreachable" },
|
||||
});
|
||||
expect(failure.bodyText).toBe("");
|
||||
expect(failure.message).toBe("The database is unreachable. Please retry.");
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("derives local messages without retaining a malicious known-code message", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({
|
||||
code: "dwh_unreachable",
|
||||
message: "Bearer eyJhbGciOiJIUzI1NiJ9.password=do-not-show",
|
||||
error: "<html>password=do-not-show</html>",
|
||||
}), { status: 503, headers: { "content-type": "application/json" } }),
|
||||
);
|
||||
|
||||
try {
|
||||
const failure = await apiFetch<unknown>("/known-malicious").catch((error: unknown) => error) as ApiError;
|
||||
expect(failure.code).toBe("dwh_unreachable");
|
||||
expect(failure.message).toBe("The database is unreachable. Please retry.");
|
||||
expect(failure.bodyText).toBe("");
|
||||
expect(failure.payload).toEqual({ code: "dwh_unreachable" });
|
||||
expect(failure.message).not.toMatch(/Bearer|password|html|do-not-show/i);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("uses a generic local message for unknown malicious codes", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({
|
||||
code: "unknown-secret-code",
|
||||
message: "Bearer eyJhbGciOiJIUzI1NiJ9",
|
||||
error: "password=do-not-show",
|
||||
}), { status: 500, headers: { "content-type": "application/json" } }),
|
||||
);
|
||||
|
||||
try {
|
||||
const failure = await apiFetch<unknown>("/unknown-malicious").catch((error: unknown) => error) as ApiError;
|
||||
expect(failure.code).toBeUndefined();
|
||||
expect(failure.message).toBe("Request failed. Please try again.");
|
||||
expect(failure.bodyText).toBe("");
|
||||
expect(failure.payload).toBeUndefined();
|
||||
expect(apiErrorMessage(failure)).toBe("Request failed. Please try again.");
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("releases the response reader after a successful bounded read", async () => {
|
||||
const reader = {
|
||||
read: vi.fn()
|
||||
.mockResolvedValueOnce({ done: false, value: new TextEncoder().encode("{not-json") })
|
||||
.mockResolvedValueOnce({ done: true, value: undefined }),
|
||||
cancel: vi.fn().mockResolvedValue(undefined),
|
||||
releaseLock: vi.fn(),
|
||||
};
|
||||
const response = new Response(new ReadableStream(), { status: 500 });
|
||||
vi.spyOn(response.body!, "getReader").mockReturnValue(reader as unknown as ReadableStreamReader<Uint8Array<ArrayBuffer>>);
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(response);
|
||||
|
||||
try {
|
||||
await expect(apiFetch("/reader-success")).rejects.toBeInstanceOf(ApiError);
|
||||
expect(reader.cancel).not.toHaveBeenCalled();
|
||||
expect(reader.releaseLock).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("cancels and releases the response reader on overflow", async () => {
|
||||
const reader = {
|
||||
read: vi.fn()
|
||||
.mockResolvedValueOnce({ done: false, value: new Uint8Array(9 * 1024) }),
|
||||
cancel: vi.fn().mockResolvedValue(undefined),
|
||||
releaseLock: vi.fn(),
|
||||
};
|
||||
const response = new Response(new ReadableStream(), { status: 500 });
|
||||
vi.spyOn(response.body!, "getReader").mockReturnValue(reader as unknown as ReadableStreamReader<Uint8Array<ArrayBuffer>>);
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(response);
|
||||
|
||||
try {
|
||||
await expect(apiFetch("/reader-overflow")).rejects.toBeInstanceOf(ApiError);
|
||||
expect(reader.cancel).toHaveBeenCalledOnce();
|
||||
expect(reader.releaseLock).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("cancels and releases the response reader when a read throws", async () => {
|
||||
const reader = {
|
||||
read: vi.fn().mockRejectedValue(new Error("stream broke")),
|
||||
cancel: vi.fn().mockResolvedValue(undefined),
|
||||
releaseLock: vi.fn(),
|
||||
};
|
||||
const response = new Response(new ReadableStream(), { status: 500 });
|
||||
vi.spyOn(response.body!, "getReader").mockReturnValue(reader as unknown as ReadableStreamReader<Uint8Array<ArrayBuffer>>);
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(response);
|
||||
|
||||
try {
|
||||
await expect(apiFetch("/reader-throws")).rejects.toThrow("stream broke");
|
||||
expect(reader.cancel).toHaveBeenCalledOnce();
|
||||
expect(reader.releaseLock).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects malformed and sensitive JSON error bodies without surfacing their content", async () => {
|
||||
const bodies = [
|
||||
"{not-json",
|
||||
JSON.stringify({ code: "unknown_secret_code", error: "password=super-secret" }),
|
||||
];
|
||||
|
||||
for (const body of bodies) {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(body, { status: 500, headers: { "content-type": "application/json" } }),
|
||||
);
|
||||
try {
|
||||
const result = await apiFetch<unknown>("/unsafe-error").catch((error: unknown) => error);
|
||||
const failure = result as ApiError;
|
||||
expect(failure.payload).toBeUndefined();
|
||||
expect(failure.message).not.toContain("super-secret");
|
||||
expect(failure.message).not.toContain("not-json");
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
const knownCode = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: "auth_forbidden", error: "token=super-secret" }), {
|
||||
status: 403, headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const result = await apiFetch<unknown>("/known-sensitive-error").catch((error: unknown) => error);
|
||||
const failure = result as ApiError;
|
||||
expect(failure.payload).toEqual({ code: "auth_forbidden" });
|
||||
expect(failure.message).toBe("Access is not permitted.");
|
||||
expect(failure.message).not.toContain("super-secret");
|
||||
} finally {
|
||||
knownCode.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("refuses a cross-origin request before sending credentials", () => {
|
||||
expect(() => assertSameOriginRequestUrl("https://attacker.example/api/me")).toThrow(/same-origin/i);
|
||||
});
|
||||
|
||||
test("preserves explicit 403 and 503 statuses for presentation", async () => {
|
||||
server.use(
|
||||
http.get("/api/forbidden", () => HttpResponse.json({ code: "auth_forbidden" }, { status: 403 })),
|
||||
http.get("/api/unavailable", () => HttpResponse.json({ code: "auth_unavailable" }, { status: 503 })),
|
||||
);
|
||||
await expect(apiFetch("/forbidden")).rejects.toBeInstanceOf(ApiError);
|
||||
await expect(apiFetch("/unavailable")).rejects.toMatchObject({ status: 503 });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user