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.
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user