mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
fix(api): preserve Request inputs in authenticated fetch (#7254)
* fix(api): preserve Request inputs in authenticated fetch * docs(frontend): keep fetch guidance within the size budget * test(api): address fetcher review feedback --------- Co-authored-by: Cordis798 <284918518+Cordis798@users.noreply.github.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
2ad2132336
commit
17f2bea275
@ -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.
|
||||
|
||||
@ -66,23 +66,26 @@ export async function fetch(
|
||||
input: RequestInfo | string,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
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",
|
||||
|
||||
144
frontend/tests/unit/core/api/fetcher.test.ts
Normal file
144
frontend/tests/unit/core/api/fetcher.test.ts
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
@ -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");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user