Files
cyclone/backend/tests/test_cli_backup.py
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

145 lines
4.7 KiB
Python

"""SP17 — `cyclone backup` CLI subcommand tests.
Uses Click's CliRunner + monkeypatching of Keychain + DB env so the
subcommands can run without the operator's machine state.
"""
from __future__ import annotations
from click.testing import CliRunner
from datetime import datetime, timezone
import pytest
@pytest.fixture
def _cli_env(tmp_path, monkeypatch):
"""Fresh sqlite DB + in-memory Keychain stub."""
from cyclone import db
from cyclone import backup_service as svc_mod
from cyclone import secrets as secrets_mod
from cyclone.db import Batch
import uuid
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
db._reset_for_tests()
db.init_db()
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()
# In-memory Keychain so passphrase + salt persist across
# separate CliRunner invocations within one test (each
# subprocess-like invocation would otherwise generate a fresh
# random salt and fail to decrypt).
store: dict[str, str] = {}
store[svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT] = "cli-test-passphrase"
# Pre-populate a stable salt so the very first invocation
# doesn't generate a new random one (which the next invocation
# would then fail to reproduce).
store[svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT] = "0123456789abcdef0123456789abcdef"
def _get(name):
return store.get(name)
def _set(name, value):
store[name] = value
return True
monkeypatch.setattr(secrets_mod, "get_secret", _get)
monkeypatch.setattr(secrets_mod, "set_secret", _set)
backup_dir = tmp_path / "backups"
monkeypatch.setenv("CYCLONE_BACKUP_DIR", str(backup_dir))
monkeypatch.setenv("CYCLONE_BACKUP_RETENTION_DAYS", "7")
yield backup_dir
db._reset_for_tests()
def _run(args, env):
from cyclone.cli import main
runner = CliRunner()
return runner.invoke(main, args, catch_exceptions=False)
def test_backup_create_list_verify_status(_cli_env):
"""Happy path: create → list → verify → status."""
backup_dir = _cli_env
# create
r = _run(["backup", "create"], _cli_env)
assert r.exit_code == 0, r.output
assert "created backup id=" in r.output
# list
r = _run(["backup", "list"], _cli_env)
assert r.exit_code == 0, r.output
assert ".bin" in r.output
# verify (we don't know the id, parse it from the list output)
import re
m = re.search(r"^\s*(\d+)\s+ok\s+", r.output, re.MULTILINE)
assert m, r.output
backup_id = int(m.group(1))
r = _run(["backup", "verify", str(backup_id)], _cli_env)
assert r.exit_code == 0, r.output
assert r.output.startswith("OK:")
# status
r = _run(["backup", "status"], _cli_env)
assert r.exit_code == 0, r.output
assert '"totals"' in r.output
assert '"ok": 1' in r.output
def test_backup_verify_fails_on_tampered_ciphertext(_cli_env):
from cyclone import backup as backup_mod
from cyclone import backup_service as svc_mod
from cyclone import secrets as secrets_mod
# Create a backup.
r = _run(["backup", "create"], _cli_env)
assert r.exit_code == 0
# Tamper.
bin_path = next(_cli_env.glob("*.bin"))
data = bytearray(bin_path.read_bytes())
data[backup_mod.NONCE_LEN + 5] ^= 0x01
bin_path.write_bytes(bytes(data))
# Verify should fail.
r = _run(["backup", "verify", "1"], _cli_env)
assert r.exit_code == 1
assert "FAIL" in r.output
def test_backup_restore_requires_yes_flag(_cli_env):
"""Without --yes, an interactive confirm blocks and the command aborts."""
r = _run(["backup", "create"], _cli_env)
assert r.exit_code == 0
# Click's runner auto-declines the confirm prompt; expect abort.
r = _run(["backup", "restore", "1"], _cli_env, )
# CliRunner auto-aborts confirm prompts by default → exit code != 0.
assert r.exit_code != 0
def test_backup_prune_aborts_without_yes(_cli_env):
r = _run(["backup", "create"], _cli_env)
assert r.exit_code == 0
# Same auto-abort for the prune confirm.
r = _run(["backup", "prune"], _cli_env)
assert r.exit_code != 0
def test_backup_init_passphrase_rejects_short(_cli_env):
"""init-passphrase enforces a 12-char minimum."""
r = _run(["backup", "init-passphrase", "--passphrase", "short"], _cli_env)
assert r.exit_code != 0
assert "12 characters" in r.output