coulomb-social/tests/test_paired_backup.py
tegwick a2cb673e0a
Some checks failed
Use-case regression gate / usecase-regressions (push) Failing after 6m5s
Prepare verified immutable paired backups and attended recovery
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a11538-05ea-7b63-b3c7-a2df5f38898d
2026-10-07 23:23:33 +02:00

227 lines
9 KiB
Python

"""Coherent backup publication needs version-pinned readback, never PUT alone."""
import fcntl
import hashlib
import importlib.util
import json
import tarfile
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
@pytest.fixture
def backup():
path = Path(__file__).resolve().parents[1] / "scripts/paired-backup.py"
spec = importlib.util.spec_from_file_location("csoc_paired_backup", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
NOW = datetime(2026, 10, 11, 3, 15, tzinfo=UTC)
KEY = (
"platform-pg/application-archives/coulomb-social/2026/10/11/20261011T031500Z-"
+ "a" * 24
+ ".tar.age"
)
def pair(directory):
directory.mkdir(mode=0o700)
receipt = {
"status": "captured",
"writers_quiesced": True,
"app_resumed": True,
"database": "coulomb_social_db",
"image": "synthetic@sha256:fixture",
"started_at": NOW.isoformat(),
"elapsed_seconds": 45,
}
for name, key, data in [
("database.dump", "database", b"PGDMPsynthetic"),
("content.tar.gz", "content", b"synthetic-content"),
]:
(directory / name).write_bytes(data)
(directory / name).chmod(0o600)
receipt[key + "_bytes"] = len(data)
receipt[key + "_sha256"] = hashlib.sha256(data).hexdigest()
(directory / "receipt.json").write_text(json.dumps(receipt))
(directory / "receipt.json").chmod(0o600)
return receipt
def setup(backup, monkeypatch, tmp_path, failure=None):
state = tmp_path / "state"
calls, publications = [], []
def command(argv, **kwargs):
calls.append(argv)
if "--execute" in argv:
if failure == "capture":
raise backup.Refused("capture_failed")
pair(Path(argv[-1]))
else:
assert argv[:3] == ["age", "-r", "age1-public-test-key"]
with tarfile.open(argv[-1]) as bundle:
assert set(bundle.getnames()) == {"database.dump", "content.tar.gz", "receipt.json"}
assert bundle.extractfile("database.dump").read() == b"PGDMPsynthetic"
if failure == "encrypt":
raise backup.Refused("encryption_failed")
Path(argv[4]).write_bytes(b"age-encryption.org/v1\nsynthetic-ciphertext")
def publish(encrypted, fetched, transfer_receipt, captured_at, tool, python):
assert encrypted.stat().st_mode & 0o777 == 0o600
publications.append(encrypted.read_bytes())
if failure == "upload":
raise backup.Refused("upload_failed")
fetched.write_bytes(encrypted.read_bytes())
fetched.chmod(0o600)
return {
"object_key": KEY,
"destination": "s3://synthetic/" + KEY,
"version_id": "version-1",
}
monkeypatch.setattr(backup, "command", command)
monkeypatch.setattr(backup, "publish", publish)
return state, calls, publications
def test_success_keeps_one_fetched_pair_and_records_no_application_restore(
backup, monkeypatch, tmp_path
):
state, calls, publications = setup(backup, monkeypatch, tmp_path)
receipt = backup.run_backup(state, tmp_path / "primary.py", "age1-public-test-key", NOW)
assert receipt["status"] == "offsite_fetched_verified"
assert receipt["remote_retrieval_verified"]
assert not receipt["application_restore_verified"]
assert receipt["version_id"] == "version-1"
assert len(publications) == 1 and len(calls) == 2
assert not list(state.glob("pending-*"))
assert not list(state.rglob("database.dump"))
assert state.stat().st_mode & 0o777 == 0o700
assert (state / "last-success.json").stat().st_mode & 0o777 == 0o600
assert json.loads((state / "inventory.json").read_text()) == [receipt]
assert backup.check_freshness(state, NOW + timedelta(hours=25))["status"] == "fresh"
with pytest.raises(backup.Refused, match="backup_stale"):
backup.check_freshness(state, NOW + timedelta(hours=27))
(state / receipt["local_archive"]).write_bytes(b"corrupt")
with pytest.raises(backup.Refused, match="local_encrypted_hash_mismatch"):
backup.check_freshness(state, NOW)
@pytest.mark.parametrize("failure", ["capture", "encrypt", "upload"])
def test_failures_keep_private_evidence_and_block_further_capture(
backup, monkeypatch, tmp_path, failure
):
state, _, _ = setup(backup, monkeypatch, tmp_path, failure)
with pytest.raises(backup.Refused):
backup.run_backup(state, tmp_path / "primary.py", "age1-public-test-key", NOW)
assert not (state / "last-success.json").exists()
assert not (state / "inventory.json").exists()
pending = list(state.glob("pending-*"))
assert len(pending) == 1 and pending[0].stat().st_mode & 0o777 == 0o700
with pytest.raises(backup.Refused, match="pending_backup_requires_review"):
backup.run_backup(state, tmp_path / "primary.py", "age1-public-test-key", NOW)
assert len(list(state.glob("pending-*"))) == 1
def test_live_lock_and_nonprivate_state_refuse_before_capture(backup, monkeypatch, tmp_path):
state, calls, _ = setup(backup, monkeypatch, tmp_path)
state.mkdir(mode=0o700)
with (state / "backup.lock").open("w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
with pytest.raises(backup.Refused, match="backup_already_running"):
backup.run_backup(state, tmp_path / "primary.py", "age1-public-test-key", NOW)
state.chmod(0o755)
with pytest.raises(backup.Refused, match="state_directory_not_private"):
backup.run_backup(state, tmp_path / "primary.py", "age1-public-test-key", NOW)
assert calls == []
def test_pair_hashes_and_app_resume_required(backup, tmp_path):
directory = tmp_path / "pair"
receipt = pair(directory)
assert backup.validate_pair(directory) == receipt
(directory / "database.dump").write_bytes(b"corrupt")
with pytest.raises(backup.Refused, match="pair_hash_mismatch"):
backup.validate_pair(directory)
receipt["app_resumed"] = False
(directory / "receipt.json").write_text(json.dumps(receipt))
with pytest.raises(backup.Refused, match="pair_incomplete"):
backup.validate_pair(directory)
def test_pending_retry_reuses_ciphertext_without_recapture_or_reencrypt(
backup, monkeypatch, tmp_path
):
state, _, publications = setup(backup, monkeypatch, tmp_path, "upload")
with pytest.raises(backup.Refused):
backup.run_backup(state, tmp_path / "primary.py", "age1-public-test-key", NOW)
ciphertext = publications[0]
_, calls, publications = setup(backup, monkeypatch, tmp_path)
receipt = backup.run_backup(
state,
tmp_path / "primary.py",
"age1-public-test-key",
NOW + timedelta(hours=1),
resume=True,
)
assert calls == [] and publications == [ciphertext]
assert receipt["captured_at"] == NOW.isoformat()
assert not list(state.glob("pending-*"))
def test_pending_ciphertext_tamper_refuses_before_publication(backup, monkeypatch, tmp_path):
state, _, _ = setup(backup, monkeypatch, tmp_path, "upload")
with pytest.raises(backup.Refused):
backup.run_backup(state, tmp_path / "primary.py", "age1-public-test-key", NOW)
next(state.glob("pending-*/packet.tar.age")).write_bytes(b"tampered")
_, calls, publications = setup(backup, monkeypatch, tmp_path)
with pytest.raises(backup.Refused, match="sealed_packet_binding_mismatch"):
backup.run_backup(state, tmp_path / "primary.py", "age1-public-test-key", NOW, resume=True)
assert calls == [] and publications == []
@pytest.mark.parametrize(
"invalid", ["hash", "version", "retrieval", "immutable", "capture", "ciphertext"]
)
def test_publication_requires_complete_bound_readback_receipt(
backup, monkeypatch, tmp_path, invalid
):
encrypted, fetched, receipt_path = [
tmp_path / name for name in ["a.age", "readback.age", "receipt.json"]
]
encrypted.write_bytes(b"synthetic ciphertext")
receipt = {
"status": "offsite_fetched_verified",
"application": "coulomb-social",
"captured_at": NOW.isoformat(),
"remote_retrieval_verified": True,
"immutable_create": True,
"version_id": "version-1",
"ciphertext_sha256": backup.digest(encrypted),
"ciphertext_bytes": encrypted.stat().st_size,
}
changes = {
"hash": ("ciphertext_sha256", "bad"),
"version": ("version_id", "null"),
"retrieval": ("remote_retrieval_verified", False),
"immutable": ("immutable_create", False),
"capture": ("captured_at", "other"),
}
if invalid in changes:
key, value = changes[invalid]
receipt[key] = value
def command(argv, **kwargs):
fetched.write_bytes(b"corrupted" if invalid == "ciphertext" else encrypted.read_bytes())
receipt_path.write_text(json.dumps(receipt))
monkeypatch.setattr(backup, "command", command)
with pytest.raises(backup.Refused, match="primary_receipt_not_verified"):
backup.publish(
encrypted, fetched, receipt_path, NOW.isoformat(), Path("tool"), Path("python")
)