Files
cma-management/backend/tests/test_entity_isolation_sweep_20260923.py
T
wecom-fullstack 477515c453
ci/woodpecker/push/main Pipeline was canceled
fix(cma): 隔离扫尾 — kpi_alert_higher_better 拆账套 + tenant/switch 审计收口
任务 dama-entity-isolation-sweep-20260923(DAMA 独立回查漏项 / 任总 2026-09-23「按照建议执行」)

① 补漏同源全局键 kpi_alert_higher_better(system_configs,零 DDL)
   - deviation_engine: 新增 higher_better_key(entity_id);get_higher_better_codes(db, entity_id)
     改为只读本账套键、不回退旧全局键;未配置 → DEFAULT_HIGHER_BETTER(兜底,与原行为逐字一致)
   - budget.py: GET/PUT /alert-direction 挂 get_entity_id,只读写会话账套键;body entity_id 不信
   - scripts/migrate_higher_better_key_entity_20260923.py: --check/--apply/--down + 库名保护 + 幂等
   - 生产实况:库内该键 0 行 ⇒ 无原值可搬运(--apply 走 skip 零写入),e1 行为不变

② kpi_visibility 口径裁定:角色级天然全局 = 非账套维度,不拆(关闭本项)
   - auth_middleware 注释 + docstring 登记「有意不做账套后缀」;口径字典
     docs/global-config-keys-caliber-20260923.md(含 budget_mode/budget_driver_mode 评估结论)

③ tenant/switch 审计收口
   - detail 显式 from_entity_id → to_entity_id + source + operator{id,username,name,role}
   - 审计失败不静默:同事务回滚 + AUDIT_WRITE_FAILED ERROR + HTTP 500(不留「改了但没审计」)
   - GET /current 无写路径不落审计;历史缺口不补写

证据:新增/改写用例 17 条;全量 1397 passed(去新增文件 1380 + 17);RED 态 8 failed
2026-09-23 18:29:52 +08:00

381 lines
19 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-23
任务:`dama-entity-isolation-sweep-20260923`
(DAMA 独立回查新增漏项 / 任总 2026-09-23「按照建议执行」;父单
`dama-workbench-entity-isolation-fullstack-20260922`)
三块(逐条对应一个测试类):
① `kpi_alert_higher_better` 拆账套 → `kpi_alert_higher_better.<entity_id>`(零 DDL)
(消费点:`utils/deviation_engine.py::get_higher_better_codes`;
`api/budget.py` GET/PUT `/alert-direction`)
② `kpi_visibility`(`role_permissions`)**口径裁定**:角色级天然全局、**非账套维度**,
不拆;口径写进 `auth_middleware` 注释 + `docs/global-config-keys-caliber-20260923.md`
③ `POST /tenant/switch` 审计收口:`detail` 显式 from→to/source/operator;
**审计写失败不静默**(同事务回滚 + `AUDIT_WRITE_FAILED` ERROR + HTTP 500)
外加:搬运脚本 `scripts/migrate_higher_better_key_entity_20260923.py` 的
check/apply/down 幂等、「原值只落目标账套」与「库内无旧值 ⇒ skip 零写入」。
全部用例只跑内存 SQLite / 临时文件 SQLite(tests/conftest.py 机制),
**零生产库 / 零生产 Redis 连接**(conftest 已硬阻断)。
"""
import importlib
import json
import os
import re
import sys
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.orm import Session
from app.models import Entity, OperationLog, RolePermission, SystemConfig
from app.database import Base
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
BUDGET = "/api/cma/budget"
TENANT = "/api/cma/tenant"
LEGACY_KEY = "kpi_alert_higher_better"
SENTINEL_CODE = "ZZ_LEGACY_ONLY_CODE" # 只存在于「旧全局键」的哨兵,用于证伪回退
# ──────────────────────────────────────────────
# 夹具
# ──────────────────────────────────────────────
def _ensure_entity(db: Session, eid: int, name: str, status: str = "active") -> Entity:
ent = db.query(Entity).filter(Entity.id == eid).first()
if not ent:
ent = Entity(id=eid, name=name, short_name=name, status=status)
db.add(ent)
db.commit()
db.refresh(ent)
return ent
def _seed_two_entities(db: Session):
_ensure_entity(db, 1, "陕西酣客文化传媒")
_ensure_entity(db, 2, "陕西博海网络科技")
def _login_as(client: TestClient, username: str, entity_id: int) -> str:
resp = client.post("/api/cma/auth/login", json={
"username": username,
"password": TEST_USER_PASSWORD,
"entity_id": entity_id,
})
assert resp.status_code == 200, f"登录 {username}@entity{entity_id} 失败: {resp.text[:300]}"
return resp.json()["token"]
def _set_legacy_global(db: Session, codes: list) -> None:
"""模拟事故现场:旧全局唯一键有值(拆键前所有账套共用的那一行)"""
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == LEGACY_KEY).first()
val = json.dumps(codes, ensure_ascii=False)
if cfg:
cfg.config_value = val
else:
db.add(SystemConfig(config_key=LEGACY_KEY, config_value=val,
description="历史全局键(事故值)"))
db.commit()
# ══════════════════════════════════════════════
# ① kpi_alert_higher_better 拆账套
# ══════════════════════════════════════════════
class TestHigherBetterPerEntity:
def test_two_entities_do_not_overwrite_each_other(self, client: TestClient, db: Session):
"""e1 / e2 各写方向配置后各自读回本账套值(拆键前共用一个唯一键)"""
_seed_two_entities(db)
create_test_user(db)
t1 = _login_as(client, "testadmin", 1)
t2 = _login_as(client, "testadmin", 2)
r1 = client.put(f"{BUDGET}/alert-direction", headers=auth_header(t1),
json={"codes": ["SALES_TOTAL", "CUSTOMER_COUNT"]})
assert r1.status_code == 200, r1.text
r2 = client.put(f"{BUDGET}/alert-direction", headers=auth_header(t2),
json={"codes": ["COST_TOTAL"]})
assert r2.status_code == 200, r2.text
v1 = client.get(f"{BUDGET}/alert-direction", headers=auth_header(t1)).json()
v2 = client.get(f"{BUDGET}/alert-direction", headers=auth_header(t2)).json()
assert v1["codes"] == ["SALES_TOTAL", "CUSTOMER_COUNT"], f"e1 配置被覆盖:{v1}"
assert v2["codes"] == ["COST_TOTAL"], f"e2 配置被覆盖:{v2}"
assert v1["entity_id"] == 1 and v2["entity_id"] == 2
assert v1["config_key"] == f"{LEGACY_KEY}.1"
assert v2["config_key"] == f"{LEGACY_KEY}.2"
db.expire_all()
keys = {r[0] for r in db.query(SystemConfig.config_key)
.filter(SystemConfig.config_key.like(f"{LEGACY_KEY}%")).all()}
assert keys == {f"{LEGACY_KEY}.1", f"{LEGACY_KEY}.2"}, keys
assert LEGACY_KEY not in keys, "不得再写全局唯一键"
def test_no_cross_entity_fallback_to_legacy_key(self, client: TestClient, db: Session):
"""旧全局键有值(事故值),本账套未配置 → 走内置默认,**不借用**旧全局值"""
_seed_two_entities(db)
create_test_user(db)
_set_legacy_global(db, [SENTINEL_CODE]) # 事故现场
t2 = _login_as(client, "testadmin", 2)
body = client.get(f"{BUDGET}/alert-direction", headers=auth_header(t2)).json()
assert SENTINEL_CODE not in body["codes"], \
f"e2 未配置却读到旧全局键(跨账套借用):{body}"
assert body["codes"], "未配置须回落内置默认表(兜底语义)"
assert body["is_configured"] is False
def test_helper_requires_entity_id(self):
"""读/写辅助函数的账套参数必传:漏传 → TypeError / ValueError(防落默认账套)"""
from app.utils.deviation_engine import get_higher_better_codes, higher_better_key
with pytest.raises(TypeError):
get_higher_better_codes(None) # 缺 entity_id(位置参数必传)
with pytest.raises(ValueError):
higher_better_key(None)
assert higher_better_key(2) == f"{LEGACY_KEY}.2"
def test_body_entity_id_not_trusted(self, client: TestClient, db: Session):
"""带 token 时 body 里的 entity_id 不可信(仍写会话账套)"""
_seed_two_entities(db)
create_test_user(db)
t1 = _login_as(client, "testadmin", 1)
r = client.put(f"{BUDGET}/alert-direction", headers=auth_header(t1),
json={"codes": ["SALES_TOTAL"], "entity_id": 2})
assert r.status_code == 200, r.text
assert r.json()["entity_id"] == 1, r.json()
db.expire_all()
assert db.query(SystemConfig).filter(
SystemConfig.config_key == f"{LEGACY_KEY}.2").first() is None
def test_patch_migrate_scope_unchanged(self):
"""读侧不得再出现「回退旧全局键」分支(源级断言,防回归)"""
src = open(os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"app", "utils", "deviation_engine.py"), encoding="utf-8").read()
fn = src[src.index("def get_higher_better_codes"):src.index("# ====", src.index("def get_higher_better_codes"))]
assert "higher_better_key(entity_id)" in fn
assert re.search(r"config_key\s*==\s*CONFIG_KEY_HIGHER_BETTER\b", fn) is None, \
"读路径不得回退旧全局键(回退即跨账套借用)"
# ══════════════════════════════════════════════
# ② kpi_visibility 口径裁定:非账套维度
# ══════════════════════════════════════════════
class TestKpiVisibilityNonEntityDimension:
"""裁定:角色级天然全局(不拆账套)——用结构 + 行为 + 登记三面钉住"""
def test_role_permissions_has_no_entity_column(self, db: Session):
"""结构面:role_permissions 无 entity_id 列(key 唯一)⇒ 天然无账套维度"""
cols = {c["name"] for c in inspect(db.get_bind()).get_columns("role_permissions")}
assert "entity_id" not in cols, cols
def test_visibility_is_role_keyed_not_entity_keyed(self, db: Session):
"""行为面:读的是裸键 `kpi_visibility`(值 = {role: [规则]}),不受账套影响"""
from app import auth_middleware as am
db.add(RolePermission(key="kpi_visibility",
value={"finance": ["finance_*"], "ceo": ["*"]}))
db.commit()
am._load_kpi_visibility._cache = {"data": None, "ts": 0} # 清缓存
vis = am._load_kpi_visibility(db)
assert vis["finance"] == ["finance_*"] and vis["ceo"] == ["*"]
am._load_kpi_visibility._cache = {"data": None, "ts": 0}
# 不带账套参数也照读(角色维度,无账套语义)
assert am._load_kpi_visibility(db)["finance"] == ["finance_*"]
def test_caliber_documented_in_code(self):
"""登记面:「非账套维度」口径必须写在代码里(否则下轮审计重复报缺口)"""
p = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"app", "auth_middleware.py")
src = open(p, encoding="utf-8").read()
assert "非账套维度" in src and "kpi_visibility" in src
# ══════════════════════════════════════════════
# ③ tenant/switch 审计收口
# ══════════════════════════════════════════════
class TestTenantSwitchAudit:
def _seed_tenant_key(self, db: Session, entity_id: int, name: str):
db.add(SystemConfig(
config_key="current_tenant",
config_value=json.dumps({"tenant_id": "company_a", "entity_id": entity_id,
"name": name, "short_name": name,
"source": "seed", "switched_at": "2026-09-22T10:36:01"},
ensure_ascii=False),
description="当前租户状态记录"))
db.commit()
def test_audit_detail_carries_from_to_source_operator(self, client: TestClient, db: Session):
_seed_two_entities(db)
create_test_user(db)
self._seed_tenant_key(db, 1, "陕西酣客文化传媒")
t1 = _login_as(client, "testadmin", 1)
r = client.post(f"{TENANT}/switch", headers=auth_header(t1),
json={"entity_id": 2, "source": "sweep-test"})
assert r.status_code == 200, r.text
db.expire_all()
log = (db.query(OperationLog)
.filter(OperationLog.action == "switch_tenant_state")
.order_by(OperationLog.id.desc()).first())
assert log is not None, "写路径必须落审计(修复前无 operation_logs 行)"
assert log.user_id == 1
assert log.target_type == "entity" and log.target_id == 2
d = log.detail or {}
assert d.get("from_entity_id") == 1, f"detail 须含 from→to:{d}"
assert d.get("to_entity_id") == 2, d
assert d.get("entity_id") == 2, d # 兼容既有消费方
assert d.get("source") == "sweep-test", d
op = d.get("operator") or {}
assert op.get("user_id") == 1 and op.get("username") == "testadmin", op
assert op.get("role") == "ceo", op
def test_audit_write_failure_is_not_silent(self, client: TestClient, db: Session,
monkeypatch, caplog):
"""审计写不进去 → 状态变更一并回滚 + ERROR 留痕 + HTTP 500(不静默)"""
import logging
from app.api import tenant as tenant_mod
_seed_two_entities(db)
create_test_user(db)
self._seed_tenant_key(db, 1, "陕西酣客文化传媒")
t1 = _login_as(client, "testadmin", 1)
class _Boom:
def __init__(self, *a, **kw):
raise RuntimeError("simulated audit table failure")
monkeypatch.setattr(tenant_mod, "OperationLog", _Boom)
with caplog.at_level(logging.ERROR):
r = client.post(f"{TENANT}/switch", headers=auth_header(t1),
json={"entity_id": 2, "source": "sweep-test"})
assert r.status_code == 500, f"审计失败必须显式失败,实际 {r.status_code}: {r.text[:200]}"
assert any("AUDIT_WRITE_FAILED" in rec.getMessage() for rec in caplog.records), \
[rec.getMessage() for rec in caplog.records]
db.expire_all()
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "current_tenant").first()
assert json.loads(cfg.config_value)["entity_id"] == 1, \
"审计失败时状态变更必须回滚(不得留「改了但没审计」)"
assert db.query(OperationLog).filter(
OperationLog.action == "switch_tenant_state").count() == 0
def test_get_current_has_no_write_path(self, client: TestClient, db: Session):
"""GET /current 纯只读:读一次不产生任何审计行(不给读操作写审计表)"""
_ensure_entity(db, 1, "陕西酣客文化传媒")
create_test_user(db)
t1 = _login_as(client, "testadmin", 1)
assert client.get(f"{TENANT}/current", headers=auth_header(t1)).status_code == 200
db.expire_all()
assert db.query(OperationLog).count() == 0
# ══════════════════════════════════════════════
# 搬运脚本(临时文件 SQLite,绝不碰 app.database → 生产库)
# ══════════════════════════════════════════════
MIGRATION = "scripts.migrate_higher_better_key_entity_20260923"
class TestMigrationScript:
pytestmark = []
@pytest.fixture
def mig_env(self, tmp_path, monkeypatch):
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
mod = importlib.import_module(MIGRATION)
eng = create_engine(f"sqlite:///{tmp_path/'mig.db'}")
Base.metadata.create_all(bind=eng)
with eng.begin() as conn:
conn.execute(text("INSERT INTO entities (id, name, short_name, status) "
"VALUES (1, '酣客', '酣客', 'active')"))
conn.execute(text("INSERT INTO entities (id, name, short_name, status) "
"VALUES (2, '博海', '博海', 'active')"))
conn.execute(text("INSERT INTO system_configs (config_key, config_value, description) "
"VALUES ('kpi_alert_higher_better', '[\"SALES_TOTAL\"]', '历史全局键')"))
monkeypatch.setattr(mod, "_engine", lambda: eng)
return mod, eng
def _run(self, mod, argv):
old = sys.argv
sys.argv = ["migrate"] + argv
try:
return mod.main()
finally:
sys.argv = old
def test_check_is_readonly(self, mig_env):
mod, eng = mig_env
assert self._run(mod, ["--check", "--allow-db", "sqlite"]) == 0
with eng.connect() as conn:
assert conn.execute(text("SELECT COUNT(*) FROM system_configs "
"WHERE config_key LIKE 'kpi_alert_higher_better.%'")).scalar() == 0
def test_apply_moves_value_to_target_only_and_is_idempotent(self, mig_env):
mod, eng = mig_env
assert self._run(mod, ["--apply", "--allow-db", "sqlite",
"--target-entity", "1", "--json"]) == 0
with eng.connect() as conn:
v = conn.execute(text("SELECT config_value FROM system_configs "
"WHERE config_key = 'kpi_alert_higher_better.1'")).scalar()
assert json.loads(v) == ["SALES_TOTAL"]
assert conn.execute(text("SELECT COUNT(*) FROM system_configs "
"WHERE config_key = 'kpi_alert_higher_better.2'")).scalar() == 0
assert conn.execute(text("SELECT config_value FROM system_configs "
"WHERE config_key = 'kpi_alert_higher_better'")).scalar() == \
'["SALES_TOTAL"]' # 旧键保留(回退杠杆)
assert self._run(mod, ["--apply", "--allow-db", "sqlite",
"--target-entity", "1", "--json"]) == 0 # 二跑幂等
with eng.connect() as conn:
assert conn.execute(text("SELECT COUNT(*) FROM system_configs "
"WHERE config_key LIKE 'kpi_alert_higher_better.%'")).scalar() == 1
def test_apply_without_legacy_row_is_skip_zero_write(self, tmp_path, monkeypatch):
"""生产实况:库内无旧值 ⇒ --apply 走 skip、零写入(拆键后行为与拆键前逐字一致)"""
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
mod = importlib.import_module(MIGRATION)
eng = create_engine(f"sqlite:///{tmp_path/'empty.db'}")
Base.metadata.create_all(bind=eng)
with eng.begin() as conn:
conn.execute(text("INSERT INTO entities (id, name, short_name, status) "
"VALUES (1, '酣客', '酣客', 'active')"))
monkeypatch.setattr(mod, "_engine", lambda: eng)
assert self._run(mod, ["--apply", "--allow-db", "sqlite", "--json"]) == 0
with eng.connect() as conn:
assert conn.execute(text("SELECT COUNT(*) FROM system_configs "
"WHERE config_key LIKE 'kpi_alert_higher_better%'")).scalar() == 0
def test_down_removes_new_keys_keeps_legacy(self, mig_env):
mod, eng = mig_env
self._run(mod, ["--apply", "--allow-db", "sqlite", "--json"])
assert self._run(mod, ["--down", "--allow-db", "sqlite", "--json"]) == 0
with eng.connect() as conn:
assert conn.execute(text("SELECT COUNT(*) FROM system_configs "
"WHERE config_key LIKE 'kpi_alert_higher_better.%'")).scalar() == 0
assert conn.execute(text("SELECT COUNT(*) FROM system_configs "
"WHERE config_key = 'kpi_alert_higher_better'")).scalar() == 1
def test_prod_db_name_guard(self, mig_env):
mod, eng = mig_env
with pytest.raises(SystemExit) as e:
self._run(mod, ["--apply", "--json"]) # sqlite → DATABASE() 不可用 → db=''
assert e.value.code == 2
def test_target_entity_must_exist(self, mig_env):
mod, eng = mig_env
with pytest.raises(SystemExit) as e:
self._run(mod, ["--apply", "--allow-db", "sqlite", "--target-entity", "99", "--json"])
assert e.value.code == 2