fix(cma): action plan entity_id on create + okr decomposition entity scope

create_plan wrote no entity_id (fell back to model default 1) while the OKR decomposition view filtered weekly_actions by objective_id only, so cross-entity plans leaked into the other tenant view. Write the session entity on create (before adding the read-side condition - order matters) and scope weekly_actions by entity_id. Adds 4 regression tests (both directions + same-entity positive control).
This commit is contained in:
Hermes CI Fix
2026-09-18 20:03:55 +08:00
parent bab6137aa2
commit b072602191
3 changed files with 278 additions and 0 deletions
+5
View File
@@ -184,6 +184,11 @@ def create_plan(
_validate_due_date_against_quarter(due_date, obj.quarter)
plan = ActionPlan(
# 账套隔离(2026-09-18 batch3 派生单):必须显式写会话账套 ——
# 旧实现不写 entity_id,落模型默认值 1(e2 会话创建的计划库内 entity_id=1,
# 实测 OBS1)。list/stats/update/delete 靠 join KPIDefinition 判账套,
# 但任何按 ActionPlan.entity_id 过滤的读路径(如 OKR 分解视图)会失效。
entity_id=entity_id,
alert_id=data.get("alert_id"),
kpi_id=data["kpi_id"],
objective_id=objective_id,
+5
View File
@@ -547,6 +547,11 @@ def get_okr_decomposition(okr_id: int, db: Session = Depends(get_db),
week_end = week_start + timedelta(days=7)
action_plans = db.query(ActionPlan).filter(
ActionPlan.objective_id == okr_id,
# 账套隔离(2026-09-18 batch3 派生单):objective_id 已由
# _get_objective_or_404 校验归属,但计划侧此前无账套条件 →
# 对端账套计划(含脏行)会出现在本账套分解视图(实测 OBS2)。
# 前置条件:create_plan 必须已写会话账套 entity_id(顺序铁律)。
ActionPlan.entity_id == entity_id,
ActionPlan.due_date.between(week_start, week_end)
).all()
@@ -0,0 +1,268 @@
"""CMA 跨实体隔离 — ActionPlan 归属写侧缺失 + OKR 分解视图读侧缺失(2026-09-18 batch3 派生单)
任务:fullstack-20260918-1905b-actionplan-entity-write-and-okr-read
父单:fullstack-20260918-1905-actionplan-objective-id-scope(OBS1/OBS2 只读侦查,
证据 _reports/evidence-fullstack-20260918-1905-probe.txt)
缺陷(两处必须同单做,顺序铁律:先修缺陷1再修缺陷2):
1. action_plans.py:create_plan 构造 ActionPlan(...) 不写 entity_id
→ 落模型默认值 1。实测 e2 会话经 API 创建计划后库内 entity_id=1(OBS1)。
现状 list_plans/stats/update/delete 全靠 join KPIDefinition 判账套,
ActionPlan.entity_id 属"不可信字段"(已埋雷)。
2. okr.py:get_okr_decomposition 的 weekly_actions 仅按 objective_id + due_date
过滤,无账套条件 → 对端账套计划挂本账套目标时出现在本账套分解视图(OBS2)。
若只修 2 不修 1,则 e2 自己经 API 新建的计划(entity_id=1)会从 e2 自己的
分解视图消失(把脏归属变成用户可见的假阴性)。
本用例(每条双向 + 同账套正对照;改前失败条目见
_reports/evidence-fullstack-20260918-1905b-red.txt):
- T1 create_plan 落库写入会话账套(e1→1 / e2→2,双向正对照)【改前 e2 侧失败】
- T2 本账套分解视图不含"对端账套计划挂本账套目标"的脏行;本账套合法计划仍出现
(双向,正对照内嵌)【改前失败】
- T3 本账套经 API 创建的计划出现在自己的分解视图(防缺陷1修好后被读侧条件误杀)【改前通过,
仅修缺陷2不修缺陷1时失败 —— 顺序铁律的机器守卫】
- T4 鉴权底线:无 token → 401(改前改后都应通过)
"""
import datetime as _dt
from datetime import time as _time
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import ActionPlan, Entity, KPIDefinition, Objective
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
PLANS = "/api/cma/action-plans"
OKR = "/api/cma/okr"
E1_KPI = "ZZ_APENT_E1_KPI_001"
E2_KPI = "ZZ_APENT_E2_KPI_001"
# ──────────────────────────────────────────────
# 夹具
# ──────────────────────────────────────────────
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 _mk_objective(db: Session, eid: int, title: str, quarter: str = "2026Q3") -> Objective:
obj = Objective(entity_id=eid, title=title, quarter=quarter, status="active")
db.add(obj)
db.commit()
db.refresh(obj)
return obj
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
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 _plans_by_title(db: Session, title: str):
db.expire_all()
return db.query(ActionPlan).filter(ActionPlan.title == title).all()
def _this_week_due() -> _dt.datetime:
"""本周内的时间点(分解视图只取 week_start..week_start+7d)"""
today = _dt.date.today()
return _dt.datetime.combine(today, _time(12, 0))
def _today_iso() -> str:
return _dt.date.today().isoformat()
def _mk_dirty_plan(db: Session, kpi_id: int, dirty_entity_id: int, objective_id: int,
title: str) -> ActionPlan:
"""构造脏行:计划归属 dirty_entity_id,却挂在 objective_id(属别的账套)
直插模拟历史遗留行(写侧校验上线前落库的跨账套归属),不经过 API。
"""
plan = ActionPlan(
entity_id=dirty_entity_id,
kpi_id=kpi_id,
objective_id=objective_id,
title=title,
assignee="张三",
priority="medium",
status="pending",
progress=0,
due_date=_this_week_due(),
)
db.add(plan)
db.commit()
db.refresh(plan)
return plan
def _weekly_titles(client: TestClient, token: str, okr_id: int):
r = client.get(f"{OKR}/{okr_id}/decomposition", headers=auth_header(token))
assert r.status_code == 200, f"分解视图应 200,实际 {r.status_code} {r.text[:200]}"
return {row["title"] for row in r.json()["weekly_actions"]}
# ──────────────────────────────────────────────
# T1. create_plan 落库写入会话账套(双向正对照)
# ──────────────────────────────────────────────
def test_create_plan_writes_session_entity_both_directions(client: TestClient, db: Session):
"""e1 会话 → 库内 entity_id=1;e2 会话 → 库内 entity_id=2(不得落模型默认值 1)"""
k1, k2 = _seed_two_entities(db)
t1 = "ZZ_APENT 归属 e1 创建"
r1 = client.post(PLANS, headers=auth_header(_login(client, 1)),
json={"title": t1, "kpi_id": k1.id, "due_date": _today_iso()})
assert r1.status_code == 200, f"e1 创建应 200,实际 {r1.status_code} {r1.text[:200]}"
rows1 = _plans_by_title(db, t1)
assert len(rows1) == 1, f"e1 创建的计划未落库: {rows1}"
assert rows1[0].entity_id == 1, (
f"e1 会话创建的计划 entity_id 应为 1,实际 {rows1[0].entity_id}"
)
t2 = "ZZ_APENT 归属 e2 创建"
r2 = client.post(PLANS, headers=auth_header(_login(client, 2)),
json={"title": t2, "kpi_id": k2.id, "due_date": _today_iso()})
assert r2.status_code == 200, f"e2 创建应 200,实际 {r2.status_code} {r2.text[:200]}"
rows2 = _plans_by_title(db, t2)
assert len(rows2) == 1, f"e2 创建的计划未落库: {rows2}"
assert rows2[0].entity_id == 2, (
f"e2 会话创建的计划 entity_id 应为 2(会话账套),实际 {rows2[0].entity_id}"
" —— create_plan 未写 entity_id,落模型默认值 1"
)
# ──────────────────────────────────────────────
# T2. 分解视图不含对端账套脏行、含本账套合法计划(双向)
# ──────────────────────────────────────────────
def test_okr_decomposition_excludes_dirty_cross_entity_row(client: TestClient, db: Session):
"""e1 视图:脏行(entity_id=2 挂 e1 目标)不出现,e1 合法计划仍出现(正对照)
e2 视图:脏行(entity_id=1 挂 e2 目标)不出现,e2 合法计划仍出现"""
k1, k2 = _seed_two_entities(db)
obj1 = _mk_objective(db, 1, "e1 目标")
obj2 = _mk_objective(db, 2, "e2 目标")
# e1 侧:脏行 = 归属 e2 的计划挂 e1 目标
dirty_in_e1 = "ZZ_APENT 脏行e2挂e1目标"
_mk_dirty_plan(db, k2.id, 2, obj1.id, dirty_in_e1)
own_in_e1 = "ZZ_APENT e1合法计划"
ro1 = client.post(PLANS, headers=auth_header(_login(client, 1)),
json={"title": own_in_e1, "kpi_id": k1.id,
"objective_id": obj1.id, "due_date": _today_iso()})
assert ro1.status_code == 200, ro1.text[:200]
titles1 = _weekly_titles(client, _login(client, 1), obj1.id)
assert dirty_in_e1 not in titles1, (
f"对端账套(entity_id=2)脏行出现在 e1 分解视图: {sorted(titles1)}"
)
assert own_in_e1 in titles1, (
f"e1 本账套合法计划未出现在自己视图(正对照失效): {sorted(titles1)}"
)
# e2 侧(反向):脏行 = 归属 e1 的计划挂 e2 目标
dirty_in_e2 = "ZZ_APENT 脏行e1挂e2目标"
_mk_dirty_plan(db, k1.id, 1, obj2.id, dirty_in_e2)
own_in_e2 = "ZZ_APENT e2合法计划"
ro2 = client.post(PLANS, headers=auth_header(_login(client, 2)),
json={"title": own_in_e2, "kpi_id": k2.id,
"objective_id": obj2.id, "due_date": _today_iso()})
assert ro2.status_code == 200, ro2.text[:200]
titles2 = _weekly_titles(client, _login(client, 2), obj2.id)
assert dirty_in_e2 not in titles2, (
f"对端账套(entity_id=1)脏行出现在 e2 分解视图: {sorted(titles2)}"
)
assert own_in_e2 in titles2, (
f"e2 本账套合法计划未出现在自己视图(正对照失效): {sorted(titles2)}"
)
# ──────────────────────────────────────────────
# T3. 本账套经 API 创建的计划出现在自己的分解视图(顺序铁律守卫)
# ──────────────────────────────────────────────
def test_okr_decomposition_includes_own_api_created_plan(client: TestClient, db: Session):
"""e2 经 API 建计划(写侧落 entity_id=2)→ 出现在 e2 自己的分解视图(双向)
这是缺陷 1 与缺陷 2 的顺序守卫:只修读侧条件而不修写侧归属时,
e2 自己新建的计划(entity_id=1)会被读侧条件过滤掉(假阴性)。
"""
k1, k2 = _seed_two_entities(db)
obj1 = _mk_objective(db, 1, "e1 目标")
obj2 = _mk_objective(db, 2, "e2 目标")
t1 = "ZZ_APENT e1自建计划自见"
assert client.post(PLANS, headers=auth_header(_login(client, 1)),
json={"title": t1, "kpi_id": k1.id, "objective_id": obj1.id,
"due_date": _today_iso()}).status_code == 200
t2 = "ZZ_APENT e2自建计划自见"
assert client.post(PLANS, headers=auth_header(_login(client, 2)),
json={"title": t2, "kpi_id": k2.id, "objective_id": obj2.id,
"due_date": _today_iso()}).status_code == 200
titles1 = _weekly_titles(client, _login(client, 1), obj1.id)
assert t1 in titles1, f"e1 自建计划未出现在 e1 视图: {sorted(titles1)}"
titles2 = _weekly_titles(client, _login(client, 2), obj2.id)
assert t2 in titles2, (
f"e2 自建计划未出现在 e2 视图(读侧条件是否漏了写侧归属?): {sorted(titles2)}"
)
# ──────────────────────────────────────────────
# T4. 鉴权底线
# ──────────────────────────────────────────────
def test_decomposition_without_token_401(client: TestClient, db: Session):
"""无 token → 401(改前改后都应通过)"""
_k1, _k2 = _seed_two_entities(db)
obj1 = _mk_objective(db, 1, "e1 目标")
r = client.get(f"{OKR}/{obj1.id}/decomposition")
assert r.status_code == 401, f"无 token 应 401,实际 {r.status_code}"