Files
group_fqcd_jr/tests/unit/service/test_authorization_service.py
T

124 lines
3.9 KiB
Python
Raw Normal View History

"""`AuthorizationService.require` 契约测试(替身 session,不连数据库)。
它是所有受保护操作的公共闸门,覆盖四点:
1. 有权限时**直接返回**,不写审计、更不碰数据库——用"创建会话即报错"的替身证明;
2. 拒绝时**开新事务**写 `permission.denied` 审计,并抛 `ForbiddenAgentError`;
3. `admin=True` 时除了权限码还必须具备 admin/super_admin 角色;
4. 审计里必须留下 permission 与 trace_id,供事后追责。
"""
from typing import Any
import pytest
from app.core.contracts import RequestContext
from app.core.errors import ForbiddenAgentError
from app.service.authorization_service import AuthorizationService
BASE = RequestContext(user_id="9001", trace_id="trace-9")
def context_with(*permissions: str, roles: tuple[str, ...] = ()) -> RequestContext:
return RequestContext(
user_id="9001", trace_id="trace-9", permissions=tuple(permissions), roles=roles
)
class _AsyncContext:
async def __aenter__(self) -> None:
return None
async def __aexit__(self, *exc: object) -> bool:
return False
class FakeSession:
def __init__(self, sink: list[Any]) -> None:
self.sink = sink
def add(self, value: Any) -> None:
self.sink.append(value)
def begin(self) -> Any:
return _AsyncContext()
def patch_session(monkeypatch: pytest.MonkeyPatch, sink: list[Any]) -> None:
class FakeFactory:
async def __aenter__(self) -> FakeSession:
return FakeSession(sink)
async def __aexit__(self, *exc: object) -> bool:
return False
monkeypatch.setattr("app.service.authorization_service.SessionFactory", FakeFactory)
def explode_on_session() -> None:
raise AssertionError("有权限时不应创建数据库会话")
async def test_allowed_permission_returns_without_database_access(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("app.service.authorization_service.SessionFactory", explode_on_session)
await AuthorizationService.require(
context_with("conversation:feedback"), "conversation:feedback"
)
async def test_denied_permission_writes_audit_and_raises(
monkeypatch: pytest.MonkeyPatch,
) -> None:
sink: list[Any] = []
patch_session(monkeypatch, sink)
with pytest.raises(ForbiddenAgentError):
await AuthorizationService.require(context_with("agent:run"), "config:write")
assert len(sink) == 1
audit = sink[0]
assert audit.action_type == "permission.denied"
assert audit.actor_id == 9001
assert audit.actor_type == "user"
assert audit.detail == {"permission": "config:write", "trace_id": "trace-9"}
async def test_admin_flag_requires_admin_role_even_with_permission(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""权限码齐备但角色不是 admin:必须拒绝,否则普通角色能进管理面。"""
sink: list[Any] = []
patch_session(monkeypatch, sink)
with pytest.raises(ForbiddenAgentError):
await AuthorizationService.require(
context_with("config:read", roles=("customer",)), "config:read", admin=True
)
assert len(sink) == 1
async def test_admin_flag_passes_for_admin_role(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("app.service.authorization_service.SessionFactory", explode_on_session)
await AuthorizationService.require(
context_with("config:read", roles=("admin",)), "config:read", admin=True
)
async def test_admin_flag_still_requires_the_permission_code(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""反之亦然:是 admin 角色但没有该权限码,同样拒绝。"""
sink: list[Any] = []
patch_session(monkeypatch, sink)
with pytest.raises(ForbiddenAgentError):
await AuthorizationService.require(
context_with(roles=("admin",)), "config:read", admin=True
)
assert len(sink) == 1