116 lines
4.4 KiB
Python
116 lines
4.4 KiB
Python
"""`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)]
|