fix(frontend): keep workspace interactive when SSR auth probe cannot reach gateway (#3493) (#3495)

* 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)
This commit is contained in:
Huixin615
2026-06-11 21:14:49 +08:00
committed by GitHub
parent f401e7baa6
commit b6fbf0d105
12 changed files with 563 additions and 41 deletions
+11 -12
View File
@@ -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 <AuthProvider initialUser={null}>{children}</AuthProvider>;
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 (
<div className="flex h-screen flex-col items-center justify-center gap-4">
<p className="text-muted-foreground">
Service temporarily unavailable.
</p>
<Link
href="/login"
className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-4 py-2 text-sm"
>
Retry
</Link>
</div>
<GatewayOfflineFallback renderBanner>
<div className="flex h-screen flex-col items-center justify-center gap-4">
<p className="text-muted-foreground">
Service temporarily unavailable.
</p>
</div>
</GatewayOfflineFallback>
);
case "config_error":
throw new Error(result.message);
+7 -25
View File
@@ -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 (
<div className="flex h-screen flex-col items-center justify-center gap-4">
<p className="text-muted-foreground">
Service temporarily unavailable.
</p>
<p className="text-muted-foreground text-xs">
The backend may be restarting. Please wait a moment and try again.
</p>
<div className="flex gap-3">
<Link
href="/workspace"
className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-4 py-2 text-sm"
>
Retry
</Link>
<form action="/api/v1/auth/logout" method="post">
<button
type="submit"
className="text-muted-foreground hover:bg-muted rounded-md border px-4 py-2 text-sm"
>
Logout &amp; Reset
</button>
</form>
</div>
</div>
<GatewayOfflineFallback>
<WorkspaceContent gatewayUnavailable>{children}</WorkspaceContent>
</GatewayOfflineFallback>
);
case "config_error":
throw new Error(result.message);
@@ -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({
<QueryClientProvider>
<SidebarProvider className="h-screen" defaultOpen={initialSidebarOpen}>
<WorkspaceSidebar />
<SidebarInset className="min-w-0">{children}</SidebarInset>
<SidebarInset className="min-w-0">
<GatewayOfflineBanner gatewayUnavailable={gatewayUnavailable} />
{children}
</SidebarInset>
</SidebarProvider>
<CommandPalette />
<Toaster position="top-center" />
@@ -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),
};
}
@@ -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 (
<div
role="status"
aria-live="polite"
className="bg-muted text-muted-foreground flex items-center justify-between gap-3 border-b px-4 py-2 text-sm"
>
<span>
{t.workspace.gatewayUnavailable}{" "}
{t.workspace.gatewayUnavailableRetrying}
</span>
<button
type="button"
onClick={() => {
void logout();
}}
className="hover:bg-background rounded-md border px-3 py-1 text-xs"
>
{t.workspace.logout}
</button>
</div>
);
}
@@ -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 (
<AuthProvider initialUser={null}>
{renderBanner && <GatewayOfflineBanner gatewayUnavailable />}
{children}
</AuthProvider>
);
}
+29 -2
View File
@@ -26,6 +26,7 @@ interface AuthContextType {
isLoading: boolean;
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
applyUser: (user: User | null) => void;
}
const AuthContext = createContext<AuthContextType | undefined>(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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+2
View File
@@ -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
+2
View File
@@ -172,6 +172,8 @@ export interface Translations {
contactUs: string;
about: string;
logout: string;
gatewayUnavailable: string;
gatewayUnavailableRetrying: string;
};
// Conversation
+2
View File
@@ -229,6 +229,8 @@ export const zhCN: Translations = {
contactUs: "联系我们",
about: "关于 DeerFlow",
logout: "退出登录",
gatewayUnavailable: "网关暂时不可用。",
gatewayUnavailableRetrying: "正在后台重试…",
},
// Conversation
@@ -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",
});
});
});
@@ -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",
});
});
});