"""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