deer-flow/backend/tests/test_pat_auth.py
Zeren Wang a58ab484a6
feat(projects): Projects MVP Phase 2 — instructions, document shelf, promotion, trash (#5443)
* feat(projects): Projects MVP Phase 2 — instructions, document shelf, promotion, trash

Implements docs/superpowers/specs/2026-09-12-projects-mvp-phase2-design.md
(issue #5160, tracker #5129) in the slice order of the spec's §16.

Slices:
- A: ProjectsConfig + write-time 422 UTF-8 byte cap; PROJECT_CONTEXT_KEY
  admission pinning (both server-owned sets + worker hoist); latest-only
  request-scoped <project> block via DynamicContextMiddleware
  wrap_model_call/awrap_model_call (idempotent reassembly, reserved ID
  prefix + marker + provenance, never persisted); journal audit
  fingerprints; Instructions tab.
- B: ProjectDocumentRow + migration 0023; ProjectDocumentRepository with
  locked check-and-set; hash-qualified immutable shelf storage with
  Paths helpers; upload/list/content/delete-to-trash routes; project
  delete trashes the shelf in-transaction; request-scoped bounded
  <documents> index with honest count/shown + actionable overflow note;
  list_project_documents/read_project_document tools registered only on
  pinned runs; PAT allowlist + drift guards; blocking-IO anchors.
- C: shared thread-upload ingestion service (uploads router refactored to
  parity); POST from-thread with provenance; attach-to-thread with
  lock-staged copy (archived source allowed); read-only thread-files
  view with per-group truncation reporting.
- D: restore (restored/merged/not_found/no_target/content_missing; no
  file moves), purge (continuous row lock across unlink/delete/commit,
  retryable on FS errors), retention sweep (lazy + startup, 24h orphan
  guard, row-side reconciliation never deletes).
- E: Documents tab (shelf + conversation-files browser, provenance,
  archived banner, content-missing rows), /workspace/trash route,
  sidebar entry, composer attach handoff, i18n (en-US/zh-CN), e2e mocks
  + specs.

Review hardening folded in (10 rounds, all with tests):
- force active shelf content (HTML/XML family) to download; nosniff on
  artifact + content responses; unified unsandboxed-iframe PDF preview
  (fixes the pre-existing Chromium sandbox blank in the artifact viewer)
- scope document trash to the URL project under the document lock
- atomic no-overwrite filename reservation for ALL ingestion (seeded
  claims + os.link commit with suffix retry; same-name re-upload now
  unique-names instead of replacing); hidden staging only, no visible
  placeholders; lease cleanup on setup failure
- serialize conversion under the document lock with post-lock active
  revalidation; drain locked filesystem work on cancellation; preserve
  bytes when an insert's commit state is uncertain (including trashed
  rows)
- original-integrity checks before serving text or cached conversions;
  content_missing surfaced in list responses (UI reads the flag, no
  409-probe); downloads always serve original bytes
- bounded streaming document reads with cached char counts; shelf limits
  declared in middleware release identity
- thread-root confinement for from-thread sources; config fallback
  rejects fractional/infinite values; composer counts staged
  attachments; pending attachments persist until submission or removal;
  in-flight instruction/rename edits survive save refetches; shelf and
  trash pagination; conversation-file and thread-files pages stay
  subscribed to refetches

Docs: README/README_zh, backend API.md/ARCHITECTURE.md, AGENTS.md
contracts, config.example.yaml projects block.

Review follow-ups (head b4807477 → this revision):
- The trash retention sweep is split so repeated lazy triggers stay
  bounded: the indexed expiry purge still runs on every trigger
  (GET /api/trash/documents, POST /api/trash/purge) while the
  O(all rows + all files) reconciliation is throttled to one run per
  user per 15 minutes (process-local, per-user window). The startup
  sweep now runs as a background task instead of blocking gateway
  readiness, and shutdown awaits it (bounded).
- The export scrub (stripInternalMarkers) is fence- and indentation-aware
  like the render path, so a pasted, fenced <project>/<documents> snippet
  survives markdown export while real injected blocks (never fenced) are
  still removed. Fence regexes moved to a dependency-free leaf module to
  avoid the messages↔streamdown import cycle.
- The artifact viewer's PDF iframe no longer carries an added title
  attribute (the upstream e2e contract locates it via :not([title])), and
  the upstream artifact-preview spec now pins the new contract: PDFs
  render unsandboxed, images keep sandbox="".

* fix(projects): round-2 review — cancel an overrun trash sweep, restore the PDF frame title

- Shutdown cancelled only the shield around the background startup sweep,
  so an all-users reconciliation that outlived the 5s budget kept walking
  rows and files while the document repo and DB engine were disposed
  underneath it. The wait now lives in `_shutdown_startup_trash_sweep`,
  which cancels the task and drains it before worker exit: the shield
  keeps the wait bounded, the cancel makes it final (CancelledError lands
  at the sweep's next await, and `_run_startup_trash_sweep` only catches
  `Exception`, so nothing swallows it).
- The browser-preview iframe lost `title={getFileName(filepath)}` in the
  previous fix round, leaving the PDF frame without an accessible name
  while its siblings keep theirs. Restore it (WCAG frame titles), assert
  it in the DOM test, and anchor the e2e on `iframe[title="report.pdf"]`
  instead of `iframe:not([title])`.

* fix(projects): round-3 review — report the sweep's late finish, not a phantom cancel

`Task.cancel()` returns False when the sweep already finished inside the
window between the deadline firing and the cancel, so the shutdown log
claimed a cancellation that never happened. Branch on that outcome: the
warning stays for a real cancel, a late finish is logged at info, and both
paths still reap the task before worker exit.

* fix(projects): round-4 review — make Empty trash delete what it confirms

`POST /api/trash/purge` only ran the retention sweep, and the sweep's
candidate selection is age-gated, so a freshly trashed document survived
"Empty trash" even though the confirmation promises that every listed
document is permanently deleted. With one trashed row the route answered
`{"purged": 0}` and left it in place; `GET /api/trash/documents` sweeps
expired rows before listing, so the visible rows were normally ineligible
for the action by construction.

Empty trash now drives `purge_all_trashed`: the caller's trashed rows
(`list_all_trashed`, no age filter) each go through the same guarded,
row-locked `purge` as the single-document delete — bytes first, then the
row, in one transaction — so a row restored mid-flight is skipped instead of
force-deleted, and an unlink failure rolls that row back and answers 500 with
a retryable message. Retention expiry stays where it was: the sweep's
`purge_candidates` is now the only age-gated selection, and the lazy
retention sweep still runs on the listing and at startup.

Tests: the router suite replaces the retention-gated expectation with the
reviewer's repro (fresh row purged, bytes unlinked, shelf and other users'
trash untouched, a failing unlink stays retryable and 500); a blocking-I/O
anchor drives the new entry point through the offload; the mocked e2e covers
the action end to end; a new real-backend spec performs it against the real
gateway and re-reads `GET /api/trash/documents`. README, API, ARCHITECTURE
and the phase-2 design docs (en+zh) state the age-independent contract.
2026-09-16 18:46:18 +08:00

804 lines
34 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 import project_documents, project_thread_files, projects, trash
def concrete(path: str) -> str:
return path.replace("{project_id}", "p1").replace("{document_id}", "d1").replace("{thread_id}", "t1")
for router in (projects.router, project_documents.router, project_thread_files.router, trash.router):
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"
# ``/config`` is a literal collection route, not a project id: admitted
# for PATs holding projects:read, with wrong methods default-denied.
assert is_pat_allowed_route("GET", "/api/projects/config") is True
assert is_pat_allowed_route("POST", "/api/projects/config") is False
assert is_pat_allowed_route("PUT", "/api/projects/config") is False
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"),
# Document-shelf neighbors that must stay default-denied: the bare
# item read, collection deletes, wrong-method variants of the
# Slice C routes, and the Slice D trash routes.
("GET", "/api/projects/p1/documents/d1"),
("POST", "/api/projects/p1/documents/d1"),
("PUT", "/api/projects/p1/documents"),
("DELETE", "/api/projects/p1/documents"),
("DELETE", "/api/projects/p1/documents/d1/content"),
("GET", "/api/projects/p1/documents/from-thread"),
("GET", "/api/projects/p1/documents/d1/attach-to-thread/t1"),
("POST", "/api/projects/p1/thread-files"),
# Trash-tier neighbors that must stay default-denied: collection
# mutations on the listing, wrong-method variants of the Slice D
# routes, and the bare trash root.
("POST", "/api/trash/documents"),
("DELETE", "/api/trash/documents"),
("GET", "/api/trash/documents/d1/restore"),
("GET", "/api/trash/documents/d1/purge"),
("DELETE", "/api/trash/documents/d1"),
("GET", "/api/trash/purge"),
("DELETE", "/api/trash/purge"),
("GET", "/api/trash"),
]:
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"