- 新增 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不存在/审计日志
190 lines
8.0 KiB
Python
190 lines
8.0 KiB
Python
"""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_name(X-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
|