aps-agent/tests/golden/test_approval_migration.py

445 lines
21 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.

# ============================================================
# 审批文件→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}