aps-agent/tests/golden/test_approval_migration.py

638 lines
28 KiB
Python
Raw Normal View History

# ============================================================
# 审批文件→DB 存量迁移黄金测试(plan.md §3.3 / 矩阵 112 行剩余项)
# 覆盖:幂等迁移、dry_run 不动库、缺文件空源、后端前置校验、迁移后可读回。
# ============================================================
from __future__ import annotations
import copy
import json
import threading
from pathlib import Path
import pytest
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from server.agent_core.approval_migrate import migrate_file_to_db
from server.agent_core.approval_store import ApprovalStore, token_digest
from server.db.database import get_session, reset_engine
from server.db.models import (
ApprovalEventRecord,
ApprovalGrantRecord,
ApprovalRequestRecord,
)
@pytest.fixture
def database_backend(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("APS_APPROVAL_BACKEND", "database")
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 = {
"tok1" * 8: {
"confirmId": "abc1111111", "action": "mes.dispatch", "power": "P3",
"tenantUuid": "platform", "projectId": "p1", "ownerUserId": 1001,
"paramsHash": "h1", "status": "ACTIVE",
"createdAtEpoch": 1750000000.0, "expiresAtEpoch": 1750003600.0,
},
"tok2" * 8: {
"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_file_store(tmp_path: Path) -> Path:
path = tmp_path / "approvals.json"
path.write_text(json.dumps(_file_store(), ensure_ascii=False), encoding="utf-8")
return path
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}
assert first["grants"] == {"migrated": 2, "skipped": 0}
assert first["events"] == {"migrated": 3, "skipped": 0}
assert first["dryRun"] is False
assert _counts() == {"pending": 2, "grants": 2, "events": 3}
second = migrate_file_to_db(str(path))
assert second["pending"] == {"migrated": 0, "skipped": 2}
assert second["grants"] == {"migrated": 0, "skipped": 2}
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}
assert report["grants"] == {"migrated": 0, "skipped": 0}
assert report["events"] == {"migrated": 0, "skipped": 0}
def test_migrate_requires_database_backend(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""未配置 database 后端 → 显式 RuntimeError(fail closed)。"""
monkeypatch.delenv("APS_APPROVAL_BACKEND", raising=False)
monkeypatch.delenv("APS_APPROVAL_PATH", raising=False)
with pytest.raises(RuntimeError, match="APS_APPROVAL_BACKEND=database"):
migrate_file_to_db(str(tmp_path / "approvals.json"))
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)
# ============================================================
# Round 67: fail-closed migration, digest normalization, atomicity
# ============================================================
@pytest.fixture(autouse=True)
def _migration_quiesced(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("APS_APPROVAL_MIGRATION_QUIESCED", "1")
def _write_payload(tmp_path: Path, payload: object, *, name: str = "round67-approvals.json") -> Path:
path = tmp_path / name
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
return path
def _snapshot_db() -> dict:
def payload(value: dict) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
with get_session() as session:
requests = session.execute(
select(ApprovalRequestRecord).order_by(ApprovalRequestRecord.confirm_id)
).scalars().all()
grants = session.execute(
select(ApprovalGrantRecord).order_by(ApprovalGrantRecord.token_hash)
).scalars().all()
events = session.execute(
select(ApprovalEventRecord).order_by(ApprovalEventRecord.id)
).scalars().all()
return {
"requests": [(
row.confirm_id, row.tenant_uuid, row.project_id, row.owner_user_id,
row.action, row.power, row.params_hash, row.status, row.approval_step,
row.required_approvals, row.created_at_epoch, row.expires_at_epoch,
row.revision, payload(row.payload),
) for row in requests],
"grants": [(
row.token_hash, row.confirm_id, row.tenant_uuid, row.project_id,
row.owner_user_id, row.action, row.params_hash, row.status,
row.created_at_epoch, row.expires_at_epoch, row.consumed_at_epoch,
row.revision, payload(row.payload),
) for row in grants],
"events": [(
row.id, row.confirm_id, row.tenant_uuid, row.project_id,
row.owner_user_id, row.action, row.power, row.status,
row.decided_at_epoch, payload(row.payload),
) for row in events],
}
def _real_file_p3_grant(tmp_path: Path) -> tuple[Path, str, dict]:
path = tmp_path / "real-file-approvals.json"
store = ApprovalStore(str(path))
record = {
"confirmId": "round67-p3-confirm",
"sessionId": "round67-session",
"action": "mes.dispatch",
"power": "P3",
"params": {"versionId": 67},
"paramsHash": "round67-params-hash",
"tenantUuid": "platform",
"projectId": "round67-project",
"ownerUserId": 6701,
"worldKey": "round67-world",
"requiredApprovals": 2,
"approvalStep": 0,
"approvals": [],
"createdAtEpoch": 1_800_000_000.0,
"expiresAtEpoch": 1_900_000_000.0,
}
assert store.stage(record, now_epoch=1_800_000_000.0)
first = store.decide(
record["confirmId"], approve=True, approver={"userId": 6702, "name": "A"},
allowed=lambda _record: True, now_epoch=1_800_000_010.0,
grant_ttl_seconds=3600,
)
assert first and first["needsSecondConfirm"] is True
second = store.decide(
record["confirmId"], approve=True, approver={"userId": 6703, "name": "B"},
allowed=lambda _record: True, now_epoch=1_800_000_020.0,
grant_ttl_seconds=3600,
)
assert second and second.get("executionGrant")
return path, str(second["executionGrant"]), record
def _terminal_grant_source(raw_token: str, *, action: str = "mes.dispatch") -> dict:
grant = {
"confirmId": "terminal-confirm",
"sessionId": "terminal-session",
"action": action,
"power": "P3",
"tenantUuid": "platform",
"projectId": "terminal-project",
"ownerUserId": 6801,
"worldKey": "terminal-world",
"paramsHash": "terminal-params",
"approvals": [{"userId": 6802}, {"userId": 6803}],
"status": "ACTIVE",
"createdAtEpoch": 1_800_100_000.0,
"expiresAtEpoch": 1_900_100_000.0,
}
event = {
"confirmId": grant["confirmId"], "sessionId": grant["sessionId"],
"action": action, "power": "P3", "status": "APPROVED",
"tenantUuid": "platform", "projectId": grant["projectId"],
"ownerUserId": grant["ownerUserId"], "worldKey": grant["worldKey"],
"paramsHash": grant["paramsHash"], "decidedAtEpoch": 1_800_100_010.0,
}
return {"schemaVersion": 1, "pending": {}, "grants": {raw_token: grant}, "history": [event]}
def _seed_terminal_request_and_grant(source: dict, token_hash: str, *, grant_action: str | None = None) -> None:
raw_token, grant = next(iter(source["grants"].items()))
del raw_token
with get_session() as session:
session.add(ApprovalRequestRecord(
confirm_id=grant["confirmId"], tenant_uuid=grant["tenantUuid"],
project_id=grant["projectId"], owner_user_id=grant["ownerUserId"],
action=grant["action"], power=grant["power"], params_hash=grant["paramsHash"],
status="APPROVED", approval_step=2, required_approvals=2,
created_at_epoch=grant["createdAtEpoch"], expires_at_epoch=grant["expiresAtEpoch"],
revision=1, payload=copy.deepcopy(grant),
))
session.add(ApprovalGrantRecord(
token_hash=token_hash, confirm_id=grant["confirmId"],
tenant_uuid=grant["tenantUuid"], project_id=grant["projectId"],
owner_user_id=grant["ownerUserId"], action=grant_action or grant["action"],
params_hash=grant["paramsHash"], status="ACTIVE",
created_at_epoch=grant["createdAtEpoch"], expires_at_epoch=grant["expiresAtEpoch"],
consumed_at_epoch=None, revision=0, payload=copy.deepcopy(grant),
))
session.commit()
def test_real_file_p3_grant_migrates_as_digest_and_consumes_once(database_backend, tmp_path: Path):
path, raw_grant, record = _real_file_p3_grant(tmp_path)
migrate_file_to_db(str(path))
digest = token_digest(raw_grant)
with get_session() as session:
assert session.get(ApprovalGrantRecord, raw_grant) is None
row = session.get(ApprovalGrantRecord, digest)
assert row is not None
assert row.confirm_id == record["confirmId"]
rebuilt = session.get(ApprovalRequestRecord, record["confirmId"])
assert rebuilt is not None
assert rebuilt.status == "APPROVED"
assert raw_grant not in json.dumps(_snapshot_db(), ensure_ascii=False, sort_keys=True)
kwargs = {
"confirm_id": record["confirmId"],
"action": record["action"],
"params_hash": record["paramsHash"],
"allowed": lambda _record: True,
"decided_by": {"userId": 6704},
"now_epoch": 1_800_000_030.0,
}
assert database_backend.consume_grant(raw_grant, **kwargs) is True
assert database_backend.consume_grant(raw_grant, **kwargs) is False
def test_terminal_grant_without_pending_rebuilds_approved_request(database_backend, tmp_path: Path):
source = _terminal_grant_source("terminal-token")
migrate_file_to_db(str(_write_payload(tmp_path, source)))
with get_session() as session:
request = session.get(ApprovalRequestRecord, "terminal-confirm")
assert request is not None
assert request.status == "APPROVED"
assert request.action == "mes.dispatch"
assert request.required_approvals == 2
def _invalid_source(case: str) -> object:
data = _file_store()
if case == "top-level-list":
return []
if case == "schema-version":
data["schemaVersion"] = 99
elif case == "pending-container":
data["pending"] = []
elif case == "pending-record":
data["pending"] = {"bad": "not-an-object"}
elif case == "grant-container":
data["grants"] = []
elif case == "grant-record":
data["grants"] = {"token": "not-an-object"}
elif case == "history-container":
data["history"] = {}
elif case == "history-record":
data["history"] = ["not-an-object"]
elif case == "orphan-grant":
data = _terminal_grant_source("orphan-token")
data["history"] = []
elif case == "mismatched-pending-key":
data["pending"]["abc1111111"]["confirmId"] = "different-confirm"
return data
@pytest.mark.parametrize("case", [
"top-level-list", "schema-version", "pending-container", "pending-record",
"grant-container", "grant-record", "history-container", "history-record",
"orphan-grant", "mismatched-pending-key",
])
def test_invalid_source_fails_closed_without_database_writes(database_backend, tmp_path: Path, case: str):
path = tmp_path / f"invalid-{case}.json"
path.write_text(json.dumps(_invalid_source(case), ensure_ascii=False), encoding="utf-8")
with pytest.raises((RuntimeError, ValueError)) as exc:
migrate_file_to_db(str(path))
assert str(exc.value)
assert _counts() == {"pending": 0, "grants": 0, "events": 0}
def test_corrupt_json_fails_closed_without_database_writes(database_backend, tmp_path: Path):
path = tmp_path / "corrupt.json"
path.write_text('{"schemaVersion": 1, "pending": ', encoding="utf-8")
with pytest.raises((RuntimeError, ValueError)):
migrate_file_to_db(str(path))
assert _counts() == {"pending": 0, "grants": 0, "events": 0}
def test_migration_requires_explicit_quiesced_flag(database_backend, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
path = _write_file_store(tmp_path)
monkeypatch.delenv("APS_APPROVAL_MIGRATION_QUIESCED", raising=False)
with pytest.raises(RuntimeError, match="QUIESCED|quiesc"):
migrate_file_to_db(str(path))
assert _counts() == {"pending": 0, "grants": 0, "events": 0}
def test_existing_request_immutable_conflict_rolls_back(database_backend, tmp_path: Path):
data = {"schemaVersion": 1, "pending": {"conflict-confirm": {
"confirmId": "conflict-confirm", "sessionId": "s", "action": "mes.dispatch",
"power": "P2", "params": {}, "paramsHash": "expected-hash",
"tenantUuid": "platform", "projectId": "p", "ownerUserId": 1,
"createdAtEpoch": 1_800_000_000.0, "expiresAtEpoch": 1_900_000_000.0,
}}, "grants": {}, "history": []}
with get_session() as session:
session.add(ApprovalRequestRecord(
confirm_id="conflict-confirm", tenant_uuid="platform", project_id="p",
owner_user_id=1, action="different.action", power="P2",
params_hash="expected-hash", status="PENDING", approval_step=0,
required_approvals=1, created_at_epoch=1_800_000_000.0,
expires_at_epoch=1_900_000_000.0, revision=0,
payload=copy.deepcopy(data["pending"]["conflict-confirm"]),
))
session.commit()
before = _snapshot_db()
with pytest.raises((RuntimeError, ValueError)):
migrate_file_to_db(str(_write_payload(tmp_path, data)))
assert _snapshot_db() == before
def test_database_state_may_advance_but_migration_never_regresses_it(database_backend, tmp_path: Path):
path, raw_grant, record = _real_file_p3_grant(tmp_path)
migrate_file_to_db(str(path))
assert database_backend.consume_grant(
raw_grant, confirm_id=record["confirmId"], action=record["action"],
params_hash=record["paramsHash"], allowed=lambda _record: True,
decided_by={"userId": 6704}, now_epoch=1_800_000_030.0,
)
with get_session() as session:
before = session.get(ApprovalGrantRecord, token_digest(raw_grant))
expected = (before.status, before.consumed_at_epoch, before.revision)
migrate_file_to_db(str(path))
with get_session() as session:
after = session.get(ApprovalGrantRecord, token_digest(raw_grant))
assert (after.status, after.consumed_at_epoch, after.revision) == expected
assert after.status == "CONSUMED"
def test_consistent_legacy_raw_token_row_is_repaired_atomically(database_backend, tmp_path: Path):
raw = "legacy-raw-token-round67"
source = _terminal_grant_source(raw)
_seed_terminal_request_and_grant(source, raw)
migrate_file_to_db(str(_write_payload(tmp_path, source)))
with get_session() as session:
assert session.get(ApprovalGrantRecord, raw) is None
repaired = session.get(ApprovalGrantRecord, token_digest(raw))
assert repaired is not None
assert repaired.action == "mes.dispatch"
grant = next(iter(source["grants"].values()))
kwargs = {
"confirm_id": grant["confirmId"],
"action": grant["action"],
"params_hash": grant["paramsHash"],
"allowed": lambda _record: True,
"decided_by": {"userId": 6704},
"now_epoch": 1_800_100_100.0,
}
assert database_backend.consume_grant(raw, **kwargs) is True
assert database_backend.consume_grant(raw, **kwargs) is False
def test_legacy_raw_token_payload_conflict_fails_without_changes(
database_backend,
tmp_path: Path,
):
raw = "legacy-payload-conflict-round67"
source = _terminal_grant_source(raw)
_seed_terminal_request_and_grant(source, raw)
with get_session() as session:
row = session.get(ApprovalGrantRecord, raw)
assert row is not None
row.payload = {**copy.deepcopy(row.payload), "action": "forged.action"}
session.commit()
before = _snapshot_db()
with pytest.raises(RuntimeError, match="payload immutable conflict"):
migrate_file_to_db(str(_write_payload(tmp_path, source)))
assert _snapshot_db() == before
def test_inconsistent_legacy_raw_token_row_fails_without_changes(database_backend, tmp_path: Path):
raw = "legacy-raw-token-conflict"
source = _terminal_grant_source(raw)
_seed_terminal_request_and_grant(source, raw, grant_action="different.action")
before = _snapshot_db()
with pytest.raises((RuntimeError, ValueError)):
migrate_file_to_db(str(_write_payload(tmp_path, source)))
assert _snapshot_db() == before
def test_canonical_event_digest_preserves_same_second_near_events_and_is_idempotent(database_backend, tmp_path: Path):
pending = {
"confirmId": "event-confirm", "sessionId": "event-session", "action": "mes.dispatch",
"power": "P3", "params": {}, "paramsHash": "event-hash",
"tenantUuid": "platform", "projectId": "event-project", "ownerUserId": 1,
"requiredApprovals": 2, "createdAtEpoch": 1_800_000_000.0,
"expiresAtEpoch": 1_900_000_000.0,
}
common = {
"confirmId": "event-confirm", "action": "mes.dispatch", "power": "P3",
"status": "SOD_DENIED", "tenantUuid": "platform", "projectId": "event-project",
"ownerUserId": 1, "decidedAtEpoch": 1_800_000_010.0,
}
events = [
{**common, "decidedBy": {"userId": 2}, "note": "first", "detail": {"n": 1}},
{**common, "decidedBy": {"userId": 3}, "note": "second", "detail": {"n": 2}},
]
path = _write_payload(tmp_path, {
"schemaVersion": 1, "pending": {"event-confirm": pending},
"grants": {}, "history": events,
})
migrate_file_to_db(str(path))
migrate_file_to_db(str(path))
with get_session() as session:
rows = session.execute(select(ApprovalEventRecord).order_by(ApprovalEventRecord.id)).scalars().all()
assert len(rows) == 2
assert {row.payload["note"] for row in rows} == {"first", "second"}
assert {row.payload["decidedBy"]["userId"] for row in rows} == {2, 3}
def _seed_unrelated_target() -> None:
with get_session() as session:
session.add(ApprovalRequestRecord(
confirm_id="existing-target", tenant_uuid="platform", project_id="existing",
owner_user_id=1, action="existing.action", power="P2", params_hash="existing-hash",
status="PENDING", approval_step=0, required_approvals=1,
created_at_epoch=1_800_000_000.0, expires_at_epoch=1_900_000_000.0,
revision=0, payload={"confirmId": "existing-target", "action": "existing.action"},
))
session.add(ApprovalEventRecord(
confirm_id="existing-target", tenant_uuid="platform", project_id="existing",
owner_user_id=1, action="existing.action", power="P2", status="STAGED",
decided_at_epoch=1_800_000_001.0,
payload={"confirmId": "existing-target", "action": "existing.action", "status": "STAGED"},
))
session.commit()
def test_late_event_construction_failure_rolls_back_all_tables(database_backend, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
_seed_unrelated_target()
before = _snapshot_db()
source = _terminal_grant_source("late-construction-token")
path = _write_payload(tmp_path, source)
def explode_event(*_args, **_kwargs):
raise RuntimeError("injected event construction failure")
monkeypatch.setattr(ApprovalEventRecord, "__init__", explode_event)
with pytest.raises(RuntimeError):
migrate_file_to_db(str(path))
assert _snapshot_db() == before
def test_flush_failure_rolls_back_all_tables_and_existing_target(database_backend, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
_seed_unrelated_target()
before = _snapshot_db()
path = _write_payload(tmp_path, _terminal_grant_source("flush-failure-token"))
original_flush = Session.flush
def fail_flush(self, *_args, **_kwargs):
if self.new:
raise RuntimeError("injected flush failure")
return original_flush(self, *_args, **_kwargs)
monkeypatch.setattr(Session, "flush", fail_flush)
with pytest.raises(RuntimeError):
migrate_file_to_db(str(path))
monkeypatch.setattr(Session, "flush", original_flush)
assert _snapshot_db() == before
def test_source_digest_drift_after_flush_rolls_back(database_backend, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
_seed_unrelated_target()
before = _snapshot_db()
path = _write_payload(tmp_path, _terminal_grant_source("source-drift-token"))
original_flush = Session.flush
mutated = False
def drift_then_flush(self, *_args, **_kwargs):
nonlocal mutated
if self.new and not mutated:
mutated = True
changed = json.loads(path.read_text(encoding="utf-8"))
changed["history"][0]["note"] = "changed-after-load"
path.write_text(json.dumps(changed, ensure_ascii=False), encoding="utf-8")
return original_flush(self, *_args, **_kwargs)
monkeypatch.setattr(Session, "flush", drift_then_flush)
with pytest.raises((RuntimeError, ValueError)):
migrate_file_to_db(str(path))
monkeypatch.setattr(Session, "flush", original_flush)
assert mutated is True
assert _snapshot_db() == before
def test_migration_holds_file_backend_lock_through_database_flush(database_backend, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
path = _write_payload(tmp_path, _terminal_grant_source("lock-token"))
writer = ApprovalStore(str(path))
original_flush = Session.flush
flush_entered = threading.Event()
release_flush = threading.Event()
writer_started = threading.Event()
writer_done = threading.Event()
migration_errors: list[BaseException] = []
writer_results: list[bool] = []
def blocked_flush(self, *_args, **_kwargs):
if self.new and not flush_entered.is_set():
flush_entered.set()
if not release_flush.wait(5):
raise RuntimeError("test did not release migration flush")
return original_flush(self, *_args, **_kwargs)
monkeypatch.setattr(Session, "flush", blocked_flush)
def migrate() -> None:
try:
migrate_file_to_db(str(path))
except BaseException as exc: # noqa: BLE001 - captured for test-thread assertion
migration_errors.append(exc)
def write_file() -> None:
writer_started.set()
writer_results.append(writer.stage({
"confirmId": "writer-after-migration", "action": "schedule.publish",
"power": "P2", "paramsHash": "writer-hash", "tenantUuid": "platform",
"projectId": "terminal-project", "createdAtEpoch": 1_800_200_000.0,
"expiresAtEpoch": 1_900_200_000.0,
}, now_epoch=1_800_200_000.0))
writer_done.set()
migration_thread = threading.Thread(target=migrate, daemon=True)
writer_thread = threading.Thread(target=write_file, daemon=True)
migration_thread.start()
assert flush_entered.wait(5), "migration never reached database flush"
writer_thread.start()
assert writer_started.wait(2)
blocked_by_same_lock = not writer_done.wait(0.25)
release_flush.set()
migration_thread.join(5)
writer_thread.join(5)
monkeypatch.setattr(Session, "flush", original_flush)
assert blocked_by_same_lock, "file writer was not blocked by the migration lock"
assert not migration_thread.is_alive()
assert not writer_thread.is_alive()
assert migration_errors == []
assert writer_results == [True]