Files
cma-management/backend/tests/test_risk_levels.py
T
Hermes CI Fix 758f820970 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不存在/审计日志
2026-08-28 00:31:31 +08:00

190 lines
8.0 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.
"""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