f003c1f73a
Adds automated encrypted backups of the live SQLite file. Closes the 'no backup automation' gap called out in the completeness review (docs/reviews/2026-06-20-cyclone-completeness-review.md §3.1 #3) and gives the SP16 MFT scheduler a recovery path when the MFT pipeline loses days of inbound 999/277CA work in a single crash. Architecture ------------ - AES-256-GCM with PBKDF2-HMAC-SHA256 (200,000 iters, 16-byte salt) - Online backups via SQLite's .backup() API — no app downtime - Salt + passphrase persisted to macOS Keychain (separate accounts backup.passphrase + backup.salt) so the key is reproducible across processes - Two-step restore (initiate → confirm) with a one-shot 64-char hex token; the second call disposes + rebuilds the engine only if the token matches within a 5-minute TTL - Tamper-evident audit chain (SP11) — db.backup_created, db.backup_failed, db.backup_pruned, db.backup_restored, db.backup_passphrase_set - BackupService + BackupScheduler + module-level singletons - 8 admin endpoints + 6 CLI subcommands - Auto-start opt-in via CYCLONE_BACKUP_AUTOSTART=true; default interval 24h, default retention 30 days - Fallback posture: if no separate passphrase is set and SQLCipher is enabled, the key is derived from the SQLCipher DB key with a fixed salt + WARNING log (degraded but never plaintext) New modules ----------- - cyclone.backup — PBKDF2, AES-GCM, sidecar format - cyclone.backup_service — create_now / list / verify / restore / prune / status - cyclone.backup_scheduler — async tick loop with audit hooks New surface ----------- - 8 admin endpoints under /api/admin/backup/* - 6 CLI subcommands under cyclone backup (init-passphrase, create, list, verify, restore, prune, status) - Migration 0012_backups.sql + DbBackup ORM - store.add_backup_pending() Tests ----- - 14 unit tests in test_backup_crypto.py (key derivation, encrypt/ decrypt round-trip, tampered ciphertext, wrong passphrase, sidecar round-trip, filename format) - 19 tests in test_backup_service.py (create/list/verify/restore/ prune/status, error handling, fallback key, module singleton) - 14 API tests in test_api_backup.py (all 8 endpoints + scheduler endpoints, two-step restore, error responses) - 10 tests in test_backup_scheduler.py (tick / start / stop / audit / coalescing / module singleton) - 5 CLI tests in test_cli_backup.py (create / list / verify / restore confirm prompt / prune confirm prompt / init-passphrase minimum-length check) Total new tests: 62. All pass. Full backend suite: 833 passed, 9 skipped (gitignored prodfiles), 1 warning. Design doc: docs/superpowers/specs/2026-06-21-cyclone-encrypted-backup-design.md README: new 'Encrypted Backups (SP17)' section, SP17 entry in Roadmap, retention default documented in §Project layout.
401 lines
14 KiB
Python
401 lines
14 KiB
Python
"""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()
|