"""CMA 跨实体隔离收口(第二批)账套隔离回归测试 — 2026-09-18 任务:0-P1-project-20260918-entity-isolation-batch2(项目Bot 派单,任总 2026-09-18 17:59 放行) 缺陷(本单范围): 1. 写型·列表批处理 - POST /api/cma/budget/roll-forward 全账套 active 预算一起删最早月/加新月 - GET /api/cma/alerts/check-timeout 全账套 pending 超时预警一起升级为 red 2. 写型/读型·按 id 直取(无归属校验 → 跨账套可达) - POST /api/cma/alerts/{alert_id}/process | /escalate | /create-action-plan - PUT /api/cma/action-plans/{plan_id} | DELETE /api/cma/action-plans/{plan_id} 3. 读型·已在发生 - GET /api/cma/cost/abc/activities 全表取(生产 10 行全属 entity2)→ 酣客会话可见博海 ABC 作业中心 本用例覆盖(每条均双向 + 同账套正对照): - 写型批处理:会话账套触发只影响本账套行,对端账套行前后逐字段不变; - 按 id 直取:跨账套 id → 404(对齐同文件既有语义),本账套 id → 正常;且跨账套请求后对端行未被修改/删除; - 读型:会话账套只返回本账套行,不含对端行。 修复前(旧码)本文件在下列断言上必失败 —— 即「改前/改后对照」中的改前证据(见回报 §三)。 """ import pytest from fastapi.testclient import TestClient from sqlalchemy.orm import Session from datetime import datetime, timedelta import json from app.models import ( AbcActivity, ActionPlan, BudgetPlan, Entity, KPIAlert, KPIDefinition, SystemConfig, ) from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user ALERTS = "/api/cma/alerts" BUDGET = "/api/cma/budget" COST = "/api/cma/cost" PLANS = "/api/cma/action-plans" # 账套 1/2 各自的资源编码前缀(kpi_code / activity_code 库内唯一) E1_KPI = "ZZ_B2_E1_KPI_001" E2_KPI = "ZZ_B2_E2_KPI_001" E1_ACT = "ZZ_B2_E1_ACT_001" E2_ACT = "ZZ_B2_E2_ACT_001" E2_ACT2 = "ZZ_B2_E2_ACT_002" # ────────────────────────────────────────────── # 夹具 # ────────────────────────────────────────────── 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) -> 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=0, kpi_level="operational", status="active", ) db.add(kpi) db.commit() db.refresh(kpi) return kpi 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 _set_rolling(db: Session) -> None: """滚动预算模式(全局 SystemConfig,配置值为 JSON 文本,与 /budget/config 写入口径一致)""" cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "budget_mode").first() val = json.dumps({"mode": "rolling", "rolling_months": 12}, ensure_ascii=False) if cfg: cfg.config_value = val else: db.add(SystemConfig(config_key="budget_mode", config_value=val, description="预算模式: fixed=固定预算, rolling=滚动预算")) db.commit() def _seed_plans(db: Session, kpi_id: int, eid: int, months: int, base: float) -> None: for m in range(1, months + 1): db.add(BudgetPlan( entity_id=eid, kpi_id=kpi_id, period=f"2026-{m:02d}", budget_value=base + m, budget_year=2026, budget_month=m, version="v1.0", status="active", )) db.commit() def _plan_snapshot(db: Session, eid: int) -> list: """某账套全部预算行(按 period 排序,逐字段快照)""" rows = (db.query(BudgetPlan) .filter(BudgetPlan.entity_id == eid) .order_by(BudgetPlan.period) .all()) return [ (r.id, r.kpi_id, r.period, round(r.budget_value, 4), r.budget_year, r.budget_month, r.status, r.version) for r in rows ] def _mk_alert(db: Session, kpi_id: int, eid: int, **kw) -> KPIAlert: alert = KPIAlert( entity_id=eid, kpi_id=kpi_id, alert_level=kw.get("alert_level", "yellow"), alert_message=kw.get("alert_message", f"e{eid} 测试预警"), status=kw.get("status", "pending"), created_at=kw.get("created_at", datetime.now()), ) db.add(alert) db.commit() db.refresh(alert) return alert def _mk_plan(db: Session, kpi_id: int, eid: int, title: str) -> ActionPlan: plan = ActionPlan(entity_id=eid, kpi_id=kpi_id, title=title, assignee="张三", priority="medium", status="pending", progress=0) db.add(plan) db.commit() db.refresh(plan) return plan def _mk_activity(db: Session, code: str, eid: int) -> AbcActivity: act = AbcActivity( entity_id=eid, activity_code=code, activity_name=f"{code}-作业", cost_driver="动因", driver_unit="次", total_cost=1000.0, driver_volume=10, driver_rate=100.0, status="active", ) db.add(act) db.commit() db.refresh(act) return act def _seed_two_entities(db: Session): _ensure_entity(db, 1, "酣客") _ensure_entity(db, 2, "博海") create_test_user(db) k1 = _mk_kpi(db, E1_KPI, 1) k2 = _mk_kpi(db, E2_KPI, 2) return k1, k2 # ────────────────────────────────────────────── # 1. 写型·列表批处理:budget/roll-forward 按会话账套 # ────────────────────────────────────────────── def test_roll_forward_only_touches_session_entity(client: TestClient, db: Session): """酣客会话滚动延展 → 只动 e1 预算,e2 预算前后逐字段不变(含行数/最早月/新月份)""" k1, k2 = _seed_two_entities(db) _set_rolling(db) _seed_plans(db, k1.id, 1, months=12, base=1000.0) # e1: 12 个月 _seed_plans(db, k2.id, 2, months=3, base=2000.0) # e2: 3 个月 before_e1 = _plan_snapshot(db, 1) before_e2 = _plan_snapshot(db, 2) assert len(before_e1) == 12 and len(before_e2) == 3 token1 = _login(client, 1) r = client.post(f"{BUDGET}/roll-forward", headers=auth_header(token1)) assert r.status_code == 200, r.text data = r.json() after_e1 = _plan_snapshot(db, 1) after_e2 = _plan_snapshot(db, 2) # 响应只含本账套 KPI assert len(data["rolled_kpis"]) == 1, f"应只延展 1 个 KPI,实际 {data['rolled_kpis']}" assert data["rolled_kpis"][0]["kpi_id"] == k1.id # e1:删 1 行加 1 行(行数不变,最早月被删) assert len(after_e1) == 12 e1_periods = [p for _, _, p, *_ in after_e1] assert "2026-01" not in e1_periods assert data["rolled_kpis"][0]["added_period"] in e1_periods # e2:逐字段完全未变(这是缺陷核心:跨账套批量写) assert after_e2 == before_e2, ( f"entity2 预算被跨账套修改\nbefore={before_e2}\nafter={after_e2}" ) def test_roll_forward_reverse_direction(client: TestClient, db: Session): """博海会话滚动延展 → 只动 e2 预算,e1 预算前后不变""" k1, k2 = _seed_two_entities(db) _set_rolling(db) _seed_plans(db, k1.id, 1, months=12, base=1000.0) _seed_plans(db, k2.id, 2, months=3, base=2000.0) before_e1 = _plan_snapshot(db, 1) token2 = _login(client, 2) r = client.post(f"{BUDGET}/roll-forward", headers=auth_header(token2)) assert r.status_code == 200, r.text data = r.json() assert len(data["rolled_kpis"]) == 1 assert data["rolled_kpis"][0]["kpi_id"] == k2.id assert _plan_snapshot(db, 1) == before_e1, "entity1 预算被 e2 会话跨账套修改" # ────────────────────────────────────────────── # 2. 写型·列表批处理:alerts/check-timeout 按会话账套 # ────────────────────────────────────────────── def _alert_state(db: Session, eid: int): rows = db.query(KPIAlert).filter(KPIAlert.entity_id == eid).order_by(KPIAlert.id).all() return [(a.id, a.status, a.alert_level) for a in rows] def test_check_timeout_only_upgrades_session_entity(client: TestClient, db: Session): """酣客会话超时检测 → 只升级 e1 的 pending 预警,e2 保持 pending/yellow""" k1, k2 = _seed_two_entities(db) old = datetime.now() - timedelta(hours=25) _mk_alert(db, k1.id, 1, created_at=old) _mk_alert(db, k1.id, 1, created_at=old) _mk_alert(db, k2.id, 2, created_at=old) _mk_alert(db, k2.id, 2, created_at=old) token1 = _login(client, 1) r = client.get(f"{ALERTS}/check-timeout", headers=auth_header(token1)) assert r.status_code == 200, r.text body = r.json() assert body["total_timeout"] == 2, f"应只命中本账套 2 条,实际 {body}" assert body["upgraded_count"] == 2 # e1 升级为 red/processing for _id, status, level in _alert_state(db, 1): assert (status, level) == ("processing", "red"), f"e1 预警 {_id} 未升级: {status}/{level}" # e2 完全未动(缺陷核心:一次调用把所有账套升级) for _id, status, level in _alert_state(db, 2): assert (status, level) == ("pending", "yellow"), f"e2 预警 {_id} 被跨账套升级: {status}/{level}" def test_check_timeout_reverse_direction(client: TestClient, db: Session): """博海会话超时检测 → 只升级 e2,e1 保持 pending/yellow""" k1, k2 = _seed_two_entities(db) old = datetime.now() - timedelta(hours=25) _mk_alert(db, k1.id, 1, created_at=old) _mk_alert(db, k2.id, 2, created_at=old) token2 = _login(client, 2) r = client.get(f"{ALERTS}/check-timeout", headers=auth_header(token2)) assert r.status_code == 200, r.text assert r.json()["upgraded_count"] == 1 for _id, status, level in _alert_state(db, 1): assert (status, level) == ("pending", "yellow"), f"e1 预警 {_id} 被 e2 会话跨账套升级" for _id, status, level in _alert_state(db, 2): assert (status, level) == ("processing", "red") # ────────────────────────────────────────────── # 3. 按 id 直取:alerts 三条(跨账套 → 404,同账套 → 正常) # ────────────────────────────────────────────── def test_alert_process_entity_scoped(client: TestClient, db: Session): k1, k2 = _seed_two_entities(db) mine = _mk_alert(db, k1.id, 1) other = _mk_alert(db, k2.id, 2) token1 = _login(client, 1) # 跨账套 → 404(对齐 /resolve 既有语义:查无此记录) r = client.post(f"{ALERTS}/{other.id}/process", headers=auth_header(token1), json={"assignee": "李四"}) assert r.status_code == 404, f"跨账套 process 应 404,实际 {r.status_code} {r.text[:200]}" db.refresh(other) assert other.status == "pending", "跨账套 process 竟改动了对端账套记录" # 同账套 → 正常 r = client.post(f"{ALERTS}/{mine.id}/process", headers=auth_header(token1), json={"assignee": "李四"}) assert r.status_code == 200, r.text db.refresh(mine) assert mine.status == "processing" and mine.assignee == "李四" def test_alert_escalate_entity_scoped(client: TestClient, db: Session): k1, k2 = _seed_two_entities(db) mine = _mk_alert(db, k1.id, 1) other = _mk_alert(db, k2.id, 2) token1 = _login(client, 1) r = client.post(f"{ALERTS}/{other.id}/escalate", headers=auth_header(token1), json={"assignee": "李四"}) assert r.status_code == 404, f"跨账套 escalate 应 404,实际 {r.status_code} {r.text[:200]}" db.refresh(other) assert other.alert_level == "yellow", "跨账套 escalate 竟升级了对端账套预警" r = client.post(f"{ALERTS}/{mine.id}/escalate", headers=auth_header(token1), json={"assignee": "李四"}) assert r.status_code == 200, r.text db.refresh(mine) assert mine.alert_level == "red" def test_alert_create_action_plan_entity_scoped(client: TestClient, db: Session): k1, k2 = _seed_two_entities(db) mine = _mk_alert(db, k1.id, 1) other = _mk_alert(db, k2.id, 2) token1 = _login(client, 1) r = client.post(f"{ALERTS}/{other.id}/create-action-plan", headers=auth_header(token1), json={"assignee": "李四"}) assert r.status_code == 404, f"跨账套 create-action-plan 应 404,实际 {r.status_code} {r.text[:200]}" assert db.query(ActionPlan).filter(ActionPlan.alert_id == other.id).count() == 0, \ "跨账套 create-action-plan 竟为对端账套预警建了计划" db.refresh(other) assert other.action_plan_linked_id is None r = client.post(f"{ALERTS}/{mine.id}/create-action-plan", headers=auth_header(token1), json={"assignee": "李四"}) assert r.status_code == 200, r.text db.refresh(mine) assert mine.action_plan_linked_id is not None # ────────────────────────────────────────────── # 4. 按 id 直取:action-plans 改/删(跨账套 → 404,同账套 → 正常) # ────────────────────────────────────────────── def test_update_plan_entity_scoped(client: TestClient, db: Session): k1, k2 = _seed_two_entities(db) mine = _mk_plan(db, k1.id, 1, "e1 计划") other = _mk_plan(db, k2.id, 2, "e2 计划") token1 = _login(client, 1) r = client.put(f"{PLANS}/{other.id}", headers=auth_header(token1), json={"title": "被跨账套改名", "status": "completed"}) assert r.status_code == 404, f"跨账套 update 应 404,实际 {r.status_code} {r.text[:200]}" db.refresh(other) assert other.title == "e2 计划" and other.status == "pending", "跨账套 update 竟改动了对端计划" r = client.put(f"{PLANS}/{mine.id}", headers=auth_header(token1), json={"title": "本账套改名", "status": "in_progress"}) assert r.status_code == 200, r.text db.refresh(mine) assert mine.title == "本账套改名" and mine.status == "in_progress" def test_delete_plan_entity_scoped(client: TestClient, db: Session): k1, k2 = _seed_two_entities(db) mine = _mk_plan(db, k1.id, 1, "e1 计划") other = _mk_plan(db, k2.id, 2, "e2 计划") token1 = _login(client, 1) r = client.delete(f"{PLANS}/{other.id}", headers=auth_header(token1)) assert r.status_code == 404, f"跨账套 delete 应 404,实际 {r.status_code} {r.text[:200]}" assert db.query(ActionPlan).filter(ActionPlan.id == other.id).count() == 1, \ "跨账套 delete 竟删除了对端账套计划" r = client.delete(f"{PLANS}/{mine.id}", headers=auth_header(token1)) assert r.status_code == 200, r.text assert db.query(ActionPlan).filter(ActionPlan.id == mine.id).count() == 0 # ────────────────────────────────────────────── # 5. 读型:cost/abc/activities 按会话账套 # ────────────────────────────────────────────── def _abc_codes(client: TestClient, token: str) -> set: r = client.get(f"{COST}/abc/activities", headers=auth_header(token)) assert r.status_code == 200, r.text return {row["activity_code"] for row in r.json()["data"]} def test_abc_activities_isolated_by_entity(client: TestClient, db: Session): """酣客会话不应看到博海 ABC 作业中心(生产:10 行全属 entity2)""" _seed_two_entities(db) _mk_activity(db, E1_ACT, 1) _mk_activity(db, E2_ACT, 2) _mk_activity(db, E2_ACT2, 2) token1 = _login(client, 1) token2 = _login(client, 2) codes1 = _abc_codes(client, token1) codes2 = _abc_codes(client, token2) assert codes1 == {E1_ACT}, f"entity1 会话应只见本账套作业中心,实际 {sorted(codes1)}" assert not any(c.startswith("ZZ_B2_E2_") for c in codes1), "酣客会话混入博海 ABC 作业中心" assert codes2 == {E2_ACT, E2_ACT2}, f"entity2 会话应只见本账套,实际 {sorted(codes2)}" def test_abc_activities_without_token_401(client: TestClient, db: Session): """鉴权底线不变:无 Bearer token → 401""" _seed_two_entities(db) _mk_activity(db, E1_ACT, 1) r = client.get(f"{COST}/abc/activities") assert r.status_code == 401, f"无 token 应 401,实际 {r.status_code}"