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).
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
"""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}"
|
||||
Reference in New Issue
Block a user