mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-11 18:05:58 +00:00
b6fbf0d105
* 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)
171 lines
4.5 KiB
TypeScript
171 lines
4.5 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
|
|
import { AUTH_DISABLED_USER } from "@/core/auth/auth-disabled-user";
|
|
import { STATIC_WEBSITE_USER } from "@/core/auth/static-user";
|
|
|
|
vi.mock("next/headers", () => ({
|
|
cookies: vi.fn(() => {
|
|
throw new Error("cookies should not be read in static website mode");
|
|
}),
|
|
}));
|
|
|
|
const ENV_KEYS = [
|
|
"DEER_FLOW_AUTH_DISABLED",
|
|
"DEER_FLOW_ENV",
|
|
"ENVIRONMENT",
|
|
"NEXT_PUBLIC_STATIC_WEBSITE_ONLY",
|
|
] as const;
|
|
|
|
type EnvSnapshot = Partial<
|
|
Record<(typeof ENV_KEYS)[number], string | undefined>
|
|
>;
|
|
|
|
function snapshotEnv(): EnvSnapshot {
|
|
const snapshot: EnvSnapshot = {};
|
|
for (const key of ENV_KEYS) {
|
|
snapshot[key] = process.env[key];
|
|
}
|
|
return snapshot;
|
|
}
|
|
|
|
function setEnv(key: (typeof ENV_KEYS)[number], value: string | undefined) {
|
|
const env = process.env as Record<string, string | undefined>;
|
|
if (value === undefined) {
|
|
delete env[key];
|
|
} else {
|
|
env[key] = value;
|
|
}
|
|
}
|
|
|
|
function restoreEnv(snapshot: EnvSnapshot) {
|
|
for (const key of ENV_KEYS) {
|
|
setEnv(key, snapshot[key]);
|
|
}
|
|
}
|
|
|
|
async function loadFreshServerAuth() {
|
|
vi.resetModules();
|
|
return await import("@/core/auth/server");
|
|
}
|
|
|
|
describe("getServerSideUser", () => {
|
|
let saved: EnvSnapshot;
|
|
|
|
beforeEach(() => {
|
|
saved = snapshotEnv();
|
|
setEnv("DEER_FLOW_AUTH_DISABLED", undefined);
|
|
setEnv("DEER_FLOW_ENV", undefined);
|
|
setEnv("ENVIRONMENT", undefined);
|
|
setEnv("NEXT_PUBLIC_STATIC_WEBSITE_ONLY", undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
restoreEnv(saved);
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
test("bypasses gateway auth in static website mode", async () => {
|
|
setEnv("NEXT_PUBLIC_STATIC_WEBSITE_ONLY", "true");
|
|
const fetchSpy = vi.fn(() => {
|
|
throw new Error("fetch should not be called in static website mode");
|
|
});
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
|
|
const { getServerSideUser } = await loadFreshServerAuth();
|
|
|
|
await expect(getServerSideUser()).resolves.toEqual({
|
|
tag: "authenticated",
|
|
user: STATIC_WEBSITE_USER,
|
|
});
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("bypasses gateway auth in auth-disabled mode", async () => {
|
|
setEnv("DEER_FLOW_AUTH_DISABLED", "1");
|
|
const fetchSpy = vi.fn(() => {
|
|
throw new Error("fetch should not be called in auth-disabled mode");
|
|
});
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
|
|
const { getServerSideUser } = await loadFreshServerAuth();
|
|
|
|
await expect(getServerSideUser()).resolves.toEqual({
|
|
tag: "authenticated",
|
|
user: AUTH_DISABLED_USER,
|
|
});
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("does not enable auth-disabled mode in explicit production environments", async () => {
|
|
setEnv("DEER_FLOW_AUTH_DISABLED", "1");
|
|
setEnv("DEER_FLOW_ENV", "production");
|
|
|
|
const { isAuthDisabledMode } =
|
|
await import("@/core/auth/auth-disabled-user");
|
|
|
|
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",
|
|
});
|
|
});
|
|
});
|