102 lines
4.8 KiB
Python
102 lines
4.8 KiB
Python
|
|
"""Fail-closed installation tests for the reviewed Soteria recovery artifact."""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import importlib.util
|
||
|
|
import shutil
|
||
|
|
import sqlite3
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
|
||
|
|
ROOT = Path(__file__).parents[2]
|
||
|
|
SPEC = importlib.util.spec_from_file_location(
|
||
|
|
"recover_soteria_kanban", ROOT / "services/hermes/scripts/recover_soteria_kanban.py"
|
||
|
|
)
|
||
|
|
assert SPEC and SPEC.loader
|
||
|
|
recovery = importlib.util.module_from_spec(SPEC)
|
||
|
|
SPEC.loader.exec_module(recovery)
|
||
|
|
|
||
|
|
|
||
|
|
def _candidate(path: Path) -> Path:
|
||
|
|
"""Build the reviewed-shape candidate without relying on production data."""
|
||
|
|
with sqlite3.connect(path) as connection:
|
||
|
|
connection.executescript("""
|
||
|
|
CREATE TABLE tasks (id TEXT); CREATE TABLE task_events (id INTEGER);
|
||
|
|
CREATE TABLE task_comments (id INTEGER); CREATE TABLE task_runs (id INTEGER);
|
||
|
|
CREATE TABLE task_links (id INTEGER); CREATE TABLE task_attachments (id INTEGER);
|
||
|
|
CREATE TABLE kanban_notify_subs (id INTEGER); CREATE TABLE supervisor_roots (root_task_id TEXT);
|
||
|
|
CREATE TABLE supervisor_children (id TEXT);
|
||
|
|
""")
|
||
|
|
connection.executemany("INSERT INTO tasks VALUES(?)", [(str(value),) for value in range(4)])
|
||
|
|
connection.executemany("INSERT INTO task_events VALUES(?)", [(value,) for value in range(159)])
|
||
|
|
connection.executemany("INSERT INTO task_comments VALUES(?)", [(value,) for value in range(8)])
|
||
|
|
connection.executemany("INSERT INTO task_runs VALUES(?)", [(value,) for value in range(4)])
|
||
|
|
connection.executemany("INSERT INTO supervisor_roots VALUES(?)", [(value,) for value in recovery.ROOTS])
|
||
|
|
return path
|
||
|
|
|
||
|
|
|
||
|
|
def test_recovery_replaces_only_verified_source_and_is_restart_safe(tmp_path, monkeypatch):
|
||
|
|
candidate = _candidate(tmp_path / "candidate.db")
|
||
|
|
database = tmp_path / "kanban.db"
|
||
|
|
shutil.copyfile(candidate, database)
|
||
|
|
with sqlite3.connect(database) as connection:
|
||
|
|
connection.execute("CREATE TABLE forensic_source (value TEXT)")
|
||
|
|
monkeypatch.setattr(recovery, "CANDIDATE_SHA", recovery._sha(candidate))
|
||
|
|
monkeypatch.setattr(recovery, "SOURCE_SHA", recovery._sha(database))
|
||
|
|
|
||
|
|
result = recovery.recover(database, candidate)
|
||
|
|
assert result["state"] == "recovered"
|
||
|
|
assert recovery._integrity(database)
|
||
|
|
assert list(tmp_path.glob("kanban.db.corrupt.recovery-*.bak"))
|
||
|
|
candidate.unlink()
|
||
|
|
assert recovery.recover(database, candidate)["state"] == "already-recovered"
|
||
|
|
|
||
|
|
|
||
|
|
def test_recovery_refuses_an_unexpected_live_database(tmp_path, monkeypatch):
|
||
|
|
candidate = _candidate(tmp_path / "candidate.db")
|
||
|
|
database = _candidate(tmp_path / "kanban.db")
|
||
|
|
with sqlite3.connect(database) as connection:
|
||
|
|
connection.execute("CREATE TABLE unexpected_live_state (value TEXT)")
|
||
|
|
monkeypatch.setattr(recovery, "CANDIDATE_SHA", recovery._sha(candidate))
|
||
|
|
monkeypatch.setattr(recovery, "SOURCE_SHA", "0" * 64)
|
||
|
|
|
||
|
|
with pytest.raises(recovery.RecoveryError, match="quarantined source"):
|
||
|
|
recovery.recover(database, candidate)
|
||
|
|
assert not list(tmp_path.glob("*.bak"))
|
||
|
|
|
||
|
|
|
||
|
|
def test_recovery_rejects_unreviewed_wal_before_replacement(tmp_path, monkeypatch):
|
||
|
|
candidate = _candidate(tmp_path / "candidate.db")
|
||
|
|
database = tmp_path / "kanban.db"
|
||
|
|
shutil.copyfile(candidate, database)
|
||
|
|
with sqlite3.connect(database) as connection:
|
||
|
|
connection.execute("CREATE TABLE forensic_source (value TEXT)")
|
||
|
|
monkeypatch.setattr(recovery, "CANDIDATE_SHA", recovery._sha(candidate))
|
||
|
|
monkeypatch.setattr(recovery, "SOURCE_SHA", recovery._sha(database))
|
||
|
|
database.with_name("kanban.db-wal").write_bytes(b"unreviewed writes")
|
||
|
|
|
||
|
|
with pytest.raises(recovery.RecoveryError, match="unreviewed journal"):
|
||
|
|
recovery.recover(database, candidate)
|
||
|
|
assert not list(tmp_path.glob("*.bak"))
|
||
|
|
|
||
|
|
|
||
|
|
def test_recovery_finalizes_an_interrupted_atomic_replacement(tmp_path, monkeypatch):
|
||
|
|
candidate = _candidate(tmp_path / "candidate.db")
|
||
|
|
database = tmp_path / "kanban.db"
|
||
|
|
shutil.copyfile(candidate, database)
|
||
|
|
with sqlite3.connect(database) as connection:
|
||
|
|
connection.execute("CREATE TABLE forensic_source (value TEXT)")
|
||
|
|
monkeypatch.setattr(recovery, "CANDIDATE_SHA", recovery._sha(candidate))
|
||
|
|
monkeypatch.setattr(recovery, "SOURCE_SHA", recovery._sha(database))
|
||
|
|
original_marker = recovery._write_marker
|
||
|
|
monkeypatch.setattr(recovery, "_write_marker", lambda _database: (_ for _ in ()).throw(OSError("crash")))
|
||
|
|
|
||
|
|
with pytest.raises(OSError, match="crash"):
|
||
|
|
recovery.recover(database, candidate)
|
||
|
|
assert recovery._sha(database) == recovery.CANDIDATE_SHA
|
||
|
|
assert list(tmp_path.glob("kanban.db.corrupt.recovery-*.bak"))
|
||
|
|
monkeypatch.setattr(recovery, "_write_marker", original_marker)
|
||
|
|
assert recovery.recover(database, candidate)["state"] == "recovery-finalized"
|
||
|
|
assert recovery.recover(database, candidate)["state"] == "already-recovered"
|