"""admin 应用装配层单测(不连库,FR-ADM 鉴权语义,T-P2-09)。 覆盖三件事: 1. ``ADMIN_TOKEN=""`` 时全部业务路由不注册(404 = 等价未部署); 2. 令牌缺失 / 错误 / 非 ASCII → 40100(比较必须走 bytes:str 版 ``compare_digest`` 遇非 ASCII 会 TypeError 变 500); 3. 正确令牌 → 200:服务依赖用假实现替换(单测不碰 DB),顺带验证 发布编排(publish → 规则缓存 + 检索缓存双失效)在路由层的接线。 真实的 40900 / 缓存副作用语义由 ``tests/integration/test_admin_console.py`` 用真库验证。 """ from __future__ import annotations from collections.abc import AsyncIterator from contextlib import asynccontextmanager from datetime import UTC, datetime from types import SimpleNamespace import httpx import pytest from fastapi import FastAPI from apps.admin.app import ADMIN_API_PREFIX, create_admin_app from apps.admin.deps import ( kb_asset_service_dep, kb_service_dep, rule_service_dep, session_dep, ) from apps.admin.publishing import invalidate_downstream_caches from packages.kb.schemas import EntityUpdate from packages.shared.config import Settings TOKEN = "unit-admin-token" P = ADMIN_API_PREFIX #: 全部业务路由的代表性样本——空令牌时一个都不该存在。 _ALL_ROUTES: tuple[tuple[str, str], ...] = ( ("GET", f"{P}/kb/items"), ("POST", f"{P}/kb/items"), ("GET", f"{P}/kb/items/1"), ("PATCH", f"{P}/kb/items/1"), ("POST", f"{P}/kb/items/1/transition"), ("GET", f"{P}/kb/items/1/graph"), ("GET", f"{P}/kb/gaps"), ("GET", f"{P}/rules"), ("POST", f"{P}/rules"), ("PATCH", f"{P}/rules/demo-rule"), ("POST", f"{P}/rules/demo-rule/transition"), ("GET", f"{P}/assets"), ("POST", f"{P}/assets"), ("POST", f"{P}/assets/1/review"), ) def _build_app(token: str) -> FastAPI: return create_admin_app(Settings(admin_token=token)) @asynccontextmanager async def _client(app: FastAPI) -> AsyncIterator[httpx.AsyncClient]: """不进 lifespan:单测不建 DB engine,只测装配与鉴权层。""" transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as http: yield http # --- 假实现:够走通序列化即可,不模拟 ORM 行为 -------------------------------- def _fake_entity(status: str) -> SimpleNamespace: now = datetime.now(UTC) return SimpleNamespace( entity_id=123456789012345678, entity_type="item", name="白衬衫", attrs={"sleeve": "long"}, status=status, version=1, source=None, license=None, updated_by=None, created_at=now, updated_at=now, ) def _fake_rule(status: str) -> SimpleNamespace: now = datetime.now(UTC) return SimpleNamespace( rule_id=987654321098765432, rule_key="demo-rule", category="body_shape", version=1, status=status, priority=90, conditions={"body_shape": "A型"}, effects={"avoid": [{"slot": "top", "attrs": {"shoulder": ["落肩"]}}]}, is_hard=False, confidence=0.9, evidence=None, author=None, reviewer=None, published_at=None, created_at=now, ) class _ListKbService: async def search_entities(self, **_: object) -> tuple[list[object], int]: return [], 0 class _ListRuleService: async def list(self, status: str | None = None) -> list[object]: return [] class _ListAssetService: async def list_assets(self, **_: object) -> tuple[list[object], int]: return [], 0 class _TransitionKbService: """记录 update 调用的假实体服务(transition 路由专用)。""" def __init__(self, entity: SimpleNamespace) -> None: self._entity = entity self.update_calls: list[dict[str, object]] = [] async def get_entity(self, *, entity_id: int) -> SimpleNamespace: return self._entity async def update_entity( self, *, entity_id: int, payload: EntityUpdate, updated_by: str | None = None, ) -> SimpleNamespace: self.update_calls.append({"entity_id": entity_id, "target": payload.status}) self._entity.status = payload.status return self._entity class _TransitionRuleService: def __init__(self, row: SimpleNamespace) -> None: self._row = row self.calls: list[str] = [] async def publish(self, rule_key: str) -> SimpleNamespace: self.calls.append(f"publish:{rule_key}") self._row.status = "published" return self._row async def archive(self, rule_key: str) -> SimpleNamespace: self.calls.append(f"archive:{rule_key}") self._row.status = "archived" return self._row class _FakeScalars: def all(self) -> list[object]: return [] class _FakeExecResult: def scalars(self) -> _FakeScalars: return _FakeScalars() class _FakeSession: async def execute(self, stmt: object) -> _FakeExecResult: return _FakeExecResult() async def _fake_session_dep() -> AsyncIterator[_FakeSession]: yield _FakeSession() # --- 1. 空令牌 = 未部署 -------------------------------------------------------- async def test_empty_token_registers_no_routes_at_all() -> None: app = create_admin_app(Settings(admin_token="")) async with _client(app) as client: for method, path in _ALL_ROUTES: resp = await client.request( method, path, json={}, headers={"X-Admin-Token": "anything"} ) assert resp.status_code == 404, (method, path) # 404 也保持统一信封(经 StarletteHTTPException 处理器转 40400) assert resp.json()["code"] == 40400 # openapi / docs 一并关闭,扫描器探不到任何面 assert (await client.get(f"{P}/openapi.json")).status_code == 404 assert (await client.get(f"{P}/docs")).status_code == 404 # --- 2. 令牌校验 -------------------------------------------------------------- async def test_missing_or_wrong_token_is_40100() -> None: app = _build_app(TOKEN) async with _client(app) as client: cases: list[dict[str, str] | None] = [ None, # 无头 {"X-Admin-Token": "wrong-token"}, {"X-Admin-Token": ""}, # 空头与缺失同罪 {"X-Admin-Token": TOKEN + "x"}, # 前缀对也不行(compare_digest 全长比较) ] for headers in cases: resp = await client.get(f"{P}/rules", headers=headers) assert resp.status_code == 401 assert resp.json()["code"] == 40100 async def test_non_ascii_token_does_not_blow_up_into_500() -> None: """str 版 compare_digest 遇非 ASCII 会 TypeError;实现必须走 bytes 比较。 httpx 不许 str 头带非 ASCII,但线上恶意客户端可以发任意字节——这里用 bytes 头值模拟(starlette 会以 latin-1 解出非 ASCII str)。 """ app = _build_app(TOKEN) async with _client(app) as client: resp = await client.get(f"{P}/rules", headers={"X-Admin-Token": "非ASCII令牌".encode()}) assert resp.status_code == 401 assert resp.json()["code"] == 40100 # --- 3. 正确令牌 → 200(假服务) ---------------------------------------------- async def test_valid_token_reaches_all_read_handlers() -> None: app = _build_app(TOKEN) app.dependency_overrides[kb_service_dep] = lambda: _ListKbService() app.dependency_overrides[rule_service_dep] = lambda: _ListRuleService() app.dependency_overrides[kb_asset_service_dep] = lambda: _ListAssetService() app.dependency_overrides[session_dep] = _fake_session_dep headers = {"X-Admin-Token": TOKEN} async with _client(app) as client: for path in (f"{P}/kb/items", f"{P}/rules", f"{P}/assets", f"{P}/kb/gaps"): resp = await client.get(path, headers=headers) assert resp.status_code == 200, path body = resp.json() assert body["code"] == 0 assert body["data"]["items"] == [] assert body["request_id"] # 中间件注入,V1 操作留证的依托 # --- 4. 发布编排接线 ---------------------------------------------------------- def test_invalidate_downstream_caches_calls_both_domains( monkeypatch: pytest.MonkeyPatch, ) -> None: from apps.admin import publishing calls: list[str] = [] monkeypatch.setattr( publishing.rules_service, "invalidate_published_cache", lambda: calls.append("rules") ) monkeypatch.setattr( publishing.retrieval_service, "invalidate_cache", lambda: calls.append("retrieval") ) invalidate_downstream_caches(domain="kb_entity", key="1", target="published") assert calls == ["rules", "retrieval"] async def test_entity_publish_transition_triggers_double_invalidation( monkeypatch: pytest.MonkeyPatch, ) -> None: app = _build_app(TOKEN) svc = _TransitionKbService(_fake_entity(status="reviewing")) app.dependency_overrides[kb_service_dep] = lambda: svc triggered: list[dict[str, str]] = [] monkeypatch.setattr( "apps.admin.routers.kb.invalidate_downstream_caches", lambda **kw: triggered.append(kw), ) async with _client(app) as client: resp = await client.post( f"{P}/kb/items/1/transition", json={"target": "published"}, headers={"X-Admin-Token": TOKEN}, ) assert resp.status_code == 200 assert resp.json()["data"]["status"] == "published" assert svc.update_calls == [{"entity_id": 1, "target": "published"}] assert triggered == [ {"domain": "kb_entity", "key": "123456789012345678", "target": "published"} ] async def test_entity_transition_replay_is_noop_without_invalidation( monkeypatch: pytest.MonkeyPatch, ) -> None: """与当前状态相同的重放幂等成功,但不触发缓存重建(重试不该有副作用)。""" app = _build_app(TOKEN) svc = _TransitionKbService(_fake_entity(status="published")) app.dependency_overrides[kb_service_dep] = lambda: svc triggered: list[dict[str, str]] = [] monkeypatch.setattr( "apps.admin.routers.kb.invalidate_downstream_caches", lambda **kw: triggered.append(kw), ) async with _client(app) as client: resp = await client.post( f"{P}/kb/items/1/transition", json={"target": "published"}, headers={"X-Admin-Token": TOKEN}, ) assert resp.status_code == 200 assert resp.json()["data"]["status"] == "published" assert svc.update_calls == [] assert triggered == [] async def test_entity_transition_accepts_draft_target_at_schema_level( monkeypatch: pytest.MonkeyPatch, ) -> None: """target 收全量四态:draft 能通过 body 校验到达处理器(证明不是 422 拦截)。 这里用「draft→draft 的幂等重放分支」验证到达;真实的 published→draft 40900 语义由集成测试对真状态机验证。 """ app = _build_app(TOKEN) svc = _TransitionKbService(_fake_entity(status="draft")) app.dependency_overrides[kb_service_dep] = lambda: svc triggered: list[dict[str, str]] = [] monkeypatch.setattr( "apps.admin.routers.kb.invalidate_downstream_caches", lambda **kw: triggered.append(kw), ) async with _client(app) as client: resp = await client.post( f"{P}/kb/items/1/transition", json={"target": "draft"}, headers={"X-Admin-Token": TOKEN}, ) assert resp.status_code == 200 assert resp.json()["data"]["status"] == "draft" assert svc.update_calls == [] async def test_rule_transition_publish_and_archive_both_invalidate( monkeypatch: pytest.MonkeyPatch, ) -> None: app = _build_app(TOKEN) svc = _TransitionRuleService(_fake_rule(status="draft")) app.dependency_overrides[rule_service_dep] = lambda: svc triggered: list[dict[str, str]] = [] monkeypatch.setattr( "apps.admin.routers.rules.invalidate_downstream_caches", lambda **kw: triggered.append(kw), ) headers = {"X-Admin-Token": TOKEN} async with _client(app) as client: published = await client.post( f"{P}/rules/demo-rule/transition", json={"target": "published"}, headers=headers ) assert published.status_code == 200 assert published.json()["data"]["status"] == "published" archived = await client.post( f"{P}/rules/demo-rule/transition", json={"target": "archived"}, headers=headers ) assert archived.status_code == 200 assert archived.json()["data"]["status"] == "archived" # 规则状态机没有「回 draft」动作,schema 层直接收窄成 422 bad = await client.post( f"{P}/rules/demo-rule/transition", json={"target": "draft"}, headers=headers ) assert bad.status_code == 422 assert bad.json()["code"] == 40001 assert svc.calls == ["publish:demo-rule", "archive:demo-rule"] assert [t["target"] for t in triggered] == ["published", "archived"] async def test_entity_transition_rejects_unknown_target() -> None: """未知目标值(不在四态里)在 schema 层拦成 422。""" app = _build_app(TOKEN) async with _client(app) as client: resp = await client.post( f"{P}/kb/items/1/transition", json={"target": "frozen"}, headers={"X-Admin-Token": TOKEN}, ) assert resp.status_code == 422 assert resp.json()["code"] == 40001