mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 10:56:02 +00:00
* test(auth): lock gateway-unavailable logout to POST (#3001) Issue #3001 reports that the gateway-unavailable fallback rendered the recovery action as a plain link to /api/v1/auth/logout, which browsers navigate via GET against a POST-only endpoint — returning 405 and leaving the stale session cookie intact while the gateway is down or restarting. The code-level fix already landed in #3495: <GatewayOfflineBanner> renders a <button onClick={logout}> wired to AuthProvider.logout's fetch(..., { method: "POST" }). That PR's test suite, however, only covers the banner's pure helpers (visibility + retry interval) and the gateway_unavailable SSR tag — it never asserts that the recovery action actually reaches the network as a POST, so a regression back to a GET-style link/navigation would slip through silently. Add a DOM-level regression test that renders the banner inside a real AuthProvider, simulates a still-down gateway for the /auth/me probe (so the banner stays mounted and its recovery button stays actionable), clicks the button, and asserts that the resulting request is POST /api/v1/auth/logout — never GET. This pins the exact behaviour #3001 requires and fails loudly if the affordance ever regresses. Closes #3001. * test(auth): guard logoutCall against undefined in gateway-offline-banner test TypeScript's noUncheckedIndexedAccess types logoutCalls[0] as T|undefined, which surfaced as TS18048 on the three logoutCall.{url,method} accesses. The waitFor callback already asserts toHaveLength(1) before returning; add an explicit throw guard so the value narrows to a defined Call and the assertions below type-check. Unblocks lint-frontend on #4506. --------- Co-authored-by: now-ing <24534365+now-ing@users.noreply.github.com>
This commit is contained in:
parent
d726ae60c3
commit
d07a4bf7eb
@ -0,0 +1,131 @@
|
|||||||
|
import { afterEach, describe, expect, it, rs } from "@rstest/core";
|
||||||
|
import {
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/react";
|
||||||
|
|
||||||
|
import { GatewayOfflineBanner } from "@/components/workspace/gateway-offline-banner";
|
||||||
|
import { AuthProvider } from "@/core/auth/AuthProvider";
|
||||||
|
|
||||||
|
// AuthProvider pulls useRouter/usePathname from next/navigation. Hand it a
|
||||||
|
// no-op router so logout()'s `router.push("/")` stays inert under happy-dom
|
||||||
|
// instead of trying to drive a real Next.js router.
|
||||||
|
rs.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({ push: rs.fn(), replace: rs.fn(), refresh: rs.fn() }),
|
||||||
|
usePathname: () => "/workspace",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Force the SPA (non-static) branch of AuthProvider.logout — that is the
|
||||||
|
// branch that actually performs the POST that issue #3001 is about. The
|
||||||
|
// static branch would short-circuit into a `router.push("/")` and never
|
||||||
|
// touch the network, masking the very regression we need to lock down.
|
||||||
|
rs.mock("@/core/static-mode", () => ({
|
||||||
|
isStaticWebsiteOnly: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Keep the banner's i18n lookup hermetic — real translations are not
|
||||||
|
// relevant here, we just need a stable accessible name for the button.
|
||||||
|
rs.mock("@/core/i18n/hooks", () => ({
|
||||||
|
useI18n: () => ({
|
||||||
|
locale: "en-US",
|
||||||
|
t: {
|
||||||
|
workspace: {
|
||||||
|
logout: "Log out",
|
||||||
|
gatewayUnavailable: "Gateway unavailable.",
|
||||||
|
gatewayUnavailableRetrying: "Retrying…",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
changeLocale: rs.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rs.restoreAllMocks();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install a fetch double that keeps the gateway "unavailable" for the
|
||||||
|
* banner's `/auth/me` probe (so the banner stays mounted and the recovery
|
||||||
|
* button stays actionable) while recording every call so the logout
|
||||||
|
* request can be inspected. Returns the captured calls for assertions.
|
||||||
|
*/
|
||||||
|
function installFetch(): Array<{ url: string; method: string }> {
|
||||||
|
const calls: Array<{ url: string; method: string }> = [];
|
||||||
|
rs.spyOn(globalThis, "fetch").mockImplementation(
|
||||||
|
(input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
// `input` may be a Request object whose default stringification would
|
||||||
|
// yield "[object Request]" — normalise to the URL string either way.
|
||||||
|
const url =
|
||||||
|
typeof input === "string"
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.toString()
|
||||||
|
: input.url;
|
||||||
|
const method = (init?.method ?? "GET").toUpperCase();
|
||||||
|
calls.push({ url, method });
|
||||||
|
if (url.includes("/auth/logout")) {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify({ message: "Successfully logged out" }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// `/auth/me` probe — return a transient failure so `classifyProbe`
|
||||||
|
// yields "transient" and the banner keeps showing the logout button.
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response("service unavailable", { status: 503 }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBanner() {
|
||||||
|
return render(
|
||||||
|
<AuthProvider initialUser={null}>
|
||||||
|
<GatewayOfflineBanner gatewayUnavailable />
|
||||||
|
</AuthProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GatewayOfflineBanner logout recovery (#3001)", () => {
|
||||||
|
it("renders a <button> (not a GET-style link) as the logout affordance", async () => {
|
||||||
|
installFetch();
|
||||||
|
renderBanner();
|
||||||
|
|
||||||
|
// The recovery control must be a <button>, not an <a>/<Link> whose
|
||||||
|
// browser navigation would turn into a GET against the POST-only
|
||||||
|
// /auth/logout endpoint — the exact 405 reported in #3001.
|
||||||
|
const logout = await screen.findByRole("button", { name: /log out/i });
|
||||||
|
expect(logout.tagName).toBe("BUTTON");
|
||||||
|
expect(logout.hasAttribute("href")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("issues a POST (never a GET) to /api/v1/auth/logout when clicked", async () => {
|
||||||
|
const calls = installFetch();
|
||||||
|
renderBanner();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: /log out/i }));
|
||||||
|
|
||||||
|
const logoutCall = await waitFor(() => {
|
||||||
|
const logoutCalls = calls.filter((c) => c.url.includes("/auth/logout"));
|
||||||
|
expect(logoutCalls).toHaveLength(1);
|
||||||
|
const call = logoutCalls[0];
|
||||||
|
if (!call) {
|
||||||
|
throw new Error("expected exactly one /auth/logout call");
|
||||||
|
}
|
||||||
|
return call;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(logoutCall.url).toBe("/api/v1/auth/logout");
|
||||||
|
expect(logoutCall.method).toBe("POST");
|
||||||
|
// Lock down the regression documented in #3001: a GET against this
|
||||||
|
// POST-only endpoint returns 405 and fails to clear the session.
|
||||||
|
expect(logoutCall.method).not.toBe("GET");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user