Files
cma-management/backend/tests/test_entity_isolation_sweep_20260923.py
wecom-fullstack 467472311d feat(cma): audit write-side entity closure + entity-dimension data dictionary
task dama-entity-isolation-sweep-20260923 (sections 2-2 4/5, merged by projectBot 2026-09-23 18:2x)

4) operation_logs write side: every audit write carries the account (no DDL, no column added)

   - new app/utils/oplog.py::log_op() single wrapper: sets detail.entity_id; scope=global for

     by-design non-entity writes (platform dict / role-level); scope=batch + OPLOG_BATCH_NO_ENTITY

     sentinel for cross-account batches; no context -> OPLOG_NO_ENTITY sentinel (never guess)

   - 16 remaining construction points converted (data/alert_rules/bi_reports/kpi_causality/

     bot_bridge x2/budget_generate/driver_budget/kpis _log x3/templates _log x2/erp_sync);

     pre-4 state had 20 sites without entity_id, now 0 outside the wrapper

   - read side untouched (knowledge_service still user_id fallback); history not backfilled

5) docs/entity-dimension-data-dictionary.md: 8-table three-state registry

   (kpi_templates/driver_factor_templates/scenario_suggestions/bot_bridge_config = non-entity;

    kpi_subject_map = inherited via kpi_id; analysis_results/subjects = account dimension,

    legacy rows still NULL -> other task; operation_logs = non-entity column + detail.entity_id)

Evidence: RED 2 failed/7 passed (reverted call sites) -> GREEN full 1428 passed; 9 new tests

including two read-back rows; app md5 restore verified identical; no cross-account writes

LOCAL commit only - push is gated (ahead contains the P1 entity-dim batch commit fb4609b)
2026-09-23 23:08:26 +08:00

539 lines
28 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.
"""隔离扫尾 · 同源全局键补漏 + 审计缺口收口 — 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 ast
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 app.utils.oplog import (BATCH_NO_ENTITY_TAG, NO_ENTITY_TAG, SCOPE_BATCH,
SCOPE_GLOBAL, log_op)
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
# ══════════════════════════════════════════════
# ④ operation_logs 写侧统一带账套(`detail.entity_id`,零 DDL)
# 任务单 §二之二④(项目Bot 2026-09-23 18:2x 并入)
# ══════════════════════════════════════════════
BACKEND_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OPLOG_REL = os.path.join("app", "utils", "oplog.py")
def _operation_log_construction_sites():
"""全仓 `OperationLog(` 构造点 → [(相对路径, 行号, 所在语句源码)]
语句源码取「包含该构造调用的完整 Assign/Expr 语句」(多行 dict 字面量整段在内),
用于断言「写入时带账套」这件事落在**语句级**,而不是碰巧命中了别处的 entity_id。
"""
out = []
for rel in ("app", "scripts"):
for dp, _dn, fns in os.walk(os.path.join(BACKEND_ROOT, rel)):
if "__pycache__" in dp:
continue
for fn in fns:
if not fn.endswith(".py"):
continue
path = os.path.join(dp, fn)
src = open(path, encoding="utf-8").read()
tree = ast.parse(src)
lines = src.splitlines()
stmts = [(n.lineno, n.end_lineno) for n in ast.walk(tree)
if isinstance(n, (ast.Assign, ast.Expr, ast.Return))
and getattr(n, "lineno", None) and getattr(n, "end_lineno", None)]
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
fn_node = node.func
name = getattr(fn_node, "id", None) or getattr(fn_node, "attr", None)
if name != "OperationLog":
continue
cand = [s for s in stmts if s[0] <= node.lineno and s[1] >= node.end_lineno]
s, e = min(cand, key=lambda x: x[1] - x[0]) if cand else (node.lineno, node.end_lineno)
out.append((os.path.relpath(path, BACKEND_ROOT).replace("\\", "/"),
node.lineno, "\n".join(lines[s - 1:e])))
return out
class TestAuditWriteSideEntityId:
"""④ 写侧收口:所有审计行带账套,且集中封装(不加列、不回填历史)"""
def test_no_new_raw_OperationLog_sites_lack_entity(self):
"""源级断言:除集中封装件外,每个构造点的语句里必须出现 entity_id"""
sites = _operation_log_construction_sites()
assert len(sites) >= 13, f"构造点数量异常(疑似收集失败): {len(sites)}"
missing = [(p, l) for p, l, seg in sites
if p != OPLOG_REL and "entity_id" not in seg]
assert missing == [], f"以下构造点未带账套(禁止猜账套,必须显式): {missing}"
def test_wrapper_is_single_place_constructing_rows(self):
"""集中封装:裸构造 OperationLog 只允许出现在 app/utils/oplog.py(防第 28 处再漏)"""
wrappers = [(p, l) for p, l, _seg in _operation_log_construction_sites() if p == OPLOG_REL]
assert len(wrappers) == 1, f"oplog.py 应恰好 1 处裸构造(log_op 本体): {wrappers}"
def test_log_op_callers_pass_entity_or_scope(self):
"""所有 `log_op(` 调用点必须传 entity_id= 或 scope=(否则等于留覆盖缺口)"""
bad = []
for rel in ("app", "scripts"):
for dp, _dn, fns in os.walk(os.path.join(BACKEND_ROOT, rel)):
if "__pycache__" in dp:
continue
for fn in fns:
if not fn.endswith(".py"):
continue
path = os.path.join(dp, fn)
tree = ast.parse(open(path, encoding="utf-8").read())
for node in ast.walk(tree):
if isinstance(node, ast.Call) and getattr(node.func, "id", None) == "log_op":
if not ({"entity_id", "scope"} & {k.arg for k in node.keywords}):
bad.append((os.path.relpath(path, BACKEND_ROOT), node.lineno))
assert bad == [], f"log_op 调用点缺账套参数: {bad}"
# ── 落库实证 ────────────────────────────────
def test_readback_entity_id_persisted(self, db: Session):
"""落库实证 ①:log_op 写入 → **raw SELECT 回查数据库**,detail.entity_id 在表里"""
log_op(db, "unit_probe_entity", target_type="kpi", target_id=123,
detail={"kpi_code": "PROBE_KPI"}, entity_id=2, commit=True)
row = db.execute(text("SELECT action, detail FROM operation_logs "
"WHERE action = 'unit_probe_entity'")).fetchone()
assert row is not None, "审计行未落库"
detail = json.loads(row[1]) if isinstance(row[1], str) else row[1]
assert detail.get("entity_id") == 2, f"落库 detail 未带账套: {detail}"
assert detail.get("kpi_code") == "PROBE_KPI", f"原 detail 内容被破坏: {detail}"
def test_readback_kpi_create_endpoint_audit_entity(self, client: TestClient, db: Session):
"""落库实证 ②:真实写端点(POST /kpis,会话账套 e2)→ 回查审计行 detail.entity_id == 2"""
_seed_two_entities(db)
create_test_user(db)
token = _login_as(client, "testadmin", 2) # 博海 e2
resp = client.post("/api/cma/kpis", headers=auth_header(token), json={
"kpi_code": "F_AUDIT_PROBE_E2", "kpi_name": "审计落库探针",
"dimension": "finance", "target_value": 100.0, "unit": "%",
"formula": "测试公式", "data_source": "测试系统", "data_owner": "测试管理员",
})
assert resp.status_code == 200, resp.text[:300]
row = db.execute(text("SELECT detail FROM operation_logs WHERE action = 'create' "
"AND target_type = 'kpi' ORDER BY id DESC LIMIT 1")).scalar()
assert row is not None, "端点未写审计行"
detail = json.loads(row) if isinstance(row, str) else row
assert detail.get("entity_id") == 2, f"审计 detail 未带会话账套: {detail}"
# ── 形态与哨兵 ──────────────────────────────
def test_legacy_json_string_detail_is_not_nested(self, db: Session):
"""历史写法(detail=json.dumps({...}))归一后不嵌套:entity_id 与原键同一层"""
log_op(db, "unit_probe_legacy", target_type="budget", target_id=1,
detail=json.dumps({"year": 2026, "mode": "rolling"}, ensure_ascii=False),
entity_id=1, commit=True)
detail = db.execute(text("SELECT detail FROM operation_logs "
"WHERE action = 'unit_probe_legacy'")).scalar()
detail = json.loads(detail) if isinstance(detail, str) else detail
assert detail == {"year": 2026, "mode": "rolling", "entity_id": 1}, detail
def test_global_scope_no_gap_warning(self, db: Session, caplog):
"""非账套维度(平台/角色级):写 entity_scope=global,且不产生缺口告警"""
with caplog.at_level("WARNING", logger="cma.oplog"):
log_op(db, "unit_probe_global", target_type="template", target_id=1,
detail={"kpi_code": "T1"}, scope=SCOPE_GLOBAL, commit=True)
detail = db.execute(text("SELECT detail FROM operation_logs "
"WHERE action = 'unit_probe_global'")).scalar()
detail = json.loads(detail) if isinstance(detail, str) else detail
assert detail.get("entity_scope") == "global" and "entity_id" not in detail, detail
assert NO_ENTITY_TAG not in caplog.text
def test_missing_context_warns_sentinel_never_guesses(self, db: Session, caplog):
"""无账套上下文:留空 + 可 grep 哨兵(绝不猜账套)"""
with caplog.at_level("WARNING", logger="cma.oplog"):
log_op(db, "unit_probe_unknown", target_type="kpi", target_id=1,
detail={"note": "no context"}, commit=True)
assert NO_ENTITY_TAG in caplog.text
detail = db.execute(text("SELECT detail FROM operation_logs "
"WHERE action = 'unit_probe_unknown'")).scalar()
detail = json.loads(detail) if isinstance(detail, str) else detail
assert "entity_id" not in detail, detail
def test_batch_scope_warns_own_sentinel(self, db: Session, caplog):
"""跨账套批量:entity_scope=batch + 独立哨兵(不猜单账套)"""
with caplog.at_level("WARNING", logger="cma.oplog"):
log_op(db, "unit_probe_batch", target_type="kpi", detail={"total": 3},
scope=SCOPE_BATCH, commit=True)
assert BATCH_NO_ENTITY_TAG in caplog.text
detail = db.execute(text("SELECT detail FROM operation_logs "
"WHERE action = 'unit_probe_batch'")).scalar()
detail = json.loads(detail) if isinstance(detail, str) else detail
assert detail.get("entity_scope") == "batch", detail