feat: Bot API L1-L4风险分级标注 + 操作审计日志

- 新增 app/risk_levels.py: RISK_LEVELS定义 + @risk_level装饰器 + API_RISK_MAP
  (L1只读21 / L2业务写5 / L3批量写3 / L4=0安全底线)
- 新增 app/api/audit_log.py: Bot API审计中间件 → backend/logs/bot_audit.log
  (JSON行: timestamp/bot_name/endpoint/method/risk_level/entity_id/status,
   L3额外记rows行数, 不阻塞业务)
- bot_bridge/bot_bridge_v2/bot_kpis/bot_iron_law 全部29路由标注级别
- 新增 GET /api/cma/bot/risk-levels (X-BOT-KEY鉴权): API→级别→处理方式清单
- main.py 注册审计中间件
- tests/test_risk_levels.py: 覆盖路由标注/risk-levels端点/L4不存在/审计日志
This commit is contained in:
Hermes CI Fix
2026-08-28 00:31:31 +08:00
parent 5ceda333e2
commit 758f820970
8 changed files with 586 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
"""Bot API 风险分级(L1-L4)标注 + 操作审计日志 测试
覆盖:
1. API_RISK_MAP 覆盖所有 /api/cma/bot* 路由(app.routes 遍历核对)
2. GET /api/cma/bot/risk-levels 返回200且含L1-L4定义
3. Bot API面不存在L4端点(无 drop/truncate/delete 批量端点,安全底线)
4. 审计日志在调用Bot API后写入(monkeypatch + 真实日志文件双验证)
"""
import io
import json
import pytest
from openpyxl import Workbook
from app.main import app
from app.models import KPIDefinition
from app.risk_levels import API_RISK_MAP, RISK_LEVELS
import app.api.audit_log as audit_log_module
BOT_KEY = {"X-BOT-KEY": "cma-bot-finance-2026"}
_HTTP_METHODS = ("GET", "POST", "PUT", "DELETE", "PATCH")
def _bot_routes():
"""遍历 app.routes,返回所有 /api/cma/bot* 路由 (route, methods列表)"""
routes = []
for r in app.routes:
path = getattr(r, "path", "")
if path.startswith("/api/cma/bot"):
methods = sorted(m for m in (getattr(r, "methods", set()) or set())
if m in _HTTP_METHODS)
routes.append((r, methods))
return routes
class TestRiskMapCoverage:
def test_api_risk_map_covers_all_bot_routes(self):
"""每个 /api/cma/bot* 路由都在 API_RISK_MAP 有级别标注,且函数有@risk_level装饰器"""
missing = []
unlabeled = []
for r, methods in _bot_routes():
for m in methods:
key = f"{m} {r.path}"
if key not in API_RISK_MAP:
missing.append(key)
if not getattr(r.endpoint, "risk_level", None):
unlabeled.append(f"{sorted(methods)} {r.path}")
assert not missing, f"API_RISK_MAP 缺少以下路由标注: {missing}"
assert not unlabeled, f"以下路由函数缺少 @risk_level 装饰器: {unlabeled}"
def test_risk_level_counts(self):
"""分级统计与方案一致:L1=21(20项清单+risk-levels端点)、L2=5(4项清单+okr/create)、L3=3、L4=0"""
from collections import Counter
counts = Counter(API_RISK_MAP.values())
assert counts["L1"] == 21, counts
assert counts["L2"] == 5, counts
assert counts["L3"] == 3, counts
assert counts["L4"] == 0, "Bot API面不得存在L4端点(安全底线)"
def test_no_l4_bot_endpoints(self):
"""Bot API面不存在L4端点:无DELETE方法、无drop/truncate/delete危险路径"""
danger_keywords = ("drop", "truncate", "delete")
for r, methods in _bot_routes():
assert getattr(r.endpoint, "risk_level", None) != "L4", \
f"{r.path} 不应被标注为L4"
assert "DELETE" not in methods, f"Bot路由不应有DELETE方法: {r.path}"
low = r.path.lower()
for kw in danger_keywords:
assert kw not in low, f"Bot路由不应含危险路径片段: {r.path}"
class TestRiskLevelsEndpoint:
def test_risk_levels_requires_bot_key(self, client):
"""无X-BOT-KEY → 401"""
resp = client.get("/api/cma/bot/risk-levels")
assert resp.status_code == 401
def test_risk_levels_returns_200_with_definitions(self, client):
"""X-BOT-KEY → 200,含L1-L4定义与API→级别→处理方式清单"""
resp = client.get("/api/cma/bot/risk-levels", headers=BOT_KEY)
assert resp.status_code == 200
data = resp.json()
# L1-L4 定义齐全
for level, label in RISK_LEVELS.items():
assert data["risk_levels"][level] == label, f"缺少 {level} 定义"
# API→级别→处理方式 清单
apis = {f"{a['method']} {a['path']}": a for a in data["apis"]}
assert apis["GET /api/cma/bot/ping"]["risk_level"] == "L1"
assert apis["POST /api/cma/bot/kpi-value-with-check"]["risk_level"] == "L2"
assert apis["POST /api/cma/bot/import"]["risk_level"] == "L3"
assert apis["POST /api/cma/bot/import"]["handling"] # 处理方式非空
# 分级统计
assert data["summary"]["L1"] == 21
assert data["summary"]["L2"] == 5
assert data["summary"]["L3"] == 3
assert data["summary"]["L4"] == 0
class TestAuditLog:
def test_audit_record_after_bot_call(self, client, monkeypatch):
"""调用Bot API后写出审计记录(monkeypatch捕获)"""
records = []
monkeypatch.setattr(audit_log_module, "write_audit_line",
lambda rec: records.append(rec))
resp = client.get("/api/cma/bot/ping")
assert resp.status_code == 200
assert len(records) >= 1
rec = records[-1]
assert rec["method"] == "GET"
assert rec["endpoint"] == "/api/cma/bot/ping"
assert rec["risk_level"] == "L1"
assert rec["status"] == 200
# 字段齐全
for field in ("timestamp", "bot_name", "endpoint", "method",
"risk_level", "entity_id", "status"):
assert field in rec, f"审计记录缺少字段: {field}"
def test_audit_file_written_json_lines(self, client, tmp_path, monkeypatch):
"""真实日志文件:调用Bot API后 bot_audit.log 追加JSON行"""
log_file = tmp_path / "bot_audit.log"
monkeypatch.setenv("CMA_BOT_AUDIT_LOG", str(log_file))
monkeypatch.setattr(audit_log_module, "_audit_logger", None)
resp = client.get("/api/cma/bot/ping")
assert resp.status_code == 200
assert log_file.exists()
lines = log_file.read_text(encoding="utf-8").strip().splitlines()
assert lines, "审计日志文件为空"
rec = json.loads(lines[-1])
assert rec["endpoint"] == "/api/cma/bot/ping"
assert rec["risk_level"] == "L1"
assert rec["status"] == 200
def test_audit_entity_id_and_bot_name(self, client, db, monkeypatch):
"""审计记录含 bot_nameX-BOT-KEY映射)与 entity_id"""
records = []
monkeypatch.setattr(audit_log_module, "write_audit_line",
lambda rec: records.append(rec))
kpi = KPIDefinition(kpi_code="AUDIT_EID", kpi_name="审计实体", dimension="finance",
status="active", target_value=1.0, entity_id=1)
db.add(kpi)
db.commit()
resp = client.post("/api/cma/bot/kpi-value-with-check",
json={"kpi_id": kpi.id, "actual_value": 66.0,
"period": "2026-08", "entity_id": 1},
headers=BOT_KEY)
assert resp.status_code == 200
assert records, "应有审计记录"
rec = records[-1]
assert rec["bot_name"] == "财务BOT"
assert rec["entity_id"] == 1
assert rec["risk_level"] == "L2"
assert rec["status"] == 200
# JSON body 读取未破坏业务
assert resp.json()["status"] == "ok"
def test_l3_batch_write_records_rows(self, client, db, monkeypatch):
"""L3批量写(/import):审计记录额外含 rows 行数"""
kpi = KPIDefinition(kpi_code="AUDIT_ROWS", kpi_name="审计行数", dimension="finance",
status="active", target_value=1.0)
db.add(kpi)
db.commit()
wb = Workbook()
ws = wb.active
ws.append(["kpi_code", "period", "actual_value"])
ws.append(["AUDIT_ROWS", "2026-08", 88.0])
ws.append(["AUDIT_ROWS", "2026-07", 77.0])
buf = io.BytesIO()
wb.save(buf)
records = []
monkeypatch.setattr(audit_log_module, "write_audit_line",
lambda rec: records.append(rec))
files = {"file": ("kpi.xlsx", buf.getvalue(),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}
resp = client.post("/api/cma/bot/import", headers=BOT_KEY, files=files)
assert resp.status_code == 200
assert resp.json()["imported"] == 2
assert records, "应有审计记录"
rec = records[-1]
assert rec["risk_level"] == "L3"
assert rec["rows"] == 2