Files
cma-management/backend/tests/test_dismiss_suggestion_entity_scope_20260918.py
T
Hermes CI Fix 9cb960da25 fix(cma): scope ai-suggestion dismiss and target KPI to session entity
dismiss_suggestion: add get_entity_id dependency and filter AISuggestion.entity_id (cross-entity dismiss returned 200 and mutated the foreign row).

_apply_kpi_target/_apply_budget_adjust: constrain target KPIDefinition lookup to sug.entity_id so suggestion_data pointing at a foreign KPI yields 404 instead of rewriting it.

Regression: backend/tests/test_dismiss_suggestion_entity_scope_20260918.py (RED 3 failed before, GREEN 5 passed after; full suite 1220 -> 1225 passed, 1 xfailed).
2026-09-18 20:33:39 +08:00

276 lines
13 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.
"""CMA 跨实体隔离 — AI 建议「忽略」与「应用目标 KPI」账套收敛(2026-09-18 派生单)
任务:fullstack-20260918-2030-dismiss-suggestion-entity-scope
父单:fullstack-20260918-2025-read-side-entity-scope-gaps
(其 §五 范围外发现 第 1/2 条:写侧同族漏网点)
缺陷(同文件 ai_suggestions.py 的写侧漏网点):
1. dismiss_suggestion — POST /{suggestion_id}/dismiss 按 id 直取、无账套条件,
且会改状态(status="dismissed")
⇒ 本账套会话带对端账套建议 id 可跨账套忽略对端建议(数据被改写)
2. _apply_kpi_target — 目标 KPIDefinition 只按 id 查,不校验归属会话账套
3. _apply_budget_adjust — 同上(BudgetPlan 侧已按 sug.entity_id,KPI 侧未校验)
⇒ 建议自身归属校验通过后,其 suggestion_data 指向对端账套 KPI 时,
应用动作会改到对端账套的 target_value / 预算行(纵深防御缺口)
对端记录口径 = 404「建议不存在」/「KPI {id} 不存在」(对齐同文件
apply_suggestion 与 1905b/2015 的写法)。
本用例(改前失败条目见 _reports/evidence-fullstack-20260918-2030-red.txt):
- T1 忽略建议:对端账套 → 404 且建议状态保持 unapplied(双向);本账套 → 200
且状态 dismissed(双向正对照)
- T2 忽略幂等:同一建议重复忽略仍 200、状态仍 dismissed(既有语义不得改动)
- T3 应用 kpi_target:目标 KPI 非本账套 → 404,对端 KPI target_value 逐值未变,
建议状态保持 unapplied;本账套应用正常(正对照,目标值已写入)
- T4 应用 budget_adjust:目标 KPI 非本账套 → 404,不落任何预算行(对端/本账套都不落)
- T5 鉴权底线:dismiss 无 token → 401(改前改后都应通过)
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import AISuggestion, BudgetPlan, Entity, KPIDefinition
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
SUGG = "/api/cma/ai/suggestions"
# kpi_code 全局唯一 → 每账套独立 code
E1_KPI = "ZZ_DSE1_KPI"
E2_KPI = "ZZ_DSE2_KPI"
E1_SUG_TITLE = "ZZ_DS e1 私有建议"
E2_SUG_TITLE = "ZZ_DS e2 私有建议"
E1_CROSS_TITLE = "ZZ_DS e2 指向对端 KPI 的建议"
# ──────────────────────────────────────────────
# 夹具(沿用同族风格:双向登录 + 同账套正对照)
# ──────────────────────────────────────────────
def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
ent = db.query(Entity).filter(Entity.id == eid).first()
if not ent:
ent = Entity(id=eid, name=name, short_name=name, status="active")
db.add(ent)
db.commit()
db.refresh(ent)
return ent
def _mk_kpi(db: Session, code: str, eid: int, target: float = 1000.0) -> KPIDefinition:
kpi = KPIDefinition(
entity_id=eid,
kpi_code=code,
kpi_name=f"{code}-名称",
dimension="finance",
category="revenue_growth",
formula="-",
data_source_type="excel",
data_source="Excel导入",
data_owner="财务部",
frequency="monthly",
unit="元",
target_value=target,
kpi_level="operational",
status="active",
)
db.add(kpi)
db.commit()
db.refresh(kpi)
return kpi
def _seed_two_entities(db: Session):
_ensure_entity(db, 1, "酣客")
_ensure_entity(db, 2, "博海")
create_test_user(db)
def _login(client: TestClient, entity_id: int) -> str:
resp = client.post("/api/cma/auth/login", json={
"username": "testadmin",
"password": TEST_USER_PASSWORD,
"entity_id": entity_id,
})
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
return resp.json()["token"]
def _mk_suggestion(client: TestClient, token: str, title: str,
target_id: int, kpi_id: int, target_value: float = 2000.0) -> int:
body = {
"suggestion_type": "kpi_target",
"target_type": "kpi",
"target_id": target_id,
"title": title,
"content": "目标偏低,建议上调",
"suggestion_data": {"kpi_id": kpi_id, "target_value": target_value},
}
r = client.post(SUGG, json=body, headers=auth_header(token))
assert r.status_code == 200, f"创建建议失败: {r.status_code} {r.text[:300]}"
return r.json()["data"]["id"]
def _sug_status(db: Session, sid: int) -> str:
db.expire_all()
row = db.query(AISuggestion).filter(AISuggestion.id == sid).first()
assert row is not None, f"建议 #{sid} 不存在"
return row.status
def _kpi_target(db: Session, kid: int) -> float:
db.expire_all()
row = db.query(KPIDefinition).filter(KPIDefinition.id == kid).first()
assert row is not None, f"KPI #{kid} 不存在"
return row.target_value
# ──────────────────────────────────────────────
# T1. 忽略建议按会话账套限定(双向 + 正对照)
# ──────────────────────────────────────────────
def test_dismiss_suggestion_scoped_to_session_entity(client: TestClient, db: Session):
"""对端账套建议 → 404 且状态保持 unapplied;本账套 → 200 且 dismissed(双向)"""
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_KPI, 1)
k2 = _mk_kpi(db, E2_KPI, 2)
tok1 = _login(client, 1)
tok2 = _login(client, 2)
s1 = _mk_suggestion(client, tok1, E1_SUG_TITLE, k1.id, k1.id)
s2 = _mk_suggestion(client, tok2, E2_SUG_TITLE, k2.id, k2.id)
# 方向 A:e2 会话忽略 e1 建议 → 404 且 e1 建议状态未改
r_a = client.post(f"{SUGG}/{s1}/dismiss", headers=auth_header(tok2))
assert r_a.status_code == 404, (
f"e2 会话忽略 e1 建议应 404,实际 {r_a.status_code} {r_a.text[:200]}"
" —— dismiss_suggestion 按 id 直取、未按会话账套限定"
)
assert _sug_status(db, s1) == "unapplied", (
"跨账套忽略竟改动了对端账套建议状态(应为 unapplied)"
)
# 方向 B:e1 会话忽略 e2 建议 → 404 且 e2 建议状态未改
r_b = client.post(f"{SUGG}/{s2}/dismiss", headers=auth_header(tok1))
assert r_b.status_code == 404, (
f"e1 会话忽略 e2 建议应 404,实际 {r_b.status_code} {r_b.text[:200]}"
)
assert _sug_status(db, s2) == "unapplied", (
"跨账套忽略竟改动了对端账套建议状态(应为 unapplied)"
)
# 正对照:本账套忽略自己的建议 → 200 且状态 dismissed
r_ok1 = client.post(f"{SUGG}/{s1}/dismiss", headers=auth_header(tok1))
assert r_ok1.status_code == 200, f"e1 忽略自己的建议应 200,实际 {r_ok1.status_code}"
assert _sug_status(db, s1) == "dismissed", "本账套忽略未落状态 dismissed"
r_ok2 = client.post(f"{SUGG}/{s2}/dismiss", headers=auth_header(tok2))
assert r_ok2.status_code == 200, f"e2 忽略自己的建议应 200,实际 {r_ok2.status_code}"
assert _sug_status(db, s2) == "dismissed", "本账套忽略未落状态 dismissed"
# ──────────────────────────────────────────────
# T2. 忽略幂等:既有语义不得改动
# ──────────────────────────────────────────────
def test_dismiss_suggestion_repeat_keeps_existing_semantics(client: TestClient, db: Session):
"""已 dismissed 的建议重复忽略仍 200、状态仍 dismissed(加账套条件不得改动既有语义)"""
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_KPI, 1)
tok1 = _login(client, 1)
s1 = _mk_suggestion(client, tok1, E1_SUG_TITLE, k1.id, k1.id)
assert client.post(f"{SUGG}/{s1}/dismiss",
headers=auth_header(tok1)).status_code == 200
r2 = client.post(f"{SUGG}/{s1}/dismiss", headers=auth_header(tok1))
assert r2.status_code == 200, (
f"重复忽略应维持既有 200 语义,实际 {r2.status_code} {r2.text[:200]}"
)
assert _sug_status(db, s1) == "dismissed", "重复忽略后状态异常"
# ──────────────────────────────────────────────
# T3. 应用 kpi_target:目标 KPI 归属校验
# ──────────────────────────────────────────────
def test_apply_kpi_target_foreign_kpi_404(client: TestClient, db: Session):
"""建议自身属本账套、但其目标 KPI 属对端 → 404;对端 KPI target_value 逐值未变"""
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_KPI, 1, target=1000.0)
_mk_kpi(db, E2_KPI, 2, target=3000.0)
tok1 = _login(client, 1)
tok2 = _login(client, 2)
# e2 会话创建建议(归属 e2),但 suggestion_data/target_id 指向 e1 的 KPI
# (模拟手填数据 / 迁移遗留 / Bot 通道写入的不自洽数据)
s_cross = _mk_suggestion(client, tok2, E1_CROSS_TITLE, k1.id, k1.id, target_value=88888.0)
before_k1 = _kpi_target(db, k1.id)
assert before_k1 == 1000.0, f"前置:e1 KPI 目标值异常 {before_k1}"
r = client.post(f"{SUGG}/{s_cross}/apply", headers=auth_header(tok2),
json={"action": "kpi_target", "target_value": 99999.0})
assert r.status_code == 404, (
f"目标 KPI 属对端账套应 404,实际 {r.status_code} {r.text[:200]}"
" —— _apply_kpi_target 只按 id 查 KPIDefinition,未校验归属会话账套"
)
assert _kpi_target(db, k1.id) == before_k1, (
f"对端账套 KPI target_value 被改写:{before_k1} → {_kpi_target(db, k1.id)}"
)
assert _sug_status(db, s_cross) == "unapplied", (
"越权应用竟改动了建议状态(应保持 unapplied)"
)
# 正对照:本账套建议 + 本账套 KPI → 200 且目标值已写入
k2 = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == E2_KPI).first()
s_ok = _mk_suggestion(client, tok2, E2_SUG_TITLE, k2.id, k2.id, target_value=4500.0)
r_ok = client.post(f"{SUGG}/{s_ok}/apply", headers=auth_header(tok2),
json={"action": "kpi_target", "target_value": 4500.0})
assert r_ok.status_code == 200, f"本账套应用应 200,实际 {r_ok.status_code} {r_ok.text[:200]}"
assert _kpi_target(db, k2.id) == 4500.0, "本账套应用未写入目标值"
# ──────────────────────────────────────────────
# T4. 应用 budget_adjust:目标 KPI 归属校验
# ──────────────────────────────────────────────
def test_apply_budget_adjust_foreign_kpi_404(client: TestClient, db: Session):
"""budget_adjust 目标 KPI 属对端 → 404 且不落任何预算行(含对端账套)"""
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_KPI, 1, target=1000.0)
_mk_kpi(db, E2_KPI, 2, target=3000.0)
tok2 = _login(client, 2)
s_cross = _mk_suggestion(client, tok2, E1_CROSS_TITLE, k1.id, k1.id, target_value=7777.0)
db.expire_all()
plans_before = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == k1.id).count()
r = client.post(f"{SUGG}/{s_cross}/apply", headers=auth_header(tok2),
json={"action": "budget_adjust", "period": "2026-09", "budget_value": 7777.0})
assert r.status_code == 404, (
f"budget_adjust 目标 KPI 属对端账套应 404,实际 {r.status_code} {r.text[:200]}"
" —— _apply_budget_adjust 的 KPI 查询未校验归属会话账套"
)
db.expire_all()
plans_after = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == k1.id).count()
assert plans_after == plans_before, (
f"越权 budget_adjust 落了预算行:{plans_before} → {plans_after}"
)
assert _sug_status(db, s_cross) == "unapplied", "越权应用竟改动了建议状态"
# ──────────────────────────────────────────────
# T5. 鉴权底线(dismiss / apply 无 token → 401)
# ──────────────────────────────────────────────
def test_dismiss_and_apply_without_token_401(client: TestClient, db: Session):
"""无 token 一律 401(改前改后都应通过)"""
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_KPI, 1)
tok1 = _login(client, 1)
s1 = _mk_suggestion(client, tok1, E1_SUG_TITLE, k1.id, k1.id)
r1 = client.post(f"{SUGG}/{s1}/dismiss")
assert r1.status_code == 401, f"dismiss 无 token 应 401,实际 {r1.status_code}"
r2 = client.post(f"{SUGG}/{s1}/apply", json={"action": "kpi_target", "target_value": 1})
assert r2.status_code == 401, f"apply 无 token 应 401,实际 {r2.status_code}"