diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md index 55ccf9a61d..610a71302f 100644 --- a/frontend/src/AGENTS.md +++ b/frontend/src/AGENTS.md @@ -174,6 +174,8 @@ Array previews coalesce consecutive generated markers only at the end into one o ### Key Patterns +REST `core/api/fetcher.ts`: preserve Request; CSRF uses effective method/headers. + Conversation exports read every persisted message page through `fetchThreadExportMessages` (200 rows/page). Run `mergeMessages` after history deduplication to restore earliest trusted positions for identities repeated across runs. diff --git a/frontend/src/core/api/fetcher.ts b/frontend/src/core/api/fetcher.ts index 9f3eb779e6..d4016180ee 100644 --- a/frontend/src/core/api/fetcher.ts +++ b/frontend/src/core/api/fetcher.ts @@ -66,23 +66,26 @@ export async function fetch( input: RequestInfo | string, init?: RequestInit, ): Promise { + const request = typeof input === "string" ? undefined : input; const url = typeof input === "string" ? input : input.url; + const method = init?.method ?? request?.method ?? "GET"; + let headers = init?.headers ?? request?.headers; // Static demos have no Gateway. Resolve REST calls before credentials, // CSRF, or redirects; demo assets and explicit mock routes still use HTTP. if (isStaticWebsiteOnly()) { const response = await staticApiResponse(url, { ...init, - method: - init?.method ?? (typeof input === "string" ? "GET" : input.method), + method, + headers, + signal: init?.signal === undefined ? request?.signal : init.signal, }); if (response) return response; } // Inject CSRF for state-changing methods. GET/HEAD/OPTIONS/TRACE skip // it to mirror the gateway's ``should_check_csrf`` logic exactly. - let headers = init?.headers; - if (isStateChangingMethod(init?.method ?? "GET")) { + if (isStateChangingMethod(method)) { const token = readCsrfCookie(); if (token) { // Fresh Headers instance so we don't mutate caller-supplied objects. @@ -94,7 +97,7 @@ export async function fetch( } } - const res = await globalThis.fetch(url, { + const res = await globalThis.fetch(input, { ...init, headers, credentials: "include", diff --git a/frontend/tests/unit/core/api/fetcher.test.ts b/frontend/tests/unit/core/api/fetcher.test.ts new file mode 100644 index 0000000000..75cf12d1d8 --- /dev/null +++ b/frontend/tests/unit/core/api/fetcher.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core"; + +import { fetch as apiFetch } from "@/core/api/fetcher"; + +describe("api fetcher Request inputs", () => { + let sent: Request[]; + + beforeEach(() => { + sent = []; + rs.stubGlobal("document", { cookie: "csrf_token=cookie-token" }); + rs.stubGlobal( + "fetch", + rs.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + sent.push(new Request(input, init)); + return new Response("ok"); + }), + ); + }); + + afterEach(() => { + rs.unstubAllGlobals(); + }); + + it("preserves a Request's method, body, headers and cancellation", async () => { + const controller = new AbortController(); + const request = new Request("http://localhost/api/threads", { + method: "POST", + headers: { "Content-Type": "application/json", "X-Request-ID": "run-1" }, + body: JSON.stringify({ title: "Research" }), + signal: controller.signal, + }); + + await apiFetch(request); + + expect(sent[0]?.method).toBe("POST"); + expect(await sent[0]?.text()).toBe(JSON.stringify({ title: "Research" })); + expect(sent[0]?.headers.get("Content-Type")).toBe("application/json"); + expect(sent[0]?.headers.get("X-Request-ID")).toBe("run-1"); + expect(sent[0]?.headers.get("X-CSRF-Token")).toBe("cookie-token"); + expect(sent[0]?.credentials).toBe("include"); + expect(request.headers.has("X-CSRF-Token")).toBe(false); + controller.abort(); + expect(sent[0]?.signal.aborted).toBe(true); + }); + + it.each(["POST", "PUT", "PATCH", "DELETE"])( + "injects CSRF for a Request with method %s", + async (method) => { + await apiFetch(new Request("http://localhost/api/threads", { method })); + expect(sent[0]?.method).toBe(method); + expect(sent[0]?.headers.get("X-CSRF-Token")).toBe("cookie-token"); + }, + ); + + it.each(["GET", "HEAD", "OPTIONS"])( + "does not inject CSRF for a Request with method %s", + async (method) => { + await apiFetch(new Request("http://localhost/api/threads", { method })); + expect(sent[0]?.method).toBe(method); + expect(sent[0]?.headers.has("X-CSRF-Token")).toBe(false); + }, + ); + + it("does not inject CSRF for TRACE in init", async () => { + // Native Request rejects TRACE, so inspect options before that boundary. + const nativeFetch = rs.fn().mockResolvedValue(new Response("ok")); + rs.stubGlobal("fetch", nativeFetch); + + await apiFetch("http://localhost/api/threads", { method: "TRACE" }); + + expect(nativeFetch).toHaveBeenCalledWith("http://localhost/api/threads", { + method: "TRACE", + headers: undefined, + credentials: "include", + }); + }); + + it("preserves an explicit CSRF header inherited from the Request", async () => { + await apiFetch( + new Request("http://localhost/api/threads", { + method: "POST", + headers: { "X-CSRF-Token": "explicit-token" }, + }), + ); + expect(sent[0]?.headers.get("X-CSRF-Token")).toBe("explicit-token"); + }); + + it("lets init replace Request headers and body without mutating either header set", async () => { + const request = new Request("http://localhost/api/threads", { + method: "POST", + headers: { "X-Original": "original", "X-CSRF-Token": "request-token" }, + body: "original body", + }); + const headers = new Headers({ "X-Override": "override" }); + + await apiFetch(request, { + method: "PUT", + headers, + body: "replacement body", + }); + + expect(sent[0]?.method).toBe("PUT"); + expect(await sent[0]?.text()).toBe("replacement body"); + expect(sent[0]?.headers.get("X-Override")).toBe("override"); + expect(sent[0]?.headers.has("X-Original")).toBe(false); + expect(sent[0]?.headers.get("X-CSRF-Token")).toBe("cookie-token"); + expect(headers.has("X-CSRF-Token")).toBe(false); + expect(request.headers.get("X-CSRF-Token")).toBe("request-token"); + }); + + it("uses an init method override when deciding whether CSRF is required", async () => { + await apiFetch( + new Request("http://localhost/api/threads", { method: "DELETE" }), + { method: "GET" }, + ); + expect(sent[0]?.method).toBe("GET"); + expect(sent[0]?.headers.has("X-CSRF-Token")).toBe(false); + }); + + it("preserves Request headers and body when there is no CSRF cookie", async () => { + document.cookie = ""; + await apiFetch( + new Request("http://localhost/api/threads", { + method: "POST", + headers: { "X-Request-ID": "run-2" }, + body: "payload", + }), + ); + expect(sent[0]?.headers.get("X-Request-ID")).toBe("run-2"); + expect(sent[0]?.headers.has("X-CSRF-Token")).toBe(false); + expect(await sent[0]?.text()).toBe("payload"); + }); + + it("retains string-input behavior and an explicit init CSRF header", async () => { + await apiFetch("http://localhost/api/threads", { + method: "POST", + headers: { "X-CSRF-Token": "init-token" }, + body: "payload", + }); + expect(sent[0]?.method).toBe("POST"); + expect(sent[0]?.headers.get("X-CSRF-Token")).toBe("init-token"); + expect(await sent[0]?.text()).toBe("payload"); + }); +}); diff --git a/frontend/tests/unit/core/api/static-mode.test.ts b/frontend/tests/unit/core/api/static-mode.test.ts index d72974773f..1d904abff2 100644 --- a/frontend/tests/unit/core/api/static-mode.test.ts +++ b/frontend/tests/unit/core/api/static-mode.test.ts @@ -168,6 +168,31 @@ describe("static website API requests", () => { expect(network).toHaveBeenCalledTimes(2); }); + it("respects cancellation inherited from a Request before resolving a static response", async () => { + const controller = new AbortController(); + const request = new Request("http://127.0.0.1:3000/api/features", { + signal: controller.signal, + }); + controller.abort(); + + await expect(apiFetch(request)).rejects.toThrow(); + expect(network).not.toHaveBeenCalled(); + }); + + it("lets an init signal override an aborted Request in static mode", async () => { + const controller = new AbortController(); + const override = new AbortController(); + const request = new Request("http://127.0.0.1:3000/api/features", { + signal: controller.signal, + }); + controller.abort(); + + expect((await apiFetch(request, { signal: override.signal })).status).toBe( + 200, + ); + expect(network).not.toHaveBeenCalled(); + }); + it("uses the current browser origin for fixtures and respects cancellation", async () => { rs.stubEnv("HOSTNAME", "demo.internal"); rs.stubEnv("PORT", "4000");