From b6fbf0d1054bdc7227fcd26b3b0a0b72f246c90f Mon Sep 17 00:00:00 2001 From: Huixin615 Date: Thu, 11 Jun 2026 21:14:49 +0800 Subject: [PATCH] fix(frontend): keep workspace interactive when SSR auth probe cannot reach gateway (#3493) (#3495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(frontend): keep workspace interactive when SSR auth probe cannot reach gateway (#3493) When the SSR auth probe at /api/v1/auth/me times out or fails, the workspace layout used to render a static fallback page without AuthProvider or QueryClientProvider, making logout and every other interaction non-functional until the gateway recovered. Render the normal WorkspaceContent in 'gateway_unavailable' mode instead, surfacing a polite offline banner that re-probes the gateway in the background and hides itself the moment refreshUser() returns an authenticated user. The probe is reentrancy-guarded so a slow gateway cannot pile up parallel /auth/me requests. Closes #3493 * fix(workspace): silent probe in offline banner to avoid /login redirect during gateway recovery (#3493) The banner previously delegated retry probes to AuthProvider.refreshUser(), which treats any 401 from /api/v1/auth/me as 'session expired' and force-redirects to /login. During gateway recovery, the first few requests may transiently return 401 before the gateway is fully ready, which would incorrectly kick the user out — defeating the purpose of the offline banner. Now the banner silently fetches /api/v1/auth/me itself and only delegates to refreshUser() on 200 OK. Non-200 responses (401 / 5xx / network) are swallowed and retried on the next interval tick, ensuring the user stays logged in across short gateway outages. Verified in Docker: - docker pause deer-flow-gateway → banner appears, page interactive - docker unpause deer-flow-gateway → banner auto-disappears within 10s, user remains on /workspace/chats/new with full session restored - All 117 unit tests pass * fix(workspace): fix banner polling leak and persistent 401 handling (#3493) - Stop polling immediately after user recovery: add user to effect dependencies, cleanup interval when user !== null - Handle persistent 401: trigger login redirect after 3 consecutive unauthorized responses - Extract decision logic to pure helper, add 8 unit tests covering all critical paths * fix(workspace): address CR feedback on gateway offline recovery (#3493) - gateway-offline-banner-helpers: decrement (not reset) auth-failure streak on transient outcomes so a flapping gateway (401 alternating with 5xx) still converges on session-expired - gateway-offline-banner: reuse probe response body to apply user directly via new AuthProvider.applyUser, halving the recovery burst against an already-struggling gateway - gateway-offline-banner: extract classifyProbe into helpers for unit testability; log probe failures via console.warn instead of swallowing - gateway-offline-fallback: new shared component used by both workspace and (auth) layouts so auth pages recover the same way the workspace does, fixing the lockup where bare static HTML had no AuthProvider - AuthProvider.logout: fall back to hard navigation when the gateway logout fetch fails, matching legacy form-POST behaviour and avoiding stale client state during outage - tests: extend gateway-offline-banner-helpers.test with flapping convergence and classifyProbe branch coverage (19 cases total) --- frontend/src/app/(auth)/layout.tsx | 23 ++- frontend/src/app/workspace/layout.tsx | 32 +-- .../src/app/workspace/workspace-content.tsx | 12 +- .../gateway-offline-banner-helpers.ts | 87 ++++++++ .../workspace/gateway-offline-banner.tsx | 130 ++++++++++++ .../workspace/gateway-offline-fallback.tsx | 36 ++++ frontend/src/core/auth/AuthProvider.tsx | 31 ++- frontend/src/core/i18n/locales/en-US.ts | 2 + frontend/src/core/i18n/locales/types.ts | 2 + frontend/src/core/i18n/locales/zh-CN.ts | 2 + .../gateway-offline-banner-helpers.test.ts | 185 ++++++++++++++++++ frontend/tests/unit/core/auth/server.test.ts | 62 ++++++ 12 files changed, 563 insertions(+), 41 deletions(-) create mode 100644 frontend/src/components/workspace/gateway-offline-banner-helpers.ts create mode 100644 frontend/src/components/workspace/gateway-offline-banner.tsx create mode 100644 frontend/src/components/workspace/gateway-offline-fallback.tsx create mode 100644 frontend/tests/unit/components/workspace/gateway-offline-banner-helpers.test.ts diff --git a/frontend/src/app/(auth)/layout.tsx b/frontend/src/app/(auth)/layout.tsx index 0b35d4ac1..a7a5f413e 100644 --- a/frontend/src/app/(auth)/layout.tsx +++ b/frontend/src/app/(auth)/layout.tsx @@ -1,7 +1,7 @@ -import Link from "next/link"; import { redirect } from "next/navigation"; import { type ReactNode } from "react"; +import { GatewayOfflineFallback } from "@/components/workspace/gateway-offline-fallback"; import { AuthProvider } from "@/core/auth/AuthProvider"; import { getServerSideUser } from "@/core/auth/server"; import { assertNever } from "@/core/auth/types"; @@ -25,18 +25,17 @@ export default async function AuthLayout({ case "unauthenticated": return {children}; case "gateway_unavailable": + // Auth pages have no banner of their own, so render one here. The + // fallback's AuthProvider replaces the bare-HTML branch that + // previously locked users out without any logout/retry capability. return ( -
-

- Service temporarily unavailable. -

- - Retry - -
+ +
+

+ Service temporarily unavailable. +

+
+
); case "config_error": throw new Error(result.message); diff --git a/frontend/src/app/workspace/layout.tsx b/frontend/src/app/workspace/layout.tsx index 0d214f0d3..0f576930a 100644 --- a/frontend/src/app/workspace/layout.tsx +++ b/frontend/src/app/workspace/layout.tsx @@ -1,6 +1,6 @@ -import Link from "next/link"; import { redirect } from "next/navigation"; +import { GatewayOfflineFallback } from "@/components/workspace/gateway-offline-fallback"; import { AuthProvider } from "@/core/auth/AuthProvider"; import { getServerSideUser } from "@/core/auth/server"; import { assertNever } from "@/core/auth/types"; @@ -28,31 +28,13 @@ export default async function WorkspaceLayout({ case "unauthenticated": redirect("/login"); case "gateway_unavailable": + // GatewayOfflineFallback supplies the AuthProvider; WorkspaceContent + // already mounts the banner inside its sidebar layout, so renderBanner + // stays false here to avoid double-mounting. return ( -
-

- Service temporarily unavailable. -

-

- The backend may be restarting. Please wait a moment and try again. -

-
- - Retry - -
- -
-
-
+ + {children} + ); case "config_error": throw new Error(result.message); diff --git a/frontend/src/app/workspace/workspace-content.tsx b/frontend/src/app/workspace/workspace-content.tsx index 85c20b2ca..ded8c81ee 100644 --- a/frontend/src/app/workspace/workspace-content.tsx +++ b/frontend/src/app/workspace/workspace-content.tsx @@ -4,6 +4,7 @@ import { Toaster } from "sonner"; import { QueryClientProvider } from "@/components/query-client-provider"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { CommandPalette } from "@/components/workspace/command-palette"; +import { GatewayOfflineBanner } from "@/components/workspace/gateway-offline-banner"; import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar"; function parseSidebarOpenCookie( @@ -16,7 +17,11 @@ function parseSidebarOpenCookie( export async function WorkspaceContent({ children, -}: Readonly<{ children: React.ReactNode }>) { + gatewayUnavailable = false, +}: Readonly<{ + children: React.ReactNode; + gatewayUnavailable?: boolean; +}>) { const cookieStore = await cookies(); const initialSidebarOpen = parseSidebarOpenCookie( cookieStore.get("sidebar_state")?.value, @@ -26,7 +31,10 @@ export async function WorkspaceContent({ - {children} + + + {children} + diff --git a/frontend/src/components/workspace/gateway-offline-banner-helpers.ts b/frontend/src/components/workspace/gateway-offline-banner-helpers.ts new file mode 100644 index 000000000..f76756e14 --- /dev/null +++ b/frontend/src/components/workspace/gateway-offline-banner-helpers.ts @@ -0,0 +1,87 @@ +export const OFFLINE_BANNER_RETRY_INTERVAL_MS = 10_000; + +/** + * Number of consecutive 401 responses before treating the session as + * expired and delegating to AuthProvider.refreshUser() for /login redirect. + * + * Threshold > 1 absorbs transient 401s that may occur in the first few + * milliseconds after a gateway becomes ready again, without indefinitely + * masking a genuinely expired cookie. + */ +export const OFFLINE_BANNER_AUTH_FAILURE_THRESHOLD = 3; + +import type { User } from "@/core/auth/types"; + +export function shouldShowOfflineBanner( + user: User | null, + gatewayUnavailable: boolean, +): boolean { + return gatewayUnavailable && user === null; +} + +/** Categorised outcome of a single /auth/me probe. */ +export type ProbeOutcome = + | { kind: "ok"; user: User } // 2xx with parsed body + | { kind: "unauthorized" } // 401 + | { kind: "transient" }; // 5xx, network, abort, malformed body, etc. + +/** Next action the banner effect should take after a probe. */ +export type ProbeAction = + | { type: "apply-user"; user: User } + | { type: "delegate-refresh"; reason: "session-expired" } + | { type: "noop"; nextFailureCount: number }; + +/** + * Pure: classify an HTTP probe outcome into ProbeOutcome. + * + * Extracted from the banner effect so it can be unit-tested independently. + * `parsedUser` is the JSON body of a 2xx response (or null if absent/malformed); + * surfacing it through ProbeOutcome lets the caller apply it directly instead + * of paying for a second /auth/me round-trip via refreshUser(). + */ +export function classifyProbe( + res: Response | null, + errored: boolean, + parsedUser: User | null = null, +): ProbeOutcome { + if (errored || res === null) return { kind: "transient" }; + if (res.ok && parsedUser !== null) return { kind: "ok", user: parsedUser }; + if (res.ok) return { kind: "transient" }; // 2xx but body unusable + if (res.status === 401) return { kind: "unauthorized" }; + return { kind: "transient" }; +} + +/** + * Pure state machine for what to do after a probe lands. + * + * Inputs: how many consecutive 401s we've seen so far + the new outcome. + * Outputs: either "apply the user body we just fetched", "delegate to + * refreshUser() for /login redirect", or "do nothing, update counter". + * + * Transient outcomes (5xx / network / abort) decrement the auth-failure + * streak by 1 (floored at 0) rather than resetting it. This prevents a + * flapping gateway that alternates 401 ↔ 5xx from indefinitely masking a + * genuinely expired session: the streak still converges on the threshold. + */ +export function decideProbeAction( + consecutiveAuthFailures: number, + outcome: ProbeOutcome, + threshold: number = OFFLINE_BANNER_AUTH_FAILURE_THRESHOLD, +): ProbeAction { + if (outcome.kind === "ok") { + return { type: "apply-user", user: outcome.user }; + } + if (outcome.kind === "unauthorized") { + const next = consecutiveAuthFailures + 1; + if (next >= threshold) { + return { type: "delegate-refresh", reason: "session-expired" }; + } + return { type: "noop", nextFailureCount: next }; + } + // transient: decrement rather than reset so a flapping gateway + // (alternating 401 ↔ 5xx) still converges on session-expired. + return { + type: "noop", + nextFailureCount: Math.max(0, consecutiveAuthFailures - 1), + }; +} diff --git a/frontend/src/components/workspace/gateway-offline-banner.tsx b/frontend/src/components/workspace/gateway-offline-banner.tsx new file mode 100644 index 000000000..31e3a7426 --- /dev/null +++ b/frontend/src/components/workspace/gateway-offline-banner.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import { useAuth } from "@/core/auth/AuthProvider"; +import { userSchema, type User } from "@/core/auth/types"; +import { useI18n } from "@/core/i18n/hooks"; + +import { + OFFLINE_BANNER_RETRY_INTERVAL_MS, + classifyProbe, + decideProbeAction, + shouldShowOfflineBanner, +} from "./gateway-offline-banner-helpers"; + +interface GatewayOfflineBannerProps { + /** + * True when the server-side auth probe at `/api/v1/auth/me` could not + * reach the gateway. The banner stays mounted until a client-side probe + * confirms the gateway is healthy and `user` becomes populated. + */ + gatewayUnavailable: boolean; +} + +export function GatewayOfflineBanner({ + gatewayUnavailable, +}: GatewayOfflineBannerProps) { + const { t } = useI18n(); + const { user, applyUser, refreshUser, logout } = useAuth(); + // Guard against piling up probe calls while the gateway is still slow. + const inFlightRef = useRef(false); + // Count consecutive 401s so we can distinguish "transient warm-up 401" + // from "session actually expired" and avoid lying with the banner. + const authFailuresRef = useRef(0); + + useEffect(() => { + if (!gatewayUnavailable) return; + // Once AuthProvider has a user again the banner has served its + // purpose; tear down the polling so we don't keep probing every 10s + // for the entire lifetime of the page (gatewayUnavailable is a + // server-rendered prop and stays true until a full reload). + if (user !== null) return; + + const probe = async () => { + if (inFlightRef.current) return; + inFlightRef.current = true; + let res: Response | null = null; + let errored = false; + let parsedUser: User | null = null; + try { + res = await fetch("/api/v1/auth/me", { + credentials: "include", + cache: "no-store", + }); + // Reuse the probe's own response body instead of triggering a + // second /auth/me request via refreshUser() — halves the recovery + // burst against an already-struggling gateway. + if (res.ok) { + try { + const data = await res.json(); + const parsed = userSchema.safeParse(data); + if (parsed.success) parsedUser = parsed.data; + } catch (err) { + console.warn( + "[gateway-offline-banner] probe body parse failed:", + err, + ); + } + } + } catch (err) { + console.warn("[gateway-offline-banner] probe failed:", err); + errored = true; + } finally { + inFlightRef.current = false; + } + + const action = decideProbeAction( + authFailuresRef.current, + classifyProbe(res, errored, parsedUser), + ); + + if (action.type === "apply-user") { + authFailuresRef.current = 0; + applyUser(action.user); + return; + } + if (action.type === "delegate-refresh") { + // Hand off to AuthProvider, which on 401 will /login-redirect. + authFailuresRef.current = 0; + await refreshUser(); + return; + } + authFailuresRef.current = action.nextFailureCount; + }; + + void probe(); + const handle = window.setInterval(() => { + void probe(); + }, OFFLINE_BANNER_RETRY_INTERVAL_MS); + return () => { + window.clearInterval(handle); + }; + }, [gatewayUnavailable, user, applyUser, refreshUser]); + + if (!shouldShowOfflineBanner(user, gatewayUnavailable)) { + return null; + } + + return ( +
+ + {t.workspace.gatewayUnavailable}{" "} + {t.workspace.gatewayUnavailableRetrying} + + +
+ ); +} diff --git a/frontend/src/components/workspace/gateway-offline-fallback.tsx b/frontend/src/components/workspace/gateway-offline-fallback.tsx new file mode 100644 index 000000000..03ff0e217 --- /dev/null +++ b/frontend/src/components/workspace/gateway-offline-fallback.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { AuthProvider } from "@/core/auth/AuthProvider"; + +import { GatewayOfflineBanner } from "./gateway-offline-banner"; + +interface GatewayOfflineFallbackProps { + /** + * When true, this component renders its own banner. The workspace layout + * sets this to false because WorkspaceContent already mounts the banner + * inside its sidebar layout. The (auth) layout sets it to true because + * its plain children have no banner of their own. + */ + renderBanner?: boolean; + children?: React.ReactNode; +} + +/** + * Shared fallback shown by both the workspace and (auth) layouts when the + * server-side auth probe could not reach the gateway. Wraps the children + * with an AuthProvider so the banner's probe / logout / refresh hooks work + * — fixing the `(auth)/layout.tsx` lockup where the bare static HTML had + * no AuthProvider / QueryClientProvider and the user could not recover + * without a manual reload. + */ +export function GatewayOfflineFallback({ + renderBanner = false, + children, +}: GatewayOfflineFallbackProps) { + return ( + + {renderBanner && } + {children} + + ); +} diff --git a/frontend/src/core/auth/AuthProvider.tsx b/frontend/src/core/auth/AuthProvider.tsx index 5824c5f7b..959c965b7 100644 --- a/frontend/src/core/auth/AuthProvider.tsx +++ b/frontend/src/core/auth/AuthProvider.tsx @@ -26,6 +26,7 @@ interface AuthContextType { isLoading: boolean; logout: () => Promise; refreshUser: () => Promise; + applyUser: (user: User | null) => void; } const AuthContext = createContext(undefined); @@ -52,6 +53,15 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) { const isAuthenticated = user !== null; + /** + * Apply a user value supplied by a caller (e.g. banner probe) that has + * already fetched it. Equivalent to setUser, exposed with a stable name + * so consumers don't reach into React internals. + */ + const applyUser = useCallback((next: User | null) => { + setUser(next); + }, []); + /** * Fetch current user from FastAPI * Used when initialUser might be stale (e.g., after tab was inactive) @@ -87,6 +97,13 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) { /** * Logout - call FastAPI logout endpoint and clear local state * Per RFC-001: Immediately clear local state, don't wait for server confirmation + * + * When the gateway is unreachable the fetch silently fails — the SPA + * router.push("/") would leave the user on "/" still holding stale + * React state and any in-flight SSE / fetch / query subscriptions. + * We therefore fall back to a hard navigation (window.location.href), + * which discards all client state the same way the legacy form-POST + * logout used to. */ const logout = useCallback(async () => { // Immediately clear local state to prevent UI flicker @@ -97,14 +114,23 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) { return; } + let logoutFailed = false; try { - await fetch("/api/v1/auth/logout", { + const res = await fetch("/api/v1/auth/logout", { method: "POST", credentials: "include", }); + if (!res.ok) logoutFailed = true; } catch (err) { console.error("Logout request failed:", err); - // Still redirect even if logout request fails + logoutFailed = true; + } + + if (logoutFailed && typeof window !== "undefined") { + // Hard navigation ensures every in-flight subscription is torn down, + // matching the legacy form-POST logout behaviour during a gateway outage. + window.location.href = "/"; + return; } // Redirect to home page @@ -140,6 +166,7 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) { isLoading, logout, refreshUser, + applyUser, }; return {children}; diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 98b19aed3..a39181af1 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -241,6 +241,8 @@ export const enUS: Translations = { contactUs: "Contact us", about: "About DeerFlow", logout: "Log out", + gatewayUnavailable: "Gateway is temporarily unavailable.", + gatewayUnavailableRetrying: "Retrying in the background…", }, // Conversation diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 251d42d57..a44d12f70 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -172,6 +172,8 @@ export interface Translations { contactUs: string; about: string; logout: string; + gatewayUnavailable: string; + gatewayUnavailableRetrying: string; }; // Conversation diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 5c0581d34..d4e6b9b51 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -229,6 +229,8 @@ export const zhCN: Translations = { contactUs: "联系我们", about: "关于 DeerFlow", logout: "退出登录", + gatewayUnavailable: "网关暂时不可用。", + gatewayUnavailableRetrying: "正在后台重试…", }, // Conversation diff --git a/frontend/tests/unit/components/workspace/gateway-offline-banner-helpers.test.ts b/frontend/tests/unit/components/workspace/gateway-offline-banner-helpers.test.ts new file mode 100644 index 000000000..952a749ff --- /dev/null +++ b/frontend/tests/unit/components/workspace/gateway-offline-banner-helpers.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "vitest"; + +import { + OFFLINE_BANNER_AUTH_FAILURE_THRESHOLD, + OFFLINE_BANNER_RETRY_INTERVAL_MS, + classifyProbe, + decideProbeAction, + shouldShowOfflineBanner, +} from "@/components/workspace/gateway-offline-banner-helpers"; +import type { User } from "@/core/auth/types"; + +const fakeUser: User = { + id: "u1", + email: "user@example.com", + system_role: "user", + needs_setup: false, +}; + +function makeResponse(status: number, ok = status >= 200 && status < 300) { + return { status, ok } as Response; +} + +describe("shouldShowOfflineBanner", () => { + it("hides when the gateway is reachable", () => { + expect(shouldShowOfflineBanner(null, false)).toBe(false); + expect(shouldShowOfflineBanner(fakeUser, false)).toBe(false); + }); + + it("shows when the gateway is unavailable and the client has no user yet", () => { + expect(shouldShowOfflineBanner(null, true)).toBe(true); + }); + + it("hides as soon as the client recovers an authenticated user", () => { + expect(shouldShowOfflineBanner(fakeUser, true)).toBe(false); + }); +}); + +describe("OFFLINE_BANNER_RETRY_INTERVAL_MS", () => { + it("is a positive finite number", () => { + expect(OFFLINE_BANNER_RETRY_INTERVAL_MS).toBeGreaterThan(0); + expect(Number.isFinite(OFFLINE_BANNER_RETRY_INTERVAL_MS)).toBe(true); + }); +}); + +describe("OFFLINE_BANNER_AUTH_FAILURE_THRESHOLD", () => { + it("is an integer greater than 1 so a single transient 401 cannot expire the session", () => { + expect(Number.isInteger(OFFLINE_BANNER_AUTH_FAILURE_THRESHOLD)).toBe(true); + expect(OFFLINE_BANNER_AUTH_FAILURE_THRESHOLD).toBeGreaterThan(1); + }); +}); + +describe("classifyProbe", () => { + it("returns transient when fetch errored", () => { + expect(classifyProbe(null, true)).toEqual({ kind: "transient" }); + }); + + it("returns transient when response is null with no error flag", () => { + expect(classifyProbe(null, false)).toEqual({ kind: "transient" }); + }); + + it("returns ok with parsed user for a 2xx response with body", () => { + expect(classifyProbe(makeResponse(200), false, fakeUser)).toEqual({ + kind: "ok", + user: fakeUser, + }); + }); + + it("returns transient for a 2xx response whose body failed to parse", () => { + // Defensive: a 200 with malformed JSON / schema mismatch should not be + // treated as 'ok' because the caller has no user to apply. + expect(classifyProbe(makeResponse(200), false, null)).toEqual({ + kind: "transient", + }); + }); + + it("returns unauthorized for a 401 response", () => { + expect(classifyProbe(makeResponse(401), false)).toEqual({ + kind: "unauthorized", + }); + }); + + it("returns transient for 5xx responses", () => { + expect(classifyProbe(makeResponse(503), false)).toEqual({ + kind: "transient", + }); + expect(classifyProbe(makeResponse(500), false)).toEqual({ + kind: "transient", + }); + }); + + it("returns transient for unexpected non-401 4xx responses", () => { + expect(classifyProbe(makeResponse(429), false)).toEqual({ + kind: "transient", + }); + }); +}); + +describe("decideProbeAction", () => { + it("returns apply-user with the body on a 2xx response", () => { + expect(decideProbeAction(0, { kind: "ok", user: fakeUser })).toEqual({ + type: "apply-user", + user: fakeUser, + }); + // Even if we'd accumulated some 401s, a 200 wins immediately. + expect(decideProbeAction(2, { kind: "ok", user: fakeUser })).toEqual({ + type: "apply-user", + user: fakeUser, + }); + }); + + it("treats a single 401 as transient noise and only bumps the counter", () => { + expect(decideProbeAction(0, { kind: "unauthorized" })).toEqual({ + type: "noop", + nextFailureCount: 1, + }); + }); + + it("treats consecutive 401s below the threshold as still transient", () => { + expect(decideProbeAction(1, { kind: "unauthorized" })).toEqual({ + type: "noop", + nextFailureCount: 2, + }); + }); + + it("delegates to refreshUser as 'session-expired' once 401s reach the threshold", () => { + expect(decideProbeAction(2, { kind: "unauthorized" })).toEqual({ + type: "delegate-refresh", + reason: "session-expired", + }); + }); + + it("honours a custom threshold (parameterised for safer tests)", () => { + expect(decideProbeAction(0, { kind: "unauthorized" }, 2)).toEqual({ + type: "noop", + nextFailureCount: 1, + }); + expect(decideProbeAction(1, { kind: "unauthorized" }, 2)).toEqual({ + type: "delegate-refresh", + reason: "session-expired", + }); + }); + + it("decrements (not resets) the auth-failure streak on a transient outcome", () => { + // Was 2 → 1, so a flapping gateway (401↔5xx) still converges on the + // threshold instead of indefinitely masking session expiry. + expect(decideProbeAction(2, { kind: "transient" })).toEqual({ + type: "noop", + nextFailureCount: 1, + }); + // Floored at 0; never goes negative. + expect(decideProbeAction(0, { kind: "transient" })).toEqual({ + type: "noop", + nextFailureCount: 0, + }); + expect(decideProbeAction(1, { kind: "transient" })).toEqual({ + type: "noop", + nextFailureCount: 0, + }); + }); + + it("convergence: alternating 401/transient still triggers session-expired", () => { + // Simulate the exact scenario from #3493 CR: flapping gateway alternates + // 401 (session gone) and 503 (overloaded). With decrement-by-1, the + // counter still nets +1 per 401/transient pair and reaches threshold. + let count = 0; + const seq: Array<"unauthorized" | "transient"> = [ + "unauthorized", // count -> 1 + "transient", // count -> 0 + "unauthorized", // count -> 1 + "unauthorized", // count -> 2 + "transient", // count -> 1 + "unauthorized", // count -> 2 + ]; + for (const kind of seq) { + const action = decideProbeAction(count, { kind }); + expect(action.type).toBe("noop"); + if (action.type === "noop") count = action.nextFailureCount; + } + // Next 401 should trip the wire (2 -> 3 == threshold). + expect(decideProbeAction(count, { kind: "unauthorized" })).toEqual({ + type: "delegate-refresh", + reason: "session-expired", + }); + }); +}); diff --git a/frontend/tests/unit/core/auth/server.test.ts b/frontend/tests/unit/core/auth/server.test.ts index 1dd02da33..dac90cacc 100644 --- a/frontend/tests/unit/core/auth/server.test.ts +++ b/frontend/tests/unit/core/auth/server.test.ts @@ -106,3 +106,65 @@ describe("getServerSideUser", () => { expect(isAuthDisabledMode()).toBe(false); }); }); + +describe("getServerSideUser — gateway_unavailable contract (issue #3493)", () => { + let saved: EnvSnapshot; + + beforeEach(() => { + saved = snapshotEnv(); + setEnv("DEER_FLOW_AUTH_DISABLED", undefined); + setEnv("NEXT_PUBLIC_STATIC_WEBSITE_ONLY", undefined); + }); + + afterEach(() => { + restoreEnv(saved); + vi.unstubAllGlobals(); + vi.doUnmock("next/headers"); + }); + + test("returns gateway_unavailable when /auth/me fetch rejects (e.g. AbortError)", async () => { + vi.doMock("next/headers", () => ({ + cookies: vi.fn(async () => ({ + get: (name: string) => + name === "access_token" ? { value: "stub-token" } : undefined, + })), + })); + const abortErr = new DOMException("Aborted", "AbortError"); + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.reject(abortErr)), + ); + + const { getServerSideUser } = await loadFreshServerAuth(); + + await expect(getServerSideUser()).resolves.toEqual({ + tag: "gateway_unavailable", + }); + }); + + test("returns gateway_unavailable when /auth/me responds with a 5xx", async () => { + vi.doMock("next/headers", () => ({ + cookies: vi.fn(async () => ({ + get: (name: string) => + name === "access_token" ? { value: "stub-token" } : undefined, + })), + })); + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve( + new Response("upstream error", { + status: 503, + statusText: "Service Unavailable", + }), + ), + ), + ); + + const { getServerSideUser } = await loadFreshServerAuth(); + + await expect(getServerSideUser()).resolves.toEqual({ + tag: "gateway_unavailable", + }); + }); +});