diff --git a/frontend/tests/unit/components/workspace/gateway-offline-banner.dom.test.tsx b/frontend/tests/unit/components/workspace/gateway-offline-banner.dom.test.tsx
new file mode 100644
index 000000000..b4c573c5f
--- /dev/null
+++ b/frontend/tests/unit/components/workspace/gateway-offline-banner.dom.test.tsx
@@ -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(
+
+
+ ,
+ );
+}
+
+describe("GatewayOfflineBanner logout recovery (#3001)", () => {
+ it("renders a