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
381 lines
19 KiB
Python
381 lines
19 KiB
Python
"""隔离扫尾 · 同源全局键补漏 + 审计缺口收口 — 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
|