Files
cma-management/backend/tests/test_alignment_entity_isolation_20260918.py
wecom-fullstack 0c2e57a4cf fix(cma): 工作台跨实体污染治本 — 全局键拆账套 + 缺列收口 + 预警归属兜底
任务 dama-workbench-entity-isolation-fullstack-20260922(DAMA 诊断 / 项目Bot batch5 §B / 任总 2026-09-22「都批」)

B1 现金基线拆账套(零 DDL)
- cash_forecast_engine: 键 cash.current_balance → cash.current_balance.<entity_id>;
  读只读本账套键、不做旧键回退;set_* 签名 entity_id 必传(漏传 TypeError)
- cash.py: POST /balance 挂 get_entity_id;网银流水联动写回带账套
B3(a) alignment_config 拆账套(零 DDL)
- alignment.py: GET/POST /config、GET /tree 只读 alignment_config.<entity_id>;未配置=configured:false
B3(b) 预警 KPI 归属兜底
- bot_bridge_v2: 去掉 kpi_id or 1 兜底;本账套无 KPI 定义 → 跳过建预警 + OperationLog 留痕
  (kpi_alerts.kpi_id NOT NULL ⇒ 方案A 置 NULL 不可行,走方案B 跳过+审计)
B2 缺列五表分表处置
- notification_logs: 经 alert_id → kpi_alerts.entity_id join 归属(无父键行不返回)
- notification_channels / okr_templates: 集团共享口径写入 docstring(有意不过滤)
- system_configs: 由 B1/B3(a) 拆键覆盖;kpi_data_quality_logs 沿用 batch5 的 join 收口
P0 语义收敛
- tenant.py: current_tenant 收敛为项目Bot A2A 状态记录,GET 仅 it/ceo;POST 补 operation_logs 审计
证据:新增/改写用例 22 条;全量 1370 passed(基线 1348)
2026-09-22 11:06:02 +08:00

195 lines
7.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""战略执行看板(/alignment)账套隔离回归测试 — 2026-09-18
任务:project-20260918-alignment-entity-leak(任总 2026-09-18 派单)
缺陷:GET /api/cma/alignment/tree 未接账套(entity_id)→ 返回全实体 active KPI,
三种对齐模式共用同一份 KPI 列表 → 酣客账套视图混入博海 KPI。
本用例覆盖(三模式 × 双向隔离):
1. 纵向分解 / 横向支撑 / BSC瀑布链 三种模式,均只返回会话账套的 KPI;
2. 双向:entity1 会话看不到 entity2 的 KPI,entity2 会话看不到 entity1 的 KPI;
3. 跨实体显式传参 ?entity_id=<对方> → 403(对齐 get_entity_id 既有语义);
4. 鉴权语义未改:无 token → 401;
5. total 计数 = 本账套 active KPI 数(不是全库)。
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import Entity, KPIDefinition, RolePermission
from tests.conftest import (
TEST_USER_PASSWORD,
auth_header,
create_test_user,
get_token_for_user,
)
ALIGN = "/api/cma/alignment"
MODES = ["vertical_decomposition", "horizontal_support", "bsc_chain"]
# 两个账套各自的 KPI 编码(kpi_code 库内唯一,故两侧编码前缀不同)
E1_CODES = ["HK_REVENUE_001", "HK_REVENUE_002", "HK_PROFIT_001"]
E2_CODES = ["BH_REVENUE_001", "BH_PROFIT_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 _set_mode(db: Session, mode: str, entity_ids=(1, 2)) -> None:
"""写入对齐模式配置 —— 2026-09-22 B3(a) 拆账套后为 `alignment_config.<entity_id>`
本文件断言的语义 =「两账套处在同一对齐模式时,各自只返回本账套 KPI」,
故对 e1/e2 各写一份(修复前为全局唯一键,一份两账套共用)。
"""
for eid in entity_ids:
key = f"alignment_config.{eid}"
perm = db.query(RolePermission).filter(RolePermission.key == key).first()
if perm:
perm.value = {"mode": mode}
else:
db.add(RolePermission(key=key, value={"mode": mode}))
db.commit()
def _mk_kpi(db: Session, code: str, eid: int, dimension: str, category: str) -> KPIDefinition:
kpi = KPIDefinition(
entity_id=eid,
kpi_code=code,
kpi_name=f"{code}-名称",
dimension=dimension,
category=category,
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 _seed(db: Session) -> None:
"""两个账套各 2-3 个 active KPI(三模式均可构建出层级)"""
_ensure_entity(db, 1, "酣客")
_ensure_entity(db, 2, "博海")
for c in E1_CODES:
_mk_kpi(db, c, 1, "finance", "revenue_growth")
for c in E2_CODES:
_mk_kpi(db, c, 2, "finance", "revenue_growth")
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 _collect_codes(nodes) -> set:
"""递归收集树内所有 kpi_code(维度组/类别组节点无 kpi_code,跳过)"""
found = set()
for node in nodes or []:
if not isinstance(node, dict):
continue
if node.get("kpi_code"):
found.add(node["kpi_code"])
found |= _collect_codes(node.get("children"))
return found
@pytest.mark.parametrize("mode", MODES)
def test_tree_isolated_by_entity_both_directions(client: TestClient, db: Session, mode: str):
"""三模式 × 双向:只返回本账套 KPI,不含对方账套 code"""
_seed(db)
create_test_user(db)
_set_mode(db, mode)
token1 = _login(client, 1)
token2 = _login(client, 2)
r1 = client.get(f"{ALIGN}/tree", headers=auth_header(token1))
assert r1.status_code == 200, r1.text
body1 = r1.json()
codes1 = _collect_codes(body1["tree"])
r2 = client.get(f"{ALIGN}/tree", headers=auth_header(token2))
assert r2.status_code == 200, r2.text
body2 = r2.json()
codes2 = _collect_codes(body2["tree"])
# 酣客(e1):只见酣客 code,绝不含博海 code
assert codes1 == set(E1_CODES), f"[{mode}] entity1 返回 {sorted(codes1)}"
assert not any(c.startswith("BH_") for c in codes1), f"[{mode}] entity1 混入博海 KPI"
# 博海(e2):反向同理
assert codes2 == set(E2_CODES), f"[{mode}] entity2 返回 {sorted(codes2)}"
assert not any(c.startswith("HK_") for c in codes2), f"[{mode}] entity2 混入酣客 KPI"
# total = 本账套 active KPI 数(修复前 = 全库 5)
assert body1["total"] == len(E1_CODES), f"[{mode}] entity1 total={body1['total']}"
assert body2["total"] == len(E2_CODES), f"[{mode}] entity2 total={body2['total']}"
assert body1["mode"] == mode and body2["mode"] == mode
def test_tree_cross_entity_explicit_param_403(client: TestClient, db: Session):
"""跨实体显式传参 → 403(会话 token 绑定账套为准,未改鉴权语义外的任何行为)"""
_seed(db)
create_test_user(db)
_set_mode(db, "horizontal_support")
token1 = _login(client, 1)
token2 = _login(client, 2)
r = client.get(f"{ALIGN}/tree?entity_id=2", headers=auth_header(token1))
assert r.status_code == 403, f"entity1 会话读 entity2 应 403,实际 {r.status_code}"
assert "无权访问" in r.json()["detail"]
r = client.get(f"{ALIGN}/tree?entity_id=1", headers=auth_header(token2))
assert r.status_code == 403, f"entity2 会话读 entity1 应 403,实际 {r.status_code}"
def test_tree_without_token_401(client: TestClient, db: Session):
"""鉴权底线不变:无 Bearer token → 401"""
_seed(db)
_set_mode(db, "bsc_chain")
r = client.get(f"{ALIGN}/tree")
assert r.status_code == 401, f"无 token 应 401,实际 {r.status_code}"
def test_tree_same_entity_explicit_param_ok(client: TestClient, db: Session):
"""同账套显式传参与 token 一致 → 200(不是一律拒绝)"""
_seed(db)
create_test_user(db)
_set_mode(db, "bsc_chain")
token1 = get_token_for_user(client) # entity1
r = client.get(f"{ALIGN}/tree?entity_id=1", headers=auth_header(token1))
assert r.status_code == 200, r.text
assert _collect_codes(r.json()["tree"]) == set(E1_CODES)
def test_tree_kpi_id_of_other_entity_not_returned(client: TestClient, db: Session):
"""kpi_id 指向对方账套的 KPI → 不返回该 KPI(隔离后树内查无此 id)"""
_seed(db)
create_test_user(db)
_set_mode(db, "vertical_decomposition")
other = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == E2_CODES[0]).first()
token1 = _login(client, 1)
r = client.get(f"{ALIGN}/tree?kpi_id={other.id}", headers=auth_header(token1))
assert r.status_code == 200
body = r.json()
assert body is None or not _collect_codes([body]), f"entity1 不应取到 entity2 的 KPI: {body}"