deer-flow/backend/tests/test_pat_auth.py
Zeren Wang 5951c89b5b
feat(projects): project workspaces with scoped chats and thread membership (#5265)
* feat(projects): project workspaces with scoped chats and thread membership

Backend:
- projects table model and migration; fail-closed ProjectRepository with
  ownership checks, CRUD/archive/restore/delete router, and atomic thread
  move between projects
- threads_meta.project_id column exposed as reserved deerflow_project_id
  metadata; project-aware thread create/search with pagination bounds and
  membership echoed in create responses
- first-run admission assigns the project only at genuine first run, seeded
  at write time and dropped when invalid; serialized against project
  deletion and thread assignment
- branch creation inherits the source thread's project membership (an
  archived/deleted project degrades the branch to unassigned instead of
  failing the request)

Frontend:
- projects data layer, thread move API, and sidebar projects section with
  flat/grouped modes, archived-project threads, and stable virtual-list
  offsets
- project detail page with project-scoped new chat
  (/workspace/chats/new?project=) and paginated thread list
- move-to-project thread menu, new-project dialog, archived-project gates
- project-scoped new chats pre-create the thread with membership before the
  first submit or /goal set, so runs never proceed outside the project
- goal-set preparation is fenced against conversation switches: a stale
  continuation is dropped instead of saving the goal or launching the
  abandoned submission on the newly opened conversation
- project thread lists join thread lifecycle invalidations (stop, pin) so
  an open project page never keeps stale titles, recency, or pagination

* fix(chats): keep archive undo toast when the sidebar row unmounts

The archive success toast was fired from per-mutate callbacks passed to
mutation.mutate. React Query drops those handlers when the observer
component unmounts before the mutation settles; archiving the open chat
removes its sidebar row mid-flight, so the undo toast never appeared and
the e2e archive-undo test timed out waiting for it.

Move the success/error handlers to the mutation level (useArchiveThread
options, same pattern as useMoveThreadToProject) where callbacks are
delivered even after the originating row unmounts.

* fix(projects): pin project thread listing contract and exclude archived chats

GET /api/projects/{id}/threads returned the thread store row verbatim
(list[dict], no response_model): user_id/assistant_id leaked, any future
ThreadMetaRow column would auto-leak, and the OpenAPI schema was empty.
Return a narrow ProjectThreadResponse (the exact fields ProjectThread
declares) with the same metadata secret redaction the surrounding thread
endpoints get from _MetadataRedactingResponse.

The listing also ran search() without the archived filter, so a retired
chat rendered as a normal row on the project page while the sidebar hid
it. Search archived=False to mirror the sidebar's archived:false lists;
restore stays on the global Archived tab.

Both regressions pinned by new router tests: wire-shape allowlist and
archived-member exclusion.

* docs(migrations): record the 0019/0020 chain against the bootstrap reservation

The tree now chains 0018 -> 0019_projects -> 0020_threads_meta_project_id,
so migrations/AGENTS.md was stale twice over: the revision index stopped at
0018 and the rolling-forward section still claimed the tree 'deliberately
remains at 0018'.

Document the new head and record the intentional numeric-prefix reuse of
0019: 0019_projects is in-chain while 0019_thread_incarnations stays the
reserved, allowlisted out-of-tree rollout id. The owning rollout revision
must re-parent onto this tree's head when it merges so alembic never sees
two heads off 0018; bootstrap.py now cross-references that note next to
_FORWARD_COMPATIBLE_REVISION.

* fix(chats): invalidate project thread lists on archive/restore

useArchiveThread refreshed the infinite sidebar cache, threads/search and
the per-thread metadata cache but not the project-scoped list
([...PROJECTS_QUERY_KEY, 'threads', id]) this PR adds — the one thread
mutation not wired to that key, after usePinThread, useRenameThread,
useDeleteThread, useMoveThreadToProject and invalidateStoppedThreadCaches.

An archive from a sidebar row while a project page is open therefore left
the archived chat rendered as a normal row until remount (and undo left it
missing). Invalidate the prefix in the mutation-level success handler.

Regression test asserts the project-list prefix is invalidated on success.

* fix(projects): fetch project discovery only in grouped sidebar mode

RecentChatList mounted two useProjects queries per sidebar render, but
knownProjectIds is consumed only by the grouped-mode exclusion filter; in
the default flat mode every page load paid two GET /api/projects?status=
round trips for data nothing read. Gate both queries on grouped mode —
GroupedProjectList fetches the same keys when the toggle is on and
TanStack dedupes the observers.

Also set retry: false on useProject: a deleted or foreign project 404s
deterministically, and the page renders a dedicated not-found state for
it, so the default 1s/2s/4s retry backoff kept deep links in 'loading'
for ~7s before that state appeared. Matches useThreadMetadata /
useThreadTokenUsage.

* fix(threads): fail closed on project-scoped create in memory mode

MemoryThreadMetaStore.create accepted project_id and silently ignored it,
making memory mode the one membership path that fails open: POST
/api/threads with a project id returned 200 and the run started
unassigned, violating the invariant that a run never proceeds outside the
selected project (the SQL store raises ProjectNotAssignableError inside
the insert transaction for the same request).

Raise ProjectNotAssignableError whenever project_id is present so the
router's existing 404 mapping applies, the frontend keeps the composer
text for a retry, and memory mode behaves exactly like SQL mode.
set_project already reports rejection; create now matches it.

Store-level test (raises, nothing persisted, project filter stays empty,
unscoped creates still work) plus a router-level test asserting the 404
and that no row is left behind.

* fix(projects): window the project page thread list

ProjectThreadsSection rendered every loaded page as a plain Link row, so a
long-lived project accumulated unbounded DOM on the page's scroll surface:
each load-more appended another 100 rows and every formatTimeAgo tick
re-rendered the whole list.

Reuse VirtualThreadList (now generic over any row shape with a
thread_id), pointing its scroll parent at this page's ScrollArea viewport
via the shared [data-slot="scroll-area-viewport"] selector used by
/workspace/chats; under the 60-row threshold it falls back to the plain
render, so small projects are unchanged.

* fix(projects): restore row dividers and pin them with a render test

The row class template literal concatenated transition-colors directly
with the conditional border-b token, so non-final rows rendered the
invalid class 'transition-colorsborder-b' and lost both the divider and
the transition. Compose the row classes with cn() and a boolean guard
instead.

The section moved out of page.tsx into a testable component so the row
markup finally has coverage: a DOM test asserts every row except the
final data row carries border-b (index-based, not last: — correct under
virtualization where the last mounted row is not the last data row), and
the untitled fallback plus load-more button render for a partial page.

* fix(projects): validate forward schemas and fence membership reads
2026-09-08 17:00:26 +08:00

776 lines
32 KiB
Python

"""Integration tests for PAT authentication (#4849).
Covers credential precedence in AuthMiddleware, the CSRF boundary for
Bearer-authenticated requests and for the safe-method stream join (#5092),
scope intersection, PAT management routes, and the self-protection rules
(a PAT may not manage PATs or auth state).
"""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
import pytest
from fastapi import Depends, FastAPI, Request
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from starlette.testclient import TestClient
import deerflow.persistence.models # noqa: F401 (register every table)
from app.gateway.auth_disabled import AUTH_SOURCE_PAT, AUTH_SOURCE_SESSION
from app.gateway.auth_middleware import AuthMiddleware
from app.gateway.authz import require_cancel_permission_if
from app.gateway.csrf_middleware import CSRFMiddleware
from app.gateway.routers.auth import router as auth_router
from app.gateway.run_models import RunCreateRequest
from deerflow.config.authorization_config import AuthorizationConfig
from deerflow.persistence.base import Base
from deerflow.persistence.personal_access_tokens import PersonalAccessTokenRepository
TEST_JWT_SECRET = "test-pat-jwt-secret-0123456789abcdef"
class _FakeProvider:
"""Minimal LocalAuthProvider stand-in: resolves users by id."""
def __init__(self, *users) -> None:
self._users = {str(user.id): user for user in users}
async def get_user(self, user_id: str):
return self._users.get(str(user_id))
def _fake_user(user_id: str = "user-1", *, system_role: str = "user"):
return SimpleNamespace(
id=user_id,
email=f"{user_id}@example.com",
system_role=system_role,
needs_setup=False,
token_version=0,
oauth_provider=None,
password_hash=None,
)
@pytest.fixture(autouse=True)
def _default_route_authorization_config(monkeypatch):
monkeypatch.setattr(
"app.gateway.authz._get_route_authorization_config",
lambda: AuthorizationConfig(),
)
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "")
from app.gateway.auth.config import AuthConfig, set_auth_config
set_auth_config(AuthConfig(jwt_secret=TEST_JWT_SECRET, token_expiry_days=7))
def _make_pat_app(with_pat_repo: bool = True):
app = FastAPI()
# Production order: AuthMiddleware added first (inner), CSRF last (outer).
app.add_middleware(AuthMiddleware)
app.add_middleware(CSRFMiddleware)
app.include_router(auth_router)
@app.get("/api/threads/whoami")
async def whoami(request: Request):
return {"user_id": str(request.state.user.id), "auth_source": request.state.auth_source}
@app.get("/api/admin-check")
async def admin_check(request: Request):
from app.gateway.deps import is_admin_user
return {"is_admin": await is_admin_user(request)}
@app.post("/api/threads/{thread_id}/runs/stream")
async def run_stream(request: Request):
return {"ok": True, "permissions": list(request.state.auth.permissions)}
@app.delete("/api/memory")
async def memory_delete(request: Request):
return {"deleted": True}
@app.delete("/api/threads/{thread_id}")
async def thread_delete(request: Request):
return {"deleted": True}
# Mirrors the real stateless run entrypoint (routers/runs.py), including
# the @require_permission decorator, so scope enforcement is exercised
# end-to-end through the middleware's permission intersection.
from app.gateway.authz import require_permission
@app.post("/api/runs/stream")
@require_permission("runs", "create")
async def stateless_run_stream(request: Request):
return {"ok": True}
# Mirrors the real cancel-then-stream entrypoint (thread_runs.py
# stream_existing_run): runs:read at the decorator, plus the real
# conditional runs:cancel check the handler applies when `action` is set.
from app.gateway.routers.thread_runs import require_cancel_permission_when_action
@app.post("/api/threads/{thread_id}/runs/{run_id}/stream")
@require_permission("runs", "read")
async def cancel_then_stream(thread_id: str, run_id: str, request: Request, action: str | None = None):
require_cancel_permission_when_action(request, action)
return {"ok": True}
# Mirrors the real GET-only join surface (thread_runs.py
# join_existing_run_stream): registers the production route dependency
# that rejects cancel actions before thread ownership or run lookup, so
# the guard is exercised through the production middleware order above.
from app.gateway.routers.thread_runs import _reject_get_stream_action
@app.get(
"/api/threads/{thread_id}/runs/{run_id}/stream",
dependencies=[Depends(_reject_get_stream_action)],
)
@require_permission("runs", "read")
async def join_stream(thread_id: str, run_id: str, request: Request):
return {"ok": True}
# Mirrors the real run-creation entrypoints (thread_runs.py / runs.py):
# runs:create at the decorator, plus the cancel-capability gate that
# start_run applies to mutating multitask strategies. RunCreateRequest is
# imported at module level — FastAPI resolves body annotations against
# module globals under postponed annotation evaluation.
@app.post("/api/threads/{thread_id}/runs")
@require_permission("runs", "create")
async def create_run(thread_id: str, body: RunCreateRequest, request: Request):
require_cancel_permission_if(request, body.multitask_strategy != "reject")
return {"ok": True}
return app
@pytest.fixture
def pat_env(tmp_path, monkeypatch):
"""Engine + PAT repo + patched user provider; returns (client, repo)."""
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/pats.db", poolclass=NullPool)
asyncio.run(_create_tables(engine))
repo = PersonalAccessTokenRepository(async_sessionmaker(engine, expire_on_commit=False))
fake_provider = _FakeProvider(_fake_user("user-1"), _fake_user("user-2"), _fake_user("admin-1", system_role="admin"))
monkeypatch.setattr("app.gateway.deps.get_local_provider", lambda: fake_provider)
monkeypatch.setattr("app.gateway.routers.auth.get_local_provider", lambda: fake_provider)
app = _make_pat_app()
app.state.pat_repo = repo
return app, repo, engine
async def _create_tables(engine) -> None:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
@pytest.fixture
def client(pat_env):
app, repo, engine = pat_env
with TestClient(app) as test_client:
yield test_client
asyncio.run(engine.dispose())
def _session_cookie(client: TestClient, user_id: str = "user-1", token_version: int = 0) -> str:
from app.gateway.auth import create_access_token
token = create_access_token(user_id, token_version=token_version)
client.cookies.set("access_token", token)
return token
def _create_pat(client: TestClient, *, name: str = "test-token", scopes: list[str] | None = None, user_id: str = "user-1", expires_in_days: int | None = None) -> dict:
"""Create a PAT via the management API with session auth + CSRF pair."""
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, CSRF_HEADER_NAME, generate_csrf_token
_session_cookie(client, user_id=user_id)
csrf = generate_csrf_token()
client.cookies.set(CSRF_COOKIE_NAME, csrf)
payload = {"name": name, "scopes": scopes or ["runs:read", "threads:read"]}
if expires_in_days is not None:
payload["expires_in_days"] = expires_in_days
response = client.post(
"/api/v1/auth/pats",
json=payload,
headers={CSRF_HEADER_NAME: csrf},
)
assert response.status_code == 201, response.text
payload = response.json()
assert payload["token"].startswith("dfp_")
return payload
# ── Middleware precedence (#4849 point 3) ─────────────────────────────────
def test_valid_pat_authenticates_without_cookie(client):
created = _create_pat(client)
client.cookies.clear()
response = client.get("/api/threads/whoami", headers={"Authorization": f"Bearer {created['token']}"})
assert response.status_code == 200
assert response.json() == {"user_id": "user-1", "auth_source": AUTH_SOURCE_PAT}
def test_invalid_bearer_never_falls_back_to_session_cookie(client):
_session_cookie(client) # victim session is present and valid
response = client.get("/api/threads/whoami", headers={"Authorization": "Bearer dfp_not-a-real-token"})
assert response.status_code == 401
assert response.json()["detail"] == "Invalid token"
def test_non_bearer_authorization_scheme_is_rejected(client):
_session_cookie(client)
response = client.get("/api/threads/whoami", headers={"Authorization": "Basic dXNlcjpwYXNz"})
assert response.status_code == 401
def test_valid_pat_takes_precedence_over_session_cookie(client):
created = _create_pat(client) # sets a session cookie too
response = client.get("/api/threads/whoami", headers={"Authorization": f"Bearer {created['token']}"})
assert response.status_code == 200
assert response.json()["auth_source"] == AUTH_SOURCE_PAT
def test_no_bearer_header_keeps_session_behavior(client):
_session_cookie(client)
response = client.get("/api/threads/whoami")
assert response.status_code == 200
assert response.json()["auth_source"] == AUTH_SOURCE_SESSION
def test_revoked_pat_is_rejected_immediately(client):
created = _create_pat(client)
delete = client.delete(f"/api/v1/auth/pats/{created['id']}", headers={"X-CSRF-Token": client.cookies.get("csrf_token")})
assert delete.status_code == 200, delete.text
client.cookies.clear()
response = client.get("/api/threads/whoami", headers={"Authorization": f"Bearer {created['token']}"})
assert response.status_code == 401
def test_pat_with_unresolvable_user_is_rejected(client, pat_env):
app, repo, _engine = pat_env
# Row owned by a user the provider cannot resolve (deleted user).
from app.gateway.auth.pat import generate_pat_token, pat_token_digest
token = generate_pat_token()
asyncio.run(repo.create(user_id="user-deleted", name="orphan", scopes=["runs:read"], token_digest=pat_token_digest(token)))
client.cookies.clear()
response = client.get("/api/threads/whoami", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 401
def test_pat_without_durable_store_is_rejected():
from fastapi import FastAPI
app = FastAPI()
app.add_middleware(AuthMiddleware)
@app.get("/api/threads/whoami")
async def whoami(request): # pragma: no cover - never reached
return {}
with TestClient(app) as bare_client:
response = bare_client.get("/api/threads/whoami", headers={"Authorization": "Bearer dfp_whatever"})
assert response.status_code == 401
# ── Scope intersection ────────────────────────────────────────────────────
def test_pat_scopes_intersect_user_permissions(client):
created = _create_pat(client, scopes=["runs:read"])
client.cookies.clear()
response = client.post("/api/threads/t1/runs/stream", headers={"Authorization": f"Bearer {created['token']}"})
assert response.status_code == 200
permissions = response.json()["permissions"]
assert "runs:read" in permissions
assert "runs:create" not in permissions
assert "threads:read" not in permissions
# ── CSRF posture (#4849 point 4) ──────────────────────────────────────────
def test_bearer_request_skips_double_submit(client):
created = _create_pat(client)
client.cookies.clear() # no csrf_token cookie, no X-CSRF-Token header
response = client.post("/api/threads/t1/runs/stream", headers={"Authorization": f"Bearer {created['token']}"})
assert response.status_code == 200
def test_garbage_bearer_riding_cookie_dies_at_auth_not_csrf(client):
_session_cookie(client)
response = client.post("/api/threads/t1/runs/stream", headers={"Authorization": "Bearer garbage"})
# 401 from AuthMiddleware (invalid credential), not 403 from CSRF.
assert response.status_code == 401
def test_empty_authorization_header_is_present_and_dies_at_auth_not_csrf(client):
_session_cookie(client)
response = client.post("/api/threads/t1/runs/stream", headers={"Authorization": ""})
# An explicitly empty header is present-but-invalid: the same 401 from
# AuthMiddleware as any other invalid credential, never a CSRF 403.
assert response.status_code == 401
def test_auth_endpoint_origin_check_not_bypassed_by_bearer(client):
response = client.post(
"/api/v1/auth/login/local",
json={"email": "a@b.c", "password": "whatever1!"},
headers={"Origin": "https://evil.example", "Authorization": "Bearer dfp_garbage"},
)
assert response.status_code == 403
assert response.json()["detail"] == "Cross-site auth request denied."
def test_session_get_stream_action_dies_at_route_gate_not_csrf(client):
"""#5092 defence-in-depth, end-to-end through the production middleware
order: SameSite=Lax still attaches the session cookie to a cross-site
top-level GET, and CSRF exempts safe methods — so the route gate is the
only thing standing between that navigation and a run cancel. An
authenticated GET with ?action=interrupt is answered 405 + Allow: POST by
the real _reject_get_stream_action dependency, while the same
unauthenticated GET dies at AuthMiddleware's 401 before any route logic
runs."""
_session_cookie(client)
denied = client.get("/api/threads/t1/runs/run-1/stream?action=interrupt")
assert denied.status_code == 405
assert denied.headers["allow"] == "POST"
assert denied.json()["detail"] == "`action` is only supported on POST requests"
client.cookies.clear()
unauthed = client.get("/api/threads/t1/runs/run-1/stream?action=interrupt")
assert unauthed.status_code == 401
# ── Management routes + self-protection (#4849 point 6) ───────────────────
def test_create_returns_show_once_token_and_list_hides_it(client):
created = _create_pat(client)
listed = client.get("/api/v1/auth/pats")
assert listed.status_code == 200
entries = listed.json()
assert [entry["id"] for entry in entries] == [created["id"]]
assert "token" not in entries[0]
assert "token_digest" not in entries[0]
def test_create_rejects_unknown_scope(client):
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, CSRF_HEADER_NAME, generate_csrf_token
_session_cookie(client)
csrf = generate_csrf_token()
client.cookies.set(CSRF_COOKIE_NAME, csrf)
response = client.post("/api/v1/auth/pats", json={"name": "bad", "scopes": ["runs:write"]}, headers={CSRF_HEADER_NAME: csrf})
assert response.status_code == 400
assert "Unknown PAT scopes" in response.json()["detail"]
def test_create_rejects_whitespace_only_name(client):
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, CSRF_HEADER_NAME, generate_csrf_token
_session_cookie(client)
csrf = generate_csrf_token()
client.cookies.set(CSRF_COOKIE_NAME, csrf)
for name in (" ", "\t\n"):
response = client.post("/api/v1/auth/pats", json={"name": name, "scopes": ["runs:read"]}, headers={CSRF_HEADER_NAME: csrf})
# Rejected by request validation (422) before token generation.
assert response.status_code == 422, name
assert "non-whitespace" in response.text
def test_create_trims_surrounding_whitespace_in_name(client):
created = _create_pat(client, name=" ci bot ")
assert created["name"] == "ci bot"
def test_revoke_is_scoped_to_owner(client):
created = _create_pat(client, user_id="user-1")
# user-2 tries to revoke user-1's token.
_session_cookie(client, user_id="user-2")
from app.gateway.csrf_middleware import CSRF_HEADER_NAME
response = client.delete(f"/api/v1/auth/pats/{created['id']}", headers={CSRF_HEADER_NAME: client.cookies.get("csrf_token")})
assert response.status_code == 404
def test_pat_cannot_manage_pats(client):
created = _create_pat(client)
client.cookies.clear()
headers = {"Authorization": f"Bearer {created['token']}"}
assert client.get("/api/v1/auth/pats", headers=headers).status_code == 403
assert client.post("/api/v1/auth/pats", json={"name": "child", "scopes": ["runs:read"]}, headers=headers).status_code == 403
assert client.delete(f"/api/v1/auth/pats/{created['id']}", headers=headers).status_code == 403
def test_pat_cannot_change_password(client):
created = _create_pat(client)
client.cookies.clear()
response = client.post(
"/api/v1/auth/change-password",
json={"current_password": "x", "new_password": "Whatever123!"},
headers={"Authorization": f"Bearer {created['token']}"},
)
assert response.status_code == 403
# The default-deny route policy blocks the request at the middleware,
# before the route-level session-only guard gets a chance; the 403 is the
# security property either way.
assert "pat" in response.json()["detail"].lower()
def test_successful_pat_auth_stamps_last_used(client, pat_env):
_app, repo, _engine = pat_env
created = _create_pat(client)
client.cookies.clear()
assert client.get("/api/threads/whoami", headers={"Authorization": f"Bearer {created['token']}"}).status_code == 200
records = asyncio.run(repo.list_for_user("user-1"))
assert records[0]["last_used_at"] is not None
def test_expired_pat_rejected_at_middleware(client, pat_env):
_app, repo, _engine = pat_env
from app.gateway.auth.pat import generate_pat_token, pat_token_digest
token = generate_pat_token()
asyncio.run(
repo.create(
user_id="user-1",
name="already-expired",
scopes=["runs:read"],
token_digest=pat_token_digest(token),
expires_at=datetime.now(UTC) - timedelta(seconds=1),
)
)
client.cookies.clear()
response = client.get("/api/threads/whoami", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 401
def test_create_with_expiry_returns_expires_at(client):
created = _create_pat(client, expires_in_days=30)
assert created["expires_at"] is not None
def test_pat_never_carries_admin_capability_even_for_admin_owner(client):
created = _create_pat(client, user_id="admin-1", scopes=["runs:read"])
client.cookies.clear()
# The route-level default-deny policy blocks the PAT before the route
# runs; the is_admin_user guard inside it remains as defense in depth
# for compositions without the middleware.
response = client.get("/api/admin-check", headers={"Authorization": f"Bearer {created['token']}"})
assert response.status_code == 403
# Control: the same admin over a session cookie keeps admin capability.
_session_cookie(client, user_id="admin-1")
control = client.get("/api/admin-check")
assert control.status_code == 200
assert control.json() == {"is_admin": True}
def test_pat_default_denied_on_route_outside_pat_policy(client):
"""P1 regression (#5041 review): a PAT holding every scope must not reach
destructive routes that have no PAT policy — scope intersection only
constrains @require_permission routes, so undecorated mutation routes
would otherwise accept a runs:read-only token."""
created = _create_pat(client, scopes=["threads:read", "threads:write", "threads:delete", "runs:create", "runs:read", "runs:cancel"])
client.cookies.clear()
response = client.delete("/api/memory", headers={"Authorization": f"Bearer {created['token']}"})
assert response.status_code == 403
assert "PAT" in response.json()["detail"]
def test_session_cookie_reaches_route_that_denies_pat(client):
"""The default-deny is PAT-specific: the same route stays open to the
owning user's session cookie (PATs narrow, never widen, and never
restrict the interactive path)."""
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, CSRF_HEADER_NAME, generate_csrf_token
_session_cookie(client, user_id="user-1")
csrf = generate_csrf_token()
client.cookies.set(CSRF_COOKIE_NAME, csrf)
response = client.delete("/api/memory", headers={CSRF_HEADER_NAME: csrf})
assert response.status_code == 200
assert response.json() == {"deleted": True}
def test_pat_policy_allows_thread_lifecycle_routes(client):
created = _create_pat(client, scopes=["threads:delete"])
client.cookies.clear()
response = client.delete("/api/threads/t1", headers={"Authorization": f"Bearer {created['token']}"})
assert response.status_code == 200
assert response.json() == {"deleted": True}
def test_pat_policy_does_not_pre_authorize_unimplemented_methods():
"""Route-policy regression (#5041 review): the allowlist must not admit
methods the router does not implement. The Gateway has no GET collection
route for /api/threads — pre-authorizing it would make a future GET
collection route PAT-reachable without an explicit policy change."""
from app.gateway.auth.pat import is_pat_allowed_route
assert is_pat_allowed_route("POST", "/api/threads") is True
assert is_pat_allowed_route("GET", "/api/threads") is False
def test_pat_runs_policy_admits_exactly_the_mounted_routes():
"""The runs subtree is enumerated, not wildcarded: every GET/POST route
the thread_runs router actually implements is admitted (derived from the
mounted router, not a hand-maintained list), routes in this router
outside the runs subtree stay denied, and representative unimplemented
neighbors — including the POST-only collection names on GET — are
default-denied. A new route under /runs fails here until explicitly
allowlisted; a removed one leaves a dead rule visible."""
from fastapi.routing import APIRoute
from app.gateway.auth.pat import is_pat_allowed_route
from app.gateway.routers.thread_runs import router
def concrete(path: str) -> str:
return path.replace("{thread_id}", "t1").replace("{run_id}", "r1")
for route in router.routes:
if not isinstance(route, APIRoute):
continue
path = concrete(route.path)
under_runs = route.path.startswith("/api/threads/{thread_id}/runs")
for method in sorted(route.methods - {"HEAD", "OPTIONS"}):
admitted = is_pat_allowed_route(method, path)
if under_runs:
assert admitted, f"{method} {path} is implemented but PAT-denied"
else:
# /messages, /messages/page, /token-usage sit outside the runs
# subtree and are PAT-denied pending the polling-surface
# decision — pinned here so widening it is a conscious edit.
assert not admitted, f"{method} {path} is outside the PAT policy"
for method, path in [
("GET", "/api/threads/t1/runs/stream"),
("GET", "/api/threads/t1/runs/wait"),
("GET", "/api/threads/t1/runs/regenerate"),
("GET", "/api/threads/t1/runs/edit-regenerate"),
("POST", "/api/threads/t1/runs/r1/messages"),
("DELETE", "/api/threads/t1/runs/r1"),
("POST", "/api/threads/t1/runs/summary"),
("GET", "/api/threads/t1/runs/r1/transfer"),
]:
assert not is_pat_allowed_route(method, path), f"{method} {path} is not implemented and must stay denied"
def test_pat_projects_policy_admits_exactly_the_mounted_routes():
"""Projects subtree (and the thread move endpoint) follow the same
enumerated-no-dead-methods discipline as the runs subtree: every
method/path the projects router actually implements is admitted (derived
from the mounted router, not a hand-maintained list), and deliberately
unimplemented neighbors stay default-denied. A new projects route fails
here until explicitly allowlisted; a removed one leaves a dead rule
visible."""
from fastapi.routing import APIRoute
from app.gateway.auth.pat import is_pat_allowed_route
from app.gateway.routers.projects import router
def concrete(path: str) -> str:
return path.replace("{project_id}", "p1")
for route in router.routes:
if not isinstance(route, APIRoute):
continue
path = concrete(route.path)
for method in sorted(route.methods - {"HEAD", "OPTIONS"}):
assert is_pat_allowed_route(method, path), f"{method} {path} is implemented but PAT-denied"
for method, path in [
("PUT", "/api/projects"),
("DELETE", "/api/projects"),
("PUT", "/api/projects/p1"),
("GET", "/api/projects/p1/archive"),
("GET", "/api/projects/p1/restore"),
("POST", "/api/projects/p1/threads"),
]:
assert not is_pat_allowed_route(method, path), f"{method} {path} is not implemented and must stay denied"
# Thread move (POST /api/threads/{id}/move) is admitted for PATs holding
# threads:write; other methods on the same path stay denied.
move_path = "/api/threads/6f1c2f0e-3b7a-4d2e-9c1a-2b5f0e8a1d3c/move"
assert is_pat_allowed_route("POST", move_path) is True
assert is_pat_allowed_route("GET", move_path) is False
def test_pat_scopes_enforced_on_stateless_run_entry(client):
"""Follow-up to the review's P1-1: the stateless run entrypoints now
carry @require_permission("runs", "create"), so a threads:read-only PAT
cannot start runs even though the route sits inside the PAT allowlist."""
read_only = _create_pat(client, scopes=["threads:read"])
client.cookies.clear()
denied = client.post("/api/runs/stream", headers={"Authorization": f"Bearer {read_only['token']}"})
assert denied.status_code == 403
create_scope = _create_pat(client, scopes=["runs:create"])
client.cookies.clear()
allowed = client.post("/api/runs/stream", headers={"Authorization": f"Bearer {create_scope['token']}"})
assert allowed.status_code == 200
def test_runs_read_only_pat_cannot_cancel_then_stream(client):
"""Review follow-up: cancel-then-stream (`?action=interrupt|rollback`) must
require runs:cancel even though the route decorator gates at runs:read —
otherwise a read-only PAT bypasses the separate cancel scope."""
read_only = _create_pat(client, scopes=["runs:read"])
client.cookies.clear()
denied = client.post(
"/api/threads/t1/runs/run-1/stream?action=interrupt",
headers={"Authorization": f"Bearer {read_only['token']}"},
)
assert denied.status_code == 403
assert denied.json()["detail"] == "Permission denied: runs:cancel"
# The same route without an action is a plain stream join: runs:read is
# sufficient there.
join = client.post(
"/api/threads/t1/runs/run-1/stream",
headers={"Authorization": f"Bearer {read_only['token']}"},
)
assert join.status_code == 200
cancel_scope = _create_pat(client, scopes=["runs:read", "runs:cancel"])
client.cookies.clear()
allowed = client.post(
"/api/threads/t1/runs/run-1/stream?action=rollback",
headers={"Authorization": f"Bearer {cancel_scope['token']}"},
)
assert allowed.status_code == 200
# Session callers keep the full permission set (with the CSRF pair their
# cookie-authenticated POST requires).
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, CSRF_HEADER_NAME, generate_csrf_token
_session_cookie(client)
csrf = generate_csrf_token()
client.cookies.set(CSRF_COOKIE_NAME, csrf)
session_allowed = client.post(
"/api/threads/t1/runs/run-1/stream?action=interrupt",
headers={CSRF_HEADER_NAME: csrf},
)
assert session_allowed.status_code == 200
def test_runs_create_only_pat_cannot_use_mutating_multitask_strategy(client):
"""Review round 5, P1-a: interrupt/rollback multitask strategies terminate
an already-active run — runs:cancel capability, not runs:create — so a
create-only PAT must be denied; "reject" (the default) stays within
runs:create and must keep working."""
create_only = _create_pat(client, scopes=["runs:create"])
client.cookies.clear()
for strategy in ("interrupt", "rollback"):
denied = client.post(
"/api/threads/t1/runs",
headers={"Authorization": f"Bearer {create_only['token']}"},
json={"multitask_strategy": strategy},
)
assert denied.status_code == 403, denied.text
assert denied.json()["detail"] == "Permission denied: runs:cancel"
# "reject" — explicitly and as the omitted default — does not touch
# existing runs and stays available to a create-only credential.
for body in ({"multitask_strategy": "reject"}, {}):
allowed = client.post(
"/api/threads/t1/runs",
headers={"Authorization": f"Bearer {create_only['token']}"},
json=body,
)
assert allowed.status_code == 200
cancel_scope = _create_pat(client, scopes=["runs:create", "runs:cancel"])
client.cookies.clear()
privileged = client.post(
"/api/threads/t1/runs",
headers={"Authorization": f"Bearer {cancel_scope['token']}"},
json={"multitask_strategy": "interrupt"},
)
assert privileged.status_code == 200
# Session callers keep the full permission set (with the CSRF pair their
# cookie-authenticated POST requires).
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, CSRF_HEADER_NAME, generate_csrf_token
_session_cookie(client)
csrf = generate_csrf_token()
client.cookies.set(CSRF_COOKIE_NAME, csrf)
session_allowed = client.post(
"/api/threads/t1/runs",
headers={CSRF_HEADER_NAME: csrf},
json={"multitask_strategy": "interrupt"},
)
assert session_allowed.status_code == 200
def test_start_run_gates_mutating_strategies_at_the_choke_point():
"""The strategy gate lives inside start_run itself — the single choke point
every run-creation path (all five HTTP entrypoints plus internal
launchers) flows through — so no entry point can bypass it. Mirrored
routes prove the middleware path; this anchor proves the choke point."""
import inspect
from app.gateway.services import start_run
source = inspect.getsource(start_run)
assert "require_cancel_permission_if" in source
assert "multitask_strategy" in source
def test_start_run_gate_denies_create_only_credential_behaviorally():
"""Behavioral pin on the real start_run (the mirror route and source
anchor above prove wiring, but this drives the production choke point
itself): a create-only auth context gets 403 for a mutating strategy,
and the gate never misfires on "reject" — with no cancel permission at
all, the call proceeds past the gate (failing later on missing test
wiring, never with a permission 403)."""
from fastapi import HTTPException
from app.gateway.authz import AuthContext
from app.gateway.run_models import RunCreateRequest
from app.gateway.services import start_run
def _request(permissions):
return SimpleNamespace(state=SimpleNamespace(auth=AuthContext(user=SimpleNamespace(id="user-1"), permissions=permissions)))
async def _denied():
with pytest.raises(HTTPException) as exc:
await start_run(RunCreateRequest(multitask_strategy="interrupt"), "t1", _request(["runs:create"]))
return exc.value
exc = asyncio.run(_denied())
assert exc.status_code == 403
assert exc.detail == "Permission denied: runs:cancel"
async def _allowed_past_gate():
try:
await start_run(RunCreateRequest(), "t1", _request([]))
except HTTPException as gate_misfire:
pytest.fail(f"gate misfired on reject: {gate_misfire.status_code} {gate_misfire.detail}")
except Exception:
pass # expected wiring failure past the gate — the gate let it through
asyncio.run(_allowed_past_gate())
def test_auth_disabled_mode_ignores_bearer_header(monkeypatch, tmp_path):
"""DEER_FLOW_AUTH_DISABLED is an operator override of all authentication.
A stray Authorization header (e.g. added by a proxy in front of an E2E
sandbox) must not turn into a 401 in that mode.
"""
monkeypatch.setattr("app.gateway.auth_middleware.is_auth_disabled", lambda: True)
app = _make_pat_app()
with TestClient(app) as disabled_client:
response = disabled_client.get("/api/threads/whoami", headers={"Authorization": "Bearer dfp_garbage"})
assert response.status_code == 200
assert response.json()["auth_source"] == "auth_disabled"