deer-flow/backend/tests/test_user_context.py
greatmengqi 4b139fb689 feat(auth): enforce owner_id across 2.0-rc persistence layer
Add request-scoped contextvar-based owner filtering to threads_meta,
runs, run_events, and feedback repositories. Router code is unchanged
— isolation is enforced at the storage layer so that any caller that
forgets to pass owner_id still gets filtered results, and new routes
cannot accidentally leak data.

Core infrastructure
-------------------
- deerflow/runtime/user_context.py (new):
  - ContextVar[CurrentUser | None] with default None
  - runtime_checkable CurrentUser Protocol (structural subtype with .id)
  - set/reset/get/require helpers
  - AUTO sentinel + resolve_owner_id(value, method_name) for sentinel
    three-state resolution: AUTO reads contextvar, explicit str
    overrides, explicit None bypasses the filter (for migration/CLI)

Repository changes
------------------
- ThreadMetaRepository: create/get/search/update_*/delete gain
  owner_id=AUTO kwarg; read paths filter by owner, writes stamp it,
  mutations check ownership before applying
- RunRepository: put/get/list_by_thread/delete gain owner_id=AUTO kwarg
- FeedbackRepository: create/get/list_by_run/list_by_thread/delete
  gain owner_id=AUTO kwarg
- DbRunEventStore: list_messages/list_events/list_messages_by_run/
  count_messages/delete_by_thread/delete_by_run gain owner_id=AUTO
  kwarg. Write paths (put/put_batch) read contextvar softly: when a
  request-scoped user is available, owner_id is stamped; background
  worker writes without a user context pass None which is valid
  (orphan row to be bound by migration)

Schema
------
- persistence/models/run_event.py: RunEventRow.owner_id = Mapped[
  str | None] = mapped_column(String(64), nullable=True, index=True)
- No alembic migration needed: 2.0 ships fresh, Base.metadata.create_all
  picks up the new column automatically

Middleware
----------
- auth_middleware.py: after cookie check, call get_optional_user_from_
  request to load the real User, stamp it into request.state.user AND
  the contextvar via set_current_user, reset in a try/finally. Public
  paths and unauthenticated requests continue without contextvar, and
  @require_auth handles the strict 401 path

Test infrastructure
-------------------
- tests/conftest.py: @pytest.fixture(autouse=True) _auto_user_context
  sets a default SimpleNamespace(id="test-user-autouse") on every test
  unless marked @pytest.mark.no_auto_user. Keeps existing 20+
  persistence tests passing without modification
- pyproject.toml [tool.pytest.ini_options]: register no_auto_user
  marker so pytest does not emit warnings for opt-out tests
- tests/test_user_context.py: 6 tests covering three-state semantics,
  Protocol duck typing, and require/optional APIs
- tests/test_thread_meta_repo.py: one test updated to pass owner_id=
  None explicitly where it was previously relying on the old default

Test results
------------
- test_user_context.py: 6 passed
- test_auth*.py + test_langgraph_auth.py + test_ensure_admin.py: 127
- test_run_event_store / test_run_repository / test_thread_meta_repo
  / test_feedback: 92 passed
- Full backend suite: 1905 passed, 2 failed (both @requires_llm flaky
  integration tests unrelated to auth), 1 skipped
2026-04-08 11:08:48 +08:00

70 lines
2.0 KiB
Python

"""Tests for runtime.user_context — contextvar three-state semantics.
These tests opt out of the autouse contextvar fixture (added in
commit 6) because they explicitly test the cases where the contextvar
is set or unset.
"""
from types import SimpleNamespace
import pytest
from deerflow.runtime.user_context import (
CurrentUser,
get_current_user,
require_current_user,
reset_current_user,
set_current_user,
)
@pytest.mark.no_auto_user
def test_default_is_none():
"""Before any set, contextvar returns None."""
assert get_current_user() is None
@pytest.mark.no_auto_user
def test_set_and_reset_roundtrip():
"""set_current_user returns a token that reset restores."""
user = SimpleNamespace(id="user-1")
token = set_current_user(user)
try:
assert get_current_user() is user
finally:
reset_current_user(token)
assert get_current_user() is None
@pytest.mark.no_auto_user
def test_require_current_user_raises_when_unset():
"""require_current_user raises RuntimeError if contextvar is unset."""
assert get_current_user() is None
with pytest.raises(RuntimeError, match="without user context"):
require_current_user()
@pytest.mark.no_auto_user
def test_require_current_user_returns_user_when_set():
"""require_current_user returns the user when contextvar is set."""
user = SimpleNamespace(id="user-2")
token = set_current_user(user)
try:
assert require_current_user() is user
finally:
reset_current_user(token)
@pytest.mark.no_auto_user
def test_protocol_accepts_duck_typed():
"""CurrentUser is a runtime_checkable Protocol matching any .id-bearing object."""
user = SimpleNamespace(id="user-3")
assert isinstance(user, CurrentUser)
@pytest.mark.no_auto_user
def test_protocol_rejects_no_id():
"""Objects without .id do not satisfy CurrentUser Protocol."""
not_a_user = SimpleNamespace(email="no-id@example.com")
assert not isinstance(not_a_user, CurrentUser)