feat(bot-tier): M1 Bot侧高危建议创建通道+目标值直写收口 (P0-②分层授权)
- 新增 POST /api/cma/bot/ai/suggestions (bot_suggestions.py, X-BOT-KEY鉴权, L2) 高危建议(kpi_target/budget_adjust/action_plan) status恒unapplied 等人审批 source白名单(bot/rule)防冒充manual; entity多租户归属校验; 幂等防重 - risk_levels.py: API_RISK_MAP加新端点 + 文件头补写操作语义分级low/high说明 - 收口: bot/kpis/create-with-links 拒绝直写非零target_value(显式置0, 考核基准=人拍板) - test_risk_levels: L2计数5→6; 新增test_bot_suggestion_channel 12条(鉴权/语义/多租户/幂等/审批闭环/审计)
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
"""Bot侧高危建议创建通道 + 分层授权 测试 (P0-② 2026-09-03)
|
||||
|
||||
覆盖:
|
||||
1. 鉴权: 无 X-BOT-KEY → 401; X-BOT-KEY → 200
|
||||
2. 高危语义: status 恒 unapplied; source 白名单(bot/rule); 类型白名单
|
||||
3. 多租户: 建议只能指向当前账套KPI(跨账套 target → 404); entity 不存在/未激活 → 403
|
||||
4. 幂等: 同 entity+type+target_id+title 未应用已存在 → 不重复创建
|
||||
5. 审批闭环: 人 apply → KPI目标变更 + apply_detail before/after + applied_by/applied_at + 重复apply拒绝
|
||||
6. audit 留痕: bot 创建建议写入 audit(monkeypatch捕获 /api/cma/bot/* 中间件)
|
||||
7. create-with-links 收口: Bot 直写非零 target_value → 400
|
||||
"""
|
||||
import json
|
||||
|
||||
from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
|
||||
from app.models import AISuggestion, KPIDefinition, Entity
|
||||
|
||||
BOT_KEY = {"X-BOT-KEY": "cma-bot-finance-2026"}
|
||||
|
||||
BOT_PATH = "/api/cma/bot/ai/suggestions"
|
||||
|
||||
|
||||
def _bot_create(client, kpi_id, **kw):
|
||||
body = {
|
||||
"suggestion_type": "kpi_target",
|
||||
"target_id": kpi_id,
|
||||
"title": "Bot建议:上调目标(E2E-验证)",
|
||||
"content": "规则引擎/财务Bot分析建议",
|
||||
"source": "bot",
|
||||
"suggestion_data": {"kpi_id": kpi_id, "target_value": 150.0},
|
||||
}
|
||||
body.update(kw)
|
||||
return client.post(BOT_PATH, json=body, headers=BOT_KEY)
|
||||
|
||||
|
||||
class TestBotAuth:
|
||||
def test_requires_bot_key(self, client, db):
|
||||
kpi = create_test_kpi(db)
|
||||
r = client.post(BOT_PATH, json={"suggestion_type": "kpi_target", "title": "x",
|
||||
"target_id": kpi.id})
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_create_with_bot_key(self, client, db):
|
||||
kpi = create_test_kpi(db)
|
||||
r = _bot_create(client, kpi.id)
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()["data"]
|
||||
assert data["status"] == "unapplied"
|
||||
assert data["source"] == "bot"
|
||||
assert data["suggestion_type"] == "kpi_target"
|
||||
assert data["user_id"] is None
|
||||
# 落库
|
||||
sug = db.query(AISuggestion).filter(AISuggestion.id == data["id"]).first()
|
||||
assert sug is not None and sug.status == "unapplied"
|
||||
|
||||
def test_human_token_rejected_on_bot_path(self, client, db):
|
||||
"""人token不能走Bot通道(Bot端点只认X-BOT-KEY)"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
r = client.post(BOT_PATH, json={"suggestion_type": "kpi_target", "title": "x",
|
||||
"target_id": kpi.id, "target_value": 150},
|
||||
headers=auth_header(token))
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
class TestBotSemantics:
|
||||
def test_type_whitelist(self, client, db):
|
||||
kpi = create_test_kpi(db)
|
||||
r = _bot_create(client, kpi.id, suggestion_type="delete_all")
|
||||
assert r.status_code == 400
|
||||
assert "仅支持" in r.text
|
||||
|
||||
def test_source_whitelist_no_spoof(self, client, db):
|
||||
kpi = create_test_kpi(db)
|
||||
r = _bot_create(client, kpi.id, source="manual")
|
||||
assert r.status_code == 400
|
||||
assert "source" in r.text
|
||||
|
||||
def test_kpi_target_requires_kpi_and_value(self, client, db):
|
||||
# 无 kpi/target
|
||||
r = _bot_create(client, None, suggestion_data={})
|
||||
assert r.status_code == 400
|
||||
# kpi_target 无 target_value
|
||||
kpi = create_test_kpi(db)
|
||||
r2 = _bot_create(client, kpi.id, suggestion_data={"kpi_id": kpi.id})
|
||||
assert r2.status_code == 400
|
||||
|
||||
def test_cross_entity_target_rejected(self, client, db):
|
||||
"""多租户铁律: 建议只能指向当前账套KPI"""
|
||||
db.add(Entity(id=2, name="另一企业", short_name="B", status="active"))
|
||||
db.commit()
|
||||
kpi_e1 = create_test_kpi(db, entity_id=1)
|
||||
# 以 entity_id=2 身份建议 entity1 的KPI → 404
|
||||
r = _bot_create(client, kpi_e1.id, entity_id=2)
|
||||
assert r.status_code == 404
|
||||
assert "不存在于当前账套" in r.text
|
||||
# entity 不存在/未激活 → 403
|
||||
r2 = _bot_create(client, kpi_e1.id, entity_id=99)
|
||||
assert r2.status_code == 403
|
||||
|
||||
def test_idempotent(self, client, db):
|
||||
kpi = create_test_kpi(db)
|
||||
r1 = _bot_create(client, kpi.id)
|
||||
r2 = _bot_create(client, kpi.id)
|
||||
assert r1.status_code == 200
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["duplicated"] is True
|
||||
assert r2.json()["data"]["id"] == r1.json()["data"]["id"]
|
||||
assert db.query(AISuggestion).count() == 1
|
||||
|
||||
|
||||
class TestApproveClosedLoop:
|
||||
def test_bot_suggestion_apply_chain(self, client, db):
|
||||
"""bot创建kpi_target建议 → unapplied → 人apply → 目标值生效 + 留痕完整"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db, target_value=100.0)
|
||||
|
||||
r = _bot_create(client, kpi.id, title="Bot建议:上调目标(E2E-验证)")
|
||||
sug_id = r.json()["data"]["id"]
|
||||
assert r.json()["data"]["status"] == "unapplied"
|
||||
|
||||
# 待批列表可见(status=unapplied)
|
||||
lst = client.get("/api/cma/ai/suggestions?status=unapplied", headers=auth_header(token)).json()
|
||||
assert any(s["id"] == sug_id for s in lst["data"])
|
||||
|
||||
# 人 apply
|
||||
app = client.post(
|
||||
f"/api/cma/ai/suggestions/{sug_id}/apply",
|
||||
json={"action": "kpi_target", "target_value": 150.0},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert app.status_code == 200, app.text
|
||||
ad = app.json()["data"]
|
||||
assert ad["status"] == "applied"
|
||||
assert ad["applied_by"] == "测试管理员"
|
||||
assert ad["applied_at"]
|
||||
assert ad["apply_detail"][0]["before"] == 100.0
|
||||
assert ad["apply_detail"][0]["after"] == 150.0
|
||||
assert ad["apply_detail"][0]["action"] == "update_target_value"
|
||||
|
||||
db.refresh(kpi)
|
||||
assert kpi.target_value == 150.0
|
||||
|
||||
# 重复apply拒绝
|
||||
app2 = client.post(
|
||||
f"/api/cma/ai/suggestions/{sug_id}/apply",
|
||||
json={"action": "kpi_target", "target_value": 200.0},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert app2.status_code == 400
|
||||
|
||||
|
||||
class TestAuditCoverage:
|
||||
def test_bot_suggestion_audited(self, client, db, monkeypatch):
|
||||
"""/api/cma/bot/ai/suggestions 被 bot_audit_middleware 留痕(risk L2)"""
|
||||
import app.api.audit_log as audit_log_module
|
||||
records = []
|
||||
monkeypatch.setattr(audit_log_module, "write_audit_line",
|
||||
lambda rec: records.append(rec))
|
||||
kpi = create_test_kpi(db)
|
||||
r = _bot_create(client, kpi.id)
|
||||
assert r.status_code == 200
|
||||
assert records, "应有审计记录"
|
||||
rec = records[-1]
|
||||
assert rec["endpoint"] == BOT_PATH
|
||||
assert rec["method"] == "POST"
|
||||
assert rec["risk_level"] == "L2"
|
||||
assert rec["bot_name"] == "财务BOT"
|
||||
assert rec["status"] == 200
|
||||
|
||||
|
||||
class TestCreateWithLinksClosure:
|
||||
def test_nonzero_target_value_rejected(self, client, db):
|
||||
"""收口: create-with-links 直写非零 target_value → 400"""
|
||||
r = client.post("/api/cma/bot/kpis/create-with-links",
|
||||
json={"kpi_code": "E2E_TGT", "kpi_name": "收口验证KPI",
|
||||
"dimension": "finance", "target_value": 500000,
|
||||
"entity_id": 1},
|
||||
headers=BOT_KEY)
|
||||
assert r.status_code == 400
|
||||
assert "不允许直写目标值" in r.text
|
||||
|
||||
def test_without_target_value_ok(self, client, db):
|
||||
"""省略/置0 target_value 仍可创建KPI(收口自动置0,不阻断KPI创建)"""
|
||||
r = client.post("/api/cma/bot/kpis/create-with-links",
|
||||
json={"kpi_code": "F_E2E_TGT2", "kpi_name": "收口放行KPI",
|
||||
"dimension": "finance", "unit": "万元", "formula": "营业收入",
|
||||
"data_source": "Bot创建验证", "data_owner": "财务部",
|
||||
"frequency": "monthly", "kpi_level": "operational",
|
||||
"entity_id": 1},
|
||||
headers=BOT_KEY)
|
||||
assert r.status_code == 200, r.text
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == "F_E2E_TGT2").first()
|
||||
assert kpi is not None
|
||||
assert kpi.target_value == 0
|
||||
@@ -49,11 +49,11 @@ class TestRiskMapCoverage:
|
||||
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"""
|
||||
"""分级统计与方案一致:L1=21(20项清单+risk-levels端点)、L2=6(4项清单+okr/create+bot建议通道)、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["L2"] == 6, counts
|
||||
assert counts["L3"] == 3, counts
|
||||
assert counts["L4"] == 0, "Bot API面不得存在L4端点(安全底线)"
|
||||
|
||||
@@ -94,7 +94,7 @@ class TestRiskLevelsEndpoint:
|
||||
|
||||
# 分级统计
|
||||
assert data["summary"]["L1"] == 21
|
||||
assert data["summary"]["L2"] == 5
|
||||
assert data["summary"]["L2"] == 6
|
||||
assert data["summary"]["L3"] == 3
|
||||
assert data["summary"]["L4"] == 0
|
||||
|
||||
|
||||
Reference in New Issue
Block a user