fix(cma): 隔离扫尾 — kpi_alert_higher_better 拆账套 + tenant/switch 审计收口
ci/woodpecker/push/main Pipeline was canceled

任务 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
This commit is contained in:
wecom-fullstack
2026-09-23 18:29:52 +08:00
parent d13bf1b6e4
commit 477515c453
10 changed files with 965 additions and 33 deletions
@@ -0,0 +1,380 @@
"""隔离扫尾 · 同源全局键补漏 + 审计缺口收口 — 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