Files
hermes-agent/tests/hermes_cli/test_restore_source_integrity.py
kshitijk4poor 513b1bc469 test(backup): assert the integrity-failure message on corrupt import
f55f937cee made run_import tell the user when an archived database fails
its integrity check instead of blaming a live holder, but nothing asserted
that message: reverting its prod hunk left the suite green. Extend the
existing corrupt-source test to require "failed its integrity check" in
the import output, so the misleading holder message cannot come back.
Verified: 24 pass on head; 9 fail with f55f937cee's prod hunk reverted.
2026-09-26 21:38:28 +05:30

127 lines
4.8 KiB
Python

"""Restore must read the literal source path and validate it before mutating a live database."""
import json
import sqlite3
import zipfile
from argparse import Namespace
from contextlib import closing
import pytest
from hermes_cli import backup
# Unescaped in a file: URI, '#' truncates the path and '%23' decodes to a different one.
HOME_NAMES = ["x#y", "x%23y", "x y"]
def _database(path, count):
with closing(sqlite3.connect(path)) as conn:
conn.execute("CREATE TABLE sessions(id INTEGER PRIMARY KEY)")
conn.execute("CREATE TABLE messages(id INTEGER PRIMARY KEY)")
conn.executemany("INSERT INTO sessions VALUES (?)", [(n,) for n in range(count)])
conn.executemany("INSERT INTO messages VALUES (?)", [(n,) for n in range(count)])
conn.commit()
def _assert_no_decoy(tmp_path, home_name):
assert {p.name for p in tmp_path.iterdir()} <= {home_name, "native", "backup.zip", "hermes_test"}
@pytest.fixture(params=HOME_NAMES)
def home(tmp_path, monkeypatch, request):
import hermes_cli.gateway as gateway
home = tmp_path / request.param
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
# An existing default install keeps import from installing/starting a gateway.
native = tmp_path / "native"
native.mkdir()
(native / "config.yaml").write_text("{}", encoding="utf-8")
monkeypatch.setattr(backup, "_get_platform_default_hermes_home", lambda: native)
monkeypatch.setattr(gateway, "ensure_gateway_service", lambda **kwargs: False)
monkeypatch.setattr(gateway, "_is_service_running", lambda: False)
return home
def _restore(entry, home, snapshot_id, source):
if entry == "snapshot":
return backup.restore_quick_snapshot(snapshot_id, hermes_home=home)
archive = home.parent / "backup.zip"
with zipfile.ZipFile(archive, "w") as zf:
zf.write(source, "state.db")
return backup.run_import(Namespace(zipfile=str(archive), force=True)) != 1
@pytest.mark.parametrize("entry", ["snapshot", "import"])
def test_restore_uses_literal_paths_and_preserves_live_connection(
tmp_path, capsys, home, entry
):
from hermes_cli.sqlite_safe_read import connect_tracked
target = home / "state.db"
_database(target, 3)
snapshot = home / "state-snapshots" / "saved"
snapshot.mkdir(parents=True)
source = snapshot / "state.db"
_database(source, 1)
(snapshot / "manifest.json").write_text(
json.dumps({"files": {"state.db": source.stat().st_size}}), encoding="utf-8"
)
holder = connect_tracked(target)
try:
assert _restore(entry, home, "saved", source)
if entry == "import":
assert "3 session(s) / 3 message(s) -> 1 / 1" in capsys.readouterr().out
assert holder.execute("SELECT id FROM messages").fetchall() == [(0,)]
with closing(sqlite3.connect(target)) as reopened:
assert reopened.execute("SELECT id FROM messages").fetchall() == [(0,)]
finally:
holder.close()
_assert_no_decoy(tmp_path, home.name)
@pytest.mark.parametrize("entry", ["snapshot", "import"])
@pytest.mark.parametrize("damage", ["header", "btree", "truncated"])
def test_corrupt_source_cannot_replace_a_healthy_database(
tmp_path, capsys, home, entry, damage
):
live = home / "state.db"
with closing(sqlite3.connect(live)) as db:
db.execute("CREATE TABLE evidence(value TEXT)")
db.execute("INSERT INTO evidence VALUES ('snapshot')")
db.commit()
snapshot_id = backup.create_quick_snapshot(hermes_home=home)
assert snapshot_id
source = home / "state-snapshots" / snapshot_id / "state.db"
with closing(sqlite3.connect(live)) as db:
db.execute("UPDATE evidence SET value='live'")
db.commit()
with closing(sqlite3.connect(source)) as db:
page_size = db.execute("PRAGMA page_size").fetchone()[0]
root_page = db.execute(
"SELECT rootpage FROM sqlite_master WHERE name='evidence'"
).fetchone()[0]
contents = bytearray(source.read_bytes())
if damage == "header":
contents[:16] = b"not a database!!"
elif damage == "btree":
contents[(root_page - 1) * page_size] = 0 # Invalid b-tree page type.
else:
del contents[page_size:]
source.write_bytes(contents)
assert not backup.verify_sqlite_integrity(source)["valid"]
before = live.read_bytes()
inode = live.stat().st_ino
assert not _restore(entry, home, snapshot_id, source)
if entry == "import":
captured = capsys.readouterr()
assert "failed its integrity check" in captured.out + captured.err
assert live.read_bytes() == before
assert live.stat().st_ino == inode
with closing(sqlite3.connect(live)) as db:
assert db.execute("SELECT value FROM evidence").fetchall() == [("live",)]
_assert_no_decoy(tmp_path, home.name)