From d2984857a26f295b99faa107fe53863c192bd89e Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Fri, 18 Sep 2026 20:23:53 +0800 Subject: [PATCH] fix(cma): scope read-side ai-suggestion detail/preview and map nodes -m- ai_suggestions.get_suggestion: filter by session entity_id (foreign 404) - ai_suggestions.preview_suggestion: use the existing entity_id dependency in the query - deviation_push.get_map_nodes: add entity_id dependency and (entity_id or 1) ownership guard - tests: 4 regression cases (3 RED -> GREEN + auth baseline 401) --- backend/app/api/ai_suggestions.py | 13 +- backend/app/api/deviation_push.py | 5 +- ...st_read_side_entity_scope_gaps_20260918.py | 276 ++++++++++++++++++ 3 files changed, 289 insertions(+), 5 deletions(-) create mode 100644 backend/tests/test_read_side_entity_scope_gaps_20260918.py diff --git a/backend/app/api/ai_suggestions.py b/backend/app/api/ai_suggestions.py index 4789f415..a3c81efe 100644 --- a/backend/app/api/ai_suggestions.py +++ b/backend/app/api/ai_suggestions.py @@ -98,9 +98,13 @@ def list_suggestions( @router.get("/{suggestion_id}") -def get_suggestion(suggestion_id: int, db: Session = Depends(get_db)): +def get_suggestion(suggestion_id: int, db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id)): """建议详情""" - s = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first() + s = db.query(AISuggestion).filter( + AISuggestion.id == suggestion_id, + AISuggestion.entity_id == entity_id, # 2026-09-18 派生单: 建议须归属会话账套 + ).first() if not s: raise HTTPException(404, "建议不存在") return {"data": _sug_dict(s)} @@ -291,7 +295,10 @@ def preview_suggestion(suggestion_id: int, db: Session = Depends(get_db), - budget_adjust:{kpi_name, period, current_budget, new_budget} - action_plan: {kpi_name, plan_title, assignee, priority, due_date} """ - sug = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first() + sug = db.query(AISuggestion).filter( + AISuggestion.id == suggestion_id, + AISuggestion.entity_id == entity_id, # 2026-09-18 派生单: 预览须归属会话账套(原依赖未用于过滤) + ).first() if not sug: raise HTTPException(404, "建议不存在") sd = sug.suggestion_data or {} diff --git a/backend/app/api/deviation_push.py b/backend/app/api/deviation_push.py index 9b26bf72..b77da43b 100644 --- a/backend/app/api/deviation_push.py +++ b/backend/app/api/deviation_push.py @@ -199,10 +199,11 @@ def push_deviation_to_map( @router.get("/map-nodes/{map_id}") -def get_map_nodes(map_id: int, db: Session = Depends(get_db)): +def get_map_nodes(map_id: int, db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id)): """获取战略地图的全部节点(供反打选择使用)""" m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first() - if not m: + if not m or (m.entity_id or 1) != entity_id: # 2026-09-18 派生单: NULL 归属按 e1 兼容(同 push 护栏) raise HTTPException(404, "战略地图不存在") dims = m.dimensions diff --git a/backend/tests/test_read_side_entity_scope_gaps_20260918.py b/backend/tests/test_read_side_entity_scope_gaps_20260918.py new file mode 100644 index 00000000..acc711d6 --- /dev/null +++ b/backend/tests/test_read_side_entity_scope_gaps_20260918.py @@ -0,0 +1,276 @@ +"""CMA 跨实体隔离 — 读侧(只读端点)账套收敛缺口(2026-09-18 派生单) + +任务:fullstack-20260918-2025-read-side-entity-scope-gaps +父单:fullstack-20260918-2015-actionplan-entity-write-residual-sites + (其范围外只读侦查登记的三处同族只读缺口) + +缺陷(三处按 id 直取、未按会话账套限定): + 1. app/api/ai_suggestions.py::get_suggestion — GET /{suggestion_id} 无账套条件 + 2. app/api/ai_suggestions.py::preview_suggestion — GET /{suggestion_id}/preview + 已有 entity_id 依赖但未用于过滤 + 3. app/api/deviation_push.py::get_map_nodes — GET /map-nodes/{map_id} + 无账套校验且端点无 get_entity_id 依赖 + +后果:本账套会话带任意 id 即可读到对端账套的建议全文 / 应用前预览参数 / +战略地图全部节点(维度·目标·KPI 编码)。对端记录口径 = 404「不存在」 +(对齐 alerts.py::create_action_plan_from_alert 与 1905b/2015 的写侧写法)。 + +本用例(改前失败条目见 _reports/evidence--red.txt): +- T1 建议详情:对端账套 → 404 且响应体不含对端字段;本账套 → 200(双向正对照) +- T2 应用前预览:对端账套 → 404 且不泄漏对端 KPI 名;本账套 → 200 字段完整(双向) +- T3 地图节点:对端账套 → 404 且不泄漏对端目标名;本账套 → 200 节点正常(双向) +- T4 鉴权底线:三处无 token → 401(改前改后都应通过) +""" +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.models import Entity, KPIDefinition, StrategicMap +from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user + +SUGG = "/api/cma/ai/suggestions" +MAP_NODES = "/api/cma/deviation-push/map-nodes" + +# kpi_code 全局唯一 → 每账套独立 code +E1_KPI = "ZZ_RSG_E1_KPI" +E2_KPI = "ZZ_RSG_E2_KPI" +E1_MAP_KPI = "ZZ_RSG_E1_MAP_KPI" +E2_MAP_KPI = "ZZ_RSG_E2_MAP_KPI" + +E1_OBJ = "ZZ_RSG e1 目标" +E2_OBJ = "ZZ_RSG e2 目标" +E1_SUG_TITLE = "ZZ_RSG e1 私有建议" +E2_SUG_TITLE = "ZZ_RSG e2 私有建议" + + +# ────────────────────────────────────────────── +# 夹具(沿用 1905b/2015 同族风格:双向登录 + 同账套正对照) +# ────────────────────────────────────────────── + +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, target: float = 1000.0) -> 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=target, + kpi_level="operational", + status="active", + ) + db.add(kpi) + db.commit() + db.refresh(kpi) + return kpi + + +def _mk_map(db: Session, eid: int, title: str, kpi_code: str, obj_name: str) -> StrategicMap: + m = StrategicMap( + entity_id=eid, + title=title, + version="v1.0", + status="draft", + dimensions=[{ + "key": "finance", "name": "财务维度", "icon": "F", "color": "#409eff", + "objectives": [{"name": obj_name, "kpis": [kpi_code]}], + }], + canvas_data={"connections": []}, + ) + db.add(m) + db.commit() + db.refresh(m) + return m + + +def _seed_two_entities(db: Session): + _ensure_entity(db, 1, "酣客") + _ensure_entity(db, 2, "博海") + create_test_user(db) + + +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 _mk_suggestion(client: TestClient, token: str, kpi: KPIDefinition, title: str, + target_value: float = 2000.0) -> int: + body = { + "suggestion_type": "kpi_target", + "target_type": "kpi", + "target_id": kpi.id, + "title": title, + "content": "目标偏低,建议上调", + "suggestion_data": {"kpi_id": kpi.id, "target_value": target_value}, + } + r = client.post(SUGG, json=body, headers=auth_header(token)) + assert r.status_code == 200, f"创建建议失败: {r.status_code} {r.text[:300]}" + return r.json()["data"]["id"] + + +# ────────────────────────────────────────────── +# T1. 建议详情 GET /{suggestion_id} +# ────────────────────────────────────────────── + +def test_suggestion_detail_scoped_to_session_entity(client: TestClient, db: Session): + """对端账套建议 → 404 且响应体不泄漏;本账套建议 → 200(双向)""" + _seed_two_entities(db) + k1 = _mk_kpi(db, E1_KPI, 1) + k2 = _mk_kpi(db, E2_KPI, 2) + tok1 = _login(client, 1) + tok2 = _login(client, 2) + s1 = _mk_suggestion(client, tok1, k1, E1_SUG_TITLE) + s2 = _mk_suggestion(client, tok2, k2, E2_SUG_TITLE) + + # 方向 A:e2 会话读 e1 建议 → 404,响应体不得含 e1 字段 + r_a = client.get(f"{SUGG}/{s1}", headers=auth_header(tok2)) + assert r_a.status_code == 404, ( + f"e2 会话读 e1 建议应 404,实际 {r_a.status_code} {r_a.text[:200]}" + " —— get_suggestion 按 id 直取、未按会话账套限定" + ) + assert E1_SUG_TITLE not in r_a.text, "越权响应体泄漏了对端账套建议标题" + + # 方向 B:e1 会话读 e2 建议 → 404 + r_b = client.get(f"{SUGG}/{s2}", headers=auth_header(tok1)) + assert r_b.status_code == 404, ( + f"e1 会话读 e2 建议应 404,实际 {r_b.status_code} {r_b.text[:200]}" + ) + assert E2_SUG_TITLE not in r_b.text, "越权响应体泄漏了对端账套建议标题" + + # 正对照:本账套读自己的建议正常 + r_ok1 = client.get(f"{SUGG}/{s1}", headers=auth_header(tok1)) + assert r_ok1.status_code == 200, f"e1 读自己的建议应 200,实际 {r_ok1.status_code}" + assert r_ok1.json()["data"]["id"] == s1 + assert r_ok1.json()["data"]["title"] == E1_SUG_TITLE + r_ok2 = client.get(f"{SUGG}/{s2}", headers=auth_header(tok2)) + assert r_ok2.status_code == 200, f"e2 读自己的建议应 200,实际 {r_ok2.status_code}" + assert r_ok2.json()["data"]["title"] == E2_SUG_TITLE + + +# ────────────────────────────────────────────── +# T2. 应用前预览 GET /{suggestion_id}/preview +# ────────────────────────────────────────────── + +def test_suggestion_preview_scoped_to_session_entity(client: TestClient, db: Session): + """对端账套预览 → 404 且不泄漏对端 KPI 名;本账套预览 → 200 字段完整(双向)""" + _seed_two_entities(db) + k1 = _mk_kpi(db, E1_KPI, 1, target=1000.0) + k2 = _mk_kpi(db, E2_KPI, 2, target=3000.0) + tok1 = _login(client, 1) + tok2 = _login(client, 2) + s1 = _mk_suggestion(client, tok1, k1, E1_SUG_TITLE, target_value=2500.0) + s2 = _mk_suggestion(client, tok2, k2, E2_SUG_TITLE, target_value=4500.0) + + # 方向 A:e2 预览 e1 建议 → 404 + 不泄漏 e1 KPI 名 + r_a = client.get(f"{SUGG}/{s1}/preview", headers=auth_header(tok2)) + assert r_a.status_code == 404, ( + f"e2 预览 e1 建议应 404,实际 {r_a.status_code} {r_a.text[:200]}" + " —— preview_suggestion 有 entity_id 依赖但未用于过滤" + ) + assert str(k1.kpi_name) not in r_a.text, "越权预览泄漏了对端账套 KPI 名" + + # 方向 B:e1 预览 e2 建议 → 404 + r_b = client.get(f"{SUGG}/{s2}/preview", headers=auth_header(tok1)) + assert r_b.status_code == 404, ( + f"e1 预览 e2 建议应 404,实际 {r_b.status_code} {r_b.text[:200]}" + ) + assert str(k2.kpi_name) not in r_b.text, "越权预览泄漏了对端账套 KPI 名" + + # 正对照:本账套预览自己的建议,字段完整 + r_ok = client.get(f"{SUGG}/{s1}/preview", headers=auth_header(tok1)) + assert r_ok.status_code == 200, f"e1 预览自己的建议应 200,实际 {r_ok.status_code}" + data = r_ok.json()["data"] + assert data["type"] == "kpi_target", f"预览类型异常: {data}" + assert data["kpi_name"] == k1.kpi_name, f"预览 KPI 名异常: {data}" + assert data["current_target"] == 1000.0, f"预览当前值异常: {data}" + assert data["new_target"] == 2500.0, f"预览新值异常: {data}" + + r_ok2 = client.get(f"{SUGG}/{s2}/preview", headers=auth_header(tok2)) + assert r_ok2.status_code == 200, f"e2 预览自己的建议应 200,实际 {r_ok2.status_code}" + assert r_ok2.json()["data"]["new_target"] == 4500.0 + + +# ────────────────────────────────────────────── +# T3. 战略地图节点 GET /map-nodes/{map_id} +# ────────────────────────────────────────────── + +def test_map_nodes_scoped_to_session_entity(client: TestClient, db: Session): + """对端账套地图节点 → 404 且不泄漏对端目标名;本账套 → 200 节点正常(双向)""" + _seed_two_entities(db) + _mk_kpi(db, E1_MAP_KPI, 1) + _mk_kpi(db, E2_MAP_KPI, 2) + m1 = _mk_map(db, 1, "ZZ_RSG e1 地图", E1_MAP_KPI, E1_OBJ) + m2 = _mk_map(db, 2, "ZZ_RSG e2 地图", E2_MAP_KPI, E2_OBJ) + tok1 = _login(client, 1) + tok2 = _login(client, 2) + + # 方向 A:e2 读 e1 地图节点 → 404 + 不泄漏对端目标名 + r_a = client.get(f"{MAP_NODES}/{m1.id}", headers=auth_header(tok2)) + assert r_a.status_code == 404, ( + f"e2 会话读 e1 地图节点应 404,实际 {r_a.status_code} {r_a.text[:200]}" + " —— get_map_nodes 无账套校验、端点无 get_entity_id 依赖" + ) + assert E1_OBJ not in r_a.text, "越权响应体泄漏了对端账套目标名" + assert E1_MAP_KPI not in r_a.text, "越权响应体泄漏了对端账套 KPI 编码" + + # 方向 B:e1 读 e2 地图节点 → 404 + r_b = client.get(f"{MAP_NODES}/{m2.id}", headers=auth_header(tok1)) + assert r_b.status_code == 404, ( + f"e1 会话读 e2 地图节点应 404,实际 {r_b.status_code} {r_b.text[:200]}" + ) + assert E2_OBJ not in r_b.text, "越权响应体泄漏了对端账套目标名" + + # 正对照:本账套读自己的地图节点正常 + r_ok = client.get(f"{MAP_NODES}/{m1.id}", headers=auth_header(tok1)) + assert r_ok.status_code == 200, f"e1 读自己的地图节点应 200,实际 {r_ok.status_code}" + nodes = r_ok.json()["data"] + assert len(nodes) == 1, f"节点数异常: {nodes}" + assert nodes[0]["objective_name"] == E1_OBJ, f"节点目标名异常: {nodes}" + assert nodes[0]["kpi_codes"] == [E1_MAP_KPI], f"节点 KPI 编码异常: {nodes}" + + r_ok2 = client.get(f"{MAP_NODES}/{m2.id}", headers=auth_header(tok2)) + assert r_ok2.status_code == 200, f"e2 读自己的地图节点应 200,实际 {r_ok2.status_code}" + assert r_ok2.json()["data"][0]["objective_name"] == E2_OBJ + + +# ────────────────────────────────────────────── +# T4. 鉴权底线(三处无 token → 401) +# ────────────────────────────────────────────── + +def test_read_side_endpoints_without_token_401(client: TestClient, db: Session): + """三处只读端点无 token 一律 401(改前改后都应通过)""" + _seed_two_entities(db) + k1 = _mk_kpi(db, E1_KPI, 1) + m1 = _mk_map(db, 1, "ZZ_RSG e1 鉴权地图", E1_KPI, E1_OBJ) + tok1 = _login(client, 1) + s1 = _mk_suggestion(client, tok1, k1, E1_SUG_TITLE) + + r1 = client.get(f"{SUGG}/{s1}") + assert r1.status_code == 401, f"建议详情无 token 应 401,实际 {r1.status_code}" + + r2 = client.get(f"{SUGG}/{s1}/preview") + assert r2.status_code == 401, f"建议预览无 token 应 401,实际 {r2.status_code}" + + r3 = client.get(f"{MAP_NODES}/{m1.id}") + assert r3.status_code == 401, f"地图节点无 token 应 401,实际 {r3.status_code}"