fix: align auth-disabled mode and mock history loading (#3471)

* fix: align auth-disabled mode and mock history loading

* fix: address auth-disabled review feedback

* test: cover auth-disabled backend contract

* style: format frontend tests

* fix: address follow-up review comments
This commit is contained in:
DanielWalnut
2026-06-10 16:11:00 +08:00
committed by GitHub
parent a57d05fe0a
commit 2b795265e7
18 changed files with 528 additions and 52 deletions
+7 -3
View File
@@ -7,8 +7,9 @@ import { defineConfig, devices } from "@playwright/test";
* so the mock-based suite is untouched.
*
* Two webServers are started: the replay gateway (:8011) and the frontend
* (:3000, pointed at the gateway). Auth uses a throwaway test account the spec
* registers at runtime — no secrets.
* (:3000, pointed at the gateway). Auth-disabled mode is enabled on both
* servers so the no-cookie e2e contract is covered; specs that need session
* cookies still register a throwaway test account at runtime.
*/
export default defineConfig({
testDir: "./tests/e2e-real-backend",
@@ -38,7 +39,10 @@ export default defineConfig({
// Mount the test-only run/message seeder used by multi-run-order.spec.ts
// (#3352). The endpoint exists only on this replay gateway, never in the
// production app.
env: { DEERFLOW_ENABLE_TEST_SEED: "1" },
env: {
DEERFLOW_ENABLE_TEST_SEED: "1",
DEER_FLOW_AUTH_DISABLED: "1",
},
},
{
command: "pnpm build && pnpm start",
@@ -0,0 +1,23 @@
import type { User } from "./types";
export const AUTH_DISABLED_USER: User = {
id: "e2e-user",
email: "e2e@test.local",
system_role: "admin",
needs_setup: false,
};
const PRODUCTION_ENV_VALUES = new Set(["prod", "production"]);
function isExplicitProductionEnvironment() {
return ["DEER_FLOW_ENV", "ENVIRONMENT"].some((name) =>
PRODUCTION_ENV_VALUES.has((process.env[name] ?? "").trim().toLowerCase()),
);
}
export function isAuthDisabledMode() {
return (
process.env.DEER_FLOW_AUTH_DISABLED === "1" &&
!isExplicitProductionEnvironment()
);
}
+3 -7
View File
@@ -2,6 +2,7 @@ import { cookies } from "next/headers";
import { isStaticWebsiteOnly } from "../static-mode";
import { AUTH_DISABLED_USER, isAuthDisabledMode } from "./auth-disabled-user";
import { getGatewayConfig } from "./gateway-config";
import { STATIC_WEBSITE_USER } from "./static-user";
import { type AuthResult, userSchema } from "./types";
@@ -20,15 +21,10 @@ export async function getServerSideUser(): Promise<AuthResult> {
};
}
if (process.env.DEER_FLOW_AUTH_DISABLED === "1") {
if (isAuthDisabledMode()) {
return {
tag: "authenticated",
user: {
id: "e2e-user",
email: "e2e@test.local",
system_role: "admin",
needs_setup: false,
},
user: AUTH_DISABLED_USER,
};
}
+41 -14
View File
@@ -364,7 +364,7 @@ export function useThreadStream({
loadMore: loadMoreHistory,
loading: isHistoryLoading,
appendMessages,
} = useThreadHistory(onStreamThreadId ?? "");
} = useThreadHistory(onStreamThreadId ?? "", { enabled: !isMock });
// Keep listeners ref updated with latest callbacks
useEffect(() => {
@@ -854,8 +854,15 @@ export function useThreadStream({
} as const;
}
export function useThreadHistory(threadId: string) {
const runs = useThreadRuns(threadId);
type ThreadHistoryOptions = {
enabled?: boolean;
};
export function useThreadHistory(
threadId: string,
{ enabled = true }: ThreadHistoryOptions = {},
) {
const runs = useThreadRuns(threadId, { enabled });
const threadIdRef = useRef(threadId);
const runsRef = useRef(runs.data ?? []);
const indexRef = useRef(-1);
@@ -864,10 +871,15 @@ export function useThreadHistory(threadId: string) {
const loadingRunIdRef = useRef<string | null>(null);
const loadedRunIdsRef = useRef<Set<string>>(new Set());
const runBeforeSeqRef = useRef<Map<string, number>>(new Map());
const loadGenerationRef = useRef(0);
const [loading, setLoading] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const loadMessages = useCallback(async () => {
if (!enabled) {
return;
}
const loadGeneration = loadGenerationRef.current;
if (loadingRef.current) {
const pendingRunIndex = findLatestUnloadedRunIndex(
runsRef.current,
@@ -921,12 +933,15 @@ export function useThreadHistory(threadId: string) {
}).then((res) => {
return res.json();
});
if (
loadGenerationRef.current !== loadGeneration ||
threadIdRef.current !== requestThreadId
) {
return;
}
const _messages = result.data
.filter((m) => !m.metadata.caller?.startsWith("middleware:"))
.map((m) => m.content);
if (threadIdRef.current !== requestThreadId) {
return;
}
setMessages((prev) =>
dedupeMessagesByIdentity([..._messages, ...prev]),
);
@@ -961,16 +976,19 @@ export function useThreadHistory(threadId: string) {
} catch (err) {
console.error(err);
} finally {
loadingRef.current = false;
loadingRunIdRef.current = null;
setLoading(false);
if (loadGenerationRef.current === loadGeneration) {
loadingRef.current = false;
loadingRunIdRef.current = null;
setLoading(false);
}
}
}, []);
}, [enabled]);
useEffect(() => {
const threadChanged = threadIdRef.current !== threadId;
threadIdRef.current = threadId;
if (threadChanged) {
if (!enabled || threadChanged) {
loadGenerationRef.current += 1;
runsRef.current = [];
indexRef.current = -1;
pendingLoadRef.current = false;
@@ -982,6 +1000,10 @@ export function useThreadHistory(threadId: string) {
setMessages([]);
}
if (!enabled) {
return;
}
if (runs.data && runs.data.length > 0) {
runsRef.current = runs.data ?? [];
indexRef.current = findLatestUnloadedRunIndex(
@@ -992,14 +1014,15 @@ export function useThreadHistory(threadId: string) {
loadMessages().catch(() => {
toast.error("Failed to load thread history.");
});
}, [threadId, runs.data, loadMessages]);
}, [enabled, threadId, runs.data, loadMessages]);
const appendMessages = useCallback((_messages: Message[]) => {
setMessages((prev) => {
return dedupeMessagesByIdentity([...prev, ..._messages]);
});
}, []);
const hasMore = indexRef.current >= 0 || !runs.data;
const hasMore =
enabled && Boolean(threadId) && (indexRef.current >= 0 || !runs.data);
return {
runs: runs.data,
messages,
@@ -1077,7 +1100,10 @@ export function useThreads(
});
}
export function useThreadRuns(threadId?: string) {
export function useThreadRuns(
threadId?: string,
{ enabled = true }: { enabled?: boolean } = {},
) {
const apiClient = getAPIClient();
return useQuery<Run[]>({
queryKey: ["thread", threadId],
@@ -1088,6 +1114,7 @@ export function useThreadRuns(threadId?: string) {
const response = await apiClient.runs.list(threadId);
return response;
},
enabled: enabled && Boolean(threadId),
refetchOnWindowFocus: false,
});
}
@@ -0,0 +1,16 @@
import { expect, test } from "@playwright/test";
import { AUTH_DISABLED_USER } from "../../src/core/auth/auth-disabled-user";
const APP = "http://localhost:3000";
test.describe("auth-disabled contract (real backend)", () => {
test("gateway /auth/me returns the frontend synthetic user without a cookie", async ({
context,
}) => {
const resp = await context.request.get(`${APP}/api/v1/auth/me`);
expect(resp.status(), await resp.text()).toBe(200);
await expect(resp.json()).resolves.toEqual(AUTH_DISABLED_USER);
});
});
@@ -101,10 +101,11 @@ test.describe("real backend render (replay, no API key)", () => {
EXPECTED_SUGGESTION,
"fixture should contain a suggestions turn (re-record; the record spec waits for /suggestions)",
).not.toBe("");
await expect(page.getByText(EXPECTED_TITLE)).toBeVisible({
const chat = page.locator("#chat");
await expect(chat.getByText(EXPECTED_TITLE)).toBeVisible({
timeout: 60_000,
});
await expect(page.getByText(EXPECTED_SUGGESTION)).toBeVisible({
await expect(chat.getByText(EXPECTED_SUGGESTION)).toBeVisible({
timeout: 30_000,
});
+1
View File
@@ -12,6 +12,7 @@ test.describe("Chat workspace", () => {
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("button", { name: /load more/i })).toBeHidden();
});
test("can type a message in the input box", async ({ page }) => {
+79
View File
@@ -18,6 +18,7 @@ const THREADS = [
updated_at: "2025-06-02T12:00:00Z",
},
];
const DEMO_THREAD_ID = "7cfa5f8f-a2f8-47ad-acbd-da7137baf990";
test.describe("Thread history", () => {
test("sidebar shows existing threads", async ({ page }) => {
@@ -61,6 +62,84 @@ test.describe("Thread history", () => {
).toBeVisible({ timeout: 15_000 });
});
test("mock thread does not load real backend run history", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: DEMO_THREAD_ID,
title: "Forecasting 2026 Trends and Opportunities",
updated_at: "2025-06-01T12:00:00Z",
messages: [
{
type: "human",
id: `run-human-${DEMO_THREAD_ID}`,
content: [
{
type: "text",
text: "This run-message endpoint should not be called.",
},
],
},
],
},
],
});
const backendRunHistoryUrls: string[] = [];
await page.route(
/\/api\/langgraph\/threads\/[^/]+\/runs(?:\?|$)/,
(route) => {
if (
route.request().method() === "GET" &&
route
.request()
.url()
.includes(`/api/langgraph/threads/${DEMO_THREAD_ID}/runs`)
) {
backendRunHistoryUrls.push(route.request().url());
return route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({
error: "mock=true must not load real runs",
}),
});
}
return route.fallback();
},
);
await page.route(
/\/api\/threads\/[^/]+\/runs\/[^/]+\/messages(?:\?|$)/,
(route) => {
if (
route.request().method() === "GET" &&
route.request().url().includes(`/api/threads/${DEMO_THREAD_ID}/runs/`)
) {
backendRunHistoryUrls.push(route.request().url());
return route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({
error: "mock=true must not load real run messages",
}),
});
}
return route.fallback();
},
);
await page.goto(`/workspace/chats/${DEMO_THREAD_ID}?mock=true`);
await expect(
page.getByText("What might be the trends and opportunities in 2026?"),
).toBeVisible({ timeout: 15_000 });
await expect(
page.getByText("I've created a modern, minimalist website"),
).toBeVisible();
expect(backendRunHistoryUrls).toEqual([]);
});
test("chats list page shows all threads", async ({ page }) => {
mockLangGraphAPI(page, { threads: THREADS });
@@ -1,5 +1,6 @@
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", () => ({
@@ -10,6 +11,8 @@ vi.mock("next/headers", () => ({
const ENV_KEYS = [
"DEER_FLOW_AUTH_DISABLED",
"DEER_FLOW_ENV",
"ENVIRONMENT",
"NEXT_PUBLIC_STATIC_WEBSITE_ONLY",
] as const;
@@ -51,6 +54,8 @@ describe("getServerSideUser", () => {
beforeEach(() => {
saved = snapshotEnv();
setEnv("DEER_FLOW_AUTH_DISABLED", undefined);
setEnv("DEER_FLOW_ENV", undefined);
setEnv("ENVIRONMENT", undefined);
setEnv("NEXT_PUBLIC_STATIC_WEBSITE_ONLY", undefined);
});
@@ -74,4 +79,30 @@ describe("getServerSideUser", () => {
});
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);
});
});