test(auth): lock in POST logout from gateway-offline banner (#3001) (#4506)

* 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:
now-ing 2026-07-29 23:00:51 +08:00 committed by GitHub
parent d726ae60c3
commit d07a4bf7eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -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");
});
});