Files
cyclone/backend/tests/test_backup_scheduler.py
T
Tyler f003c1f73a feat(sp17): encrypted DB backup automation
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.
2026-06-21 09:43:51 -06:00

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()