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.
129 lines
4.4 KiB
Python
129 lines
4.4 KiB
Python
"""Tests for the 999 ACK migration + ORM + store helpers (SP3 P3 T13)."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import sqlalchemy as sa
|
|
|
|
from cyclone import db, db_migrate
|
|
from cyclone.db import Ack
|
|
from cyclone.store import store
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _setup(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
|
db._reset_for_tests()
|
|
db.init_db()
|
|
yield
|
|
db._reset_for_tests()
|
|
|
|
|
|
def _make_batch(batch_id: str = "b-1") -> None:
|
|
"""Insert a stub batches row so the acks.source_batch_id FK resolves."""
|
|
from cyclone.db import Batch
|
|
with db.SessionLocal()() as s:
|
|
s.add(Batch(
|
|
id=batch_id, kind="837p", input_filename="x",
|
|
parsed_at=datetime(2026, 6, 20, 12, 0, tzinfo=timezone.utc),
|
|
))
|
|
s.commit()
|
|
|
|
|
|
def test_migration_0002_creates_acks_table():
|
|
"""On a fresh DB the migration runner must create the `acks` table."""
|
|
inspector = sa.inspect(db.engine())
|
|
assert "acks" in inspector.get_table_names()
|
|
# The index declared in 0002_acks.sql must also exist.
|
|
indexes = {ix["name"] for ix in inspector.get_indexes("acks")}
|
|
assert "ix_acks_source_batch_id" in indexes
|
|
# And the column list matches the spec.
|
|
cols = {c["name"] for c in inspector.get_columns("acks")}
|
|
expected = {
|
|
"id", "source_batch_id", "accepted_count", "rejected_count",
|
|
"received_count", "ack_code", "parsed_at", "raw_json",
|
|
}
|
|
assert expected <= cols # spec columns are all present
|
|
|
|
|
|
def test_migration_latest_idempotent_on_fresh_db():
|
|
"""Re-running the migration on the same DB must be a no-op (PRAGMA
|
|
user_version already at the latest version — currently 12 after
|
|
0004-0006 line_reconciliation, 0005 ta1_acks, SP9's 0007
|
|
providers/payers/clearhouse, SP10's 0008 payer_rejected,
|
|
SP11's 0009 audit_log, SP14's 0010 payer_rejected_acknowledged,
|
|
SP16's 0011 processed_inbound_files, SP17's 0012 db_backups)."""
|
|
with db.engine().begin() as c:
|
|
v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
|
assert v1 == 12
|
|
# A second run should not raise and should not bump the version.
|
|
db_migrate.run(db.engine())
|
|
with db.engine().begin() as c:
|
|
v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
|
assert v2 == 12
|
|
|
|
|
|
def test_add_ack_persists_row():
|
|
_make_batch("b-1")
|
|
row = store.add_ack(
|
|
source_batch_id="b-1",
|
|
accepted_count=3,
|
|
rejected_count=1,
|
|
received_count=4,
|
|
ack_code="P",
|
|
raw_json={"envelope": {"control_number": "0001"}, "set_count": 1},
|
|
)
|
|
assert row.id is not None
|
|
assert row.source_batch_id == "b-1"
|
|
assert row.accepted_count == 3
|
|
assert row.rejected_count == 1
|
|
assert row.received_count == 4
|
|
assert row.ack_code == "P"
|
|
assert row.raw_json == {"envelope": {"control_number": "0001"}, "set_count": 1}
|
|
# Round-trip: fetch from the same session.
|
|
with db.SessionLocal()() as s:
|
|
loaded = s.get(Ack, row.id)
|
|
assert loaded is not None
|
|
assert loaded.ack_code == "P"
|
|
assert loaded.raw_json == {"envelope": {"control_number": "0001"}, "set_count": 1}
|
|
|
|
|
|
def test_list_acks_newest_first():
|
|
_make_batch("b-1")
|
|
# Insert two rows in a known order; the second must come first.
|
|
a1 = store.add_ack(
|
|
source_batch_id="b-1", accepted_count=1, rejected_count=0,
|
|
received_count=1, ack_code="A", raw_json={"order": 1},
|
|
)
|
|
a2 = store.add_ack(
|
|
source_batch_id="b-1", accepted_count=0, rejected_count=1,
|
|
received_count=1, ack_code="R", raw_json={"order": 2},
|
|
)
|
|
rows = store.list_acks()
|
|
assert len(rows) == 2
|
|
# Newest first means a2 should be at index 0 (auto-increment id desc).
|
|
assert rows[0].id == a2.id
|
|
assert rows[1].id == a1.id
|
|
assert rows[0].ack_code == "R"
|
|
assert rows[1].ack_code == "A"
|
|
|
|
|
|
def test_get_ack_returns_none_for_missing():
|
|
assert store.get_ack(99999) is None
|
|
|
|
|
|
def test_get_ack_returns_row_when_present():
|
|
_make_batch("b-1")
|
|
row = store.add_ack(
|
|
source_batch_id="b-1", accepted_count=1, rejected_count=0,
|
|
received_count=1, ack_code="A", raw_json={"hello": "world"},
|
|
)
|
|
fetched = store.get_ack(row.id)
|
|
assert fetched is not None
|
|
assert fetched.id == row.id
|
|
assert fetched.source_batch_id == "b-1"
|
|
assert fetched.raw_json == {"hello": "world"}
|