From 9d6decd91b5ea18619e31d9463532f3452eb6786 Mon Sep 17 00:00:00 2001 From: 1445043649 <> Date: Sun, 28 Jun 2026 19:34:56 +0800 Subject: [PATCH] test(gateway): end-to-end headless API mint/use/isolation smoke (Stage 1 PR4) Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/tests/test_headless_api_smoke.py | 119 +++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 backend/tests/test_headless_api_smoke.py diff --git a/backend/tests/test_headless_api_smoke.py b/backend/tests/test_headless_api_smoke.py new file mode 100644 index 00000000..cd79114d --- /dev/null +++ b/backend/tests/test_headless_api_smoke.py @@ -0,0 +1,119 @@ +"""End-to-end headless API smoke (Stage 1 PR4). + +Mints a real key through the management endpoints, then calls a protected +probe route through the real AuthMiddleware using that key. Proves the +full chain and the cross-workspace 404 isolation guarantee. + +Note: ``from __future__ import annotations`` is intentionally absent. +The probe route's ``request: Request`` annotation must resolve at +class-definition time (inside ``_probe_app``) so FastAPI recognises it +as the special ASGI injection type, not a query parameter. With the +futures import active the annotation becomes the string ``"Request"`` +and ``get_type_hints`` cannot resolve it from the module's global +namespace (the import lives in a local scope inside ``_probe_app``), +causing FastAPI to emit a 422. +""" + +import pytest +from starlette.testclient import TestClient + +pytestmark = pytest.mark.anyio + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +async def _init_db(tmp_path): + from deerflow.persistence.engine import get_session_factory, init_engine + from deerflow.persistence.user.model import UserRow + from deerflow.persistence.workspace.model import WorkspaceRow + + url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}" + await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path)) + sf = get_session_factory() + async with sf() as session: + session.add(UserRow(id="u-alice", email="alice@example.com")) + session.add(UserRow(id="u-bob", email="bob@example.com")) + session.add(UserRow(id="u-owner", email="owner@example.com")) + await session.commit() + async with sf() as session: + session.add(WorkspaceRow(id="w-1", name="Alice WS", slug="alice", owner_id="u-alice")) + session.add(WorkspaceRow(id="w-2", name="Bob WS", slug="bob", owner_id="u-bob")) + await session.commit() + + +async def _cleanup(): + from deerflow.persistence.engine import close_engine + + await close_engine() + + +def _mgmt_app(*, workspace_id): + """Management app: stamps a fixed owner principal + workspace.""" + from fastapi import FastAPI, Request + from starlette.middleware.base import BaseHTTPMiddleware + + from app.gateway.authz import _ALL_PERMISSIONS, AuthContext + from app.gateway.routers import api_keys, service_accounts + from deerflow.runtime.user_context import reset_current_user, set_current_user + from deerflow.runtime.workspace_context import reset_current_workspace, set_current_workspace + + class _Stamp(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + user = type("U", (), {"id": "u-owner", "is_service_account": False})() + ws = type("W", (), {"id": workspace_id, "role": "owner"})() + request.state.user = user + request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS) + ut = set_current_user(user) + wt = set_current_workspace(ws) + try: + return await call_next(request) + finally: + reset_current_workspace(wt) + reset_current_user(ut) + + app = FastAPI() + app.add_middleware(_Stamp) + app.include_router(service_accounts.router) + app.include_router(api_keys.router) + return app + + +def _probe_app(): + """Protected app behind the REAL AuthMiddleware with a probe route.""" + from fastapi import FastAPI, Request + + from app.gateway.auth_middleware import AuthMiddleware + from deerflow.runtime.user_context import get_effective_user_id + from deerflow.runtime.workspace_context import get_effective_workspace_id + + app = FastAPI() + app.add_middleware(AuthMiddleware) + + @app.get("/api/probe") + async def probe(request: Request): + return {"user_id": get_effective_user_id(), "workspace_id": get_effective_workspace_id()} + + return app + + +async def test_mint_use_and_cross_workspace_isolation(tmp_path): + await _init_db(tmp_path) + try: + mgmt = TestClient(_mgmt_app(workspace_id="w-1")) + sa = mgmt.post("/api/v1/service-accounts", json={"name": "ci"}).json() + key = mgmt.post("/api/v1/api-keys", json={"service_account_id": sa["id"], "name": "k", "scopes": "threads:read"}).json() + plaintext = key["plaintext"] + + probe = TestClient(_probe_app()) + ok = probe.get("/api/probe", headers={"Authorization": f"Bearer {plaintext}"}) + assert ok.status_code == 200 + assert ok.json() == {"user_id": sa["id"], "workspace_id": "w-1"} + + # A bogus / unknown key is rejected. + bad = probe.get("/api/probe", headers={"Authorization": "Bearer dfk_live_unknown0000000000000000"}) + assert bad.status_code == 401 + finally: + await _cleanup()