fix(auth): recover from setup status timeouts (#4371)

* fix(auth): recover from setup status timeouts

* test(auth): cover setup status recovery flows

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
urzeye 2026-07-26 10:05:05 +08:00 committed by GitHub
parent 183280ebfc
commit f881996e1a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 394 additions and 25 deletions

View File

@ -54,6 +54,12 @@ jobs:
env: env:
SKIP_ENV_VALIDATION: '1' SKIP_ENV_VALIDATION: '1'
- name: Run auth recovery E2E tests
working-directory: frontend
run: pnpm exec playwright test -c playwright.auth.config.ts
env:
SKIP_ENV_VALIDATION: '1'
- name: Upload Playwright report - name: Upload Playwright report
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
if: ${{ !cancelled() }} if: ${{ !cancelled() }}

View File

@ -0,0 +1,49 @@
import { defineConfig, devices } from "@playwright/test";
const frontendPort = process.env.E2E_AUTH_FRONTEND_PORT ?? "3001";
const frontendURL =
process.env.PLAYWRIGHT_AUTH_BASE_URL ?? `http://localhost:${frontendPort}`;
const skipWebServer = process.env.PLAYWRIGHT_SKIP_WEB_SERVER === "1";
/**
* Auth-enabled E2E coverage for the login and setup pages. The default config
* runs with auth disabled so workspace tests can skip a real session; that
* would SSR-redirect these pages before page.route() can exercise recovery.
*/
export default defineConfig({
testDir: "./tests/e2e-auth",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? "github" : "html",
timeout: 30_000,
use: {
baseURL: frontendURL,
locale: "en-US",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: skipWebServer
? undefined
: {
command: `pnpm build && pnpm exec next start -p ${frontendPort}`,
url: frontendURL,
reuseExistingServer: !process.env.CI,
timeout: 240_000,
env: {
SKIP_ENV_VALIDATION: "1",
DEER_FLOW_AUTH_DISABLED: "0",
DEER_FLOW_INTERNAL_GATEWAY_BASE_URL: "http://127.0.0.1:65535",
DEER_FLOW_TRUSTED_ORIGINS: frontendURL,
},
},
});

View File

@ -72,7 +72,10 @@ export default function LoginPage() {
const [setupStatus, setSetupStatus] = useState<SetupStatusResponse | null>( const [setupStatus, setSetupStatus] = useState<SetupStatusResponse | null>(
null, null,
); );
const [setupStatusChecked, setSetupStatusChecked] = useState(false); const [setupStatusPhase, setSetupStatusPhase] = useState<
"checking" | "ready" | "unavailable"
>("checking");
const [setupStatusAttempt, setSetupStatusAttempt] = useState(0);
// Extract error from query params (e.g., ?error=sso_failed) // Extract error from query params (e.g., ?error=sso_failed)
const errorParam = searchParams.get("error"); const errorParam = searchParams.get("error");
@ -93,10 +96,15 @@ export default function LoginPage() {
const nextParam = searchParams.get("next"); const nextParam = searchParams.get("next");
const redirectPath = validateNextParam(nextParam) ?? "/workspace"; const redirectPath = validateNextParam(nextParam) ?? "/workspace";
const regularSignupAllowed = canCreateRegularAccount({ const regularSignupAllowed = canCreateRegularAccount({
checked: setupStatusChecked, // A failed probe must not expose registration while the system's setup
// state is unknown. Existing users can still sign in normally.
checked: setupStatusPhase === "ready",
status: setupStatus, status: setupStatus,
}); });
const systemNeedsAdminSetup = setupStatus?.needs_setup === true; const systemNeedsAdminSetup = setupStatus?.needs_setup === true;
const showSetupStatusUnavailable =
setupStatusPhase === "unavailable" ||
(setupStatusAttempt > 0 && setupStatusPhase === "checking");
// Redirect if already authenticated (client-side, post-login) // Redirect if already authenticated (client-side, post-login)
useEffect(() => { useEffect(() => {
@ -113,14 +121,17 @@ export default function LoginPage() {
} }
}, []); }, []);
// Fetch setup state and SSO providers // Fetch setup state independently so retrying a slow Gateway does not also
// refetch unrelated auth-provider configuration.
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setSetupStatusPhase("checking");
void fetchSetupStatus() void fetchSetupStatus()
.then((data) => { .then((data) => {
if (cancelled) return; if (cancelled) return;
setSetupStatus(data); setSetupStatus(data);
setSetupStatusPhase("ready");
if (data.needs_setup) { if (data.needs_setup) {
setIsLogin(true); setIsLogin(true);
} }
@ -128,14 +139,20 @@ export default function LoginPage() {
.catch(() => { .catch(() => {
if (!cancelled) { if (!cancelled) {
setSetupStatus(null); setSetupStatus(null);
} setSetupStatusPhase("unavailable");
})
.finally(() => {
if (!cancelled) {
setSetupStatusChecked(true);
} }
}); });
return () => {
cancelled = true;
};
}, [setupStatusAttempt]);
// SSO providers are static for the page lifetime and should not be coupled to
// setup-status retries.
useEffect(() => {
let cancelled = false;
void fetch("/api/v1/auth/providers") void fetch("/api/v1/auth/providers")
.then((r) => r.json()) .then((r) => r.json())
.then( .then(
@ -234,6 +251,34 @@ export default function LoginPage() {
</p> </p>
</div> </div>
{showSetupStatusUnavailable && (
<div
role="status"
aria-live="polite"
className="border-l-2 border-amber-500 ps-3 text-sm"
>
<p className="font-medium">{t.login.serviceUnavailableTitle}</p>
<p className="text-muted-foreground mt-1">
{t.login.serviceUnavailableDescription}
</p>
<Button
type="button"
variant="outline"
size="sm"
className="mt-3"
disabled={setupStatusPhase === "checking"}
onClick={() => {
setSetupStatusPhase("checking");
setSetupStatusAttempt((attempt) => attempt + 1);
}}
>
{setupStatusPhase === "checking"
? t.login.pleaseWait
: t.login.retry}
</Button>
</div>
)}
{systemNeedsAdminSetup && ( {systemNeedsAdminSetup && (
<div className="border-l-2 border-blue-500 ps-3 text-sm"> <div className="border-l-2 border-blue-500 ps-3 text-sm">
<p className="font-medium">{t.login.adminSetupRequiredTitle}</p> <p className="font-medium">{t.login.adminSetupRequiredTitle}</p>

View File

@ -16,14 +16,17 @@ import {
isSystemAlreadyInitializedError, isSystemAlreadyInitializedError,
} from "@/core/auth/setup"; } from "@/core/auth/setup";
import { parseAuthError } from "@/core/auth/types"; import { parseAuthError } from "@/core/auth/types";
import { useI18n } from "@/core/i18n/hooks";
type SetupMode = "loading" | "init_admin" | "change_password"; type SetupMode = "loading" | "init_admin" | "change_password" | "unavailable";
export default function SetupPage() { export default function SetupPage() {
const router = useRouter(); const router = useRouter();
const { user, isAuthenticated } = useAuth(); const { user, isAuthenticated } = useAuth();
const { theme, resolvedTheme } = useTheme(); const { theme, resolvedTheme } = useTheme();
const { t } = useI18n();
const [mode, setMode] = useState<SetupMode>("loading"); const [mode, setMode] = useState<SetupMode>("loading");
const [setupStatusAttempt, setSetupStatusAttempt] = useState(0);
// --- Shared state --- // --- Shared state ---
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
@ -44,7 +47,9 @@ export default function SetupPage() {
if (isAuthenticated && user?.needs_setup) { if (isAuthenticated && user?.needs_setup) {
setMode("change_password"); setMode("change_password");
} else if (!isAuthenticated) { } else if (!isAuthenticated) {
// Check if the system has no users yet // Check if the system has no users yet. A slow Gateway must not leave the
// setup page in an infinite loading state or silently redirect away.
setMode("loading");
void fetchSetupStatus() void fetchSetupStatus()
.then((data: { needs_setup?: boolean }) => { .then((data: { needs_setup?: boolean }) => {
if (cancelled) return; if (cancelled) return;
@ -56,7 +61,7 @@ export default function SetupPage() {
} }
}) })
.catch(() => { .catch(() => {
if (!cancelled) router.replace("/login"); if (!cancelled) setMode("unavailable");
}); });
} else { } else {
// Authenticated but needs_setup is false — already set up // Authenticated but needs_setup is false — already set up
@ -66,7 +71,7 @@ export default function SetupPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [isAuthenticated, user, router]); }, [isAuthenticated, user, router, setupStatusAttempt]);
// ── Init-admin handler ───────────────────────────────────────────── // ── Init-admin handler ─────────────────────────────────────────────
const handleInitAdmin = async (e: React.SubmitEvent) => { const handleInitAdmin = async (e: React.SubmitEvent) => {
@ -166,6 +171,41 @@ export default function SetupPage() {
); );
} }
if (mode === "unavailable") {
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-md space-y-4 text-center">
<div>
<h1 className="text-xl font-semibold">
{t.login.serviceUnavailableTitle}
</h1>
<p className="text-muted-foreground mt-2 text-sm">
{t.login.serviceUnavailableDescription}
</p>
</div>
<div className="flex justify-center gap-3">
<Button
type="button"
onClick={() => {
setMode("loading");
setSetupStatusAttempt((attempt) => attempt + 1);
}}
>
{t.login.retry}
</Button>
<Button
type="button"
variant="outline"
onClick={() => router.replace("/login")}
>
{t.login.signIn}
</Button>
</div>
</div>
</div>
);
}
// ── Admin initialization form ────────────────────────────────────── // ── Admin initialization form ──────────────────────────────────────
if (mode === "init_admin") { if (mode === "init_admin") {
return ( return (

View File

@ -0,0 +1 @@
export const AUTH_REQUEST_TIMEOUT_MS = 5_000;

View File

@ -3,12 +3,11 @@ import { cookies } from "next/headers";
import { isStaticWebsiteOnly } from "../static-mode"; import { isStaticWebsiteOnly } from "../static-mode";
import { AUTH_DISABLED_USER, isAuthDisabledMode } from "./auth-disabled-user"; import { AUTH_DISABLED_USER, isAuthDisabledMode } from "./auth-disabled-user";
import { AUTH_REQUEST_TIMEOUT_MS } from "./constants";
import { getGatewayConfig } from "./gateway-config"; import { getGatewayConfig } from "./gateway-config";
import { STATIC_WEBSITE_USER } from "./static-user"; import { STATIC_WEBSITE_USER } from "./static-user";
import { type AuthResult, userSchema } from "./types"; import { type AuthResult, userSchema } from "./types";
const SSR_AUTH_TIMEOUT_MS = 5_000;
/** /**
* Fetch the authenticated user from the gateway using the request's cookies. * Fetch the authenticated user from the gateway using the request's cookies.
* Returns a tagged AuthResult callers use exhaustive switch, no try/catch. * Returns a tagged AuthResult callers use exhaustive switch, no try/catch.
@ -43,7 +42,7 @@ export async function getServerSideUser(): Promise<AuthResult> {
const setupController = new AbortController(); const setupController = new AbortController();
const setupTimeout = setTimeout( const setupTimeout = setTimeout(
() => setupController.abort(), () => setupController.abort(),
SSR_AUTH_TIMEOUT_MS, AUTH_REQUEST_TIMEOUT_MS,
); );
try { try {
const setupRes = await fetch( const setupRes = await fetch(
@ -68,7 +67,7 @@ export async function getServerSideUser(): Promise<AuthResult> {
} }
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), SSR_AUTH_TIMEOUT_MS); const timeout = setTimeout(() => controller.abort(), AUTH_REQUEST_TIMEOUT_MS);
try { try {
const res = await fetch(`${internalGatewayUrl}/api/v1/auth/me`, { const res = await fetch(`${internalGatewayUrl}/api/v1/auth/me`, {

View File

@ -1,3 +1,4 @@
import { AUTH_REQUEST_TIMEOUT_MS } from "./constants";
import { parseAuthError } from "./types"; import { parseAuthError } from "./types";
export type SetupStatusResponse = { export type SetupStatusResponse = {
@ -16,14 +17,21 @@ export const setupStatusFetchInit = {
} satisfies RequestInit; } satisfies RequestInit;
export async function fetchSetupStatus(): Promise<SetupStatusResponse> { export async function fetchSetupStatus(): Promise<SetupStatusResponse> {
const response = await fetch( const controller = new AbortController();
"/api/v1/auth/setup-status", const timeout = setTimeout(() => controller.abort(), AUTH_REQUEST_TIMEOUT_MS);
setupStatusFetchInit,
); try {
if (!response.ok) { const response = await fetch("/api/v1/auth/setup-status", {
throw new Error(`setup-status failed: ${response.status}`); ...setupStatusFetchInit,
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`setup-status failed: ${response.status}`);
}
return (await response.json()) as SetupStatusResponse;
} finally {
clearTimeout(timeout);
} }
return (await response.json()) as SetupStatusResponse;
} }
export function isSystemAlreadyInitializedError(data: unknown): boolean { export function isSystemAlreadyInitializedError(data: unknown): boolean {

View File

@ -1092,6 +1092,10 @@ export const enUS: Translations = {
haveAccountSignIn: "Already have an account? Sign in", haveAccountSignIn: "Already have an account? Sign in",
backToHome: "← Back to home", backToHome: "← Back to home",
networkError: "Network error. Please try again.", networkError: "Network error. Please try again.",
serviceUnavailableTitle: "Service temporarily unavailable",
serviceUnavailableDescription:
"The Gateway is taking too long to respond. Check that it is running, then try again.",
retry: "Try again",
authFailed: "Authentication failed.", authFailed: "Authentication failed.",
errors: { errors: {
sso_failed: "SSO login failed. Please try again or use email login.", sso_failed: "SSO login failed. Please try again or use email login.",

View File

@ -862,6 +862,9 @@ export interface Translations {
haveAccountSignIn: string; haveAccountSignIn: string;
backToHome: string; backToHome: string;
networkError: string; networkError: string;
serviceUnavailableTitle: string;
serviceUnavailableDescription: string;
retry: string;
authFailed: string; authFailed: string;
errors: { errors: {
sso_failed: string; sso_failed: string;

View File

@ -1043,6 +1043,10 @@ export const zhCN: Translations = {
haveAccountSignIn: "已有账号?立即登录", haveAccountSignIn: "已有账号?立即登录",
backToHome: "← 返回首页", backToHome: "← 返回首页",
networkError: "网络错误,请重试。", networkError: "网络错误,请重试。",
serviceUnavailableTitle: "服务暂时不可用",
serviceUnavailableDescription:
"网关响应时间过长。请确认服务正在运行,然后重试。",
retry: "重试",
authFailed: "身份验证失败。", authFailed: "身份验证失败。",
errors: { errors: {
sso_failed: "SSO 登录失败,请重试或使用邮箱登录。", sso_failed: "SSO 登录失败,请重试或使用邮箱登录。",

View File

@ -0,0 +1,102 @@
import { expect, test, type Page } from "@playwright/test";
const SETUP_STATUS_URL = "**/api/v1/auth/setup-status";
const SERVICE_UNAVAILABLE_TITLE = "Service temporarily unavailable";
async function mockSetupStatusRecovery(
page: Page,
recoveredStatus: {
needs_setup: boolean;
registration_enabled?: boolean;
},
) {
let requestCount = 0;
await page.route(SETUP_STATUS_URL, (route) => {
if (route.request().method() !== "GET") {
return route.fallback();
}
requestCount += 1;
if (requestCount === 1) {
return route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({ detail: "Gateway unavailable" }),
});
}
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(recoveredStatus),
});
});
return () => requestCount;
}
test.describe("auth setup-status recovery", () => {
test.beforeEach(async ({ page }) => {
await page.route("**/api/v1/auth/providers", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ providers: [] }),
}),
);
});
test("login restores registration after setup-status retry", async ({
page,
}) => {
const getRequestCount = await mockSetupStatusRecovery(page, {
needs_setup: false,
registration_enabled: true,
});
await page.goto("/login");
await expect(page.getByText(SERVICE_UNAVAILABLE_TITLE)).toBeVisible();
await expect(page.getByRole("button", { name: "Sign In" })).toBeEnabled();
await expect(
page.getByRole("button", { name: /Don't have an account/i }),
).toHaveCount(0);
expect(getRequestCount()).toBe(1);
await page.getByRole("button", { name: "Try again" }).click();
await expect
.poll(getRequestCount, { message: "setup-status should be retried" })
.toBe(2);
await expect(page.getByText(SERVICE_UNAVAILABLE_TITLE)).toBeHidden();
await expect(
page.getByRole("button", { name: /Don't have an account/i }),
).toBeVisible();
});
test("setup restores the administrator form after setup-status retry", async ({
page,
}) => {
const getRequestCount = await mockSetupStatusRecovery(page, {
needs_setup: true,
});
await page.goto("/setup");
await expect(page.getByText(SERVICE_UNAVAILABLE_TITLE)).toBeVisible();
await expect(page.getByRole("button", { name: "Try again" })).toBeVisible();
expect(getRequestCount()).toBe(1);
await page.getByRole("button", { name: "Try again" }).click();
await expect
.poll(getRequestCount, { message: "setup-status should be retried" })
.toBe(2);
await expect(page.getByText(SERVICE_UNAVAILABLE_TITLE)).toBeHidden();
await expect(
page.getByRole("button", { name: "Create Admin Account" }),
).toBeVisible();
await expect(page.getByRole("textbox", { name: "Email" })).toBeVisible();
});
});

View File

@ -1,5 +1,6 @@
import { afterEach, describe, expect, rs, test } from "@rstest/core"; import { afterEach, describe, expect, rs, test } from "@rstest/core";
import { AUTH_REQUEST_TIMEOUT_MS } from "@/core/auth/constants";
import { import {
canCreateRegularAccount, canCreateRegularAccount,
fetchSetupStatus, fetchSetupStatus,
@ -7,8 +8,32 @@ import {
setupStatusFetchInit, setupStatusFetchInit,
} from "@/core/auth/setup"; } from "@/core/auth/setup";
function pendingUntilAborted(signal: AbortSignal): Promise<Response> {
return new Promise((_resolve, reject) => {
signal.addEventListener(
"abort",
() => {
reject(
signal.reason instanceof Error
? signal.reason
: new Error("The request was aborted"),
);
},
{ once: true },
);
});
}
function requestSignal(init?: RequestInit): AbortSignal {
if (!init?.signal) {
throw new Error("Expected fetch to receive an AbortSignal");
}
return init.signal;
}
describe("auth setup helpers", () => { describe("auth setup helpers", () => {
afterEach(() => { afterEach(() => {
rs.useRealTimers();
rs.unstubAllGlobals(); rs.unstubAllGlobals();
}); });
@ -20,7 +45,7 @@ describe("auth setup helpers", () => {
}); });
test("fetchSetupStatus uses the shared no-store request options", async () => { test("fetchSetupStatus uses the shared no-store request options", async () => {
const fetchMock = rs.fn(() => const fetchMock = rs.fn((_input: RequestInfo | URL, _init?: RequestInit) =>
Promise.resolve( Promise.resolve(
new Response(JSON.stringify({ needs_setup: true }), { new Response(JSON.stringify({ needs_setup: true }), {
status: 200, status: 200,
@ -33,7 +58,90 @@ describe("auth setup helpers", () => {
await expect(fetchSetupStatus()).resolves.toEqual({ needs_setup: true }); await expect(fetchSetupStatus()).resolves.toEqual({ needs_setup: true });
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
"/api/v1/auth/setup-status", "/api/v1/auth/setup-status",
setupStatusFetchInit, expect.objectContaining(setupStatusFetchInit),
);
expect(fetchMock.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal);
});
test("aborts a setup-status request that remains pending", async () => {
rs.useFakeTimers();
const fetchMock = rs.fn((_input: RequestInfo | URL, init?: RequestInit) =>
pendingUntilAborted(requestSignal(init)),
);
rs.stubGlobal("fetch", fetchMock);
const request = fetchSetupStatus();
const rejection = expect(request).rejects.toMatchObject({
name: "AbortError",
});
await rs.advanceTimersByTimeAsync(AUTH_REQUEST_TIMEOUT_MS);
await rejection;
expect(fetchMock.mock.calls[0]![1]!.signal!.aborted).toBe(true);
});
test("clears the timeout after a successful setup-status response", async () => {
rs.useFakeTimers();
const signals: AbortSignal[] = [];
const fetchMock = rs.fn((_input: RequestInfo | URL, init?: RequestInit) => {
signals.push(requestSignal(init));
return Promise.resolve(
new Response(JSON.stringify({ needs_setup: false }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
});
rs.stubGlobal("fetch", fetchMock);
await expect(fetchSetupStatus()).resolves.toEqual({ needs_setup: false });
await rs.advanceTimersByTimeAsync(AUTH_REQUEST_TIMEOUT_MS);
expect(signals[0]!.aborted).toBe(false);
});
test("a retry uses a fresh signal after the first request times out", async () => {
rs.useFakeTimers();
const signals: AbortSignal[] = [];
const fetchMock = rs.fn((_input: RequestInfo | URL, init?: RequestInit) => {
const signal = requestSignal(init);
signals.push(signal);
if (signals.length === 1) {
return pendingUntilAborted(signal);
}
return Promise.resolve(
new Response(JSON.stringify({ needs_setup: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
});
rs.stubGlobal("fetch", fetchMock);
const firstRequest = fetchSetupStatus();
const firstRejection = expect(firstRequest).rejects.toMatchObject({
name: "AbortError",
});
await rs.advanceTimersByTimeAsync(AUTH_REQUEST_TIMEOUT_MS);
await firstRejection;
await expect(fetchSetupStatus()).resolves.toEqual({ needs_setup: true });
expect(signals).toHaveLength(2);
expect(signals[1]!).not.toBe(signals[0]!);
expect(signals[0]!.aborted).toBe(true);
expect(signals[1]!.aborted).toBe(false);
});
test("preserves setup-status HTTP errors", async () => {
rs.stubGlobal(
"fetch",
rs.fn(() => Promise.resolve(new Response(null, { status: 503 }))),
);
await expect(fetchSetupStatus()).rejects.toThrow(
"setup-status failed: 503",
); );
}); });