183 lines
6.3 KiB
Python
183 lines
6.3 KiB
Python
"""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_401_and_403(env):
|
||
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) == 2
|
||
assert all(r["guard_type"] == "illegal_param" and r["action"] == "blocked" for r in rows)
|
||
|
||
|
||
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"
|