# ============================================================ # 审批文件→DB 存量迁移黄金测试(矩阵 117 行剩余项 / round-67 R67-B1) # 覆盖:幂等迁移、dry_run、缺文件空源、后端/停写前置、digest 归一化、 # 损坏 fail-closed、身份冲突、单调状态、legacy raw-token 修复、 # canonical event digest、源漂移/后段故障整体回滚、迁移后 P3 grant 消费一次。 # ============================================================ from __future__ import annotations import json from pathlib import Path import pytest from sqlalchemy import func, select from server.agent_core import approval_migrate from server.agent_core.approval_migrate import MigrationError, migrate_file_to_db from server.agent_core.approval_store import token_digest from server.db.database import get_session, reset_engine from server.db.models import ( ApprovalEventRecord, ApprovalGrantRecord, ApprovalRequestRecord, ) _RAW_TOKEN_1 = "c0ffee00" * 4 # 32 位 raw token(文件后端 grant 键形态) _RAW_TOKEN_2 = "deadbee5" * 4 @pytest.fixture def database_backend(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("APS_APPROVAL_BACKEND", "database") monkeypatch.setenv("APS_APPROVAL_MIGRATION_QUIESCED", "1") monkeypatch.delenv("APS_APPROVAL_PATH", raising=False) monkeypatch.setenv("APS_DATABASE_URL", f"sqlite:///{(tmp_path / 'approval.db').as_posix()}") monkeypatch.setenv("APS_APPROVAL_DATABASE_ALLOW_SQLITE", "1") reset_engine() from server.agent_core import harness store = harness.configure_approval_store() yield store store.clear() reset_engine() def _file_store() -> dict: """合成文件后端数据:2 pending + 2 grants + 3 history 事件。""" pending = { "abc1111111": { "confirmId": "abc1111111", "sessionId": "s1", "action": "mes.dispatch", "power": "P3", "params": {"versionId": 1}, "paramsHash": "h1", "tenantUuid": "platform", "ownerUserId": 1001, "projectId": "p1", "worldKey": "w1", "evidenceRefs": ["schedule-version:1"], "createdAtEpoch": 1750000000.0, "expiresAtEpoch": 1750003600.0, }, "abc2222222": { "confirmId": "abc2222222", "sessionId": "s1", "action": "schedule.publish", "power": "P2", "params": {}, "paramsHash": "h2", "tenantUuid": "platform", "ownerUserId": 1002, "projectId": "p1", "createdAtEpoch": 1750000100.0, "expiresAtEpoch": 1750003700.0, }, } grants = { _RAW_TOKEN_1: { "confirmId": "abc1111111", "action": "mes.dispatch", "power": "P3", "tenantUuid": "platform", "projectId": "p1", "ownerUserId": 1001, "paramsHash": "h1", "status": "ACTIVE", "approvals": [{"userId": 1001}, {"userId": 1002}], "createdAtEpoch": 1750000000.0, "expiresAtEpoch": 1750003600.0, }, _RAW_TOKEN_2: { "confirmId": "abc2222222", "action": "schedule.publish", "power": "P2", "tenantUuid": "platform", "projectId": "p1", "ownerUserId": 1002, "paramsHash": "h2", "status": "CONSUMED", "createdAtEpoch": 1750000100.0, "expiresAtEpoch": 1750003700.0, "consumedAtEpoch": 1750000200.0, }, } history = [ {"confirmId": "abc1111111", "action": "mes.dispatch", "power": "P3", "status": "APPROVED", "tenantUuid": "platform", "projectId": "p1", "ownerUserId": 1001, "decidedAtEpoch": 1750000100.0}, {"confirmId": "abc2222222", "action": "schedule.publish", "power": "P2", "status": "APPROVED", "tenantUuid": "platform", "projectId": "p1", "ownerUserId": 1002, "decidedAtEpoch": 1750000200.0}, {"confirmId": "abc2222222", "action": "schedule.publish", "power": "P2", "status": "CONSUMED", "tenantUuid": "platform", "projectId": "p1", "ownerUserId": 1002, "decidedAtEpoch": 1750000300.0}, ] return {"schemaVersion": 1, "pending": pending, "grants": grants, "history": history} def _write(tmp_path: Path, data: dict) -> Path: path = tmp_path / "approvals.json" path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") return path def _write_file_store(tmp_path: Path) -> Path: return _write(tmp_path, _file_store()) def _counts() -> dict: with get_session() as session: return { "pending": session.execute(select(func.count()).select_from(ApprovalRequestRecord)).scalar(), "grants": session.execute(select(func.count()).select_from(ApprovalGrantRecord)).scalar(), "events": session.execute(select(func.count()).select_from(ApprovalEventRecord)).scalar(), } def test_migrate_file_to_db_idempotent(database_backend, tmp_path: Path): """迁移全量 + 幂等:二次运行全部 skipped,不重复插入。""" path = _write_file_store(tmp_path) first = migrate_file_to_db(str(path)) assert first["pending"] == {"migrated": 2, "skipped": 0, "rebuilt": 0} assert first["grants"] == {"migrated": 2, "skipped": 0, "repaired": 0} assert first["events"] == {"migrated": 3, "skipped": 0} assert first["dryRun"] is False assert first["sourceDigest"] assert _counts() == {"pending": 2, "grants": 2, "events": 3} second = migrate_file_to_db(str(path)) assert second["pending"] == {"migrated": 0, "skipped": 2, "rebuilt": 0} assert second["grants"] == {"migrated": 0, "skipped": 2, "repaired": 0} assert second["events"] == {"migrated": 0, "skipped": 3} assert _counts() == {"pending": 2, "grants": 2, "events": 3} def test_migrate_dry_run_does_not_touch_db(database_backend, tmp_path: Path): """dry_run:报告数量但库内零写入。""" path = _write_file_store(tmp_path) report = migrate_file_to_db(str(path), dry_run=True) assert report["dryRun"] is True assert report["pending"]["migrated"] == 2 assert report["grants"]["migrated"] == 2 assert report["events"]["migrated"] == 3 assert _counts() == {"pending": 0, "grants": 0, "events": 0} def test_migrate_missing_file_empty_source(database_backend, tmp_path: Path): """缺文件 → 空源,全部 0,不抛错。""" report = migrate_file_to_db(str(tmp_path / "missing.json")) assert report["pending"] == {"migrated": 0, "skipped": 0, "rebuilt": 0} assert report["grants"] == {"migrated": 0, "skipped": 0, "repaired": 0} assert report["events"] == {"migrated": 0, "skipped": 0} assert report["sourceDigest"] is None def test_migrate_requires_database_backend(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): """未配置 database 后端 → 显式失败(fail closed)。""" monkeypatch.delenv("APS_APPROVAL_BACKEND", raising=False) monkeypatch.delenv("APS_APPROVAL_PATH", raising=False) with pytest.raises(MigrationError, match="APS_APPROVAL_BACKEND=database"): migrate_file_to_db(str(tmp_path / "approvals.json")) def test_migrate_requires_quiesced(database_backend, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): """未显式停写(QUIESCED!=1)→ 明确拒绝,库表不变。""" monkeypatch.delenv("APS_APPROVAL_MIGRATION_QUIESCED") path = _write_file_store(tmp_path) with pytest.raises(MigrationError, match="QUIESCED"): migrate_file_to_db(str(path)) assert _counts() == {"pending": 0, "grants": 0, "events": 0} def test_migrated_data_readable_via_db_store(database_backend, tmp_path: Path): """迁移后 pending/历史可通过 database 后端读回(数据真实生效)。""" path = _write_file_store(tmp_path) migrate_file_to_db(str(path)) now = 1749990000.0 # 合成数据时间窗口内(expiresAt 1750003600 之前) pending_items = database_backend.pending_items( tenant_uuid="platform", project_id="p1", allowed=lambda _payload: True, now_epoch=now) assert len(pending_items) == 2 assert any(item["confirmId"] == "abc1111111" for item in pending_items) history = database_backend.history_items( tenant_uuid="platform", project_id="p1", allowed=lambda _payload: True, limit=50, now_epoch=now) assert len(history) == 3 assert all(item["status"] in ("APPROVED", "CONSUMED") for item in history) def test_db_persists_only_digests(database_backend, tmp_path: Path): """DB 仅含 64 位 SHA-256 digest,不包含 raw token。""" path = _write_file_store(tmp_path) migrate_file_to_db(str(path)) with get_session() as session: rows = list(session.execute(select(ApprovalGrantRecord)).scalars()) assert len(rows) == 2 hashes = {row.token_hash for row in rows} assert hashes == {token_digest(_RAW_TOKEN_1), token_digest(_RAW_TOKEN_2)} assert all(len(row.token_hash) == 64 for row in rows) assert _RAW_TOKEN_1 not in hashes and _RAW_TOKEN_2 not in hashes def test_migrated_p3_grant_consumable_once(database_backend, tmp_path: Path): """grant 无 pending 源记录 → 重建终态 APPROVED request;迁移后消费一次成功、重放失败。""" store = _file_store() grant = store["grants"][_RAW_TOKEN_1] store["pending"].pop("abc1111111") # 真实场景:批准后 pending 已弹出,grant 仍在 store["history"] = [e for e in store["history"] if e["confirmId"] != "abc1111111"] path = _write(tmp_path, store) report = migrate_file_to_db(str(path)) assert report["pending"]["rebuilt"] == 1 assert report["grants"]["migrated"] == 2 with get_session() as session: rebuilt = session.get(ApprovalRequestRecord, "abc1111111") assert rebuilt is not None and rebuilt.status == "APPROVED" assert rebuilt.payload.get("migratedTerminalRequest") is True now = 1750001000.0 ok = database_backend.consume_grant( _RAW_TOKEN_1, confirm_id=grant["confirmId"], action=grant["action"], params_hash=grant["paramsHash"], allowed=lambda _r: True, decided_by={"userId": 1001}, now_epoch=now) assert ok is True replay = database_backend.consume_grant( _RAW_TOKEN_1, confirm_id=grant["confirmId"], action=grant["action"], params_hash=grant["paramsHash"], allowed=lambda _r: True, decided_by={"userId": 1001}, now_epoch=now) assert replay is False def test_corrupt_json_fails_closed(database_backend, tmp_path: Path): """损坏 JSON → 明确失败,三张审批表不变。""" path = tmp_path / "approvals.json" path.write_text("{not-json", encoding="utf-8") with pytest.raises(MigrationError, match="审批源损坏"): migrate_file_to_db(str(path)) assert _counts() == {"pending": 0, "grants": 0, "events": 0} def test_invalid_schema_and_records_fail_closed(database_backend, tmp_path: Path): """错误顶层 schema / 非法 pending / 非法 history → 全部 fail closed。""" bad_schema = _write(tmp_path, {"schemaVersion": 2, "pending": {}, "grants": {}, "history": []}) with pytest.raises(MigrationError, match="schema"): migrate_file_to_db(str(bad_schema)) bad_record = _file_store() bad_record["pending"]["abc1111111"].pop("expiresAtEpoch") with pytest.raises(MigrationError, match="expiresAtEpoch"): migrate_file_to_db(str(_write(tmp_path, bad_record))) bad_history = _file_store() bad_history["history"] = [{"action": "mes.dispatch"}] # 缺 status/decidedAtEpoch with pytest.raises(MigrationError, match="history"): migrate_file_to_db(str(_write(tmp_path, bad_history))) assert _counts() == {"pending": 0, "grants": 0, "events": 0} def test_identity_conflict_fails_closed(database_backend, tmp_path: Path): """同 confirmId 不同不可变身份字段 → 冲突拒绝,已迁移数据不被覆盖。""" path = _write_file_store(tmp_path) migrate_file_to_db(str(path)) tampered = _file_store() tampered["pending"]["abc1111111"]["paramsHash"] = "hX" with pytest.raises(MigrationError, match="身份字段不一致"): migrate_file_to_db(str(_write(tmp_path, tampered))) assert _counts() == {"pending": 2, "grants": 2, "events": 3} with get_session() as session: row = session.get(ApprovalRequestRecord, "abc1111111") assert row.params_hash == "h1" # 未被覆盖 def test_monotonic_status_no_regression(database_backend, tmp_path: Path): """DB 端已前进到 CONSUMED 的 grant 不被源端 ACTIVE 回退。""" path = _write_file_store(tmp_path) migrate_file_to_db(str(path)) now = 1750001000.0 assert database_backend.consume_grant( _RAW_TOKEN_1, confirm_id="abc1111111", action="mes.dispatch", params_hash="h1", allowed=lambda _r: True, decided_by={"userId": 1001}, now_epoch=now) is True second = migrate_file_to_db(str(path)) # 源文件仍是 ACTIVE assert second["grants"]["skipped"] == 2 with get_session() as session: row = session.get(ApprovalGrantRecord, token_digest(_RAW_TOKEN_1)) assert row.status == "CONSUMED" # 未回退 def test_source_ahead_advances_db(database_backend, tmp_path: Path): """源端 CONSUMED、DB 端 ACTIVE → 迁移单调前进 DB 状态。""" store = _file_store() store["grants"].pop(_RAW_TOKEN_2) path = _write(tmp_path, store) migrate_file_to_db(str(path)) advanced = _file_store() advanced["grants"].pop(_RAW_TOKEN_2) advanced["grants"][_RAW_TOKEN_1]["status"] = "CONSUMED" advanced["grants"][_RAW_TOKEN_1]["consumedAtEpoch"] = 1750000500.0 advanced["pending"].pop("abc2222222") # 避免无关变更 advanced["grants"].pop("unused", None) report = migrate_file_to_db(str(_write(tmp_path, advanced))) assert report["grants"]["migrated"] == 1 with get_session() as session: row = session.get(ApprovalGrantRecord, token_digest(_RAW_TOKEN_1)) assert row.status == "CONSUMED" assert row.consumed_at_epoch == 1750000500.0 def _insert_legacy_raw_token_row(record: dict, raw_token: str) -> None: """模拟旧版缺陷迁移产物:token_hash 直接落 raw token。""" with get_session() as session: if session.get(ApprovalRequestRecord, record["confirmId"]) is None: session.add(ApprovalRequestRecord( confirm_id=record["confirmId"], tenant_uuid=record["tenantUuid"], project_id=record["projectId"], owner_user_id=record["ownerUserId"], action=record["action"], power=record["power"], params_hash=record["paramsHash"], status="APPROVED", approval_step=2, required_approvals=2, created_at_epoch=record["createdAtEpoch"], expires_at_epoch=record["expiresAtEpoch"], revision=0, payload=dict(record), )) session.add(ApprovalGrantRecord( token_hash=raw_token, confirm_id=record["confirmId"], tenant_uuid=record["tenantUuid"], project_id=record["projectId"], owner_user_id=record["ownerUserId"], action=record["action"], params_hash=record["paramsHash"], status="ACTIVE", created_at_epoch=record["createdAtEpoch"], expires_at_epoch=record["expiresAtEpoch"], consumed_at_epoch=None, revision=0, payload=dict(record), )) session.commit() def test_legacy_raw_token_row_repaired(database_backend, tmp_path: Path): """一致的 legacy raw-token 行在事务内原子修复为 digest,修复后可消费。""" store = _file_store() grant = store["grants"][_RAW_TOKEN_1] store["pending"].pop("abc1111111") # 批准后 pending 已弹出,grant 仍在源中 store["history"] = [e for e in store["history"] if e["confirmId"] != "abc1111111"] _insert_legacy_raw_token_row(dict(grant), _RAW_TOKEN_1) report = migrate_file_to_db(str(_write(tmp_path, store))) assert report["grants"]["repaired"] == 1 with get_session() as session: assert session.get(ApprovalGrantRecord, _RAW_TOKEN_1) is None repaired = session.get(ApprovalGrantRecord, token_digest(_RAW_TOKEN_1)) assert repaired is not None and repaired.status == "ACTIVE" assert database_backend.consume_grant( _RAW_TOKEN_1, confirm_id=grant["confirmId"], action=grant["action"], params_hash=grant["paramsHash"], allowed=lambda _r: True, decided_by={"userId": 1001}, now_epoch=1750001000.0) is True def test_legacy_raw_token_row_conflict_fails_closed(database_backend, tmp_path: Path): """不一致的 legacy raw-token 行 → fail-closed,legacy 行不被改写。""" store = _file_store() store["pending"].pop("abc1111111") # 聚焦 grant 冲突路径 grant = dict(store["grants"][_RAW_TOKEN_1]) _insert_legacy_raw_token_row(grant, _RAW_TOKEN_1) # request 行身份一致 with get_session() as session: # legacy grant 行身份不一致 row = session.get(ApprovalGrantRecord, _RAW_TOKEN_1) row.action = "mes.dispatch.v2" session.commit() with pytest.raises(MigrationError, match="legacy"): migrate_file_to_db(str(_write(tmp_path, store))) with get_session() as session: row = session.get(ApprovalGrantRecord, _RAW_TOKEN_1) assert row is not None and row.action == "mes.dispatch.v2" # 原样保留 assert session.get(ApprovalGrantRecord, token_digest(_RAW_TOKEN_1)) is None def test_known_digest_key_never_rehashed(database_backend, tmp_path: Path): """文件键本身已是 DB 已知 digest(64 位 hex)→ 按已迁移跳过,禁止再哈希。""" raw = "f" * 32 digest = token_digest(raw) # 64 位 hex store = _file_store() store["pending"] = {} store["history"] = [] grant = { "confirmId": "digestkey01", "action": "mes.dispatch", "power": "P3", "tenantUuid": "platform", "projectId": "p1", "ownerUserId": 1001, "paramsHash": "hd", "status": "ACTIVE", "approvals": [{"userId": 1001}, {"userId": 1002}], "createdAtEpoch": 1750000000.0, "expiresAtEpoch": 1750003600.0, } store["grants"] = {digest: grant} # 源键已是 digest 形态 with get_session() as session: # DB 已有该 digest 行 session.add(ApprovalRequestRecord( confirm_id="digestkey01", tenant_uuid="platform", project_id="p1", owner_user_id=1001, action="mes.dispatch", power="P3", params_hash="hd", status="APPROVED", approval_step=2, required_approvals=2, created_at_epoch=1750000000.0, expires_at_epoch=1750003600.0, revision=0, payload=dict(grant), )) session.add(ApprovalGrantRecord( token_hash=digest, confirm_id="digestkey01", tenant_uuid="platform", project_id="p1", owner_user_id=1001, action="mes.dispatch", params_hash="hd", status="ACTIVE", created_at_epoch=1750000000.0, expires_at_epoch=1750003600.0, consumed_at_epoch=None, revision=0, payload=dict(grant), )) session.commit() report = migrate_file_to_db(str(_write(tmp_path, store))) assert report["grants"] == {"migrated": 0, "skipped": 1, "repaired": 0} with get_session() as session: assert session.get(ApprovalGrantRecord, token_digest(digest)) is None # 未再哈希 assert session.get(ApprovalGrantRecord, digest) is not None def test_history_canonical_digest_preserves_near_duplicates(database_backend, tmp_path: Path): """同秒同状态但 note 不同的事件全部保留;完全重复事件幂等去重。""" store = _file_store() base = dict(store["history"][0]) twin = dict(base, note="second approver comment") # 同秒同状态不同 note store["history"] = [base, twin, dict(base)] # 第 3 条与第 1 条完全重复 path = _write(tmp_path, store) first = migrate_file_to_db(str(path)) assert first["events"] == {"migrated": 2, "skipped": 1} second = migrate_file_to_db(str(path)) assert second["events"] == {"migrated": 0, "skipped": 3} assert _counts()["events"] == 2 def test_source_drift_rolls_back(database_backend, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): """锁持有期间源文件 digest 漂移 → 整体回滚,三表不变。""" path = _write_file_store(tmp_path) original = approval_migrate._sha256_path calls = {"n": 0} def drifting(p: str) -> str: calls["n"] += 1 return original(p) if calls["n"] == 1 else "0" * 64 monkeypatch.setattr(approval_migrate, "_sha256_path", drifting) with pytest.raises(MigrationError, match="源文件发生变化"): migrate_file_to_db(str(path)) assert _counts() == {"pending": 0, "grants": 0, "events": 0} def test_late_failure_rolls_back_all(database_backend, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): """后段事件构造故障注入 → 请求/grant/事件三表整体回滚。""" path = _write_file_store(tmp_path) original = approval_migrate._canonical_event_digest calls = {"n": 0} def failing(event: dict) -> str: calls["n"] += 1 if calls["n"] == 2: raise RuntimeError("injected digest failure") return original(event) monkeypatch.setattr(approval_migrate, "_canonical_event_digest", failing) with pytest.raises(RuntimeError, match="injected"): migrate_file_to_db(str(path)) assert _counts() == {"pending": 0, "grants": 0, "events": 0}