Files
XingHuo/tests/test_audit_middleware.py
T

205 lines
7.3 KiB
Python
Raw Normal View History

"""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 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"