railiance-platform/tests/test_archive_receipt_lifecycle.py
codex a867ec269a
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Record archive recovery lifecycle and validate receipt provenance
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06ecb-456a-71c2-b41e-0755d336e883
2026-09-06 14:53:15 +02:00

99 lines
5.9 KiB
Python

"""Exercise receipt CLI boundaries without credentials, providers or Docker."""
from datetime import datetime
import hashlib
import json
from pathlib import Path
import sys
from unittest.mock import patch
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts'))
import restore_forgejo_offsite_locally as restore
import verify_nextcloud_offsite_restore as secondary
import scaleway_forgejo_archive as primary
import decrypt_primary_forgejo_archive as decrypt
def times(receipt):
start, finish = (datetime.fromisoformat(receipt[key]) for key in ('started_at', 'finished_at'))
assert start.tzinfo is not None and finish.tzinfo is not None
assert start <= finish
def provenance(archive):
return dict(schema='platform.real-offsite-recovery.v1',
status='offsite_fetched_pending_isolated_restore', decrypted=True,
upload_http_status=201, download_http_status=200, artifact='fixture.zip.age',
ciphertext_sha256='a'*64, plaintext_sha256=hashlib.sha256(archive.read_bytes()).hexdigest())
@pytest.mark.parametrize('case', ['success', 'cleanup_failure', 'wrong_schema', 'not_decrypted', 'wrong_hash'])
def test_restore_receipt_binds_provenance_and_finishes_after_cleanup(tmp_path, case):
archive=tmp_path/'archive.zip'; archive.write_bytes(b'fixture')
source=tmp_path/'transfer.json'; output=tmp_path/'receipt.json'
evidence=provenance(archive)
if case=='wrong_schema': evidence['schema']='other'
if case=='not_decrypted': evidence['decrypted']=False
if case=='wrong_hash': evidence['plaintext_sha256']='b'*64
source.write_text(json.dumps(evidence))
def run(archive, receipt, profile):
assert 'finished_at' not in receipt
receipt.update(status='restored',cleanup=case!='cleanup_failure')
with patch.object(sys,'argv',['restore','--archive',str(archive),'--receipt',str(output),'--transfer-receipt',str(source)]), patch.object(restore,'run',side_effect=run) as runner:
result=restore.main()
receipt=json.loads(output.read_text()); times(receipt)
assert receipt['transfer_receipt_sha256']==hashlib.sha256(source.read_bytes()).hexdigest()
assert result == (0 if case=='success' else 1)
assert receipt['status']==('restored' if case=='success' else 'failed')
if case in ('wrong_schema','not_decrypted','wrong_hash'): runner.assert_not_called()
@pytest.mark.parametrize('success', [False, True])
def test_secondary_records_both_terminal_outcomes(tmp_path, success):
output=tmp_path/'receipt.json'
def run(source, directory, receipt):
if not success: raise RuntimeError('PRIVATE_CANARY')
receipt['status']='offsite_fetched_pending_isolated_restore'
with patch.object(sys,'argv',['transfer','--source','unused','--directory','unused','--receipt',str(output)]), patch.object(secondary,'run',side_effect=run):
assert secondary.main()==(0 if success else 1)
receipt=json.loads(output.read_text()); times(receipt)
assert 'PRIVATE_CANARY' not in output.read_text()
def test_primary_validation_failure_has_terminal_time_without_credentials(tmp_path):
source=tmp_path/'source.json';source.write_text('{}');output=tmp_path/'receipt.json'
with patch.object(sys,'argv',['primary','--source','missing','--source-receipt',str(source),'--output',str(tmp_path/'out'),'--receipt',str(output),'--kubeconfig','unused']), patch.object(primary,'assert_cluster') as cluster:
assert primary.main()==1
cluster.assert_not_called()
times(json.loads(output.read_text()))
def test_decryption_keeps_own_schema_and_times(tmp_path):
encrypted=tmp_path/'encrypted';encrypted.write_bytes(b'fixture')
source=tmp_path/'source.json';source.write_text(json.dumps(dict(schema='platform.forgejo-primary-archive.v1',status='primary_fetched_pending_application_restore',download_hash_matches=True,ciphertext_sha256=hashlib.sha256(b'fixture').hexdigest(),started_at='2000-01-01T00:00:00Z',finished_at='2000-01-01T00:00:01Z')))
output=tmp_path/'receipt.json'
with patch.object(sys,'argv',['decrypt','--source',str(encrypted),'--output',str(tmp_path/'plain'),'--transfer-receipt',str(source),'--receipt',str(output)]), patch.object(decrypt,'bao'), patch.object(decrypt,'data',return_value={'data':{'data':{'AGE_PRIVATE_KEY':'fixture'}}}), patch.object(decrypt.subprocess,'run') as command:
command.return_value.returncode=0
assert decrypt.main()==0
receipt=json.loads(output.read_text());times(receipt)
assert receipt['schema']=='platform.forgejo-primary-decryption.v1'
assert not receipt['started_at'].startswith('2000')
assert receipt['transfer_receipt_sha256']==hashlib.sha256(source.read_bytes()).hexdigest()
@pytest.mark.parametrize('schema', ['platform.forgejo-primary-archive.v1', 'platform.forgejo-primary-decryption.v1'])
@pytest.mark.parametrize('profile', ['full', 'essentials'])
def test_primary_restore_accepts_legacy_and_distinct_decryption_receipts(tmp_path, schema, profile):
archive=tmp_path/'archive.zip';archive.write_bytes(b'fixture')
source=tmp_path/'transfer.json';output=tmp_path/'receipt.json'
source.write_text(json.dumps(dict(schema=schema,
status='primary_fetched_pending_application_restore', decrypted=True,
download_hash_matches=True, archive_profile=profile,
destination='s3://railiance-platform-pg-backup/platform-pg/application-archives/forgejo/fixture.zip.age',
ciphertext_sha256='a'*64, plaintext_sha256=hashlib.sha256(archive.read_bytes()).hexdigest())))
def run(archive, receipt, selected):
assert selected==profile
receipt.update(status='restored' if profile=='full' else 'restored_essentials',cleanup=True)
with patch.object(sys,'argv',['restore','--archive',str(archive),'--receipt',str(output),'--transfer-receipt',str(source),'--profile',profile]), patch.object(restore,'run',side_effect=run):
assert restore.main()==0
receipt=json.loads(output.read_text()); times(receipt)
assert receipt['source_provider']=='Scaleway'