"""T-02 审计中间件与统一错误体(http_access / request_id / 4xx-500 / guard 双写)。 main app 真中间件栈(audit 在 trace 内层)走 TestClient;仓储注入 sqlite。 500 路径用 monkeypatch 令依赖抛 RuntimeError 验证 trace 层兜底错误体与 访问审计留痕(B7 复审 P2-2 收口)。 """ from __future__ import annotations import re import pytest from fastapi.testclient import TestClient from sqlalchemy import text from _ddl import create_sqlite_engine from app.api import deps as deps_mod from app.api import risk as risk_api from app.api import simulate as simulate_mod from app.api import audit_middleware as audit_mod from app.main import app from app.repository.risk_repository import RiskRepository from app.service.risk import redis_gateway class FakeGateway: def publish(self, channel, payload): pass def delete(self, *keys): pass def exists(self, key): return False def set_ex(self, key, value, ttl): pass @pytest.fixture() def env(monkeypatch): engine = create_sqlite_engine() repo = RiskRepository(engine=engine) monkeypatch.setattr(risk_api, "_repo", lambda: repo) monkeypatch.setattr(simulate_mod, "_repo", lambda: repo) monkeypatch.setattr(audit_mod, "_repo", lambda: repo) monkeypatch.setattr(deps_mod, "RiskRepository", lambda: repo) with TestClient(app) as c: monkeypatch.setattr(redis_gateway, "_gateway", FakeGateway()) yield {"client": c, "repo": repo, "engine": engine} engine.dispose() def _rows(engine, sql: str, **params) -> list[dict]: with engine.connect() as conn: return [dict(r) for r in conn.execute(text(sql), params).mappings().all()] def _http_access(engine) -> list[dict]: return _rows( engine, "SELECT actor_id, decision, input_summary, trace_id FROM audit_log WHERE event_type = 'http_access'", ) def test_http_access_written_with_actor(env): r = env["client"].get( "/api/risk/alerts", headers={"X-Debug-Role": "risk_officer", "X-Debug-Actor": "STAFF-30001"} ) assert r.status_code == 200 rows = _http_access(env["engine"]) assert len(rows) == 1 assert rows[0]["actor_id"] == "STAFF-30001" summary = rows[0]["input_summary"] assert '"status": 200' in summary and "/api/risk/alerts" in summary def test_http_access_skips_health(env): env["client"].get("/health") assert _http_access(env["engine"]) == [] def test_http_access_401_anonymous(env): r = env["client"].get("/api/risk/alerts") assert r.status_code == 401 rows = _http_access(env["engine"]) assert rows and rows[0]["actor_id"] == "anonymous" and rows[0]["decision"] == "401" def test_http_access_written_on_500(env, monkeypatch): """未捕获异常:http_access 500 留痕 + 统一错误体 + trace 头回写(P2-2)。""" def _boom(): raise RuntimeError("boom") monkeypatch.setattr(risk_api, "_repo", _boom) r = env["client"].get( "/api/risk/alerts", headers={"X-Debug-Role": "risk_officer", "X-Debug-Actor": "STAFF-30001"} ) assert r.status_code == 500 body = r.json() assert body["error_code"] == "INTERNAL_ERROR" assert body["trace_id"] == r.headers["X-Trace-Id"] assert r.headers["X-Request-Id"] rows = _http_access(env["engine"]) assert rows and rows[0]["decision"] == "500" # ---------- 统一错误体:422/404/405(B7 复审 P2-2 补齐) ---------- def test_error_body_422_validation(env): """鉴权依赖先于 body 校验:带 debug 头 + 缺 product_id → 422 统一错误体。""" r = env["client"].post( "/api/risk/suitability/check", json={"customer_id": "CUST-9527"}, headers={"X-Debug-Role": "risk_officer", "X-Debug-Actor": "STAFF-30001"}, ) assert r.status_code == 422 body = r.json() assert body["error_code"] == "REQUEST_VALIDATION_FAILED" assert set(body) == {"error_code", "message", "trace_id", "request_id"} def test_error_body_404_route(env): r = env["client"].get("/api/definitely-not-here") assert r.status_code == 404 and r.json()["error_code"] == "NOT_FOUND" def test_error_body_405_method(env): r = env["client"].delete("/api/risk/alerts") assert r.status_code == 405 and r.json()["error_code"] == "METHOD_NOT_ALLOWED" # ---------- 独立 request_id(B7 复审 P3-4) ---------- def test_request_id_independent_from_trace(env): r = env["client"].get("/api/risk/alerts") tid, rid = r.headers["X-Trace-Id"], r.headers["X-Request-Id"] assert tid.startswith("trc-") and rid.startswith("req-") and tid != rid assert r.json()["trace_id"] == tid # 401 错误体四键对齐各自头 def test_request_id_passthrough(env): rid = "req-abc123def45678" r = env["client"].get("/api/risk/alerts", headers={"X-Request-Id": rid}) assert r.headers["X-Request-Id"] == rid def test_request_id_invalid_regenerated(env): r = env["client"].get("/api/risk/alerts", headers={"X-Request-Id": "bad id!"}) assert r.headers["X-Request-Id"] != "bad id!" and r.headers["X-Request-Id"].startswith("req-") # ---------- input_guard_log 双写(挂账⑧) ---------- def test_guard_log_written_on_403_only(env): """401 归 platform(准入前 agent 不可信,评审 P3-2)→ 仅 403 写 guard。""" env["client"].get("/api/risk/alerts") # 401 env["client"].post( "/api/risk/alerts/A-1/handle", json={"handler_result": "confirmed_normal"}, headers={"X-Debug-Role": "compliance", "X-Debug-Actor": "STAFF-40001"}, # 403 ) rows = _rows(env["engine"], "SELECT agent_type, actor_id, guard_type, action FROM input_guard_log") assert len(rows) == 1 assert rows[0]["guard_type"] == "illegal_param" and rows[0]["action"] == "blocked" unauth = _rows(env["engine"], "SELECT agent_type FROM audit_log WHERE decision = 'unauthenticated'") assert unauth and unauth[0]["agent_type"] == "platform" def test_http_access_audit_degrades_on_db_failure(env, monkeypatch): """P2-2:audit 写库失败降级 warning,业务响应不受阻塞。""" def _boom(): raise RuntimeError("audit db down") monkeypatch.setattr(audit_mod, "_repo", _boom) r = env["client"].get( "/api/risk/alerts", headers={"X-Debug-Role": "risk_officer", "X-Debug-Actor": "STAFF-30001"} ) assert r.status_code == 200 # 降级不阻塞 def test_options_request_skipped(env): """OPTIONS(无路由方法 405)跳过审计——预检类噪音不落 http_access。""" r = env["client"].options("/api/risk/alerts") assert r.status_code == 405 # Starlette 语义;skip 语义以不落审计为准 assert _http_access(env["engine"]) == [] def test_guard_log_skipped_for_platform_agent(env): """simulate 网关(agent_type=platform)越权仅 audit_log,不写 guard(ENUM 口径)。""" env["client"].post( "/api/simulate/trade", json={"customer_id": "CUST-9527", "product_id": "PROD-110022", "trade_type": "subscribe", "amount": 100}, headers={"X-Debug-Role": "analyst", "X-Debug-Actor": "STAFF-20001"}, ) assert _rows(env["engine"], "SELECT 1 FROM input_guard_log") == [] forbidden = _rows( env["engine"], "SELECT agent_type FROM audit_log WHERE decision = 'forbidden'" ) assert forbidden and forbidden[0]["agent_type"] == "platform" # ---------- B2 · 中间件执行顺序守卫(T-202) ---------- def test_audit_middleware_runs_inside_trace_middleware(env): """守卫:audit 必须在 trace 之内执行,否则 trace_id 静默全空且不报错。 调换 main.py 两个装饰器顺序后本测试应变红(确认有效),恢复后转绿。 """ env["client"].get( "/api/risk/alerts", headers={"X-Debug-Role": "risk_officer", "X-Debug-Actor": "STAFF-30001"}, ) rows = _http_access(env["engine"]) assert rows, "应落 http_access 审计" assert rows[-1]["trace_id"], "audit 若先于 trace 执行,此处会静默为空"