"""SP17 — BackupService integration tests. Exercises the full create / list / verify / restore / prune flow against a real on-disk SQLite file (no SQLCipher, no Keychain). We inject the passphrase directly into the BackupService constructor. """ from __future__ import annotations import os from pathlib import Path import pytest from cyclone import backup as backup_mod from cyclone import backup_service as svc_mod from cyclone import db from cyclone.backup import BackupError from cyclone.backup_service import ( BackupService, STATUS_ERROR, STATUS_OK, STATUS_PENDING, STATUS_PRUNED, configure_backup_service, get_backup_service, reset_backup_service_for_tests, ) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def fresh_db(tmp_path, monkeypatch): """Fresh sqlite DB; init_db + create tables; yield the path.""" monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db") db._reset_for_tests() db.init_db() yield tmp_path / "test.db" db._reset_for_tests() @pytest.fixture def backup_svc(fresh_db, tmp_path): """A BackupService rooted in a temp backup directory.""" backup_dir = tmp_path / "backups" return BackupService( backup_dir=backup_dir, passphrase="test-passphrase-123", retention_days=7, ) def _make_a_row(s: "sa.orm.Session") -> None: """Insert one minimal Batch row so the DB has a real schema + content. Bypasses the Claim model (which has many NOT NULL columns tied to BatchRecord lifecycle) and just writes a Batch directly — the backup flow doesn't care which tables exist, only that there are some. """ from cyclone.db import Batch import uuid from datetime import datetime, timezone from decimal import Decimal s.add(Batch( id=str(uuid.uuid4()), kind="837P", input_filename="test.x12", parsed_at=datetime.now(timezone.utc), totals_json=None, validation_json=None, raw_result_json={ "envelope": {"control_number": "1"}, "claims": [], "summary": {"passed": 0, "failed": 0, "failed_claim_ids": []}, }, )) s.commit() # --------------------------------------------------------------------------- # create_now # --------------------------------------------------------------------------- def test_create_now_writes_encrypted_blob_and_sidecar(fresh_db, backup_svc): from cyclone.db import DbBackup # Add a claim so the DB has content + table_count > 0. with db.SessionLocal()() as s: _make_a_row(s) result = backup_svc.create_now() record = result.backup sidecar = result.sidecar assert record.status == STATUS_OK assert record.size_bytes > 0 assert record.db_fingerprint.startswith("sha256:") assert record.table_count >= 1 assert record.completed_at is not None # The .bin file exists, is non-trivial size, and does NOT look # like a SQLite header (which is the whole point of encryption). bin_path = backup_svc.backup_dir / record.filename assert bin_path.exists() blob = bin_path.read_bytes() assert blob[:6] != b"SQLite" # not a plaintext SQLite file # Sidecar exists and round-trips. meta_path = backup_svc.backup_dir / backup_mod.sidecar_filename(record.filename) assert meta_path.exists() parsed = backup_mod.Sidecar.from_json(meta_path.read_text()) assert parsed.db_fingerprint == record.db_fingerprint assert parsed.table_count == record.table_count def test_create_now_marks_error_on_db_failure(fresh_db, tmp_path, monkeypatch): """If SQLite .backup() raises, the row is marked error + files cleaned.""" backup_dir = tmp_path / "backups" svc = BackupService(backup_dir=backup_dir, passphrase="x", retention_days=7) # Force the .backup() call to fail by patching sqlite3.connect to raise. import sqlite3 as _sqlite3 real_connect = _sqlite3.connect def boom(path): raise RuntimeError("simulated disk failure") monkeypatch.setattr(_sqlite3, "connect", boom) # But we also need to make sure engine.raw_connection().driver_connection # is reachable — it's still using real_connect via the engine's # internals. So patch at the higher level: the BackupService's # _sqlite_backup_to. monkeypatch.setattr(svc, "_sqlite_backup_to", lambda p: (_ for _ in ()).throw(RuntimeError("boom"))) with pytest.raises(RuntimeError, match="boom"): svc.create_now() rows = svc.list_backups() assert len(rows) == 1 assert rows[0].status == STATUS_ERROR assert "boom" in rows[0].error_message # No files left in the backup dir. assert list(backup_dir.iterdir()) == [] # --------------------------------------------------------------------------- # list_backups # --------------------------------------------------------------------------- def test_list_backups_orders_newest_first(fresh_db, backup_svc): with db.SessionLocal()() as s: _make_a_row(s) r1 = backup_svc.create_now() r2 = backup_svc.create_now() rows = backup_svc.list_backups() assert [r.id for r in rows] == [r2.backup.id, r1.backup.id] def test_list_backups_filter_by_status(fresh_db, backup_svc): with db.SessionLocal()() as s: _make_a_row(s) backup_svc.create_now() rows = backup_svc.list_backups(status=STATUS_OK) assert all(r.status == STATUS_OK for r in rows) rows = backup_svc.list_backups(status=STATUS_PENDING) assert rows == [] # --------------------------------------------------------------------------- # verify # --------------------------------------------------------------------------- def test_verify_ok_after_create(fresh_db, backup_svc): with db.SessionLocal()() as s: _make_a_row(s) r = backup_svc.create_now() v = backup_svc.verify(r.backup.id) assert v.ok assert v.expected_fingerprint == v.actual_fingerprint assert v.table_count >= 1 def test_verify_detects_tampered_ciphertext(fresh_db, backup_svc): with db.SessionLocal()() as s: _make_a_row(s) r = backup_svc.create_now() bin_path = backup_svc.backup_dir / r.backup.filename # Flip a bit in the middle of the encrypted blob. data = bytearray(bin_path.read_bytes()) idx = backup_mod.NONCE_LEN + 5 data[idx] ^= 0x01 bin_path.write_bytes(bytes(data)) v = backup_svc.verify(r.backup.id) assert not v.ok assert "decryption failed" in (v.reason or "") def test_verify_handles_missing_file(fresh_db, backup_svc): with db.SessionLocal()() as s: _make_a_row(s) r = backup_svc.create_now() (backup_svc.backup_dir / r.backup.filename).unlink() v = backup_svc.verify(r.backup.id) assert not v.ok assert "missing" in (v.reason or "") # --------------------------------------------------------------------------- # restore — two-step # --------------------------------------------------------------------------- def test_restore_two_step_round_trip(fresh_db, backup_svc, tmp_path): """Create a backup, mutate the live DB, restore, confirm mutation gone.""" from cyclone.db import Batch import uuid from datetime import datetime, timezone # 1. Backup a DB with one Batch row. with db.SessionLocal()() as s: _make_a_row(s) snap = backup_svc.create_now() # 2. Mutate the live DB (add another Batch row). with db.SessionLocal()() as s: s.add(Batch( id=str(uuid.uuid4()), kind="837P", input_filename="mutated.x12", parsed_at=datetime.now(timezone.utc), totals_json=None, validation_json=None, raw_result_json={"envelope": {"control_number": "2"}, "claims": [], "summary": {"passed": 0, "failed": 0, "failed_claim_ids": []}}, )) s.commit() with db.SessionLocal()() as s: assert s.query(Batch).count() == 2 # 3. Initiate restore. init = backup_svc.restore_initiate(snap.backup.id) assert init.table_count >= 1 assert init.current_db_fingerprint != init.db_fingerprint # live != backup now assert init.restore_token and len(init.restore_token) == 64 # 4. Confirm restore. result = backup_svc.restore_confirm(snap.backup.id, init.restore_token) assert result.new_db_fingerprint == init.db_fingerprint # 5. The live DB now reflects the snapshot (1 row, not 2). with db.SessionLocal()() as s: assert s.query(Batch).count() == 1 def test_restore_initiate_rejects_non_ok_backup(fresh_db, backup_svc, tmp_path, monkeypatch): """A backup row with status='error' cannot be restored.""" with db.SessionLocal()() as s: _make_a_row(s) r = backup_svc.create_now() # Force the row to error. from cyclone.db import DbBackup with db.SessionLocal()() as session: row = session.get(DbBackup, r.backup.id) row.status = STATUS_ERROR row.error_message = "simulated" session.commit() with pytest.raises(BackupError, match="only 'ok' backups"): backup_svc.restore_initiate(r.backup.id) def test_restore_confirm_rejects_wrong_token(fresh_db, backup_svc): with db.SessionLocal()() as s: _make_a_row(s) r = backup_svc.create_now() init = backup_svc.restore_initiate(r.backup.id) with pytest.raises(BackupError, match="not found"): backup_svc.restore_confirm(r.backup.id, "0" * 64) def test_restore_confirm_rejects_expired_token(fresh_db, backup_svc, monkeypatch): """A token whose expires_at is in the past is rejected.""" from datetime import datetime, timedelta, timezone with db.SessionLocal()() as s: _make_a_row(s) r = backup_svc.create_now() init = backup_svc.restore_initiate(r.backup.id) # Manually age the token past its expiry. with backup_svc._lock: backup_svc._pending_restores[init.restore_token] = ( init.backup_id, datetime.now(timezone.utc) - timedelta(seconds=1), ) with pytest.raises(BackupError, match="expired"): backup_svc.restore_confirm(r.backup.id, init.restore_token) # --------------------------------------------------------------------------- # prune # --------------------------------------------------------------------------- def test_prune_deletes_files_and_marks_status(fresh_db, backup_svc): with db.SessionLocal()() as s: _make_a_row(s) r1 = backup_svc.create_now() # The retention cutoff is 7 days from now. Move the row's created_at # back 30 days so it's definitely past retention. from datetime import datetime, timedelta, timezone from cyclone.db import DbBackup with db.SessionLocal()() as session: row = session.get(DbBackup, r1.backup.id) row.created_at = datetime.now(timezone.utc) - timedelta(days=30) session.commit() deleted = backup_svc.prune() assert len(deleted) == 2 # .bin + .meta.json rows = backup_svc.list_backups() assert rows[0].status == STATUS_PRUNED def test_prune_keeps_recent_backups(fresh_db, backup_svc): with db.SessionLocal()() as s: _make_a_row(s) backup_svc.create_now() deleted = backup_svc.prune() assert deleted == [] rows = backup_svc.list_backups() assert rows[0].status == STATUS_OK # --------------------------------------------------------------------------- # status # --------------------------------------------------------------------------- def test_status_reports_counts(fresh_db, backup_svc): with db.SessionLocal()() as s: _make_a_row(s) backup_svc.create_now() snap = backup_svc.status() assert snap["totals"]["ok"] == 1 assert snap["totals"]["all"] == 1 assert snap["backup_dir"] == str(backup_svc.backup_dir) assert snap["retention_days"] == 7 assert snap["used_fallback_key"] is False assert snap["last_backup_at"] is not None assert snap["last_ok_backup_at"] is not None # --------------------------------------------------------------------------- # Fallback key # --------------------------------------------------------------------------- def test_fallback_key_used_when_no_passphrase(fresh_db, tmp_path): """If no passphrase AND no SQLCipher, refuse. Otherwise fallback + warn.""" backup_dir = tmp_path / "backups" svc = BackupService(backup_dir=backup_dir, passphrase=None, retention_days=7) # No SQLCipher key either → BackupError. with pytest.raises(BackupError, match="no backup passphrase"): svc._ensure_key() def test_key_fingerprint_changes_per_passphrase(fresh_db, tmp_path): """Two services with different passphrases have different key fingerprints.""" s1 = BackupService(tmp_path / "b1", passphrase="alpha", retention_days=1) s2 = BackupService(tmp_path / "b2", passphrase="beta", retention_days=1) # Force key derivation. s1._ensure_key() s2._ensure_key() assert s1.key_fingerprint != s2.key_fingerprint # --------------------------------------------------------------------------- # Module-level singleton # --------------------------------------------------------------------------- def test_module_singleton_round_trip(fresh_db, tmp_path): reset_backup_service_for_tests() svc = configure_backup_service( tmp_path / "backups", passphrase="x", retention_days=1, ) assert get_backup_service() is svc # Second configure is a no-op (returns existing). assert configure_backup_service( tmp_path / "backups2", passphrase="y", retention_days=2, ) is svc reset_backup_service_for_tests() def test_module_singleton_get_raises_when_unset(): reset_backup_service_for_tests() with pytest.raises(RuntimeError): get_backup_service()