mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-26 07:57:57 +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:
|
||||
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() }}
|
||||
|
||||
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>(
|
||||
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() {
|
||||
</p>
|
||||
</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 && (
|
||||
<div className="border-l-2 border-blue-500 ps-3 text-sm">
|
||||
<p className="font-medium">{t.login.adminSetupRequiredTitle}</p>
|
||||
|
||||
@ -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<SetupMode>("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 (
|
||||
<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 ──────────────────────────────────────
|
||||
if (mode === "init_admin") {
|
||||
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 { 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<AuthResult> {
|
||||
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<AuthResult> {
|
||||
}
|
||||
|
||||
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`, {
|
||||
|
||||
@ -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<SetupStatusResponse> {
|
||||
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 {
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1043,6 +1043,10 @@ export const zhCN: Translations = {
|
||||
haveAccountSignIn: "已有账号?立即登录",
|
||||
backToHome: "← 返回首页",
|
||||
networkError: "网络错误,请重试。",
|
||||
serviceUnavailableTitle: "服务暂时不可用",
|
||||
serviceUnavailableDescription:
|
||||
"网关响应时间过长。请确认服务正在运行,然后重试。",
|
||||
retry: "重试",
|
||||
authFailed: "身份验证失败。",
|
||||
errors: {
|
||||
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 { 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<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", () => {
|
||||
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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user