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