diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 9a8b30a0e..7480fa27b 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -54,6 +54,12 @@ jobs: env: 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 uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} diff --git a/frontend/playwright.auth.config.ts b/frontend/playwright.auth.config.ts new file mode 100644 index 000000000..9d53b807a --- /dev/null +++ b/frontend/playwright.auth.config.ts @@ -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, + }, + }, +}); diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx index 0e28b58c6..b7acbc309 100644 --- a/frontend/src/app/(auth)/login/page.tsx +++ b/frontend/src/app/(auth)/login/page.tsx @@ -72,7 +72,10 @@ export default function LoginPage() { const [setupStatus, setSetupStatus] = useState( 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) const errorParam = searchParams.get("error"); @@ -93,10 +96,15 @@ export default function LoginPage() { const nextParam = searchParams.get("next"); const redirectPath = validateNextParam(nextParam) ?? "/workspace"; 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, }); const systemNeedsAdminSetup = setupStatus?.needs_setup === true; + const showSetupStatusUnavailable = + setupStatusPhase === "unavailable" || + (setupStatusAttempt > 0 && setupStatusPhase === "checking"); // Redirect if already authenticated (client-side, post-login) 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(() => { let cancelled = false; + setSetupStatusPhase("checking"); void fetchSetupStatus() .then((data) => { if (cancelled) return; setSetupStatus(data); + setSetupStatusPhase("ready"); if (data.needs_setup) { setIsLogin(true); } @@ -128,14 +139,20 @@ export default function LoginPage() { .catch(() => { if (!cancelled) { setSetupStatus(null); - } - }) - .finally(() => { - if (!cancelled) { - setSetupStatusChecked(true); + setSetupStatusPhase("unavailable"); } }); + 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") .then((r) => r.json()) .then( @@ -234,6 +251,34 @@ export default function LoginPage() {

+ {showSetupStatusUnavailable && ( +
+

{t.login.serviceUnavailableTitle}

+

+ {t.login.serviceUnavailableDescription} +

+ +
+ )} + {systemNeedsAdminSetup && (

{t.login.adminSetupRequiredTitle}

diff --git a/frontend/src/app/(auth)/setup/page.tsx b/frontend/src/app/(auth)/setup/page.tsx index 28ce8cc1f..d2120257f 100644 --- a/frontend/src/app/(auth)/setup/page.tsx +++ b/frontend/src/app/(auth)/setup/page.tsx @@ -16,14 +16,17 @@ import { isSystemAlreadyInitializedError, } from "@/core/auth/setup"; 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() { const router = useRouter(); const { user, isAuthenticated } = useAuth(); const { theme, resolvedTheme } = useTheme(); + const { t } = useI18n(); const [mode, setMode] = useState("loading"); + const [setupStatusAttempt, setSetupStatusAttempt] = useState(0); // --- Shared state --- const [email, setEmail] = useState(""); @@ -44,7 +47,9 @@ export default function SetupPage() { if (isAuthenticated && user?.needs_setup) { setMode("change_password"); } 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() .then((data: { needs_setup?: boolean }) => { if (cancelled) return; @@ -56,7 +61,7 @@ export default function SetupPage() { } }) .catch(() => { - if (!cancelled) router.replace("/login"); + if (!cancelled) setMode("unavailable"); }); } else { // Authenticated but needs_setup is false — already set up @@ -66,7 +71,7 @@ export default function SetupPage() { return () => { cancelled = true; }; - }, [isAuthenticated, user, router]); + }, [isAuthenticated, user, router, setupStatusAttempt]); // ── Init-admin handler ───────────────────────────────────────────── const handleInitAdmin = async (e: React.SubmitEvent) => { @@ -166,6 +171,41 @@ export default function SetupPage() { ); } + if (mode === "unavailable") { + return ( +
+
+
+

+ {t.login.serviceUnavailableTitle} +

+

+ {t.login.serviceUnavailableDescription} +

+
+
+ + +
+
+
+ ); + } + // ── Admin initialization form ────────────────────────────────────── if (mode === "init_admin") { return ( diff --git a/frontend/src/core/auth/constants.ts b/frontend/src/core/auth/constants.ts new file mode 100644 index 000000000..5b29b2701 --- /dev/null +++ b/frontend/src/core/auth/constants.ts @@ -0,0 +1 @@ +export const AUTH_REQUEST_TIMEOUT_MS = 5_000; diff --git a/frontend/src/core/auth/server.ts b/frontend/src/core/auth/server.ts index 198ef087c..622f52e94 100644 --- a/frontend/src/core/auth/server.ts +++ b/frontend/src/core/auth/server.ts @@ -3,12 +3,11 @@ import { cookies } from "next/headers"; import { isStaticWebsiteOnly } from "../static-mode"; import { AUTH_DISABLED_USER, isAuthDisabledMode } from "./auth-disabled-user"; +import { AUTH_REQUEST_TIMEOUT_MS } from "./constants"; import { getGatewayConfig } from "./gateway-config"; import { STATIC_WEBSITE_USER } from "./static-user"; 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. * Returns a tagged AuthResult — callers use exhaustive switch, no try/catch. @@ -43,7 +42,7 @@ export async function getServerSideUser(): Promise { const setupController = new AbortController(); const setupTimeout = setTimeout( () => setupController.abort(), - SSR_AUTH_TIMEOUT_MS, + AUTH_REQUEST_TIMEOUT_MS, ); try { const setupRes = await fetch( @@ -68,7 +67,7 @@ export async function getServerSideUser(): Promise { } const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), SSR_AUTH_TIMEOUT_MS); + const timeout = setTimeout(() => controller.abort(), AUTH_REQUEST_TIMEOUT_MS); try { const res = await fetch(`${internalGatewayUrl}/api/v1/auth/me`, { diff --git a/frontend/src/core/auth/setup.ts b/frontend/src/core/auth/setup.ts index 055dc05c2..1c484dcac 100644 --- a/frontend/src/core/auth/setup.ts +++ b/frontend/src/core/auth/setup.ts @@ -1,3 +1,4 @@ +import { AUTH_REQUEST_TIMEOUT_MS } from "./constants"; import { parseAuthError } from "./types"; export type SetupStatusResponse = { @@ -16,14 +17,21 @@ export const setupStatusFetchInit = { } satisfies RequestInit; export async function fetchSetupStatus(): Promise { - const response = await fetch( - "/api/v1/auth/setup-status", - setupStatusFetchInit, - ); - if (!response.ok) { - throw new Error(`setup-status failed: ${response.status}`); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), AUTH_REQUEST_TIMEOUT_MS); + + try { + const response = await fetch("/api/v1/auth/setup-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 { diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index d81ef11db..ca81ba209 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -1092,6 +1092,10 @@ export const enUS: Translations = { haveAccountSignIn: "Already have an account? Sign in", backToHome: "← Back to home", 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.", errors: { sso_failed: "SSO login failed. Please try again or use email login.", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 918d172a0..9fc9a1824 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -862,6 +862,9 @@ export interface Translations { haveAccountSignIn: string; backToHome: string; networkError: string; + serviceUnavailableTitle: string; + serviceUnavailableDescription: string; + retry: string; authFailed: string; errors: { sso_failed: string; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index eb996cabf..6d6e30209 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -1043,6 +1043,10 @@ export const zhCN: Translations = { haveAccountSignIn: "已有账号?立即登录", backToHome: "← 返回首页", networkError: "网络错误,请重试。", + serviceUnavailableTitle: "服务暂时不可用", + serviceUnavailableDescription: + "网关响应时间过长。请确认服务正在运行,然后重试。", + retry: "重试", authFailed: "身份验证失败。", errors: { sso_failed: "SSO 登录失败,请重试或使用邮箱登录。", diff --git a/frontend/tests/e2e-auth/auth-setup-recovery.spec.ts b/frontend/tests/e2e-auth/auth-setup-recovery.spec.ts new file mode 100644 index 000000000..d6a725bc7 --- /dev/null +++ b/frontend/tests/e2e-auth/auth-setup-recovery.spec.ts @@ -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(); + }); +}); diff --git a/frontend/tests/unit/core/auth/setup.test.ts b/frontend/tests/unit/core/auth/setup.test.ts index 2937fc2a2..acd31a8c7 100644 --- a/frontend/tests/unit/core/auth/setup.test.ts +++ b/frontend/tests/unit/core/auth/setup.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, rs, test } from "@rstest/core"; +import { AUTH_REQUEST_TIMEOUT_MS } from "@/core/auth/constants"; import { canCreateRegularAccount, fetchSetupStatus, @@ -7,8 +8,32 @@ import { setupStatusFetchInit, } from "@/core/auth/setup"; +function pendingUntilAborted(signal: AbortSignal): Promise { + 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", () => { afterEach(() => { + rs.useRealTimers(); rs.unstubAllGlobals(); }); @@ -20,7 +45,7 @@ describe("auth setup helpers", () => { }); 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( new Response(JSON.stringify({ needs_setup: true }), { status: 200, @@ -33,7 +58,90 @@ describe("auth setup helpers", () => { await expect(fetchSetupStatus()).resolves.toEqual({ needs_setup: true }); expect(fetchMock).toHaveBeenCalledWith( "/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", ); });