Kansho/backend/tests/test_local_backup.py
2026-08-26 10:42:20 +02:00

162 lines
6.7 KiB
Python

"""Local backup/restore. Run from backend/: python tests/test_local_backup.py"""
from __future__ import annotations
import json
import os
import sqlite3
import sys
import tempfile
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from local_backup import BackupError, create_backup, restore_backup, verify_archive
def expect(ok: bool, message: str) -> None:
if not ok:
raise SystemExit(f"FAIL: {message}")
print(f"OK {message}")
def write_db(path: Path, value: str) -> None:
conn = sqlite3.connect(path)
conn.execute("CREATE TABLE IF NOT EXISTS note (id INTEGER PRIMARY KEY, body TEXT)")
conn.execute("DELETE FROM note")
conn.execute("INSERT INTO note (body) VALUES (?)", (value,))
conn.commit()
conn.close()
def read_db(path: Path) -> str:
conn = sqlite3.connect(path)
row = conn.execute("SELECT body FROM note").fetchone()
conn.close()
return row[0] if row else ""
def main() -> None:
with tempfile.TemporaryDirectory(prefix="kansho-backup-test-") as raw:
base = Path(raw)
db = base / "live" / "kansho.sqlite"
media = base / "live" / "media"
out = base / "archives"
db.parent.mkdir(parents=True)
media.mkdir(parents=True)
write_db(db, "original-body")
sample = media / "profile" / "shot.png"
sample.parent.mkdir(parents=True)
sample.write_bytes(b"png-bytes")
secret = base / "live" / ".env"
secret.write_text("KANSHO_PROVIDER_KEY=sk-secret\n", encoding="utf-8")
archive = out / "first.zip"
created = create_backup(archive, db_path=db, media_root=media)
expect(archive.is_file(), "backup file created")
expect(created["manifest"]["kind"] == "kansho.local_backup", "manifest kind")
expect("persönliche, unverschlüsselte" in created["manifest"]["warning"].lower() or "unverschl" in created["manifest"]["warning"], "personal-data warning")
verified = verify_archive(archive)
expect(verified["manifest"]["checksums"]["kansho.sqlite"], "sqlite checksum present")
with zipfile.ZipFile(archive) as zf:
names = zf.namelist()
expect("kansho.sqlite" in names, "sqlite in archive")
expect("media/profile/shot.png" in names, "media in archive")
expect(".env" not in names, ".env excluded")
expect(all("sk-secret" not in zf.read(name).decode("latin1", errors="ignore") for name in names if name.endswith(".json") or name.endswith(".txt")), "no secret in manifest/readme")
try:
restore_backup(archive, db_path=db, media_root=media, confirm=False)
expect(False, "restore without confirm must fail")
except BackupError as exc:
expect(exc.code == "confirm_required", "restore requires explicit confirm")
try:
restore_backup(archive, db_path=db, media_root=media, confirm=True, allow_overwrite=False)
expect(False, "restore must not silently overwrite")
except BackupError as exc:
expect(exc.code == "would_overwrite", "restore refuses existing data without replace")
target_db = base / "restore" / "kansho.sqlite"
target_media = base / "restore" / "media"
restored = restore_backup(
archive,
db_path=target_db,
media_root=target_media,
confirm=True,
allow_overwrite=False,
safety_dir=out,
)
expect(read_db(target_db) == "original-body", "restored sqlite content")
expect((target_media / "profile" / "shot.png").read_bytes() == b"png-bytes", "restored media")
expect(restored["safety_backup"] is None, "empty target needs no safety backup")
write_db(target_db, "changed-live")
(target_media / "profile" / "extra.bin").write_bytes(b"extra")
replaced = restore_backup(
archive,
db_path=target_db,
media_root=target_media,
confirm=True,
allow_overwrite=True,
safety_dir=out,
)
expect(read_db(target_db) == "original-body", "replace restores snapshot")
expect(not (target_media / "profile" / "extra.bin").exists(), "extra live media not kept")
expect(Path(replaced["safety_backup"]).is_file(), "safety backup written before replace")
safety = verify_archive(Path(replaced["safety_backup"]))
expect(safety["manifest"]["kind"] == "kansho.local_backup", "safety backup is a real archive")
broken = out / "broken.zip"
with zipfile.ZipFile(archive) as src, zipfile.ZipFile(broken, "w") as dest:
for info in src.infolist():
data = src.read(info.filename)
if info.filename == "kansho.sqlite":
data = data + b"tamper"
dest.writestr(info, data)
before = read_db(target_db)
try:
restore_backup(broken, db_path=target_db, media_root=target_media, confirm=True, allow_overwrite=True)
expect(False, "tampered archive must fail")
except BackupError as exc:
expect(exc.code == "checksum_mismatch", "tamper detected before restore")
expect(read_db(target_db) == before, "failed restore leaves current db")
extra_zip = out / "extra.zip"
with zipfile.ZipFile(archive) as src, zipfile.ZipFile(extra_zip, "w") as dest:
for info in src.infolist():
dest.writestr(info, src.read(info.filename))
dest.writestr("notes.txt", "not in manifest")
extra_verified = verify_archive(extra_zip)
expect("notes.txt" in extra_verified["skipped_extra"], "extra archive files are skipped deterministically")
locked_db = base / "locked" / "kansho.sqlite"
locked_media = base / "locked" / "media"
locked_db.parent.mkdir()
locked_media.mkdir()
write_db(locked_db, "locked")
holder = sqlite3.connect(str(locked_db), timeout=1)
holder.execute("BEGIN EXCLUSIVE")
try:
restore_backup(
archive,
db_path=locked_db,
media_root=locked_media,
confirm=True,
allow_overwrite=True,
safety_dir=out,
)
expect(False, "in-use db must abort")
except BackupError as exc:
expect(exc.code in {"db_in_use", "db_unavailable", "backup_exists", "would_overwrite"} or "in_use" in exc.code or "unavailable" in exc.code, f"busy db fail-closed ({exc.code})")
finally:
holder.rollback()
holder.close()
expect(True, "backup tests used only temporary directories")
if __name__ == "__main__":
main()