#!/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)", }