diff --git a/backend/app/api/action_plans.py b/backend/app/api/action_plans.py index dcbd9b83..ac4749b6 100644 --- a/backend/app/api/action_plans.py +++ b/backend/app/api/action_plans.py @@ -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: diff --git a/backend/tests/test_cross_entity_isolation_batch3_objective_id_20260918.py b/backend/tests/test_cross_entity_isolation_batch3_objective_id_20260918.py new file mode 100644 index 00000000..2d7dbe8d --- /dev/null +++ b/backend/tests/test_cross_entity_isolation_batch3_objective_id_20260918.py @@ -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}"