pbl_evidence/tests/test_m5a_idempotency.py

317 lines
14 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.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""M5a 幂等 / C-3 fail-closed 测试(pbl_evidence)。
覆盖两条硬约束:
C-3 fail-closed
``pbl_runtime_event``(scense_runtime M11b 落库的事件表)不存在时,
``collect_evidence_from_events()`` **必须抛 CollectError 且消息含 'M11b'**,
绝不允许「静默返回空 stats 当成功」——静默空成功会让 M5b 回放/cron 轮询
误判采集已完成,证据链永久缺失。
幂等(idempotency)
去重三元组 ``(tenant_id, source_event_id, evidence_type)`` 同时是
``models/pbl_evidence.json`` 的唯一索引 ``uk_ev_dedup`` 列组合,
也是 ``build_dedup_key()`` 的输入;同一输入重复计算必须得到同一 32 位键。
无 DB 环境
本文件默认只跑 fail-closed + 纯函数/契约断言(零 DB 依赖,可离线执行)。
真实双次采集用例由 ``PBL_EVIDENCE_TEST_DB=1`` 开启,否则 skip 并写明原因。
运行:
cd modules/pbl_evidence && python3 -m pytest tests/test_m5a_idempotency.py -v
"""
import asyncio
import importlib.util
import json
import os
import pathlib
import sys
import types
import pytest
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent # modules/pbl_evidence
PKG_DIR = REPO_ROOT / 'pbl_evidence' # 包目录
MODELS_DIR = REPO_ROOT / 'models'
# 让「模块仓库未 pip install -e」时也能定位包目录(build.sh 里是 pkgs/ 软链布局)
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
def _load_collector():
"""隔离加载 ``pbl_evidence.collector``。
直接 ``import pbl_evidence.collector`` 会先执行包 ``__init__.py``,
连带拉起 ``pbl_common`` / ``ahserver`` 等宿主依赖;单元测试要能在
裸环境(无宿主应用、无 DB)里跑,故用 importlib 造一个只带 ``__path__``
的假包对象,绕过 ``__init__.py``,只加载 collector 及其同包内
``db`` / ``evidence_map``(两者仅依赖标准库)。
"""
pkg_name = 'pbl_evidence'
if pkg_name not in sys.modules:
pkg = types.ModuleType(pkg_name)
pkg.__path__ = [str(PKG_DIR)]
pkg.__package__ = pkg_name
sys.modules[pkg_name] = pkg
full = pkg_name + '.collector'
if full in sys.modules:
return sys.modules[full]
spec = importlib.util.spec_from_file_location(full, str(PKG_DIR / 'collector.py'))
if spec is None or spec.loader is None:
raise RuntimeError('cannot locate %s' % (PKG_DIR / 'collector.py',))
mod = importlib.util.module_from_spec(spec)
sys.modules[full] = mod
spec.loader.exec_module(mod)
return mod
collector = _load_collector()
CollectError = collector.CollectError
collect_evidence_from_events = collector.collect_evidence_from_events
build_dedup_key = None # 延迟取,见下方 fixture(来自 collector 同包 evidence_map)
evidence_map = sys.modules.get('pbl_evidence.evidence_map')
if evidence_map is None:
_em_spec = importlib.util.spec_from_file_location(
'pbl_evidence.evidence_map', str(PKG_DIR / 'evidence_map.py'))
evidence_map = importlib.util.module_from_spec(_em_spec)
sys.modules['pbl_evidence.evidence_map'] = evidence_map
_em_spec.loader.exec_module(evidence_map)
build_dedup_key = evidence_map.build_dedup_key
# ── 工具:证明「fail-closed 之前不碰数据库」────────────────────────────────
class _DbGuard:
"""替换 collector 的 DB 入口,任何调用都会被记录(用于断言零 DB 访问)。"""
def __init__(self, monkeypatch, allow=False):
self.calls = []
self.allow = allow
monkeypatch.setattr(collector, 'q_all', self._q_all, raising=True)
monkeypatch.setattr(collector, 'q_one', self._q_one, raising=True)
monkeypatch.setattr(collector, 'q_exec', self._q_exec, raising=True)
monkeypatch.setattr(collector, 'table_exists', self._table_exists, raising=True)
monkeypatch.setattr(collector, 'table_columns', self._table_columns, raising=True)
async def _boom(self, name):
self.calls.append(name)
if not self.allow:
raise AssertionError(
'fail-closed 路径不得访问数据库,但调用了 %s' % (name,))
return []
async def _q_all(self, sql, params=None):
await self._boom('q_all')
return []
async def _q_one(self, sql, params=None):
await self._boom('q_one')
return None
async def _q_exec(self, sql, params=None):
await self._boom('q_exec')
return None
async def _table_exists(self, table):
await self._boom('table_exists')
return False
async def _table_columns(self, table):
await self._boom('table_columns')
return set()
def _patch_event_table(monkeypatch, value):
"""mock ``resolve_event_table()`` 返回指定值(本任务要求返回 None)。"""
async def _fake():
return value
monkeypatch.setattr(collector, 'resolve_event_table', _fake, raising=True)
return _fake
def _db_enabled():
"""是否有可用测试库(默认无 → 只跑 fail-closed 路径)。"""
if os.environ.get('PBL_EVIDENCE_TEST_DB', '').strip().lower() not in (
'1', 'true', 'yes', 'on'):
return False
try:
collector.get_dbname()
except Exception:
return False
return True
# ══════════════════════════════════════════════════════════════════════════
# C-3 fail-closed
# ══════════════════════════════════════════════════════════════════════════
def test_collect_fail_closed_when_runtime_event_table_missing(monkeypatch):
"""resolve_event_table() → None 时必须抛 CollectError 且消息含 'M11b'。"""
guard = _DbGuard(monkeypatch)
_patch_event_table(monkeypatch, None)
with pytest.raises(CollectError) as excinfo:
asyncio.run(collect_evidence_from_events(tenant_id='t1'))
msg = str(excinfo.value)
assert 'M11b' in msg, 'fail-closed 消息必须点明硬依赖 M11b,实际: %s' % (msg,)
assert 'pbl_runtime_event' in msg, '消息必须给出缺失的事件表名,实际: %s' % (msg,)
# 事件表都解析不出来,绝不该走到任何查询
assert guard.calls == [], 'fail-closed 前不应访问 DB: %s' % (guard.calls,)
def test_collect_fail_closed_is_not_silent_empty_success(monkeypatch):
"""反证:不能返回 ``{'scanned': 0, ...}`` 冒充采集成功(静默空成功 = 证据链永久缺失)。"""
_patch_event_table(monkeypatch, None)
monkeypatch.setattr(collector, 'q_all',
lambda *a, **k: _unexpected_db(), raising=True)
monkeypatch.setattr(collector, 'table_columns',
lambda *a, **k: _unexpected_db(), raising=True)
result = None
raised = None
try:
result = asyncio.run(collect_evidence_from_events(tenant_id='t1'))
except CollectError as exc:
raised = exc
assert raised is not None, '事件表缺失必须抛 CollectError,不得静默返回'
assert result is None, '抛错路径不得同时产出 stats(防「空成功」)'
def _unexpected_db():
raise AssertionError('fail-closed 路径不得访问数据库')
def test_collect_fail_closed_when_tenant_missing(monkeypatch):
"""多租户铁律:tenant_id 缺失先于事件表解析 fail-closed,且完全不碰 DB。"""
guard = _DbGuard(monkeypatch)
_patch_event_table(monkeypatch, 'pbl_runtime_event') # 即便表存在也不该被走到
with pytest.raises(CollectError) as excinfo:
asyncio.run(collect_evidence_from_events(tenant_id=''))
msg = str(excinfo.value)
assert 'tenant_id' in msg and 'fail-closed' in msg, '实际: %s' % (msg,)
assert guard.calls == [], '租户缺失时不得访问 DB: %s' % (guard.calls,)
# None 与纯空白同样拒绝
for bad in (None, ' '):
with pytest.raises(CollectError):
asyncio.run(collect_evidence_from_events(tenant_id=bad))
def test_event_table_candidates_include_m11b_table():
"""候选表名必须含 M11b 真实落库表 pbl_runtime_event(否则永远 fail-closed)。"""
assert 'pbl_runtime_event' in collector.EVENT_TABLE_CANDIDATES
assert isinstance(collector.EVENT_TABLE_CANDIDATES, tuple)
def test_resolve_event_table_returns_none_without_db(monkeypatch):
"""resolve_event_table 自身在无表时返回 None(不抛错),由上层决定 fail-closed。"""
async def _no_table(table):
return False
monkeypatch.setattr(collector, 'table_exists', _no_table, raising=True)
assert asyncio.run(collector.resolve_event_table()) is None
# ══════════════════════════════════════════════════════════════════════════
# 幂等契约(纯函数 + 表定义,零 DB)
# ══════════════════════════════════════════════════════════════════════════
def test_dedup_key_is_idempotent_and_distinguishes_each_dimension():
"""同一三元组重复计算必须同键;任一维度变化必须换键(否则幂等会误合并)。"""
k1 = build_dedup_key('t1', 'evt-001', 'observation')
k2 = build_dedup_key('t1', 'evt-001', 'observation')
assert k1 == k2, '幂等键必须可重放(同输入同键)'
assert isinstance(k1, str) and len(k1) == 32, 'dedup_key 须为 32 位定长(VARCHAR(32))'
assert all(c in '0123456789abcdef' for c in k1), 'dedup_key 须为十六进制串'
assert build_dedup_key('t2', 'evt-001', 'observation') != k1
assert build_dedup_key('t1', 'evt-002', 'observation') != k1
assert build_dedup_key('t1', 'evt-001', 'artifact') != k1
def _load_model(table):
path = MODELS_DIR / ('%s.json' % table)
with open(path, 'r', encoding='utf-8') as fh:
return json.load(fh)
def test_unique_index_matches_idempotency_triple():
"""uk_ev_dedup 列组合必须 == (tenant_id, source_event_id, evidence_type)。"""
model = _load_model('pbl_evidence')
fields = [f['name'] for f in model['fields']]
for col in ('tenant_id', 'source_event_id', 'evidence_type', 'dedup_key'):
assert col in fields, 'models/pbl_evidence.json 缺列 %s' % (col,)
uk = [i for i in model.get('indexes', []) if i.get('name') == 'uk_ev_dedup']
assert len(uk) == 1, '必须且只能有一条 uk_ev_dedup 唯一索引,实际 %s' % (uk,)
assert uk[0]['idxtype'] == 'unique', 'uk_ev_dedup 必须是 unique 索引'
assert list(uk[0]['idxfields']) == ['tenant_id', 'source_event_id', 'evidence_type'], \
'唯一索引列组合与幂等键语义不一致: %s' % (uk[0]['idxfields'],)
assert model['summary'][0]['primary'] == ['id'], 'primary 必须是数组 ["id"]'
def test_crud_json_uses_same_dedup_semantics():
"""CRUD 定义不得把幂等列暴露为可编辑(否则人工改数据破坏去重)。"""
with open(REPO_ROOT / 'json' / 'pbl_evidence.json', 'r', encoding='utf-8') as fh:
crud = json.load(fh)
assert crud['tblname'] == 'pbl_evidence'
params = crud['params']
hidden = set(params.get('browserfields', {}).get('exclouded', [])) | \
set(params.get('editexclouded', [])) | \
set(params.get('editable', {}).get('exclouded', []) if isinstance(
params.get('editable', {}).get('exclouded', []), list) else [])
for col in ('dedup_key', 'tenant_id'):
assert col in hidden, '%s 不应在列表/编辑面暴露' % (col,)
# ══════════════════════════════════════════════════════════════════════════
# 真实双次采集(有 DB 才跑;无 DB 明确 skip,不伪造通过)
# ══════════════════════════════════════════════════════════════════════════
@pytest.mark.skipif(not _db_enabled(),
reason='无可用测试库(PBL_EVIDENCE_TEST_DB 未开启):'
'本环境仅验证 C-3 fail-closed 路径')
def test_double_collect_is_idempotent_with_db():
"""同一 source_event_id + evidence_type 连续采集两次:第二次 skipped≥1 且表内仅一行。"""
tid = os.environ.get('PBL_EVIDENCE_TEST_TENANT', 'm5a-idem-test')
first = asyncio.run(collect_evidence_from_events(tenant_id=tid, limit=5))
second = asyncio.run(collect_evidence_from_events(tenant_id=tid, limit=5))
assert first['scanned'] == second['scanned']
assert second['created'] == 0, '重放不得新建: %s' % (second,)
assert second['skipped'] >= 1, '重放应命中已存在证据: %s' % (second,)
rows = asyncio.run(collector.q_all(
'SELECT `source_event_id`, `evidence_type`, COUNT(*) AS c FROM `pbl_evidence` '
'WHERE `tenant_id`=${tenant_id}$ GROUP BY `source_event_id`, `evidence_type` '
'HAVING COUNT(*) > 1', {'tenant_id': tid}))
assert not rows, '出现重复行,幂等失效: %s' % (rows,)
# =============================================================================
# M5a 收口验证记录(Git 选择性收口任务 task_id=92mX2ANX5qYN4d9Q9ZOEy, 2026-09-22)
# 本块为可追溯性元数据,不参与 pytest 断言。
# =============================================================================
M5A_CLOSURE_EVIDENCE = {
"scope": [
"scripts/selfcheck_m5a.py",
"tests/test_m5a_idempotency.py",
],
"selfcheck_log": "projects/pbls/docs/02-develop/m5a-selfcheck-run.log",
"idempotency_log": "projects/pbls/docs/02-develop/m5a-idempotency-run.log",
"upstream_commits": {
"scripts/selfcheck_m5a.py": "0f5e22f",
"tests/m5a_live_db_harness.py": "80156fd",
},
"db_available": False,
"note": "环境无 DB,仅验证 fail-closed 路径(resolve_event_table() -> None 时抛 CollectError 且消息含 M11b)",
}