Files
ZY-Agent/backend/tests/test_auth_middleware_workspace.py
T
1445043649 87ea715c2a feat(persistence): PR6 T5.11 — ORM workspace_id nullable=False
Flip the 4 business ORM models (ThreadMetaRow, RunRow, FeedbackRow,
RunEventRow) to ``workspace_id: Mapped[str]`` with ``nullable=False``.
PR5's alembic 0003 already enforces NOT NULL at the DB layer; this
aligns the ORM-driven ``create_all()`` path (dev / tests) with the same
invariant so a new install ends up at the post-0003 schema without
running alembic.

Test fallout absorbed:

- `tests/conftest.py` autouse seed now produces a fully consistent
  pair: user row (default_workspace_id = test-workspace-autouse) plus
  the workspace itself. The PR5 backfill script's "users without
  default_workspace_id" query no longer picks the fixture up. Insert
  order is user → workspace → UPDATE user, walking around the chicken-
  and-egg FK between `workspaces.owner_id` and `users.default_workspace_id`.
- `tests/test_backfill_workspace_id.py` adds a file-scoped autouse
  fixture that temporarily flips `column.nullable = True` for the four
  business tables (production correctness comes from alembic 0003;
  the script's own job is exactly to fill rows between 0002 and 0003
  so its tests need that transient state to be representable). Its
  `_init_engine` also deletes the autouse seed rows to match the
  "fresh DB" model the tests assume.
- `test_thread_meta_workspace_filter::test_create_workspace_none_bypasses`
  renamed to `test_create_workspace_none_rejected_by_orm` and asserts
  the new IntegrityError on explicit None — write paths can no longer
  bypass workspace scope.
- 5 `test_workspace_context` tests + the auth-middleware reset test
  get `@pytest.mark.no_auto_workspace` so they keep testing the
  unset-contextvar path.
- `test_workspace_repo::test_list_by_user_bypass_returns_all` switches
  to membership assertions instead of strict equality since the
  autouse fixture surfaces under `user_id=None`.

3214 passed, 30 skipped; the remaining 17 are the documented
pre-existing caplog ordering flakes (all pass in isolation).
2026-05-13 18:06:53 +08:00

132 lines
5.0 KiB
Python

"""AuthMiddleware injects the workspace ContextVar (Stage 0 PR4 T4.7).
After PR4 every authenticated request has a workspace bound on
``deerflow.runtime.workspace_context._current_workspace``. The middleware
populates it from the JWT's ``wid`` / ``role`` claims, mirrors what it
already does for ``user_context``, and tears both down in a single
``try/finally`` so leaks don't cross requests.
Legacy 4-field tokens (no ``wid``) are rejected upstream by
``decode_token`` (T4.6) — those should never reach the workspace
injection branch; this file pins that the 401 they trigger carries
``AuthErrorCode.WORKSPACE_REQUIRED``.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, patch
from uuid import uuid4
import jwt
import pytest
from fastapi import FastAPI
from starlette.testclient import TestClient
from app.gateway.auth import create_access_token
from app.gateway.auth.config import get_auth_config
from app.gateway.auth.models import User
from app.gateway.auth_middleware import AuthMiddleware
from deerflow.runtime.workspace_context import get_current_workspace
@pytest.fixture(autouse=True)
def _stable_jwt_secret(monkeypatch):
monkeypatch.setenv("AUTH_JWT_SECRET", "test-secret-key-for-jwt-testing-minimum-32-chars")
yield
def _make_app() -> FastAPI:
"""App with AuthMiddleware + an inspect route that surfaces the contextvar."""
app = FastAPI()
app.add_middleware(AuthMiddleware)
@app.get("/api/v1/auth/setup-status") # public — never gates on wid
async def setup_status():
return {"needs_setup": False}
@app.get("/api/models") # protected — exercises wid injection
async def inspect_workspace():
ws = get_current_workspace()
if ws is None:
return {"workspace": None}
return {"workspace": {"id": ws.id, "role": ws.role}}
return app
def _make_user(uid: str) -> User:
return User(id=uid, email="t@example.com", password_hash="hash", token_version=0)
def _make_legacy_token() -> str:
"""Encode a pre-PR4 JWT (no wid/role) directly."""
now = datetime.now(UTC)
payload = {
"sub": str(uuid4()),
"exp": now + timedelta(hours=1),
"iat": now,
"ver": 0,
}
return jwt.encode(payload, get_auth_config().jwt_secret, algorithm="HS256")
def test_new_jwt_injects_workspace_into_contextvar() -> None:
"""Cookie with wid+role → route observes the workspace via the contextvar."""
uid = str(uuid4())
token = create_access_token(uid, workspace_id="ws-abc", role="owner")
with patch("app.gateway.deps.get_local_provider") as fn:
fn.return_value.get_user = AsyncMock(return_value=_make_user(uid))
client = TestClient(_make_app())
res = client.get("/api/models", cookies={"access_token": token})
assert res.status_code == 200, res.text
assert res.json() == {"workspace": {"id": "ws-abc", "role": "owner"}}
def test_legacy_jwt_rejected_with_workspace_required() -> None:
"""No-wid tokens get 401 with AuthErrorCode.WORKSPACE_REQUIRED, not generic token_invalid."""
client = TestClient(_make_app())
res = client.get("/api/models", cookies={"access_token": _make_legacy_token()})
assert res.status_code == 401
assert res.json()["detail"]["code"] == "workspace_required"
def test_public_path_skips_workspace_check() -> None:
"""Public whitelist (e.g. /api/v1/auth/setup-status) does not require wid."""
client = TestClient(_make_app())
res = client.get("/api/v1/auth/setup-status") # no cookie at all
assert res.status_code == 200
@pytest.mark.no_auto_workspace
def test_workspace_contextvar_resets_between_requests() -> None:
"""After dispatch returns the contextvar must be clear (no leak across requests).
Why we test this: if the try/finally is wired only for user_context but
not workspace_context, two back-to-back requests can see each other's
workspace under asyncio task switching.
"""
uid = str(uuid4())
token = create_access_token(uid, workspace_id="ws-first", role="owner")
# First request resolves to ws-first
with patch("app.gateway.deps.get_local_provider") as fn:
fn.return_value.get_user = AsyncMock(return_value=_make_user(uid))
client = TestClient(_make_app())
res1 = client.get("/api/models", cookies={"access_token": token})
assert res1.json() == {"workspace": {"id": "ws-first", "role": "owner"}}
# Outside the request scope the contextvar must be empty again.
assert get_current_workspace() is None
# Second request with a different workspace must not see ws-first.
token2 = create_access_token(uid, workspace_id="ws-second", role="owner")
with patch("app.gateway.deps.get_local_provider") as fn:
fn.return_value.get_user = AsyncMock(return_value=_make_user(uid))
client = TestClient(_make_app())
res2 = client.get("/api/models", cookies={"access_token": token2})
assert res2.json() == {"workspace": {"id": "ws-second", "role": "owner"}}