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