aps-agent/tests/golden/test_approval_migration.py

638 lines
28 KiB
Python
Raw Permalink 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 存量迁移黄金测试(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]