Files
FabledGrimoire/tests/test_services_backup_file.py
bvandeusenandClaude Opus 5.5 0f5ef0be63
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
rename(547): the server is grimoire — package, image, DB defaults, backups
Step 2 of the Scribe -> Grimoire rename.

- src/scribe -> src/grimoire, and every import, patch target, module path,
  hypercorn entry point and `python -m grimoire.restore` with it; pyproject,
  uv.lock and the frontend package are named grimoire.
- Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service
  runs as grimoire / grimoire_test.
- Compose files default the DB user, database and (prod) volume to grimoire,
  each overridable from .env, so an install created as Scribe keeps its data
  by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME.
- Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar.
  Archives named scribe-* with the scribe-backup-tar manifest still restore
  and are still counted at the destination (folder and S3), with tests.
- Exports, the MCP auth realm, the ASGI scope key and the persistent logger
  carry the new name.

Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1",
because every stored secret was derived under it.

Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is
step 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:50:45 -04:00

221 lines
7.8 KiB
Python

"""Unit tests for the backup file (services/backup_file.py).
The file exists to be deduplicated between runs by a tool like restic, so
most of what is tested here is a property of BYTES: what stays identical
when nothing changed, and how little moves when one row does. A round trip
alone would pass for a gzip of one big JSON document, which is exactly the
shape this file replaced.
"""
import io
import tarfile
from datetime import datetime, timezone
from unittest.mock import patch
import pytest
from grimoire.services import backup_file
from grimoire.services.backup_file import (
ArchiveError, archive_name, is_archive, read_archive, write_archive,
write_archive_to,
)
AT = datetime(2026, 10, 15, 3, 0, 0, tzinfo=timezone.utc)
def _payload(**over) -> dict:
payload = {
"version": 25,
"scope": "full",
"exported_at": "2026-10-15T03:00:00+00:00",
"_security_notice": "hashed passwords inside",
"_not_included": ["api_keys", "note_embeddings"],
"users": [{"id": 1, "username": "ada", "role": "admin"}],
"notes": [
{"id": n, "user_id": 1, "title": f"note {n}", "body": "x" * 200,
"tags": ["a", "b"]}
for n in range(1, 51)
],
"settings": [],
}
payload.update(over)
return payload
def _members(payload: dict) -> dict[str, bytes]:
buf = io.BytesIO()
write_archive_to(payload, buf, app_version="2026.10.15.0300")
buf.seek(0)
with tarfile.open(fileobj=buf, mode="r:") as tar:
return {m.name: tar.extractfile(m).read() for m in tar.getmembers()}
def test_the_name_is_dated_in_utc_and_sorts():
assert archive_name(AT) == "grimoire-20261015T030000Z.tar"
local = datetime(2026, 10, 14, 23, 0, 0).astimezone()
assert archive_name(local).endswith("Z.tar")
def test_round_trip_returns_the_payload_restore_takes():
payload = _payload()
buf = io.BytesIO()
write_archive_to(payload, buf, app_version="v")
buf.seek(0)
assert read_archive(buf) == payload
def test_notices_stay_in_the_manifest_not_as_sections():
"""`_not_included` is a list, but of table names; it must not become a
member that a restore would read as rows."""
names = set(_members(_payload()))
assert names == {"manifest.json", "notes.jsonl", "settings.jsonl", "users.jsonl"}
def test_members_come_in_one_order_with_fixed_headers():
buf = io.BytesIO()
write_archive_to(_payload(), buf, app_version="v")
buf.seek(0)
with tarfile.open(fileobj=buf, mode="r:") as tar:
members = tar.getmembers()
assert [m.name for m in members] == [
"manifest.json", "notes.jsonl", "settings.jsonl", "users.jsonl",
]
for m in members:
assert (m.mtime, m.uid, m.gid, m.uname, m.gname, m.mode) == (
0, 0, 0, "", "", 0o644,
)
def test_an_unchanged_payload_gives_identical_sections_on_another_run():
"""The run's own time and the app version live in the manifest and
nowhere else, so a second run over unchanged data repeats every section
byte for byte."""
first = _members(_payload())
second = _members(_payload(exported_at="2026-10-16T03:00:00+00:00"))
assert first["manifest.json"] != second["manifest.json"]
for name in first:
if name != "manifest.json":
assert first[name] == second[name], name
def test_key_order_in_a_row_does_not_change_its_bytes():
rows = _payload()["notes"]
shuffled = [dict(reversed(list(r.items()))) for r in rows]
assert _members(_payload(notes=shuffled))["notes.jsonl"] == \
_members(_payload())["notes.jsonl"]
def test_editing_one_row_changes_one_line_of_one_member():
before = _members(_payload())
notes = _payload()["notes"]
notes[24] = {**notes[24], "body": "edited"}
after = _members(_payload(notes=notes))
changed = [n for n in before if before[n] != after[n]]
assert changed == ["notes.jsonl"]
old_lines = before["notes.jsonl"].splitlines()
new_lines = after["notes.jsonl"].splitlines()
assert len(old_lines) == len(new_lines)
assert [i for i, (a, b) in enumerate(zip(old_lines, new_lines)) if a != b] == [24]
def test_a_new_row_appends_to_the_end_of_its_member():
"""Rows come in primary-key order, so a table that grows grows at its
tail: everything already sent is a prefix of the new member."""
before = _members(_payload())["notes.jsonl"]
notes = _payload()["notes"] + [{"id": 51, "user_id": 1, "title": "new"}]
after = _members(_payload(notes=notes))["notes.jsonl"]
assert after.startswith(before)
assert after.count(b"\n") == before.count(b"\n") + 1
def test_the_file_is_an_uncompressed_tar():
buf = io.BytesIO()
write_archive_to(_payload(), buf, app_version="v")
raw = buf.getvalue()
assert raw[257:262] == b"ustar"
assert b'"title":"note 7"' in raw
def test_is_archive_tells_a_tar_from_the_json_download():
buf = io.BytesIO()
write_archive_to(_payload(), buf, app_version="v")
buf.seek(0)
assert is_archive(buf)
assert buf.tell() == 0
assert not is_archive(io.BytesIO(b'{"version": 25, "users": []}'))
def test_write_archive_lands_under_the_dated_name_only(tmp_path):
path = write_archive(_payload(), tmp_path, AT, app_version="v")
assert path == tmp_path / "grimoire-20261015T030000Z.tar"
assert [p.name for p in tmp_path.iterdir()] == [path.name]
with open(path, "rb") as f:
assert read_archive(f) == _payload()
def test_a_failed_write_leaves_nothing_behind(tmp_path):
"""Neither a half backup under the final name (a watcher would count it
as arrived) nor a stray part file."""
with patch.object(backup_file, "write_archive_to", side_effect=OSError("disk full")):
with pytest.raises(OSError):
write_archive(_payload(), tmp_path, AT, app_version="v")
assert list(tmp_path.iterdir()) == []
def _rewrite(members: dict[str, bytes]) -> io.BytesIO:
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w", format=tarfile.USTAR_FORMAT) as tar:
for name, data in members.items():
info = tarfile.TarInfo(name)
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
buf.seek(0)
return buf
def test_a_missing_section_is_an_error_not_a_thinner_restore():
members = _members(_payload())
del members["notes.jsonl"]
with pytest.raises(ArchiveError, match="notes.jsonl"):
read_archive(_rewrite(members))
def test_a_section_cut_short_is_an_error():
members = _members(_payload())
members["notes.jsonl"] = b"\n".join(members["notes.jsonl"].splitlines()[:10]) + b"\n"
with pytest.raises(ArchiveError, match="10 rows"):
read_archive(_rewrite(members))
def test_a_newer_format_is_refused_by_name():
members = _members(_payload())
members["manifest.json"] = members["manifest.json"].replace(
b'"format_version": 1', b'"format_version": 99',
)
with pytest.raises(ArchiveError, match="upgrade"):
read_archive(_rewrite(members))
def test_an_archive_written_under_the_name_scribe_still_reads():
"""Grimoire was called Scribe; its archives say so in the manifest."""
members = _members(_payload())
assert b"grimoire-backup-tar" in members["manifest.json"]
members["manifest.json"] = members["manifest.json"].replace(
b"grimoire-backup-tar", b"scribe-backup-tar",
)
assert read_archive(_rewrite(members)) == _payload()
def test_another_tools_tar_is_refused():
members = _members(_payload())
members["manifest.json"] = members["manifest.json"].replace(
b"grimoire-backup-tar", b"someone-elses-tar",
)
with pytest.raises(ArchiveError, match="not a Grimoire backup"):
read_archive(_rewrite(members))
def test_garbage_is_not_an_archive():
with pytest.raises(ArchiveError):
read_archive(io.BytesIO(b"not a tar at all" * 100))