"""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