"""`AdminService` 权限映射契约测试(不连数据库)。 管理面的权限判定是纯映射逻辑,但它决定"谁能改配置、谁能激活发布"。测试手法:把 `AuthorizationService.require` 换成记录器并**立刻抛错**,流程就在闸门处停下—— 既能断言传进去的权限码与 `admin` 标志,又不需要构造 session 与 repository。 覆盖的分支: - `query`:审计资源用 `audit:read`,其余用 `config:read`,且一律要求 admin 身份; - `mutate`:模型端点用 `model-endpoint:manage`,其余用 `config:write`; `reviews` 提升为 `config:review`;`activations`/`rollbacks` **仅当资源是 `config-releases` 时**才提升为 `config:activate`; - 边界:`model-endpoints` 下的 `activations` 不应被提升为 `config:activate` (否则模型端点的激活会绕过配置发布的激活语义)。 """ from typing import Any import pytest from app.core.contracts import RequestContext from app.core.errors import ForbiddenAgentError from app.service.admin_service import AdminService CONTEXT = RequestContext(user_id="9003", trace_id="trace-1", roles=("admin",)) KEY = "k" * 16 def recording_auth(captured: list[tuple[str, bool]]) -> type: class RecordingAuth: @staticmethod async def require( context: RequestContext, permission: str, *, admin: bool = False ) -> None: del context captured.append((permission, admin)) raise ForbiddenAgentError("stop at gate") return RecordingAuth def service() -> AdminService: """绕过 __init__:本测试只验证权限映射,不涉及依赖装配。""" return AdminService.__new__(AdminService) async def record_query(monkeypatch: pytest.MonkeyPatch, resource: str) -> list[tuple[str, bool]]: captured: list[tuple[str, bool]] = [] monkeypatch.setattr("app.service.admin_service.AuthorizationService", recording_auth(captured)) with pytest.raises(ForbiddenAgentError): await service().query(resource, CONTEXT) return captured async def record_mutate( monkeypatch: pytest.MonkeyPatch, resource: str, action: str ) -> list[tuple[str, bool]]: captured: list[tuple[str, bool]] = [] monkeypatch.setattr("app.service.admin_service.AuthorizationService", recording_auth(captured)) with pytest.raises(ForbiddenAgentError): await service().mutate(resource, CONTEXT, {}, KEY, None, action=action) return captured @pytest.mark.parametrize( ("resource", "expected"), [ ("audit-records", "audit:read"), ("config-releases", "config:read"), ("negative-word-rules", "config:read"), ], ) async def test_query_permission_mapping( monkeypatch: pytest.MonkeyPatch, resource: str, expected: str ) -> None: captured = await record_query(monkeypatch, resource) assert captured == [(expected, True)] @pytest.mark.parametrize( ("resource", "action", "expected"), [ ("config-releases", "write", "config:write"), ("model-endpoints", "write", "model-endpoint:manage"), ("config-releases", "reviews", "config:review"), ("model-endpoints", "reviews", "config:review"), ("config-releases", "activations", "config:activate"), ("config-releases", "rollbacks", "config:activate"), # 边界:模型端点的激活不提升为 config:activate,仍走端点管理权限。 ("model-endpoints", "activations", "model-endpoint:manage"), ("negative-word-rules", "activations", "config:write"), ], ) async def test_mutate_permission_mapping( monkeypatch: pytest.MonkeyPatch, resource: str, action: str, expected: str ) -> None: captured = await record_mutate(monkeypatch, resource, action) assert captured == [(expected, True)] async def test_gate_is_called_before_any_database_work(monkeypatch: pytest.MonkeyPatch) -> None: """闸门必须早于任何数据库访问:这里用"创建会话即报错"的替身证明。""" captured: list[tuple[str, bool]] = [] monkeypatch.setattr("app.service.admin_service.AuthorizationService", recording_auth(captured)) def exploding_factory() -> Any: raise AssertionError("权限校验未通过时不应创建数据库会话") monkeypatch.setattr("app.service.admin_service.SessionFactory", exploding_factory) with pytest.raises(ForbiddenAgentError): await service().query("config-releases", CONTEXT) assert captured == [("config:read", True)]