deer-flow/backend/tests/test_auth_me_permissions.py
Ryker_Feng 9fda432ba1
feat(artifacts): preview CSV and TSV files as bounded tables (#5284)
* feat(artifacts): preview CSV and TSV files as bounded tables

* chore: keep preview screenshots out of the PR file diff

* fix(artifacts): detect record newlines outside quoted fields

* test(auth): include project permissions in me contract expectations
2026-09-08 19:11:11 +08:00

219 lines
7.7 KiB
Python

"""GET /api/v1/auth/me effective-permissions tests (RFC #4063 Phase 4).
Pins the /me wiring only: the field is populated from the AuthContext that
AuthMiddleware already resolves per request, and a middleware-less call
falls back to a fresh resolution with the same semantics ``_authenticate``
uses. The permission-derivation semantics themselves (disabled / fail-closed
/ fail-open / per-action requests) are pinned by
test_authorization_route_permissions.py and are not re-tested here.
"""
import asyncio
import os
from types import SimpleNamespace
import pytest
from fastapi.testclient import TestClient
os.environ.setdefault("AUTH_JWT_SECRET", "test-secret-key-auth-me-permissions-min-32")
from app.gateway.authz import Permissions # noqa: E402
from deerflow.authz.provider import AuthzDecision, AuthzReason # noqa: E402
from deerflow.config.authorization_config import AuthorizationConfig # noqa: E402
_TEST_SECRET = "test-secret-key-auth-me-permissions-min-32"
_ALL_PERMISSIONS = [
Permissions.THREADS_READ,
Permissions.THREADS_WRITE,
Permissions.THREADS_DELETE,
Permissions.RUNS_CREATE,
Permissions.RUNS_READ,
Permissions.RUNS_CANCEL,
Permissions.PROJECTS_READ,
Permissions.PROJECTS_WRITE,
Permissions.PROJECTS_DELETE,
]
class _RecordingProvider:
"""Async-only provider recording every decision request it serves."""
name = "recording"
def __init__(self, *, denied: set[str] | None = None) -> None:
self.denied = denied or set()
self.requests = []
def authorize(self, request):
raise AssertionError("route authorization must use the async provider API")
async def aauthorize(self, request):
self.requests.append(request)
allowed = request.target not in self.denied
return AuthzDecision(
allow=allowed,
reasons=[AuthzReason(code="authz.allowed" if allowed else "authz.denied")],
)
def filter_resources(self, principal, resource_type, candidates):
raise AssertionError("route authorization must preserve per-action requests")
@pytest.fixture(autouse=True)
def _setup_auth(tmp_path):
"""Fresh SQLite engine + auth config per test (test_initialize_admin pattern)."""
from app.gateway import deps
from app.gateway.auth.config import AuthConfig, set_auth_config
from app.gateway.routers.auth import _SETUP_STATUS_CACHE, _SETUP_STATUS_INFLIGHT
from deerflow.persistence.engine import close_engine, init_engine
set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET))
url = f"sqlite+aiosqlite:///{tmp_path}/auth_me.db"
asyncio.run(init_engine("sqlite", url=url, sqlite_dir=str(tmp_path)))
deps._cached_local_provider = None
deps._cached_repo = None
_SETUP_STATUS_CACHE.clear()
_SETUP_STATUS_INFLIGHT.clear()
try:
yield
finally:
deps._cached_local_provider = None
deps._cached_repo = None
_SETUP_STATUS_CACHE.clear()
_SETUP_STATUS_INFLIGHT.clear()
asyncio.run(close_engine())
@pytest.fixture(autouse=True)
def _default_route_authorization_config(monkeypatch):
"""Keep /me independent of a repository config.yaml (disabled by default)."""
monkeypatch.setattr(
"app.gateway.authz._get_route_authorization_config",
lambda: AuthorizationConfig(),
)
@pytest.fixture()
def client(_setup_auth):
from app.gateway.app import create_app
from app.gateway.auth.config import AuthConfig, set_auth_config
set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET))
app = create_app()
# No context manager: the full lifespan requires config.yaml, the auth
# routes work without it (the persistence engine is set up by _setup_auth).
yield TestClient(app)
def _initialize_admin(client: TestClient):
resp = client.post(
"/api/v1/auth/initialize",
json={"email": "admin@example.com", "password": "Str0ng!Pass99"},
)
assert resp.status_code == 201
return resp
def _enable_authorization(monkeypatch, provider) -> None:
config = AuthorizationConfig(enabled=True, fail_closed=True, default_role="user")
monkeypatch.setattr("app.gateway.authz._get_route_authorization_config", lambda: config)
monkeypatch.setattr("app.gateway.authz._get_cached_route_provider", lambda c: provider)
# ── Route-level wiring ────────────────────────────────────────────────────
def test_me_lists_all_route_permissions_when_authorization_disabled(client):
_initialize_admin(client)
res = client.get("/api/v1/auth/me")
assert res.status_code == 200
assert res.json()["permissions"] == _ALL_PERMISSIONS
def test_me_reuses_middleware_resolved_permissions(client, monkeypatch):
"""One provider decision per registered permission — /me reads the
AuthContext AuthMiddleware already stamped instead of re-resolving."""
denied = {Permissions.THREADS_DELETE, Permissions.RUNS_CANCEL}
provider = _RecordingProvider(denied=denied)
_enable_authorization(monkeypatch, provider)
_initialize_admin(client)
res = client.get("/api/v1/auth/me")
assert res.status_code == 200
assert res.json()["permissions"] == [p for p in _ALL_PERMISSIONS if p not in denied]
assert len(provider.requests) == len(_ALL_PERMISSIONS)
def test_initialize_response_leaves_permissions_unresolved(client):
"""Credential-creation responses do not resolve permissions (None), so
they never advertise a misleading empty grant set."""
resp = _initialize_admin(client)
assert resp.json()["permissions"] is None
def test_auth_disabled_me_includes_default_admin_permissions(monkeypatch, _setup_auth):
from app.gateway.app import create_app
from app.gateway.auth.config import AuthConfig, set_auth_config
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET))
client = TestClient(create_app())
res = client.get("/api/v1/auth/me")
assert res.status_code == 200
assert res.json()["permissions"] == _ALL_PERMISSIONS
# ── Middleware-less fallback ──────────────────────────────────────────────
def _fallback_request(user: SimpleNamespace, auth_source: str) -> SimpleNamespace:
return SimpleNamespace(
state=SimpleNamespace(user=user, auth_source=auth_source),
cookies={},
headers={},
)
def _stub_user() -> SimpleNamespace:
return SimpleNamespace(
id="user-123",
email="user@example.test",
system_role="user",
needs_setup=False,
oauth_provider=None,
)
@pytest.mark.asyncio
async def test_me_falls_back_to_fresh_resolution_without_middleware_context():
"""Direct handler invocation (no AuthMiddleware) resolves permissions the
same way ``_authenticate`` would instead of reporting an empty grant."""
from app.gateway.routers.auth import get_me
response = await get_me(_fallback_request(_stub_user(), "session"))
assert response.permissions == _ALL_PERMISSIONS
@pytest.mark.asyncio
async def test_me_fallback_mirrors_authenticate_internal_caller_semantics(monkeypatch):
from app.gateway.auth_disabled import AUTH_SOURCE_INTERNAL
from app.gateway.routers.auth import get_me
captured = {}
async def fake_resolve(user, *, is_internal):
captured["is_internal"] = is_internal
return ["threads:read"]
monkeypatch.setattr("app.gateway.authz.resolve_route_permissions", fake_resolve)
response = await get_me(_fallback_request(_stub_user(), AUTH_SOURCE_INTERNAL))
assert response.permissions == ["threads:read"]
assert captured["is_internal"] is True