Files
cma-management/backend/tests/test_bot_suggestion_channel.py
T
wecom-fullstack 52b693ddbb ASK-20260924-15/17: Bot 通道缺省账套→400 + 差异/趋势入口期间口径回落
- deps.get_entity_id: Bot 通道(带 X-BOT-KEY)无显式账套 → 400(旧为兜底 entity 1=酣客);
  无 key 的存量公开/用户端点行为不变(仍 401/兜底 1)
- 新增 deps.require_bot_entity_id():写端点缺省账套一处收口 → 400(406/422 同族)
  接入 bot_bridge(×2) / bot_bridge_v2 / bot_suggestions / cash_vouchers_json,并同步文档口径
- deviation_engine.run_deviation_check: period=None 不再硬编码当月,逐 KPI 回落
  「该 KPI 自己的最近可用期间」(裁定2b,单一实现收口到 alert_entity_scope);(当月无
  kpi_values ⇒ 差异/趋势入口永久 0 新增);R2/R3 窗口判定改为逐 KPI;显式 period 语义不变
- alert_generator.resolve_latest_period 委托同一实现(消除期间口径两份)
- 测试:新增 tests/test_ask15_17_engine_and_entity_required_20260925.py(12 例);
  4 个既有测试文件把「旧兜底账套 1」显式化(断言未改)
2026-09-25 22:42:25 +08:00

200 lines
8.5 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侧高危建议创建通道 + 分层授权 测试 (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)
# ASK-20260924-17:Bot 通道缺省账套 → 400(旧为兜底 entity 1)。本 helper 默认账套 1(显式化),
# 显式传 entity_id 的用例(如跨账套 404/403 断言)经 body.update(kw) 覆盖,语义不变。
body.setdefault("entity_id", 1)
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