diff --git a/ui/src/lib/__tests__/api-client.test.ts b/ui/src/lib/__tests__/api-client.test.ts index 6cc24c4..076a13c 100644 --- a/ui/src/lib/__tests__/api-client.test.ts +++ b/ui/src/lib/__tests__/api-client.test.ts @@ -43,6 +43,77 @@ describe("apiFetch", () => { spy.mockRestore(); }); + it("does NOT default Content-Type for FormData (browser must set the multipart boundary)", async () => { + const spy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("{}", { status: 200, headers: { "content-type": "application/json" } }), + ); + try { + const fd = new FormData(); + fd.append("files", new Blob(["hello"], { type: "text/plain" }), "hello.txt"); + await apiFetch("/api/upload", { method: "POST", body: fd }); + const init = (spy.mock.calls[0][1] ?? {}) as RequestInit; + const hdrs = new Headers(init.headers); + expect(hdrs.has("Content-Type")).toBe(false); + // The exact same FormData instance must reach fetch — passing through + // the spread/clone path or being re-serialized would also break uploads. + expect(init.body).toBe(fd); + } finally { + spy.mockRestore(); + } + }); + + it("does NOT default Content-Type for Blob, URLSearchParams, ArrayBuffer, or typed arrays", async () => { + const spy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new HttpResponse(null, { status: 204 })); + try { + const bodies: BodyInit[] = [ + new Blob(["x"], { type: "application/octet-stream" }), + new URLSearchParams({ a: "1" }), + new ArrayBuffer(4), + new Uint8Array([1, 2, 3]), + ]; + for (const body of bodies) { + await apiFetch("/api/raw", { method: "POST", body }); + } + for (let i = 0; i < bodies.length; i++) { + const init = (spy.mock.calls[i][1] ?? {}) as RequestInit; + const hdrs = new Headers(init.headers); + expect(hdrs.has("Content-Type")).toBe(false); + } + } finally { + spy.mockRestore(); + } + }); + + it("defaults Content-Type to application/json for string bodies", async () => { + const spy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("{}", { status: 200, headers: { "content-type": "application/json" } }), + ); + try { + await apiFetch("/api/json", { method: "POST", body: JSON.stringify({ x: 1 }) }); + const hdrs = new Headers(((spy.mock.calls[0][1] ?? {}) as RequestInit).headers); + expect(hdrs.get("Content-Type")).toBe("application/json"); + } finally { + spy.mockRestore(); + } + }); + + it("respects a caller-provided Content-Type and never overrides it", async () => { + const spy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("{}", { status: 200, headers: { "content-type": "application/json" } }), + ); + try { + await apiFetch("/api/csv", { + method: "POST", + body: "a,b,c", + headers: { "Content-Type": "text/csv" }, + }); + const hdrs = new Headers(((spy.mock.calls[0][1] ?? {}) as RequestInit).headers); + expect(hdrs.get("Content-Type")).toBe("text/csv"); + } finally { + spy.mockRestore(); + } + }); + it("does not set Authorization header on data-path fetch even when a key exists in a meta tag", async () => { const meta = document.createElement("meta"); meta.setAttribute("name", "docsiq-api-key"); diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index e3e551e..fb4b18c 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -45,13 +45,27 @@ export class ApiErrorResponse extends Error { } } +// FormData/Blob/URLSearchParams/streams/buffers carry their own framing — +// the browser sets Content-Type (with the multipart boundary, etc.) when +// fetch builds the request. Defaulting to application/json here would clobber +// that boundary and produce an unparseable body. +function isBrowserManagedBody(body: BodyInit): boolean { + if (typeof FormData !== "undefined" && body instanceof FormData) return true; + if (typeof Blob !== "undefined" && body instanceof Blob) return true; + if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) return true; + if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) return true; + if (body instanceof ArrayBuffer) return true; + if (ArrayBuffer.isView(body)) return true; + return false; +} + export async function apiFetch( path: string, init: RequestInit = {}, ): Promise { if (sessionReady) await sessionReady; const headers = new Headers(init.headers); - if (init.body && !headers.has("Content-Type")) { + if (init.body && !headers.has("Content-Type") && !isBrowserManagedBody(init.body)) { headers.set("Content-Type", "application/json"); } const res = await fetch(path, { ...init, headers, credentials: "include" });