"""Transfer binding failures and a real disposable PostgreSQL fixture restore.""" import hashlib import importlib.util import io import json import os from pathlib import Path import subprocess import tempfile import time import uuid import pytest ROOT=Path(__file__).resolve().parents[1] def load(name): spec=importlib.util.spec_from_file_location(name,ROOT/f'tools/bootstrap-recovery/{name}.py') module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module) return module fetch=load('fetch-privacyidea-backup') drill=load('check-privacyidea-database') class Response(io.BytesIO): def __init__(self,body=b'abc',etag='"same"',length='3',status=200): super().__init__(body);self.status=status self.url=fetch.owner.HOST+fetch.owner.BASE+fetch.OBJECT self.headers={'ETag':etag,'Content-Length':length} class Provider: def __init__(self,responses):self.responses=iter(responses);self.requests=[] def open(self,req,timeout):self.requests.append(req);return next(self.responses) def test_transfer_binds_head_get_and_recheck(monkeypatch,tmp_path): monkeypatch.setattr(fetch,'SIZE',3) provider=Provider([Response(),Response(),Response()]);target=tmp_path/'ciphertext' result=fetch.transfer(provider,'disposable-test-auth',target) assert result['ciphertext_sha256']==hashlib.sha256(b'abc').hexdigest() assert target.stat().st_mode&0o077==0 assert provider.requests[1].get_header('If-match')=='"same"' @pytest.mark.parametrize('responses',[ [Response(etag='W/"weak"')], [Response(),Response(etag='"changed"')], [Response(),Response(body=b'ab')], [Response(),Response(body=b'abcd')], [Response(),Response(),Response(etag='"changed"')], [Response(status=302)], ]) def test_provider_changes_truncation_oversize_and_redirect_refuse(monkeypatch,tmp_path,responses): monkeypatch.setattr(fetch,'SIZE',3) with pytest.raises(fetch.owner.Refused):fetch.transfer(Provider(responses),'fixture',tmp_path/'ciphertext') def test_existing_ciphertext_is_preserved(monkeypatch,tmp_path): monkeypatch.setattr(fetch,'SIZE',3);target=tmp_path/'ciphertext';target.write_bytes(b'existing') with pytest.raises(FileExistsError):fetch.transfer(Provider([Response(),Response()]),'fixture',target) assert target.read_bytes()==b'existing' def test_transfer_digest_and_permissions_gate_private_key_use(monkeypatch,tmp_path): monkeypatch.setattr(drill,'SIZE',3) with tempfile.TemporaryDirectory(prefix='nk49-offhost-',dir='/tmp') as directory: target=Path(directory)/'privacyidea.dump.age';target.write_bytes(b'abc');target.chmod(0o600) r={'schema':'netkingdom.privacyidea-offhost-transfer.v1','status':'ciphertext_fetched', 'object':drill.OBJECT,'ciphertext_bytes':3,'object_unchanged_during_transfer':True, 'ciphertext_path':str(target),'ciphertext_sha256':hashlib.sha256(b'abc').hexdigest()} receipt=tmp_path/'receipt.json';receipt.write_text(json.dumps(r)) assert drill.transfer_input(receipt)[0]==b'abc' target.chmod(0o644) with pytest.raises(drill.Refused):drill.transfer_input(receipt) target.chmod(0o600);target.write_bytes(b'bad') with pytest.raises(drill.Refused):drill.transfer_input(receipt) @pytest.mark.skipif(os.environ.get('NK49_RUN_DISPOSABLE_POSTGRES_TEST')!='1',reason='explicit local Docker fixture opt-in') def test_real_disposable_custom_dump_restore_and_readback(tmp_path,monkeypatch,capsys): """No provider/key/production access: three synthetic PI-shaped tables.""" fixture='nk49-fixture-'+uuid.uuid4().hex;restored='nk49-fixture-'+uuid.uuid4().hex def command(args,**kwargs): return subprocess.run(args,check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=120,**kwargs).stdout try: command(['docker','run','--detach','--pull=never','--name',fixture,'--network','none', '--tmpfs','/var/lib/postgresql/data:rw,size=268435456', '--env','POSTGRES_HOST_AUTH_METHOD=trust',drill.IMAGE,'postgres','-c','listen_addresses=']) for _ in range(60): probe=subprocess.run(['docker','exec',fixture,'pg_isready','-U','postgres'],capture_output=True) if probe.returncode==0:break time.sleep(1) command(['docker','exec','-i',fixture,'psql','-X','-U','postgres','-v','ON_ERROR_STOP=1'], input=b'CREATE TABLE token(id integer); INSERT INTO token VALUES (1),(2); CREATE TABLE resolver(id integer); CREATE TABLE realm(id integer); INSERT INTO realm VALUES (1);') dump=tmp_path/'fixture.dump' dump.write_bytes(command(['docker','exec',fixture,'pg_dump','-U','postgres','-Fc','postgres'])) result=drill.restore(dump,restored) assert result['aggregate_row_counts']=={'token':2,'resolver':0,'realm':1} assert '16.' in result['pg_restore_version'] # Exercise the actual custody/decryption/negative/restore/cleanup path # with a freshly created fixture key, never an operator key. key=tmp_path/'fixture.key' command(['age-keygen','-o',str(key)]) recipient=command(['age-keygen','-y',str(key)]).decode().strip() blob=command(['age','-r',recipient],input=dump.read_bytes()) monkeypatch.setenv('NETKINGDOM_CUSTODY_AGE_KEY_FILE',str(key)) monkeypatch.setattr(drill,'RECIPIENT',recipient) monkeypatch.setattr(drill,'SIZE',len(blob)) monkeypatch.setattr(drill,'transfer_input',lambda path:(blob,hashlib.sha256(blob).hexdigest())) receipt=tmp_path/'drill.json' monkeypatch.setattr('sys.argv',['check','--transfer-receipt','fixture','--receipt',str(receipt)]) assert drill.main()==0 r=json.loads(receipt.read_text()) assert r['wrong_key_refused'] and r['offhost_ciphertext_decryption_verified'] assert r['aggregate_row_counts']=={'token':2,'resolver':0,'realm':1} assert r['container_cleanup_verified'] and r['temporary_plaintext_cleanup_verified'] assert r['native_identity_recovery_proven'] is False assert 'AGE-SECRET-KEY-' not in capsys.readouterr().out failed=tmp_path/'wrong-recipient.json' monkeypatch.setattr(drill,'RECIPIENT','age1invalid') monkeypatch.setattr('sys.argv',['check','--transfer-receipt','fixture','--receipt',str(failed)]) assert drill.main()==1 assert json.loads(failed.read_text())['status']=='failed' finally: for name in (fixture,restored): subprocess.run(['docker','rm','--force','--volumes',name],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=30)