162 lines
6.7 KiB
Python
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()
|