"""Phase 5 of #185 (musehub staging): automated backups (belt and suspenders). Two independent mechanisms — fast local APFS snapshots ("suspenders") and verified `muse bundle` archives ("belt") — plus a restore path that refuses to clobber a currently-healthy canonical repo. All tests build a REAL scratch muse repo under tmp_path (via the real `muse` binary) and only ever point canonical_root/backup_base at tmp_path — never the real ~/ecosystem/muse, matching every prior phase's discipline. """ import shutil import subprocess import sys from pathlib import Path import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts" / "dev")) from backup import ( # noqa: E402 CanonicalHealthyError, create_bundle_backup, create_snapshot, list_bundles, list_snapshots, restore_from_bundle, restore_from_snapshot, ) MUSE = shutil.which("muse") def _run(args: list[str], cwd: Path) -> subprocess.CompletedProcess: return subprocess.run([MUSE, *args], cwd=cwd, capture_output=True, text=True, check=True) def _make_real_repo(root: Path, *, commits: int = 3) -> None: root.mkdir(parents=True, exist_ok=True) _run(["init"], cwd=root) for i in range(commits): (root / f"file{i}.txt").write_text(f"content {i}\n") _run(["code", "add", "."], cwd=root) _run(["commit", "-m", f"commit {i}"], cwd=root) def _rev_parse(root: Path, ref: str) -> str: import json proc = _run(["rev-parse", ref, "--json"], cwd=root) return json.loads(proc.stdout)["commit_id"] def _verify_all_ok(root: Path) -> bool: import json proc = subprocess.run([MUSE, "verify", "--json"], cwd=root, capture_output=True, text=True) return json.loads(proc.stdout).get("all_ok", False) def _corrupt_an_object(root: Path) -> None: objects_root = root / ".muse" / "objects" / "sha256" for shard in objects_root.iterdir(): for obj in shard.iterdir(): if obj.is_file(): obj.chmod(0o644) obj.write_text("CORRUPTED-FOR-TEST") return raise AssertionError("no object found to corrupt") @pytest.fixture(autouse=True) def _require_muse(): if MUSE is None: pytest.skip("muse not installed on this machine yet (Phase 2 not applied)") class TestSnapshotBackupRestore: def test_full_round_trip_via_snapshot(self, tmp_path: Path) -> None: canonical = tmp_path / "ecosystem" / "muse" backup_base = tmp_path / "backups" _make_real_repo(canonical) good_dev = _rev_parse(canonical, "main") snap = create_snapshot("muse", canonical_root=canonical, backup_base=backup_base) assert snap.exists() _corrupt_an_object(canonical) assert _verify_all_ok(canonical) is False restore_from_snapshot("muse", snap.name, canonical_root=canonical, backup_base=backup_base, force=True) assert _verify_all_ok(canonical) is True assert _rev_parse(canonical, "main") == good_dev def test_rotation_keeps_only_newest_n(self, tmp_path: Path) -> None: canonical = tmp_path / "ecosystem" / "muse" backup_base = tmp_path / "backups" _make_real_repo(canonical, commits=1) paths = [ create_snapshot("muse", canonical_root=canonical, backup_base=backup_base, keep=3) for _ in range(8) ] remaining = list_snapshots("muse", backup_base=backup_base) assert len(remaining) == 3 assert {p.name for p in remaining} == {p.name for p in paths[-3:]} def test_refuses_to_restore_over_healthy_canonical_without_force(self, tmp_path: Path) -> None: canonical = tmp_path / "ecosystem" / "muse" backup_base = tmp_path / "backups" _make_real_repo(canonical) snap = create_snapshot("muse", canonical_root=canonical, backup_base=backup_base) assert _verify_all_ok(canonical) is True with pytest.raises(CanonicalHealthyError): restore_from_snapshot("muse", snap.name, canonical_root=canonical, backup_base=backup_base) def test_restores_without_force_when_canonical_already_unhealthy(self, tmp_path: Path) -> None: canonical = tmp_path / "ecosystem" / "muse" backup_base = tmp_path / "backups" _make_real_repo(canonical) good_dev = _rev_parse(canonical, "main") snap = create_snapshot("muse", canonical_root=canonical, backup_base=backup_base) _corrupt_an_object(canonical) assert _verify_all_ok(canonical) is False restore_from_snapshot("muse", snap.name, canonical_root=canonical, backup_base=backup_base) # no force needed assert _verify_all_ok(canonical) is True assert _rev_parse(canonical, "main") == good_dev class TestBundleBackupRestore: def test_full_round_trip_via_bundle(self, tmp_path: Path) -> None: canonical = tmp_path / "ecosystem" / "muse" backup_base = tmp_path / "backups" _make_real_repo(canonical) good_dev = _rev_parse(canonical, "main") bundle_path = create_bundle_backup("muse", canonical_root=canonical, backup_base=backup_base) assert bundle_path.exists() _corrupt_an_object(canonical) assert _verify_all_ok(canonical) is False restore_from_bundle("muse", bundle_path.name, canonical_root=canonical, backup_base=backup_base, force=True) assert _verify_all_ok(canonical) is True assert _rev_parse(canonical, "main") == good_dev def test_bundle_diff_reports_zero_new_commits_after_restore(self, tmp_path: Path) -> None: import json canonical = tmp_path / "ecosystem" / "muse" backup_base = tmp_path / "backups" _make_real_repo(canonical) bundle_path = create_bundle_backup("muse", canonical_root=canonical, backup_base=backup_base) _corrupt_an_object(canonical) restore_from_bundle("muse", bundle_path.name, canonical_root=canonical, backup_base=backup_base, force=True) proc = _run(["bundle", "diff", str(bundle_path), "--json"], cwd=canonical) result = json.loads(proc.stdout) assert result["new_commits"] == 0 def test_bundle_is_verified_at_creation_time(self, tmp_path: Path) -> None: canonical = tmp_path / "ecosystem" / "muse" backup_base = tmp_path / "backups" _make_real_repo(canonical) bundle_path = create_bundle_backup("muse", canonical_root=canonical, backup_base=backup_base) proc = _run(["bundle", "verify", str(bundle_path), "--json"], cwd=canonical) import json assert json.loads(proc.stdout)["all_ok"] is True def test_rotation_keeps_only_newest_n(self, tmp_path: Path) -> None: canonical = tmp_path / "ecosystem" / "muse" backup_base = tmp_path / "backups" _make_real_repo(canonical, commits=1) paths = [ create_bundle_backup("muse", canonical_root=canonical, backup_base=backup_base, keep=3) for _ in range(8) ] remaining = list_bundles("muse", backup_base=backup_base) assert len(remaining) == 3 assert {p.name for p in remaining} == {p.name for p in paths[-3:]}