fix(cma): entity isolation batch2 (roll-forward/check-timeout/by-id/cost-abc)
ci/woodpecker/push/main Pipeline failed

Scope: backend/app/api/{alerts,action_plans,budget,cost}.py + tests

Task: 0-P1-project-20260918-entity-isolation-batch2
This commit is contained in:
Hermes CI Fix
2026-09-18 18:15:22 +08:00
parent b9c80f836e
commit ad953ac759
5 changed files with 467 additions and 24 deletions
@@ -0,0 +1,424 @@
"""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}"