from contextlib import closing import io import json from pathlib import Path import sqlite3 import tempfile import unittest from unittest.mock import patch import zipfile from cryptography.fernet import Fernet from fastapi import FastAPI from fastapi.testclient import TestClient from backend.app import db from backend.app.auth import get_current_user from backend.app.config import settings from backend.app.routers import backups as backup_router from backend.app.services import backups PASSPHRASE = "test backup passphrase with spaces" class BackupTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) self.root = Path(self.temp.name) self.database = self.root / "magent.db" for key, value in { "sqlite_path": str(self.database), "sqlite_journal_mode": "DELETE", "settings_encryption_key": Fernet.generate_key().decode(), "jwt_secret": "source-installation-signing-secret-for-backup-tests", "admin_username": "backup-admin", "admin_password": "a secure initial password", "jellyfin_api_key": "environment-integration-secret", "setup_token": "local-setup-token", "discord_webhook_url": "https://discord.example.invalid/api/webhooks/legacy-private-token", }.items(): context = patch.object(settings, key, value) context.start() self.addCleanup(context.stop) context = patch.object(backups, "_assets_root", return_value=self.root / "assets") context.start() self.addCleanup(context.stop) db.init_db() db.set_setting("sonarr_api_key", "database-integration-secret") db.set_setting("site_login_message", "Restored configuration") db.set_setting("installation_setup", "complete") with closing(sqlite3.connect(self.database)) as conn, conn: conn.execute("INSERT INTO requests_cache(request_id,title,payload_json) VALUES (3580,'Suits','{}')") conn.execute( "INSERT INTO signup_invites(code,enabled,created_at,updated_at) VALUES ('sha256:existing-invite',1,'now','now')" ) self.assets = self.root / "assets" (self.assets / "branding").mkdir(parents=True) (self.assets / "branding" / "logo.png").write_bytes(b"branding fixture") (self.assets / "artwork" / "tmdb" / "w342").mkdir(parents=True) (self.assets / "artwork" / "tmdb" / "w342" / "poster.jpg").write_bytes(b"cached fixture") def export(self, include_cache=True): content, filename = backups.create_backup(PASSPHRASE, include_cache) self.assertTrue(filename.endswith(".magent-backup")) return content def rewrite_archive(self, content, change): decrypted = backups._decrypt(content, PASSPHRASE) with zipfile.ZipFile(io.BytesIO(decrypted)) as archive: files = {entry.filename: archive.read(entry) for entry in archive.infolist()} change(files) output = io.BytesIO() with zipfile.ZipFile(output, "w") as archive: for name, value in files.items(): archive.writestr(name, value) return backups._encrypt(output.getvalue(), PASSPHRASE) def test_round_trip_reencrypts_secrets_preserves_invites_and_restores_cache_on_restart(self): content = self.export() self.assertNotIn(b"database-integration-secret", content) self.assertNotIn(b"environment-integration-secret", content) original_auth_version = db.get_user_by_username("backup-admin")["auth_version"] db.set_setting("site_login_message", "Live data before restart") settings.settings_encryption_key = Fernet.generate_key().decode() settings.jwt_secret = "destination-installation-signing-secret-for-backup-tests" # Simulate a different host with different env-backed integration settings. settings.jellyfin_api_key = "destination-env-value" metadata = backups.stage_restore(io.BytesIO(content), PASSPHRASE) self.assertTrue(metadata["include_cache"]) self.assertEqual(db.get_setting("site_login_message"), "Live data before restart") self.assertIsNotNone(backups.backup_status()["pending_restore"]) staged_bytes = (self.database.parent / "backups" / "pending" / "database.sqlite3").read_bytes() self.assertNotIn(b"database-integration-secret", staged_bytes) self.assertNotIn(b"environment-integration-secret", staged_bytes) self.assertNotIn(b"legacy-private-token", staged_bytes) (self.assets / "branding" / "logo.png").write_bytes(b"changed logo") (self.assets / "artwork" / "tmdb" / "w342" / "poster.jpg").unlink() self.assertTrue(backups.apply_pending_restore()) self.assertEqual(db.get_setting("site_login_message"), "Restored configuration") self.assertEqual(db.get_setting("sonarr_api_key"), "database-integration-secret") self.assertEqual(db.get_setting("jellyfin_api_key"), "environment-integration-secret") self.assertEqual(db.get_setting("discord_webhook_url"), "https://discord.example.invalid/api/webhooks/legacy-private-token") self.assertEqual(db.get_setting("installation_setup"), "complete") self.assertIsNone(db.get_setting("setup_token")) self.assertEqual((self.assets / "branding" / "logo.png").read_bytes(), b"branding fixture") self.assertEqual((self.assets / "artwork" / "tmdb" / "w342" / "poster.jpg").read_bytes(), b"cached fixture") self.assertGreater(db.get_user_by_username("backup-admin")["auth_version"], original_auth_version) with closing(sqlite3.connect(self.database)) as conn, conn: self.assertEqual(conn.execute("SELECT title FROM requests_cache WHERE request_id=3580").fetchone(), ("Suits",)) self.assertEqual(conn.execute("SELECT code FROM signup_invites").fetchone(), ("sha256:existing-invite",)) self.assertTrue(conn.execute("SELECT value FROM settings WHERE key='sonarr_api_key'").fetchone()[0].startswith("enc:v1:")) status = backups.backup_status() self.assertIsNone(status["pending_restore"]) self.assertEqual(status["last_restore"]["status"], "restored") self.assertTrue((self.database.parent / "backups" / status["last_restore"]["rollback_directory"] / "database.sqlite3").is_file()) self.assertFalse(backups.apply_pending_restore()) def test_wal_snapshot_contains_committed_uncheckpointed_rows(self): with closing(sqlite3.connect(self.database)) as writer: writer.execute("PRAGMA journal_mode=WAL") writer.execute("PRAGMA wal_autocheckpoint=0") writer.execute("UPDATE requests_cache SET title='Written in WAL' WHERE request_id=3580") writer.commit() self.assertTrue(Path(str(self.database) + "-wal").exists()) content = self.export() backups.stage_restore(io.BytesIO(content), PASSPHRASE) self.assertTrue(backups.apply_pending_restore()) with closing(sqlite3.connect(self.database)) as restored: self.assertEqual(restored.execute("SELECT title FROM requests_cache").fetchone()[0], "Written in WAL") def test_managed_restore_preserves_destination_application_origin(self): db.set_setting("magent_application_url", "https://source.example.test") content = self.export() db.set_setting("magent_application_url", "https://destination.example.test") with patch.dict("os.environ", {"MAGENT_RUNTIME_MANAGED": "1"}): backups.stage_restore(io.BytesIO(content), PASSPHRASE) self.assertTrue(backups.apply_pending_restore()) self.assertEqual(db.get_setting("magent_application_url"), "https://destination.example.test") def test_manual_restore_retains_legacy_application_url_behavior(self): db.set_setting("magent_application_url", "https://source.example.test") content = self.export() db.set_setting("magent_application_url", "https://destination.example.test") with patch.dict("os.environ", {"MAGENT_RUNTIME_MANAGED": ""}): backups.stage_restore(io.BytesIO(content), PASSPHRASE) self.assertTrue(backups.apply_pending_restore()) self.assertEqual(db.get_setting("magent_application_url"), "https://source.example.test") def test_managed_restore_without_destination_origin_does_not_stage(self): content = self.export() with patch.dict("os.environ", {"MAGENT_RUNTIME_MANAGED": "1"}), \ patch("backend.app.services.public_urls.magent_public_url", return_value=""): with self.assertRaisesRegex(backups.BackupError, "destination application address"): backups.stage_restore(io.BytesIO(content), PASSPHRASE) self.assertIsNone(backups.backup_status()["pending_restore"]) def test_process_interruption_is_recovered_on_next_startup(self): class ProcessStopped(BaseException): pass content = self.export() db.set_setting("site_login_message", "Value before interrupted restart") backups.stage_restore(io.BytesIO(content), PASSPHRASE) with patch.object(backups, "_replace_assets", side_effect=ProcessStopped): with self.assertRaises(ProcessStopped): backups.apply_pending_restore() self.assertTrue((self.database.parent / "backups" / "restore-journal.json").exists()) self.assertEqual(db.get_setting("site_login_message"), "Restored configuration") self.assertFalse(backups.apply_pending_restore()) self.assertEqual(db.get_setting("site_login_message"), "Value before interrupted restart") self.assertEqual(backups.backup_status()["last_restore"]["status"], "rolled_back") self.assertIsNone(backups.backup_status()["pending_restore"]) def test_crash_after_rollback_does_not_reapply_pending_restore(self): class ProcessStopped(BaseException): pass content = self.export() db.set_setting("site_login_message", "Value to retain") backups.stage_restore(io.BytesIO(content), PASSPHRASE) replace_assets = backups._replace_assets remove_tree = backups.shutil.rmtree calls = 0 def fail_first_copy(source, target): nonlocal calls calls += 1 if calls == 1: raise OSError("failed apply") return replace_assets(source, target) def interrupt_cleanup(path, *args, **kwargs): if Path(path).name == "pending": raise ProcessStopped() return remove_tree(path, *args, **kwargs) with patch.object(backups, "_replace_assets", side_effect=fail_first_copy), \ patch.object(backups.shutil, "rmtree", side_effect=interrupt_cleanup): with self.assertRaises(ProcessStopped): backups.apply_pending_restore() journal = json.loads((self.root / "backups" / "restore-journal.json").read_text()) self.assertEqual(journal["phase"], "rolled_back") self.assertFalse(backups.apply_pending_restore()) self.assertEqual(db.get_setting("site_login_message"), "Value to retain") self.assertIsNone(backups.backup_status()["pending_restore"]) def test_missing_runtime_column_is_rejected_even_with_current_migration_version(self): directory = self.root / "schema-test" directory.mkdir() backups._extract_archive(backups._decrypt(self.export(), PASSPHRASE), directory) source = directory / "database.sqlite3" with closing(sqlite3.connect(source)) as conn, conn: conn.execute("ALTER TABLE users DROP COLUMN auto_search_enabled") with self.assertRaisesRegex(backups.BackupError, "missing database columns"): backups._validate_database(source) def test_changed_encryption_key_since_staging_leaves_live_database_untouched(self): content = self.export() db.set_setting("site_login_message", "Current data") backups.stage_restore(io.BytesIO(content), PASSPHRASE) settings.settings_encryption_key = Fernet.generate_key().decode() with self.assertRaisesRegex(backups.BackupError, "configuration is invalid"): backups.apply_pending_restore() self.assertEqual(db.get_setting("site_login_message"), "Current data") self.assertIsNotNone(backups.backup_status()["pending_restore"]) def test_excluding_disk_cache_keeps_database_cache_and_branding(self): with zipfile.ZipFile(io.BytesIO(backups._decrypt(self.export(False), PASSPHRASE))) as archive: self.assertIn("database.sqlite3", archive.namelist()) self.assertIn("files/branding/logo.png", archive.namelist()) self.assertFalse(any("artwork" in name for name in archive.namelist())) def test_wrong_password_and_tampering_never_stage_or_touch_live_database(self): content = self.export() for bad_content, password in ((content, "incorrect password value"), (content[:-1] + bytes([content[-1] ^ 1]), PASSPHRASE)): with self.subTest(password=password): with self.assertRaisesRegex(backups.BackupError, "Incorrect passphrase or damaged"): backups.stage_restore(io.BytesIO(bad_content), password) self.assertIsNone(backups.backup_status()["pending_restore"]) self.assertEqual(db.get_setting("sonarr_api_key"), "database-integration-secret") def test_path_traversal_unknown_files_and_checksum_failures_rejected(self): content = self.export() for name in ("../outside.txt", "/absolute.txt", "files/branding/../../../escape", "files/branding/script.py"): with self.subTest(name=name): malformed = self.rewrite_archive(content, lambda files: files.update({name: b"bad"})) with self.assertRaises(backups.BackupError): backups.stage_restore(io.BytesIO(malformed), PASSPHRASE) malformed = self.rewrite_archive(content, lambda files: files.update({"files/branding/logo.png": b"tampered"})) with self.assertRaises(backups.BackupError): backups.stage_restore(io.BytesIO(malformed), PASSPHRASE) self.assertFalse((self.root / "outside.txt").exists()) def test_size_limit_and_unsupported_schema_rejected(self): content = self.export() with patch.object(backups, "MAX_UPLOAD_BYTES", 16): with self.assertRaisesRegex(backups.BackupError, "upload limit"): backups.stage_restore(io.BytesIO(content), PASSPHRASE) with patch.object(backups, "MAX_EXPANDED_BYTES", 16): with self.assertRaisesRegex(backups.BackupError, "Expanded backup"): backups.stage_restore(io.BytesIO(content), PASSPHRASE) with closing(sqlite3.connect(self.database)) as conn, conn: conn.execute("CREATE TRIGGER unsafe AFTER INSERT ON settings BEGIN DELETE FROM users; END") # Validate the original fixture to avoid executing the malicious trigger in export. with self.assertRaisesRegex(backups.BackupError, "unsupported database schema"): backups._validate_database(self.database) def test_unsupported_compression_is_rejected_before_expansion(self): content = self.export() rewritten = io.BytesIO() with zipfile.ZipFile(io.BytesIO(backups._decrypt(content, PASSPHRASE))) as original: with zipfile.ZipFile(rewritten, "w", compression=zipfile.ZIP_BZIP2) as target: for entry in original.infolist(): target.writestr(entry.filename, original.read(entry)) with self.assertRaisesRegex(backups.BackupError, "unsafe archive entry"): backups.stage_restore(io.BytesIO(backups._encrypt(rewritten.getvalue(), PASSPHRASE)), PASSPHRASE) def test_cancel_is_idempotent_and_does_not_change_database(self): content = self.export() backups.stage_restore(io.BytesIO(content), PASSPHRASE) with self.assertRaisesRegex(backups.BackupError, "already staged"): backups.stage_restore(io.BytesIO(content), PASSPHRASE) backups.cancel_restore() backups.cancel_restore() self.assertIsNone(backups.backup_status()["pending_restore"]) self.assertEqual(db.get_setting("sonarr_api_key"), "database-integration-secret") def test_failure_after_database_replacement_rolls_back_both_database_and_files(self): content = self.export() db.set_setting("site_login_message", "Keep this current value") (self.assets / "branding" / "logo.png").write_bytes(b"current logo") backups.stage_restore(io.BytesIO(content), PASSPHRASE) original = backups._replace_assets calls = 0 def fail_once(source, target): nonlocal calls calls += 1 if calls == 1: raise OSError("simulated interrupted copy") return original(source, target) with patch.object(backups, "_replace_assets", side_effect=fail_once): with self.assertRaisesRegex(OSError, "interrupted copy"): backups.apply_pending_restore() self.assertEqual(db.get_setting("site_login_message"), "Keep this current value") self.assertEqual((self.assets / "branding" / "logo.png").read_bytes(), b"current logo") self.assertEqual(backups.backup_status()["last_restore"]["status"], "rolled_back") self.assertFalse(backups.apply_pending_restore()) def test_api_requires_admin_and_restore_confirmation(self): app = FastAPI() app.include_router(backup_router.router) with TestClient(app) as client: self.assertEqual(client.get("/admin/backups").status_code, 401) app.dependency_overrides[get_current_user] = lambda: {"username": "member", "role": "user"} self.assertEqual(client.get("/admin/backups").status_code, 403) self.assertEqual(client.post("/admin/backups/export", json={"passphrase": PASSPHRASE}).status_code, 403) app.dependency_overrides[get_current_user] = lambda: {"username": "backup-admin", "role": "admin"} status = client.get("/admin/backups") self.assertEqual(status.status_code, 200) self.assertEqual(status.headers["cache-control"], "no-store") self.assertEqual(status.json()["max_expanded_bytes"], backups.MAX_EXPANDED_BYTES) response = client.post("/admin/backups/export", json={"passphrase": PASSPHRASE}) self.assertEqual(response.status_code, 200) self.assertEqual(response.headers["cache-control"], "no-store") rejected = client.post("/admin/backups/restore", files={"file": ("test.magent-backup", response.content)}, data={"passphrase": PASSPHRASE, "confirmation": "wrong"}) self.assertEqual(rejected.status_code, 422) restored = client.post("/admin/backups/restore", files={"file": ("test.magent-backup", response.content)}, data={"passphrase": PASSPHRASE, "confirmation": "RESTORE"}) self.assertEqual(restored.status_code, 202) self.assertTrue(restored.json()["restart_required"]) self.assertEqual(client.delete("/admin/backups/restore").status_code, 200) if __name__ == "__main__": unittest.main()