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.
208 lines
6.9 KiB
Python
208 lines
6.9 KiB
Python
"""SP17 — BackupScheduler unit tests.
|
|
|
|
Exercises the asyncio tick / start / stop loop without spinning up
|
|
the FastAPI app. The scheduler wraps a real BackupService against
|
|
a real on-disk sqlite DB.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from cyclone import backup_service as svc_mod
|
|
from cyclone import backup_scheduler as sched_mod
|
|
from cyclone import db
|
|
|
|
|
|
@pytest.fixture
|
|
def fresh_db(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
|
db._reset_for_tests()
|
|
db.init_db()
|
|
from cyclone.db import Batch
|
|
import uuid
|
|
with db.SessionLocal()() as s:
|
|
s.add(Batch(
|
|
id=str(uuid.uuid4()),
|
|
kind="837P",
|
|
input_filename="seed.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()
|
|
yield
|
|
db._reset_for_tests()
|
|
|
|
|
|
@pytest.fixture
|
|
def backup_svc(tmp_path):
|
|
return svc_mod.BackupService(
|
|
backup_dir=tmp_path / "backups",
|
|
passphrase="test-pass",
|
|
retention_days=7,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# tick
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tick_creates_backup_and_audits_it(fresh_db, backup_svc):
|
|
sched = sched_mod.BackupScheduler(backup_svc, interval_hours=24.0)
|
|
result = await sched.tick()
|
|
assert result.ok
|
|
assert result.created is not None
|
|
assert result.error is None
|
|
assert len(backup_svc.list_backups()) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tick_creates_audit_event(fresh_db, backup_svc):
|
|
"""db.backup_created audit event is written (SP11 hash chain)."""
|
|
sched = sched_mod.BackupScheduler(backup_svc, interval_hours=24.0)
|
|
await sched.tick()
|
|
with db.SessionLocal()() as s:
|
|
from cyclone.db import AuditLog
|
|
rows = (
|
|
s.query(AuditLog)
|
|
.filter(AuditLog.event_type == "db.backup_created")
|
|
.all()
|
|
)
|
|
assert len(rows) == 1
|
|
assert "backup_id" in rows[0].payload_json
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tick_handles_create_failure_without_crashing(fresh_db, backup_svc, monkeypatch):
|
|
"""If create_now raises, tick records the error and continues."""
|
|
def boom():
|
|
raise RuntimeError("simulated failure")
|
|
monkeypatch.setattr(backup_svc, "create_now", boom)
|
|
sched = sched_mod.BackupScheduler(backup_svc, interval_hours=24.0)
|
|
result = await sched.tick()
|
|
assert result.error is not None
|
|
assert "simulated failure" in result.error
|
|
# Audit event written for the failure.
|
|
with db.SessionLocal()() as s:
|
|
from cyclone.db import AuditLog
|
|
rows = (
|
|
s.query(AuditLog)
|
|
.filter(AuditLog.event_type == "db.backup_failed")
|
|
.all()
|
|
)
|
|
assert len(rows) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tick_prunes_old_backups_and_audits(fresh_db, backup_svc):
|
|
"""A tick prunes backups past retention and writes a db.backup_pruned event."""
|
|
from cyclone.db import DbBackup
|
|
|
|
# Take an initial backup.
|
|
initial = backup_svc.create_now()
|
|
# Age it past retention.
|
|
with db.SessionLocal()() as s:
|
|
row = s.get(DbBackup, initial.backup.id)
|
|
row.created_at = datetime.now(timezone.utc) - timedelta(days=30)
|
|
s.commit()
|
|
|
|
sched = sched_mod.BackupScheduler(backup_svc, interval_hours=24.0)
|
|
result = await sched.tick()
|
|
assert result.ok # create_now succeeded even though prune removed old
|
|
assert len(result.pruned_paths) == 2 # .bin + .meta.json
|
|
|
|
with db.SessionLocal()() as s:
|
|
from cyclone.db import AuditLog
|
|
pruned_events = (
|
|
s.query(AuditLog)
|
|
.filter(AuditLog.event_type == "db.backup_pruned")
|
|
.all()
|
|
)
|
|
assert len(pruned_events) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# start / stop / is_running
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_then_stop(fresh_db, backup_svc):
|
|
sched = sched_mod.BackupScheduler(backup_svc, interval_hours=24.0)
|
|
assert not sched.is_running()
|
|
await sched.start()
|
|
assert sched.is_running()
|
|
# Don't wait for the staggered first tick; just stop.
|
|
await sched.stop()
|
|
assert not sched.is_running()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_double_start_is_idempotent(fresh_db, backup_svc):
|
|
sched = sched_mod.BackupScheduler(backup_svc, interval_hours=24.0)
|
|
await sched.start()
|
|
await sched.start() # no-op
|
|
assert sched.is_running()
|
|
await sched.stop()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_ticks_are_coalesced(fresh_db, backup_svc):
|
|
"""Two tick() calls in flight — second waits for first."""
|
|
sched = sched_mod.BackupScheduler(backup_svc, interval_hours=24.0)
|
|
r1, r2 = await asyncio.gather(sched.tick(), sched.tick())
|
|
# Both should succeed and produce a single backup (the second
|
|
# call returned the first call's result, or ran back-to-back
|
|
# and produced a second backup — both are valid coalescings).
|
|
assert r1 is not None
|
|
assert r2 is not None
|
|
# No matter the order, exactly 1 backup should exist OR 2 if they
|
|
# ran sequentially. The point of coalescing is no-overlap, so
|
|
# both should be ok=True.
|
|
assert r1.ok
|
|
assert r2.ok
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# status
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_status_snapshot(fresh_db, backup_svc):
|
|
sched = sched_mod.BackupScheduler(backup_svc, interval_hours=12.0)
|
|
snap = sched.status()
|
|
assert snap.running is False
|
|
assert snap.interval_hours == 12.0
|
|
assert snap.backup_dir == str(backup_svc.backup_dir)
|
|
assert snap.retention_days == 7
|
|
assert snap.tick_count == 0
|
|
assert snap.last_tick is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Module-level singleton
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_module_singleton_round_trip(fresh_db, tmp_path):
|
|
sched_mod.reset_backup_scheduler_for_tests()
|
|
svc = svc_mod.BackupService(tmp_path / "b", passphrase="x", retention_days=1)
|
|
sched = sched_mod.configure_backup_scheduler(svc, interval_hours=1)
|
|
assert sched_mod.get_backup_scheduler() is sched
|
|
# Second configure is a no-op.
|
|
assert sched_mod.configure_backup_scheduler(svc) is sched
|
|
sched_mod.reset_backup_scheduler_for_tests()
|
|
|
|
|
|
def test_module_singleton_get_raises_when_unset():
|
|
sched_mod.reset_backup_scheduler_for_tests()
|
|
with pytest.raises(RuntimeError):
|
|
sched_mod.get_backup_scheduler()
|