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)
This commit is contained in:
Hermes CI Fix
2026-09-18 20:23:53 +08:00
parent d03e322a81
commit d2984857a2
3 changed files with 289 additions and 5 deletions
@@ -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-<task>-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}"