mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-11 14:38:38 +00:00
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:
parent
183280ebfc
commit
f881996e1a
6
.github/workflows/e2e-tests.yml
vendored
6
.github/workflows/e2e-tests.yml
vendored
@ -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() }}
|
||||||
|
|||||||
49
frontend/playwright.auth.config.ts
Normal file
49
frontend/playwright.auth.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
1
frontend/src/core/auth/constants.ts
Normal file
1
frontend/src/core/auth/constants.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const AUTH_REQUEST_TIMEOUT_MS = 5_000;
|
||||||
@ -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`, {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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.",
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 登录失败,请重试或使用邮箱登录。",
|
||||||
|
|||||||
102
frontend/tests/e2e-auth/auth-setup-recovery.spec.ts
Normal file
102
frontend/tests/e2e-auth/auth-setup-recovery.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user