Files
group_xinghuo_jinrong/tests/test_audit_middleware.py
T
GaoYiYuan_0626 037ce7edca docs(架构改进): 补齐 PRD/开发计划/TODO/交接文档,落地无密钥告警与 Redis 分布式锁
一、流程文档(按 AIcoding 六步落地,供新会话从交接文档开工)
- 新增 docs/PRD/PRD-架构改进与稳定性加固.md:6 条 FR(文档勘误、非缺陷说明、
  无密钥启动告警、审计失败告警、Redis 分布式锁、中间件顺序测试)
- 新增 docs/项目框架设计/改进方案评审-问题清单与对比.md:24 项问题分档 A~G,
  经两轮独立 AI 评审,无阻断级错误
- 新增 docs/项目框架设计/开发计划-架构改进.md:HOW 层设计,含合并前只做低风险
  11 项的批次策略
- 新增 docs/项目框架设计/TODO-架构改进.md:T-101~T-109、T-201~T-202 可勾选项
- 新增 docs/交接文档-架构改进.md:自包含交接入口,hy3 新会话可直接开工
- 新增 docs/项目框架设计/架构设计说明书.md:按模块/分层逐一讲解的全量架构说明

二、代码改动(T-107/108/109、T-201.1、T-201.2)
- app/main.py:启动时 DEEPSEEK_API_KEY 缺失告警,明确告知将走降级回复
- app/utils/authz.py:越权审计失败日志补 trace_id,便于串联全链路
- app/api/audit_middleware.py:审计失败日志补 status/path/request_id
- app/service/risk/redis_gateway.py:新增 acquire_lock(SET NX EX)与
  release_lock(Lua 原子释放,只删自己的锁)
- app/service/risk/locks.py:run_locked 改为双层锁,Redis 为主、进程内锁为备;
  Redis 超时沿用 fn(locked=False) 降级语义,Redis 不可用(含测试 Fake 缺方法的
  AttributeError)安全退回进程内锁,绝不抛异常

三、文档勘误(A1/A2/A3)
- MEMORY.md:文件数 42→45、Tools 4→5
- 02-mysql-agent专用.sql:会话表 5→6
- 架构设计-风控模块.md:同步更正

四、测试
- 新增 tests/test_locks_redis.py:覆盖抢锁成功、占用超时、Redis 故障降级、
  Fake 缺方法降级、只删自己锁、三处调用点 key 前缀
- tests/test_audit_middleware.py:补充告警字段断言
- 全量 pytest 510 passed(原基线 503)
2026-09-09 18:10:03 +08:00

222 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 执行,此处会静默为空"