deer-flow/frontend/tests/unit/core/api/fetcher.dom.test.ts
一片一片 52f6ff078c
fix(api): survive a malformed CSRF cookie (#7589)
* fix(api): survive a malformed CSRF cookie

`readCsrfCookie` percent-decodes a cookie the gateway sets verbatim.
Cookies are shared by every app on the same host (ports included), so a
stray or truncated `%` escape from another local app, a proxy, or a stale
value makes `decodeURIComponent` throw `URIError` inside `fetch` and
`getCsrfHeaders` — every state-changing call fails with an exception
before the gateway can answer 403.

Fall back to the raw cookie value when decoding fails, matching
`getLocaleFromCookie` (#7553).

* test(api): exercise sibling CSRF cookies in the DOM

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-27 09:47:24 +08:00

90 lines
2.9 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
import { fetch as apiFetch } from "@/core/api/fetcher";
describe("api fetcher unauthorized redirect", () => {
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
globalThis.fetch = rs.fn(
async () => new Response("", { status: 401 }),
) as unknown as typeof globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("returns the caller to the full URL, query string included", async () => {
window.history.replaceState(
{},
"",
"/artifacts/view?path=%2Fmnt%2Fuser-data%2Foutputs%2Freport.md&thread_id=t-1",
);
// The wrapper redirects and then throws UnauthorizedError; the redirect
// target is what this test is about.
await expect(
apiFetch("/api/threads/t-1/artifacts/mnt/user-data/outputs/report.md"),
).rejects.toThrow();
expect(window.location.href).toContain("/login?next=");
const next = new URL(
window.location.href,
"http://localhost",
).searchParams.get("next");
expect(next).toBe(
"/artifacts/view?path=%2Fmnt%2Fuser-data%2Foutputs%2Freport.md&thread_id=t-1",
);
});
it("redirects an account_disabled 401 to a login page that states the reason", async () => {
globalThis.fetch = rs.fn(
async () =>
new Response(
JSON.stringify({
detail: { code: "account_disabled", message: "Account disabled" },
}),
{ status: 401, headers: { "Content-Type": "application/json" } },
),
) as unknown as typeof globalThis.fetch;
window.history.replaceState({}, "", "/workspace");
await expect(apiFetch("/api/v1/auth/me")).rejects.toThrow();
expect(window.location.href).toContain("/login?error=account_disabled");
// A disabled account cannot sign back in, so no return target is kept.
expect(window.location.href).not.toContain("next=");
});
});
describe("api fetcher CSRF cookies", () => {
afterEach(() => {
document.cookie = "theme=; max-age=0; path=/";
document.cookie = "csrf_token=; max-age=0; path=/";
rs.unstubAllGlobals();
});
it("forwards a malformed CSRF cookie after an unrelated malformed cookie", async () => {
const sentHeaders: Headers[] = [];
rs.stubGlobal(
"fetch",
rs.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
sentHeaders.push(new Headers(init?.headers));
return new Response("ok");
}),
);
// Each assignment sets one cookie; semicolon-separated tails are attributes.
document.cookie = "theme=%E0; path=/";
document.cookie = "csrf_token=%gh; path=/";
expect(document.cookie).toBe("theme=%E0; csrf_token=%gh");
await apiFetch("http://localhost/api/threads", { method: "POST" });
expect(sentHeaders).toHaveLength(1);
expect(sentHeaders[0]?.get("X-CSRF-Token")).toBe("%gh");
});
});