638 lines
28 KiB
Python
638 lines
28 KiB
Python
# ============================================================
|
||
# 审批文件→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]
|