fix(cma): scope action-plan objective_id writes to session entity

create_plan/update_plan now validate Objective.entity_id ownership (404 on cross-entity, no partial row) and update_plan gets an explicit objective_id-vs-KR-derived priority rule. Adds 10 regression cases (batch3 bias: bidirectional + same-entity control + OKR decomposition read-side consequence).
This commit is contained in:
Hermes CI Fix
2026-09-18 19:34:59 +08:00
parent 50baa16f09
commit bab6137aa2
2 changed files with 417 additions and 19 deletions
+41 -19
View File
@@ -14,6 +14,9 @@ from app.api.verify import build_auto_verify_rule
logger = logging.getLogger("cma.action_plans")
# 区分「未传该字段」与「显式传 null」的哨兵(update_plan 的 objective_id 口径,2026-09-18)
_UNSET = object()
router = APIRouter(prefix="/api/cma/action-plans", tags=["改善行动"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
@@ -167,11 +170,17 @@ def create_plan(
due_date = datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None
# 校验截止日期与关联Objective的季度匹配
# 账套隔离: 关联Objective必须属于当前企业(2026-09-18 batch3 派生单)
# 旧实现只按 Objective.id 查询(仅用于季度校验),不校验 entity_id,
# 随后 ActionPlan(objective_id=...) 直接落库 → 本账套计划可挂到对端账套目标
objective_id = data.get("objective_id")
if objective_id and due_date:
obj = db.query(Objective).filter(Objective.id == objective_id).first()
if obj and obj.quarter:
if objective_id:
obj = db.query(Objective).filter(
Objective.id == objective_id, Objective.entity_id == entity_id).first()
if not obj:
raise HTTPException(404, "关联目标不存在")
# 校验截止日期与关联Objective的季度匹配
if due_date and obj.quarter:
_validate_due_date_against_quarter(due_date, obj.quarter)
plan = ActionPlan(
@@ -260,22 +269,35 @@ def update_plan(
plan.assignee = data["assignee"]
if "priority" in data:
plan.priority = data["priority"]
if "kr_id" in data:
# 行动挂KR: 支持置空(null)或改挂
if data["kr_id"] is None:
plan.kr_id = None
else:
# 账套隔离: KR 必须属于当前企业(对齐同文件 create_plan:158 既有写法;2026-09-18 batch3)
kr_ent = db.query(KR).filter(KR.id == data["kr_id"], KR.entity_id == entity_id).first()
if not kr_ent:
raise HTTPException(404, "关联KR不存在")
plan.kr_id = kr_ent.id
if data.get("objective_id") is not None:
plan.objective_id = data["objective_id"]
elif plan.objective_id is None:
plan.objective_id = kr_ent.objective_id
# 目标/KR 归属口径(2026-09-18 batch3 派生单):
# ① 显式传 objective_id(含 null=显式置空)→ 以入参为准(优先于 kr_id 推导值)
# ② 未传 objective_id、且改挂合法 KR、且计划当前无目标 → 继承该 KR 的目标
# ③ 未传 objective_id 且计划已有目标 → 保持不动
# 两者都必须是当前账套的数据,否则 404;校验全部放在改动 plan 之前,
# 保证 404 请求对计划零改动(旧实现无归属校验,且末尾无条件覆盖推导值)
kr_ent = _UNSET
if "kr_id" in data and data["kr_id"] is not None:
# 账套隔离: KR 必须属于当前企业(对齐同文件 create_plan 既有写法;2026-09-18 batch3)
kr_ent = db.query(KR).filter(KR.id == data["kr_id"], KR.entity_id == entity_id).first()
if not kr_ent:
raise HTTPException(404, "关联KR不存在")
objective_change = _UNSET
if "objective_id" in data:
plan.objective_id = data["objective_id"]
objective_change = data["objective_id"] # 口径①
elif kr_ent is not _UNSET and plan.objective_id is None:
objective_change = kr_ent.objective_id # 口径②
if objective_change is not _UNSET and objective_change is not None:
obj_ent = db.query(Objective).filter(
Objective.id == objective_change, Objective.entity_id == entity_id).first()
if not obj_ent:
raise HTTPException(404, "关联目标不存在")
# 校验全部通过后落值(口径③:objective_change 仍为 _UNSET 时目标保持不动)
if "kr_id" in data:
plan.kr_id = kr_ent.id if kr_ent is not _UNSET else None
if objective_change is not _UNSET:
plan.objective_id = objective_change
if "due_date" in data:
plan.due_date = datetime.fromisoformat(data["due_date"]) if data["due_date"] else None
if "status" in data:
@@ -0,0 +1,376 @@
"""CMA 跨实体隔离 — action_plans.objective_id 归属校验回归测试(2026-09-18 batch3 二次派生)
任务:fullstack-20260918-1905-actionplan-objective-id-scope
来源:fullstack-20260918-1845-batch3-followup-listplans-kr(读侧已修,写侧 objective_id 未修)
缺陷(本单范围,与 batch3 已修的 kr_id 缺口同源:写的归属不校验 → 读侧跨账套回显):
1. POST /api/cma/action-plans(create_plan)
`db.query(Objective).filter(Objective.id == objective_id).first()` 只用于季度校验,
不校验 `Objective.entity_id`,随后 `ActionPlan(objective_id=...)` 直接落库
→ 本账套计划可挂到对端账套 Objective。
2. PUT /api/cma/action-plans/{plan_id}(update_plan)
`if "objective_id" in data: plan.objective_id = data["objective_id"]` 既无归属校验,
又覆盖同函数内 kr_id 分支推导出的 objective_id(先按合法 KR 推导出正确目标,再被入参改掉)。
3. 可观测后果:okr.py:get_okr_decomposition 的 weekly_actions 仅按
ActionPlan.objective_id == okr_id + due_date 过滤(无账套条件)
→ 对端账套计划可出现在本账套 OKR 分解视图(下方 T8 覆盖该读侧后果)。
本用例覆盖(每条均双向 + 同账套正对照;改前失败条目 = 5 条,RED 原文见
_reports/evidence-fullstack-20260918-1905-red.txt):
- T1 create_plan 跨账套 objective_id → 404 且不落库半成品行(双向)【改前失败】
- T2 create_plan 同账套 objective_id → 200 且落库正确(双向,正对照)
- T3a objective_id 归属改造后季度校验仍在(越季 → 422,防重构掉既有校验)
- T3b create_plan 不存在的 objective_id → 404【改前失败】
- T4 update_plan 跨账套 objective_id → 404 且计划未被改动(双向)【改前失败】
- T5 update_plan 同账套 objective_id → 200(正对照)
- T6 update_plan 的「入参 objective_id vs kr_id 推导值」显式优先级(推导 / 入参覆盖 / 入参 null 置空)
—— 三条在改前均通过(旧代码同样实现「入参覆盖」),此处作为语义锁用例防后续重构反转口径
- T7 update_plan 同请求内 kr_id 合法 + objective_id 跨账套 → 整体 404 且零改动【改前失败】
- T8 读侧后果:对端账套经 API 无法把计划注入本账套 OKR 分解视图【改前失败】(本账套合法计划仍可见=正对照)
- T9 鉴权底线:无 token → 401(改前改后都应通过)
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import ActionPlan, Entity, KR, 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_B3O_E1_KPI_001"
E2_KPI = "ZZ_B3O_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 _mk_kr(db: Session, eid: int, objective_id: int, title: str) -> KR:
kr = KR(entity_id=eid, objective_id=objective_id, title=title, operator=">=",
weight=50, target_value=100)
db.add(kr)
db.commit()
db.refresh(kr)
return kr
def _mk_plan(db: Session, kpi_id: int, eid: int, title: str, **kw) -> ActionPlan:
plan = ActionPlan(entity_id=eid, kpi_id=kpi_id, objective_id=kw.get("objective_id"),
kr_id=kw.get("kr_id"), title=title, assignee="张三",
priority="medium", status=kw.get("status", "pending"), progress=0)
db.add(plan)
db.commit()
db.refresh(plan)
return plan
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 _cur_quarter() -> str:
import datetime as _dt
now = _dt.datetime.now()
return f"{now.year}Q{(now.month - 1) // 3 + 1}"
def _today_iso() -> str:
import datetime as _dt
return _dt.date.today().isoformat()
# ──────────────────────────────────────────────
# T1. create_plan 跨账套 objective_id → 404 且不落库
# ──────────────────────────────────────────────
def test_create_plan_objective_id_cross_entity_404(client: TestClient, db: Session):
"""双向:e1 会话挂 e2 目标 / e2 会话挂 e1 目标 → 均 404 且库内无半成品行"""
k1, k2 = _seed_two_entities(db)
obj1 = _mk_objective(db, 1, "e1 目标")
obj2 = _mk_objective(db, 2, "e2 目标")
t_fwd = "ZZ_B3O 跨账套目标 e1挂e2"
r1 = client.post(PLANS, headers=auth_header(_login(client, 1)),
json={"title": t_fwd, "kpi_id": k1.id, "objective_id": obj2.id})
assert r1.status_code == 404, (
f"e1 会话挂 e2 目标应 404,实际 {r1.status_code} {r1.text[:200]}"
)
assert _plans_by_title(db, t_fwd) == [], "跨账套 objective_id 竟落库(半成品行)"
t_rev = "ZZ_B3O 跨账套目标 e2挂e1"
r2 = client.post(PLANS, headers=auth_header(_login(client, 2)),
json={"title": t_rev, "kpi_id": k2.id, "objective_id": obj1.id})
assert r2.status_code == 404, (
f"e2 会话挂 e1 目标应 404,实际 {r2.status_code} {r2.text[:200]}"
)
assert _plans_by_title(db, t_rev) == [], "跨账套 objective_id 竟落库(半成品行)"
def test_create_plan_objective_id_same_entity_200(client: TestClient, db: Session):
"""同账套正对照(双向):本账套目标 → 200 且落库 objective_id 正确"""
k1, k2 = _seed_two_entities(db)
obj1 = _mk_objective(db, 1, "e1 目标")
obj2 = _mk_objective(db, 2, "e2 目标")
t1 = "ZZ_B3O 同账套目标 e1"
r1 = client.post(PLANS, headers=auth_header(_login(client, 1)),
json={"title": t1, "kpi_id": k1.id, "objective_id": obj1.id})
assert r1.status_code == 200, f"同账套 objective_id 应 200,实际 {r1.status_code} {r1.text[:200]}"
assert r1.json()["objective_id"] == obj1.id
t2 = "ZZ_B3O 同账套目标 e2"
r2 = client.post(PLANS, headers=auth_header(_login(client, 2)),
json={"title": t2, "kpi_id": k2.id, "objective_id": obj2.id})
assert r2.status_code == 200, f"同账套 objective_id 应 200,实际 {r2.status_code} {r2.text[:200]}"
assert r2.json()["objective_id"] == obj2.id
def test_create_plan_objective_quarter_validation_preserved(client: TestClient, db: Session):
"""归属校验改造不得吃掉既有季度校验:同账套目标 + 越季截止 → 422"""
import datetime as _dt
k1, _ = _seed_two_entities(db)
obj1 = _mk_objective(db, 1, "e1 目标", quarter=_cur_quarter())
far_due = (_dt.date.today() + _dt.timedelta(days=200)).isoformat()
r = client.post(PLANS, headers=auth_header(_login(client, 1)),
json={"title": "ZZ_B3O 越季截止", "kpi_id": k1.id,
"objective_id": obj1.id, "due_date": far_due})
assert r.status_code == 422, (
f"越季截止应 422(既有季度校验被重构吃掉?),实际 {r.status_code} {r.text[:200]}"
)
assert _plans_by_title(db, "ZZ_B3O 越季截止") == []
def test_create_plan_objective_id_unknown_404(client: TestClient, db: Session):
"""不存在的 objective_id → 404(不得静默落库)"""
k1, _ = _seed_two_entities(db)
r = client.post(PLANS, headers=auth_header(_login(client, 1)),
json={"title": "ZZ_B3O 幽灵目标", "kpi_id": k1.id,
"objective_id": 999999})
assert r.status_code == 404, f"不存在的目标应 404,实际 {r.status_code} {r.text[:200]}"
assert _plans_by_title(db, "ZZ_B3O 幽灵目标") == []
# ──────────────────────────────────────────────
# T4/T5. update_plan 的 objective_id 归属校验
# ──────────────────────────────────────────────
def test_update_plan_objective_id_cross_entity_404(client: TestClient, db: Session):
"""双向:e1 计划改挂 e2 目标 / e2 计划改挂 e1 目标 → 404 且计划零改动"""
k1, k2 = _seed_two_entities(db)
obj1 = _mk_objective(db, 1, "e1 目标")
obj2 = _mk_objective(db, 2, "e2 目标")
p1 = _mk_plan(db, k1.id, 1, "ZZ_B3O e1 计划")
r1 = client.put(f"{PLANS}/{p1.id}", headers=auth_header(_login(client, 1)),
json={"objective_id": obj2.id})
assert r1.status_code == 404, (
f"e1 计划挂 e2 目标应 404,实际 {r1.status_code} {r1.text[:200]}"
)
db.expire_all()
assert db.query(ActionPlan).filter(ActionPlan.id == p1.id).first().objective_id is None, \
"跨账套 objective_id 竟被写入"
p2 = _mk_plan(db, k2.id, 2, "ZZ_B3O e2 计划")
r2 = client.put(f"{PLANS}/{p2.id}", headers=auth_header(_login(client, 2)),
json={"objective_id": obj1.id})
assert r2.status_code == 404, (
f"e2 计划挂 e1 目标应 404,实际 {r2.status_code} {r2.text[:200]}"
)
db.expire_all()
assert db.query(ActionPlan).filter(ActionPlan.id == p2.id).first().objective_id is None, \
"跨账套 objective_id 竟被写入"
def test_update_plan_objective_id_same_entity_200(client: TestClient, db: Session):
"""同账套正对照:e1 计划改挂本账套目标 → 200 且落库"""
k1, _ = _seed_two_entities(db)
obj1 = _mk_objective(db, 1, "e1 目标")
p1 = _mk_plan(db, k1.id, 1, "ZZ_B3O e1 计划")
r = client.put(f"{PLANS}/{p1.id}", headers=auth_header(_login(client, 1)),
json={"objective_id": obj1.id})
assert r.status_code == 200, f"同账套 objective_id 应 200,实际 {r.status_code} {r.text[:200]}"
assert r.json()["objective_id"] == obj1.id
db.expire_all()
assert db.query(ActionPlan).filter(ActionPlan.id == p1.id).first().objective_id == obj1.id
# ──────────────────────────────────────────────
# T6. 入参 objective_id 与 kr_id 推导值的显式优先级
# ──────────────────────────────────────────────
def test_update_plan_objective_id_priority_over_kr_derived(client: TestClient, db: Session):
"""三条口径:① 未传入参 → 按 KR 推导;② 同传则入参优先(不被推导覆盖);③ 入参 null → 置空"""
k1, _ = _seed_two_entities(db)
obj_a = _mk_objective(db, 1, "e1 目标A")
obj_b = _mk_objective(db, 1, "e1 目标B")
kr_a = _mk_kr(db, 1, obj_a.id, "e1 KR-A")
kr_b = _mk_kr(db, 1, obj_b.id, "e1 KR-B")
token1 = _login(client, 1)
# ① 未传 objective_id + 改挂 KR + 计划当前无目标 → 推导自 KR
p1 = _mk_plan(db, k1.id, 1, "ZZ_B3O 优先级-推导")
r1 = client.put(f"{PLANS}/{p1.id}", headers=auth_header(token1), json={"kr_id": kr_a.id})
assert r1.status_code == 200, r1.text[:200]
assert r1.json()["objective_id"] == obj_a.id, (
f"未传入参时应按 KR 推导 objective_id={obj_a.id},实际 {r1.json()['objective_id']}"
)
# ② 同传入参 objective_id(属本账套另一目标)+ 改挂 KR-B → 入参优先,不被 KR-B 推导覆盖
p2 = _mk_plan(db, k1.id, 1, "ZZ_B3O 优先级-入参覆盖")
r2 = client.put(f"{PLANS}/{p2.id}", headers=auth_header(token1),
json={"kr_id": kr_b.id, "objective_id": obj_a.id})
assert r2.status_code == 200, r2.text[:200]
assert r2.json()["kr_id"] == kr_b.id
assert r2.json()["objective_id"] == obj_a.id, (
f"入参 objective_id 应优先于 kr_id 推导值(期望 {obj_a.id},实际 {r2.json()['objective_id']})"
)
# ③ 显式传 objective_id=null → 置空(不回落推导值)
p3 = _mk_plan(db, k1.id, 1, "ZZ_B3O 优先级-null置空", objective_id=obj_a.id)
r3 = client.put(f"{PLANS}/{p3.id}", headers=auth_header(token1),
json={"kr_id": kr_a.id, "objective_id": None})
assert r3.status_code == 200, r3.text[:200]
assert r3.json()["objective_id"] is None, (
f"显式 null 应置空 objective_id,实际 {r3.json()['objective_id']}"
)
def test_update_plan_kr_ok_objective_cross_entity_404(client: TestClient, db: Session):
"""同请求内 KR 合法 + objective_id 跨账套 → 404 且 KR/目标零改动"""
k1, _ = _seed_two_entities(db)
obj_a = _mk_objective(db, 1, "e1 目标A")
obj2 = _mk_objective(db, 2, "e2 目标")
kr_a = _mk_kr(db, 1, obj_a.id, "e1 KR-A")
p1 = _mk_plan(db, k1.id, 1, "ZZ_B3O 混合载荷")
r = client.put(f"{PLANS}/{p1.id}", headers=auth_header(_login(client, 1)),
json={"kr_id": kr_a.id, "objective_id": obj2.id})
assert r.status_code == 404, (
f"跨账套 objective_id 应 404,实际 {r.status_code} {r.text[:200]}"
)
db.expire_all()
after = db.query(ActionPlan).filter(ActionPlan.id == p1.id).first()
assert after.kr_id is None, f"404 时 kr_id 竟被写入: {after.kr_id}"
assert after.objective_id is None, f"404 时 objective_id 竟被写入: {after.objective_id}"
# ──────────────────────────────────────────────
# T8. 读侧后果:OKR 分解视图不得出现对端账套计划
# ──────────────────────────────────────────────
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"]}
def test_okr_decomposition_excludes_cross_entity_plan(client: TestClient, db: Session):
"""e2 会话无法把计划挂进 e1 目标(404)→ e1 分解视图不含该计划;本账套合法计划仍可见"""
k1, k2 = _seed_two_entities(db)
obj1 = _mk_objective(db, 1, "e1 目标", quarter=_cur_quarter())
due = _today_iso()
# 对端(e2)尝试挂 e1 目标 + 本周截止:写入侧拒绝 → 视图不应出现
cross_title = "ZZ_B3O 对端注入计划"
rc = client.post(PLANS, headers=auth_header(_login(client, 2)),
json={"title": cross_title, "kpi_id": k2.id,
"objective_id": obj1.id, "due_date": due})
assert rc.status_code == 404, (
f"对端账套挂本账套目标应 404,实际 {rc.status_code} {rc.text[:200]}"
)
# 本账套合法计划(同目标 + 本周截止)= 正对照
own_title = "ZZ_B3O 本账套合法计划"
ro = client.post(PLANS, headers=auth_header(_login(client, 1)),
json={"title": own_title, "kpi_id": k1.id,
"objective_id": obj1.id, "due_date": due})
assert ro.status_code == 200, ro.text[:200]
titles = _weekly_titles(client, _login(client, 1), obj1.id)
assert cross_title not in titles, f"对端账套计划出现在本账套分解视图: {sorted(titles)}"
assert own_title in titles, f"本账套合法计划未出现在分解视图(正对照失效): {sorted(titles)}"
# 对端账套查本账套目标 → 404(账套边界本身)
rq = client.get(f"{OKR}/{obj1.id}/decomposition", headers=auth_header(_login(client, 2)))
assert rq.status_code == 404, f"对端账套查本账套 OKR 应 404,实际 {rq.status_code}"
# ──────────────────────────────────────────────
# T9. 鉴权底线
# ──────────────────────────────────────────────
def test_objective_id_endpoints_without_token_401(client: TestClient, db: Session):
"""无 token → 401(POST 创建 / PUT 更新),改前改后都应通过"""
k1, _ = _seed_two_entities(db)
p1 = _mk_plan(db, k1.id, 1, "ZZ_B3O 无token")
r1 = client.post(PLANS, json={"title": "ZZ_B3O 无token创建", "kpi_id": k1.id,
"objective_id": 1})
assert r1.status_code == 401, f"创建无 token 应 401,实际 {r1.status_code}"
r2 = client.put(f"{PLANS}/{p1.id}", json={"objective_id": 1})
assert r2.status_code == 401, f"更新无 token 应 401,实际 {r2.status_code}"