Files
cma-management/backend/tests/test_data_entity_isolation_20260908.py
T
Hermes CI Fix 193061cf4d fix(data): 数据管理模块脱节修复 — Excel导入/sources端点 entity 隔离 + ERP直连通道诚实化(410) + 真实zxt链路展示
- import_excel/import_excel_smart KPI字典按 entity_id 过滤(防跨账套匹配串写,P0安全)
- import_excel_smart 自动建KPI编码防冲突(_next_code 循环取号)
- sources list/create/update/delete 全部按 entity_id 隔离(create 写入当前账套)
- sync-kpis + admin/erp-sync 下线返回410(erp-gateway DB凭据2026-08-31已删,真实通道=zxt智享通注入)
- 新增 GET /data/channel-status:展示真实zxt取数通道状态+遗留ERP数据源清单(诚实化)
- DataManage.vue:数据源tab顶部展示真实通道(最近注入period/时间/行数/落盘目录mtime)、ERP遗留源标注'非当前通道'、实际取数映射(kpi_value_sources)表格
- 新增 test_data_entity_isolation_20260908.py(8条:双账套导入隔离/CRUD隔离/sync-kpis 410/channel-status)
2026-09-08 11:19:38 +08:00

231 lines
9.8 KiB
Python
Raw 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.
"""数据管理模块脱节修复测试(2026-09-08 P0,project-20260908-data-manage-fix)
覆盖:
- Excel 导入不跨账套匹配(import-excel / import-excel-smart 双端点)
- 数据源管理端点 entity 隔离(list/create/update/delete + 跨账套 404)
- sync-kpis 已停用(410)
- channel-status 真实通道状态端点
"""
import io
import pandas as pd
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import KPIDefinition, KPIValue, DataSourceConfig, Entity
from tests.conftest import create_test_user, get_token_for_user, auth_header
DATA = "/api/cma/data"
def _ensure_entity(db: Session, eid: int, name: str = None) -> Entity:
ent = db.query(Entity).filter(Entity.id == eid).first()
if not ent:
ent = Entity(id=eid, name=name or f"企业{eid}", short_name=f"E{eid}", status="active")
db.add(ent)
db.commit()
db.refresh(ent)
return ent
def _login(client: TestClient, username: str, password: str, entity_id: int) -> str:
resp = client.post("/api/cma/auth/login", json={
"username": username, "password": password, "entity_id": entity_id,
})
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
return resp.json()["token"]
def _excel_bytes(df: pd.DataFrame) -> io.BytesIO:
buf = io.BytesIO()
df.to_excel(buf, index=False)
buf.seek(0)
return buf
class TestExcelImportEntityIsolation:
"""Excel 导入只匹配当前账套 KPI,防跨账套写入(P0 安全)"""
def _mk_kpi(self, db: Session, code: str, eid: int) -> KPIDefinition:
kpi = KPIDefinition(
entity_id=eid, kpi_code=code, kpi_name=f"{code}-名称",
dimension="finance", category="financial_report", 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 test_import_excel_entity2_does_not_write_entity1_kpi(self, client: TestClient, db: Session):
"""entity2 登录导入 F_REVENUE → entity1 的 F_REVENUE 零写入(旧实现会全表匹配串写)"""
_ensure_entity(db, 2)
k1 = self._mk_kpi(db, "F_REVENUE", 1)
_ensure_entity(db, 2)
create_test_user(db)
token2 = _login(client, "testadmin", "admin123", entity_id=2)
before = db.query(KPIValue).filter(KPIValue.kpi_id == k1.id).count()
df = pd.DataFrame({
"kpi_code": ["F_REVENUE"],
"period": ["2026-08"],
"actual_value": [999.0],
})
resp = client.post(
f"{DATA}/import-excel",
headers=auth_header(token2),
files={"file": ("t.xlsx", _excel_bytes(df),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
)
assert resp.status_code == 200
# entity2 无 F_REVENUE → 匹配不到 → 跳过 0 条(不是跨账套写入 entity1)
assert "0 条" in resp.json()["message"]
assert "不存在" in resp.json()["message"]
after = db.query(KPIValue).filter(KPIValue.kpi_id == k1.id).count()
assert after == before, "entity2 导入不得写入 entity1 的 KPI"
def test_import_excel_smart_creates_kpi_in_current_entity_only(self, client: TestClient, db: Session):
"""entity2 智能导入不存在的 F_REVENUE → 自动创建在 entity2(不匹配 entity1 的同名KPI)"""
_ensure_entity(db, 2)
k1 = self._mk_kpi(db, "F_REVENUE", 1)
create_test_user(db)
token2 = _login(client, "testadmin", "admin123", entity_id=2)
df = pd.DataFrame({
"kpi_code": ["F_REVENUE"],
"period": ["2026-08"],
"actual_value": [999.0],
})
resp = client.post(
f"{DATA}/import-excel-smart",
headers=auth_header(token2),
files={"file": ("测试利润表.xlsx", _excel_bytes(df),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
)
assert resp.status_code == 200, resp.text
body = resp.json()
# entity2 自动创建 KPI(PL_ 前缀因为文件名含"利润表"),不写 entity1 的 F_REVENUE
assert "自动创建1个新KPI" in body["message"]
created = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == 2,
KPIDefinition.kpi_code.like("PL_%"),
).first()
assert created is not None, "entity2 导入应自动创建 PL_xxx KPI 在 entity2 账套"
# entity1 的 F_REVENUE 及其 KPIValue 未被触碰
assert db.query(KPIValue).filter(KPIValue.kpi_id == k1.id).count() == 0
def test_import_excel_entity1_own_kpi_still_works(self, client: TestClient, db: Session):
"""正常路径:entity1 导入自己的 F_REVENUE 成功"""
k1 = self._mk_kpi(db, "F_REVENUE", 1)
create_test_user(db)
token1 = get_token_for_user(client) # entity1
df = pd.DataFrame({
"kpi_code": ["F_REVENUE"],
"period": ["2026-08"],
"actual_value": [100.0],
})
resp = client.post(
f"{DATA}/import-excel",
headers=auth_header(token1),
files={"file": ("t.xlsx", _excel_bytes(df),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
)
assert resp.status_code == 200
assert "导入成功 1 条" in resp.json()["message"]
row = db.query(KPIValue).filter(KPIValue.kpi_id == k1.id).first()
assert row is not None and row.actual_value == 100.0
class TestSourceCRUDEntityIsolation:
"""数据源管理端点 entity 隔离(P1)"""
def _mk_entity2_source(self, client, db, token2) -> int:
resp = client.post(
f"{DATA}/sources",
headers=auth_header(token2),
json={"name": "entity2数据源", "source_type": "manual"},
)
assert resp.status_code == 200
assert resp.json()["data"]["entity_id"] == 2
return resp.json()["data"]["id"]
def test_list_sources_isolated_by_entity(self, client: TestClient, db: Session):
"""entity2 登录 list_sources 看不到 entity1 的数据源"""
_ensure_entity(db, 2)
create_test_user(db)
token1 = get_token_for_user(client)
token2 = _login(client, "testadmin", "admin123", entity_id=2)
r1 = client.post(f"{DATA}/sources", headers=auth_header(token1),
json={"name": "entity1数据源", "source_type": "manual"})
assert r1.status_code == 200
# entity2 登录看不到 entity1 的源
resp2 = client.get(f"{DATA}/sources", headers=auth_header(token2))
assert resp2.status_code == 200
names2 = [s["name"] for s in resp2.json()["data"]]
assert "entity1数据源" not in names2
# entity1 登录能看到自己的
resp1 = client.get(f"{DATA}/sources", headers=auth_header(token1))
names1 = [s["name"] for s in resp1.json()["data"]]
assert "entity1数据源" in names1
def test_cross_entity_update_delete_404(self, client: TestClient, db: Session):
"""entity2 不能改/删 entity1 的数据源(404 越权防护)"""
_ensure_entity(db, 2)
create_test_user(db)
token1 = get_token_for_user(client)
token2 = _login(client, "testadmin", "admin123", entity_id=2)
r1 = client.post(f"{DATA}/sources", headers=auth_header(token1),
json={"name": "entity1的源", "source_type": "manual"})
e1_source_id = r1.json()["data"]["id"]
# entity2 尝试更新 entity1 的源 → 404(隔离生效,不再允许越权操作)
resp = client.put(f"{DATA}/sources/{e1_source_id}", headers=auth_header(token2),
json={"name": "篡改"})
assert resp.status_code == 404
resp = client.delete(f"{DATA}/sources/{e1_source_id}", headers=auth_header(token2))
assert resp.status_code == 404
# entity1 自己仍可删除
resp = client.delete(f"{DATA}/sources/{e1_source_id}", headers=auth_header(token1))
assert resp.status_code == 200
class TestSyncKpisDecommissioned:
"""sync-kpis 收口:ERP 直连通道 2026-08-31 停用,端点返回 410(下线说明)"""
def test_sync_kpis_returns_410(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
resp = client.get(f"{DATA}/sync-kpis", headers=auth_header(token))
assert resp.status_code == 410
assert "2026-08-31" in resp.json()["detail"]
assert "zxt" in resp.json()["detail"]
def test_admin_erp_sync_returns_410(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
resp = client.post("/api/cma/admin/erp-sync", headers=auth_header(token))
assert resp.status_code == 410
resp2 = client.get("/api/cma/admin/erp-sync/dry-run", headers=auth_header(token))
assert resp2.status_code == 410
class TestChannelStatus:
"""channel-status 真实取数链路状态端点"""
def test_channel_status_shape(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
resp = client.get(f"{DATA}/channel-status", headers=auth_header(token))
assert resp.status_code == 200
d = resp.json()
assert "real_channel" in d and "erp_direct" in d
assert d["real_channel"]["channel"].startswith("zxt")
assert d["erp_direct"]["enabled"] is False
assert "2026-08-31" in d["erp_direct"]["reason"]