import { http, HttpResponse } from "msw"; import { server } from "../test/msw"; 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"; server.use( http.post("/api/sessions/s1/resume", ({ request }) => { contentType = request.headers.get("content-type"); return HttpResponse.json({ id: "s1" }); }), ); await apiFetch("/sessions/s1/resume", { method: "POST" }); expect(contentType).toBeNull(); }); test("POST with a body sends application/json content-type", async () => { let contentType: string | null = null; server.use( http.post("/api/sessions/s1/steer", ({ request }) => { contentType = request.headers.get("content-type"); return new HttpResponse(null, { status: 204 }); }), ); 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((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 = `${secret}${"x".repeat(20_000)}`; 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("/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(""); 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("/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.each([ ["description_generation_target_ids_duplicate", "Description generation target IDs must be unique."], ["description_generation_no_eligible_targets", "No eligible catalog tables or columns need description generation."], ["catalog_table_not_found", "One or more selected catalog tables were not found."], ["sensitive_data_suggestion_invalid_response", "The model returned an incomplete or invalid classification. No suggestions were applied."], ["sensitive_data_suggestion_provider_unavailable", "The selected model could not complete the request. No suggestions were applied."], ["sensitive_data_suggestion_history_request_invalid", "Sensitive suggestion history parameters are invalid."], ["sensitive_data_suggestion_history_failed", "Sensitive suggestion history could not be loaded."], ["sensitive_data_suggestion_run_not_found", "The sensitive suggestion run was not found."], ["relationship_not_found", "The relationship no longer exists. Refresh and try again."], ["relationship_duplicate", "This relationship already exists."], ["relationship_target_not_unique", "The target column must be the only primary-key column of its table."], ["relationship_type_incompatible", "Source and target column types are not compatible."], ["relationship_read_only", "Physical relationships are read-only."], ["relationship_request_invalid", "The relationship request is invalid."], ["relationship_operation_failed", "The relationship operation failed."], ["relationship_schema_stale", "Synchronize the current database schema before managing logical relationships."], ["column_not_found", "The selected catalog column was not found. Refresh and try again."], ])("maps the catalog error code %s to safe local copy", async (code, message) => { const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response(JSON.stringify({ code, message: "provider detail must not be trusted" }), { status: 400, headers: { "content-type": "application/json" }, }), ); try { const failure = await apiFetch("/description-generation-error") .catch((error: unknown) => error) as ApiError; expect(failure).toMatchObject({ code, message, payload: { code } }); } 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: "password=do-not-show", }), { status: 503, headers: { "content-type": "application/json" } }), ); try { const failure = await apiFetch("/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-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>); 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>); 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>); 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("/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("/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 }); });