2026-09-07 02:32:03 +08:00
|
|
|
|
"""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 双写(挂账⑧) ----------
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-07 03:03:12 +08:00
|
|
|
|
def test_guard_log_written_on_403_only(env):
|
|
|
|
|
|
"""401 归 platform(准入前 agent 不可信,评审 P3-2)→ 仅 403 写 guard。"""
|
2026-09-07 02:32:03 +08:00
|
|
|
|
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")
|
2026-09-07 03:03:12 +08:00
|
|
|
|
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"]) == []
|
2026-09-07 02:32:03 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"
|