diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py index dd5701083..aa49b4ffc 100644 --- a/backend/app/gateway/app.py +++ b/backend/app/gateway/app.py @@ -6,6 +6,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from app.gateway.auth_disabled import warn_if_auth_disabled_enabled from app.gateway.auth_middleware import AuthMiddleware from app.gateway.config import get_gateway_config from app.gateway.csrf_middleware import CSRFMiddleware, get_configured_cors_origins @@ -172,6 +173,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: startup_config = get_app_config() apply_logging_level(startup_config.log_level) logger.info("Configuration loaded successfully") + warn_if_auth_disabled_enabled() except Exception as e: error_msg = f"Failed to load configuration during gateway startup: {e}" logger.exception(error_msg) diff --git a/backend/app/gateway/auth_disabled.py b/backend/app/gateway/auth_disabled.py new file mode 100644 index 000000000..396de7129 --- /dev/null +++ b/backend/app/gateway/auth_disabled.py @@ -0,0 +1,54 @@ +"""Shared helpers for local/E2E auth-disabled mode.""" + +from __future__ import annotations + +import logging +import os +from types import SimpleNamespace + +AUTH_DISABLED_ENV_VAR = "DEER_FLOW_AUTH_DISABLED" +AUTH_DISABLED_USER_ID = "e2e-user" +AUTH_DISABLED_USER_EMAIL = "e2e@test.local" + +AUTH_SOURCE_SESSION = "session" +AUTH_SOURCE_INTERNAL = "internal" +AUTH_SOURCE_AUTH_DISABLED = "auth_disabled" + +_PRODUCTION_ENV_VARS: tuple[str, ...] = ("DEER_FLOW_ENV", "ENVIRONMENT") +_PRODUCTION_ENV_VALUES: frozenset[str] = frozenset({"prod", "production"}) + +logger = logging.getLogger(__name__) + + +def is_explicit_production_environment() -> bool: + return any(os.environ.get(name, "").strip().lower() in _PRODUCTION_ENV_VALUES for name in _PRODUCTION_ENV_VARS) + + +def is_auth_disabled_requested() -> bool: + return os.environ.get(AUTH_DISABLED_ENV_VAR) == "1" + + +def is_auth_disabled() -> bool: + return is_auth_disabled_requested() and not is_explicit_production_environment() + + +def warn_if_auth_disabled_enabled() -> None: + if not is_auth_disabled(): + return + + logger.warning( + "%s=1 is active: authentication is bypassed and anonymous requests run as synthetic admin user %r. Do not enable this in shared or production deployments.", + AUTH_DISABLED_ENV_VAR, + AUTH_DISABLED_USER_ID, + ) + + +def get_auth_disabled_user(): + return SimpleNamespace( + id=AUTH_DISABLED_USER_ID, + email=AUTH_DISABLED_USER_EMAIL, + password_hash=None, + system_role="admin", + needs_setup=False, + token_version=0, + ) diff --git a/backend/app/gateway/auth_middleware.py b/backend/app/gateway/auth_middleware.py index 6b6452264..6d71186a0 100644 --- a/backend/app/gateway/auth_middleware.py +++ b/backend/app/gateway/auth_middleware.py @@ -17,6 +17,13 @@ from starlette.responses import JSONResponse from starlette.types import ASGIApp from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse +from app.gateway.auth_disabled import ( + AUTH_SOURCE_AUTH_DISABLED, + AUTH_SOURCE_INTERNAL, + AUTH_SOURCE_SESSION, + get_auth_disabled_user, + is_auth_disabled, +) from app.gateway.authz import _ALL_PERMISSIONS, AuthContext from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME, get_internal_user, is_valid_internal_auth_token from deerflow.runtime.user_context import reset_current_user, set_current_user @@ -80,8 +87,38 @@ class AuthMiddleware(BaseHTTPMiddleware): if is_valid_internal_auth_token(request.headers.get(INTERNAL_AUTH_HEADER_NAME)): internal_user = get_internal_user() + auth_source = AUTH_SOURCE_SESSION + access_token = request.cookies.get("access_token") + # Non-public path: require session cookie - if internal_user is None and not request.cookies.get("access_token"): + if internal_user is not None: + user = internal_user + auth_source = AUTH_SOURCE_INTERNAL + elif access_token: + # Strict JWT validation: reject junk/expired tokens with 401 + # right here instead of silently passing through. This closes + # the "junk cookie bypass" gap (AUTH_TEST_PLAN test 7.5.8): + # without this, non-isolation routes like /api/models would + # accept any cookie-shaped string as authentication. + # + # We call the *strict* resolver so that fine-grained error + # codes (token_expired, token_invalid, user_not_found, …) + # propagate from AuthErrorCode, not get flattened into one + # generic code. BaseHTTPMiddleware doesn't let HTTPException + # bubble up, so we catch and render it as JSONResponse here. + from app.gateway.deps import get_current_user_from_request + + try: + user = await get_current_user_from_request(request) + except HTTPException as exc: + if not is_auth_disabled(): + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) + user = get_auth_disabled_user() + auth_source = AUTH_SOURCE_AUTH_DISABLED + elif is_auth_disabled(): + user = get_auth_disabled_user() + auth_source = AUTH_SOURCE_AUTH_DISABLED + else: return JSONResponse( status_code=401, content={ @@ -92,32 +129,12 @@ class AuthMiddleware(BaseHTTPMiddleware): }, ) - # Strict JWT validation: reject junk/expired tokens with 401 - # right here instead of silently passing through. This closes - # the "junk cookie bypass" gap (AUTH_TEST_PLAN test 7.5.8): - # without this, non-isolation routes like /api/models would - # accept any cookie-shaped string as authentication. - # - # We call the *strict* resolver so that fine-grained error - # codes (token_expired, token_invalid, user_not_found, …) - # propagate from AuthErrorCode, not get flattened into one - # generic code. BaseHTTPMiddleware doesn't let HTTPException - # bubble up, so we catch and render it as JSONResponse here. - from app.gateway.deps import get_current_user_from_request - - if internal_user is not None: - user = internal_user - else: - try: - user = await get_current_user_from_request(request) - except HTTPException as exc: - return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) - # Stamp both request.state.user (for the contextvar pattern) # and request.state.auth (so @require_permission's "auth is # None" branch short-circuits instead of running the entire # JWT-decode + DB-lookup pipeline a second time per request). request.state.user = user + request.state.auth_source = auth_source request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS) token = set_current_user(user) try: diff --git a/backend/app/gateway/csrf_middleware.py b/backend/app/gateway/csrf_middleware.py index f34882032..c9edb83b2 100644 --- a/backend/app/gateway/csrf_middleware.py +++ b/backend/app/gateway/csrf_middleware.py @@ -14,6 +14,8 @@ from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import JSONResponse from starlette.types import ASGIApp +from app.gateway.auth_disabled import is_auth_disabled + CSRF_COOKIE_NAME = "csrf_token" CSRF_HEADER_NAME = "X-CSRF-Token" CSRF_TOKEN_LENGTH = 64 # bytes @@ -38,6 +40,9 @@ def should_check_csrf(request: Request) -> bool: if request.method not in ("POST", "PUT", "DELETE", "PATCH"): return False + if is_auth_disabled(): + return False + path = request.url.path.rstrip("/") # Exempt /api/v1/auth/me endpoint if path == "/api/v1/auth/me": diff --git a/backend/app/gateway/deps.py b/backend/app/gateway/deps.py index 5739d217d..c192828d9 100644 --- a/backend/app/gateway/deps.py +++ b/backend/app/gateway/deps.py @@ -331,6 +331,17 @@ async def get_current_user_from_request(request: Request): Raises HTTPException 401 if not authenticated. """ + state = getattr(request, "state", None) + state_user = getattr(state, "user", None) + from app.gateway.auth_disabled import AUTH_SOURCE_AUTH_DISABLED, AUTH_SOURCE_INTERNAL, AUTH_SOURCE_SESSION + + if state_user is not None and getattr(state, "auth_source", None) in { + AUTH_SOURCE_SESSION, + AUTH_SOURCE_AUTH_DISABLED, + AUTH_SOURCE_INTERNAL, + }: + return state_user + from app.gateway.auth import decode_token from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse, TokenError, token_error_to_code diff --git a/backend/app/gateway/langgraph_auth.py b/backend/app/gateway/langgraph_auth.py index 202fab2d5..3ab3d2070 100644 --- a/backend/app/gateway/langgraph_auth.py +++ b/backend/app/gateway/langgraph_auth.py @@ -20,6 +20,7 @@ from langgraph_sdk import Auth from app.gateway.auth.errors import TokenError from app.gateway.auth.jwt import decode_token +from app.gateway.auth_disabled import AUTH_DISABLED_USER_ID, is_auth_disabled from app.gateway.deps import get_local_provider auth = Auth() @@ -38,6 +39,9 @@ def _check_csrf(request) -> None: if method.upper() not in _CSRF_METHODS: return + if is_auth_disabled(): + return + cookie_token = request.cookies.get("csrf_token") header_token = request.headers.get("x-csrf-token") @@ -66,6 +70,9 @@ async def authenticate(request): # are rejected early, even if the cookie carries a valid JWT. _check_csrf(request) + if is_auth_disabled(): + return AUTH_DISABLED_USER_ID + token = request.cookies.get("access_token") if not token: raise Auth.exceptions.HTTPException( diff --git a/backend/app/gateway/routers/auth.py b/backend/app/gateway/routers/auth.py index e57182c26..ee4f074d2 100644 --- a/backend/app/gateway/routers/auth.py +++ b/backend/app/gateway/routers/auth.py @@ -341,9 +341,19 @@ async def change_password(request: Request, response: Response, body: ChangePass - Re-issues session cookie with new token_version """ from app.gateway.auth.password import hash_password_async, verify_password_async + from app.gateway.auth_disabled import AUTH_SOURCE_AUTH_DISABLED user = await get_current_user_from_request(request) + if getattr(request.state, "auth_source", None) == AUTH_SOURCE_AUTH_DISABLED: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=AuthErrorResponse( + code=AuthErrorCode.INVALID_CREDENTIALS, + message="Password changes are not available when DEER_FLOW_AUTH_DISABLED=1.", + ).model_dump(), + ) + if user.password_hash is None: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="OAuth users cannot change password").model_dump()) diff --git a/backend/tests/test_auth_middleware.py b/backend/tests/test_auth_middleware.py index 726786ac9..838bf57af 100644 --- a/backend/tests/test_auth_middleware.py +++ b/backend/tests/test_auth_middleware.py @@ -4,6 +4,7 @@ import pytest from starlette.testclient import TestClient from app.gateway.auth_middleware import AuthMiddleware, _is_public +from app.gateway.csrf_middleware import CSRFMiddleware # ── _is_public unit tests ───────────────────────────────────────────────── @@ -88,7 +89,9 @@ def test_unknown_api_path_is_protected(): def _make_app(): """Create a minimal FastAPI app with AuthMiddleware for testing.""" - from fastapi import FastAPI + from fastapi import FastAPI, Request + + from deerflow.runtime.user_context import get_effective_user_id app = FastAPI() app.add_middleware(AuthMiddleware) @@ -98,8 +101,16 @@ def _make_app(): return {"status": "ok"} @app.get("/api/v1/auth/me") - async def auth_me(): - return {"id": "1", "email": "test@test.com"} + async def auth_me(request: Request): + from app.gateway.deps import get_current_user_from_request + + user = await get_current_user_from_request(request) + return { + "id": str(user.id), + "email": user.email, + "system_role": user.system_role, + "needs_setup": user.needs_setup, + } @app.get("/api/v1/auth/setup-status") async def setup_status(): @@ -109,6 +120,29 @@ def _make_app(): async def models_get(): return {"models": []} + @app.get("/api/whoami") + async def whoami(request: Request): + user = request.state.user + return { + "id": str(user.id), + "email": getattr(user, "email", None), + "system_role": getattr(user, "system_role", None), + "context_user_id": get_effective_user_id(), + } + + @app.get("/api/current-user-from-dep") + async def current_user_from_dep(request: Request): + from app.gateway.deps import get_current_user_from_request + + user = await get_current_user_from_request(request) + state_user = request.state.user + return { + "id": str(user.id), + "state_id": str(state_user.id), + "auth_source": request.state.auth_source, + "context_user_id": get_effective_user_id(), + } + @app.put("/api/mcp/config") async def mcp_put(): return {"ok": True} @@ -132,8 +166,24 @@ def _make_app(): return app +def _make_auth_csrf_app(): + """Create a minimal app with production middleware ordering.""" + from fastapi import FastAPI + + app = FastAPI() + app.add_middleware(AuthMiddleware) + app.add_middleware(CSRFMiddleware) + + @app.post("/api/threads/abc/runs/stream") + async def protected_mutation(): + return {"ok": True} + + return app + + @pytest.fixture -def client(): +def client(monkeypatch): + monkeypatch.delenv("DEER_FLOW_AUTH_DISABLED", raising=False) return TestClient(_make_app()) @@ -161,6 +211,139 @@ def test_protected_path_no_cookie_returns_401(client): assert body["detail"]["code"] == "not_authenticated" +def test_auth_disabled_allows_protected_path_without_cookie(monkeypatch): + monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1") + client = TestClient(_make_app()) + + res = client.get("/api/models") + + assert res.status_code == 200 + assert res.json() == {"models": []} + + +def test_auth_disabled_stamps_e2e_admin_user_without_cookie(monkeypatch): + monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1") + client = TestClient(_make_app()) + + res = client.get("/api/whoami") + + assert res.status_code == 200 + assert res.json() == { + "id": "e2e-user", + "email": "e2e@test.local", + "system_role": "admin", + "context_user_id": "e2e-user", + } + + +def test_auth_disabled_auth_me_reuses_middleware_user_without_cookie(monkeypatch): + monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1") + client = TestClient(_make_app()) + + res = client.get("/api/v1/auth/me") + + assert res.status_code == 200 + assert res.json() == { + "id": "e2e-user", + "email": "e2e@test.local", + "system_role": "admin", + "needs_setup": False, + } + + +def test_auth_disabled_does_not_clobber_valid_session_cookie(monkeypatch): + from types import SimpleNamespace + + async def fake_current_user(request): + return SimpleNamespace( + id="session-user", + email="session@test.local", + system_role="user", + needs_setup=False, + ) + + monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1") + monkeypatch.setattr("app.gateway.deps.get_current_user_from_request", fake_current_user) + client = TestClient(_make_app()) + + res = client.get("/api/whoami", cookies={"access_token": "valid-session"}) + + assert res.status_code == 200 + assert res.json() == { + "id": "session-user", + "email": "session@test.local", + "system_role": "user", + "context_user_id": "session-user", + } + + +def test_auth_disabled_does_not_clobber_internal_auth_identity(monkeypatch): + from app.gateway.internal_auth import create_internal_auth_headers + from deerflow.runtime.user_context import DEFAULT_USER_ID + + monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1") + client = TestClient(_make_app()) + + res = client.get( + "/api/current-user-from-dep", + headers=create_internal_auth_headers(), + ) + + assert res.status_code == 200 + assert res.json() == { + "id": DEFAULT_USER_ID, + "state_id": DEFAULT_USER_ID, + "auth_source": "internal", + "context_user_id": DEFAULT_USER_ID, + } + + +def test_auth_disabled_skips_csrf_for_state_changing_requests(monkeypatch): + monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1") + client = TestClient(_make_auth_csrf_app()) + + res = client.post("/api/threads/abc/runs/stream") + + assert res.status_code == 200 + assert res.json() == {"ok": True} + + +def test_auth_disabled_is_ignored_in_explicit_production_env(monkeypatch): + monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1") + monkeypatch.setenv("DEER_FLOW_ENV", "production") + client = TestClient(_make_app()) + + res = client.get("/api/models") + + assert res.status_code == 401 + + +def test_auth_disabled_startup_warning_when_effective(monkeypatch, caplog): + from app.gateway.auth_disabled import warn_if_auth_disabled_enabled + + monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1") + monkeypatch.delenv("DEER_FLOW_ENV", raising=False) + monkeypatch.delenv("ENVIRONMENT", raising=False) + + with caplog.at_level("WARNING", logger="app.gateway.auth_disabled"): + warn_if_auth_disabled_enabled() + + assert "authentication is bypassed" in caplog.text + assert "e2e-user" in caplog.text + + +def test_auth_disabled_startup_warning_suppressed_in_explicit_production_env(monkeypatch, caplog): + from app.gateway.auth_disabled import warn_if_auth_disabled_enabled + + monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1") + monkeypatch.setenv("ENVIRONMENT", "production") + + with caplog.at_level("WARNING", logger="app.gateway.auth_disabled"): + warn_if_auth_disabled_enabled() + + assert "authentication is bypassed" not in caplog.text + + def test_protected_path_with_junk_cookie_rejected(client): """Junk cookie → 401. Middleware strictly validates the JWT now (AUTH_TEST_PLAN test 7.5.8); it no longer silently passes bad diff --git a/backend/tests/test_langgraph_auth.py b/backend/tests/test_langgraph_auth.py index d2ee81051..1d2d71e7c 100644 --- a/backend/tests/test_langgraph_auth.py +++ b/backend/tests/test_langgraph_auth.py @@ -21,6 +21,7 @@ from langgraph_sdk import Auth from app.gateway.auth.config import AuthConfig, set_auth_config from app.gateway.auth.jwt import create_access_token, decode_token from app.gateway.auth.models import User +from app.gateway.auth_disabled import AUTH_DISABLED_USER_ID from app.gateway.langgraph_auth import add_owner_filter, authenticate # ── Helpers ─────────────────────────────────────────────────────────────── @@ -59,6 +60,14 @@ def test_no_cookie_raises_401(): assert "Not authenticated" in str(exc.value.detail) +def test_auth_disabled_skips_csrf_and_authenticates_e2e_user(monkeypatch): + monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1") + + identity = asyncio.run(authenticate(_req(method="POST"))) + + assert identity == AUTH_DISABLED_USER_ID + + def test_invalid_jwt_raises_401(): with pytest.raises(Auth.exceptions.HTTPException) as exc: asyncio.run(authenticate(_req({"access_token": "garbage"}))) diff --git a/frontend/playwright.real-backend.config.ts b/frontend/playwright.real-backend.config.ts index 9db673b90..091386686 100644 --- a/frontend/playwright.real-backend.config.ts +++ b/frontend/playwright.real-backend.config.ts @@ -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", diff --git a/frontend/src/core/auth/auth-disabled-user.ts b/frontend/src/core/auth/auth-disabled-user.ts new file mode 100644 index 000000000..2e26a8911 --- /dev/null +++ b/frontend/src/core/auth/auth-disabled-user.ts @@ -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() + ); +} diff --git a/frontend/src/core/auth/server.ts b/frontend/src/core/auth/server.ts index 5712f1e89..198ef087c 100644 --- a/frontend/src/core/auth/server.ts +++ b/frontend/src/core/auth/server.ts @@ -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 { }; } - 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, }; } diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index 4418a9e26..2ac1a1814 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -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(null); const loadedRunIdsRef = useRef>(new Set()); const runBeforeSeqRef = useRef>(new Map()); + const loadGenerationRef = useRef(0); const [loading, setLoading] = useState(false); const [messages, setMessages] = useState([]); 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({ 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, }); } diff --git a/frontend/tests/e2e-real-backend/auth-disabled-contract.spec.ts b/frontend/tests/e2e-real-backend/auth-disabled-contract.spec.ts new file mode 100644 index 000000000..23cb08d40 --- /dev/null +++ b/frontend/tests/e2e-real-backend/auth-disabled-contract.spec.ts @@ -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); + }); +}); diff --git a/frontend/tests/e2e-real-backend/real-backend-render.spec.ts b/frontend/tests/e2e-real-backend/real-backend-render.spec.ts index 97c367d41..19047445b 100644 --- a/frontend/tests/e2e-real-backend/real-backend-render.spec.ts +++ b/frontend/tests/e2e-real-backend/real-backend-render.spec.ts @@ -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, }); diff --git a/frontend/tests/e2e/chat.spec.ts b/frontend/tests/e2e/chat.spec.ts index 4650a3c2c..50ab3c871 100644 --- a/frontend/tests/e2e/chat.spec.ts +++ b/frontend/tests/e2e/chat.spec.ts @@ -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 }) => { diff --git a/frontend/tests/e2e/thread-history.spec.ts b/frontend/tests/e2e/thread-history.spec.ts index 19fce310a..9476ca4ab 100644 --- a/frontend/tests/e2e/thread-history.spec.ts +++ b/frontend/tests/e2e/thread-history.spec.ts @@ -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 }); diff --git a/frontend/tests/unit/core/auth/server.test.ts b/frontend/tests/unit/core/auth/server.test.ts index fea6ef830..1dd02da33 100644 --- a/frontend/tests/unit/core/auth/server.test.ts +++ b/frontend/tests/unit/core/auth/server.test.ts @@ -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); + }); });