import asyncio import json import httpx import pytest from hub_core.security.audit import AuditCoreSink from hub_core.security.identity import AccessFailure RECORD = {'profile': 'hub-core.access/1.0.0', 'correlation_id': 'request:123', 'outcome': 'authorized', 'subject': 'root-sub', 'actor_tenant': 'tenant:platform', 'target_tenant': 'tenant:platform', 'decision_id': 'decision:123'} READY = {'status': 'ok', 'durable': True, 'custody_class': 'operational'} def run_sink(tmp_path, handler, record=RECORD): credential = tmp_path/'audit-token' credential.write_text('audit-only-fixture') async def run(): async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: sink = AuditCoreSink(base_url='https://audit.example', token_file=credential, client=client) await sink.append(record) asyncio.run(run()) @pytest.mark.parametrize('code,state', [(202, 'accepted'), (200, 'duplicate')]) def test_requires_exact_durable_custody_receipt(tmp_path, code, state): requests = [] def handle(request): requests.append(request) assert not request.extensions.get('follow_redirects') if request.url.path == '/readyz': assert 'authorization' not in request.headers return httpx.Response(200, json=READY) envelope = json.loads(request.content) assert set(envelope) == {'id','type','source','subject','tenant','correlation_id','occurred_at','data'} assert request.headers['authorization'] == 'Bearer audit-only-fixture' assert request.headers['idempotency-key'] == envelope['id'] assert envelope['data'] == RECORD assert envelope['source'] == 'hub-core' assert envelope['tenant'] == 'tenant:platform' return httpx.Response(code, json={'status': state, 'reference': 'audit:'+envelope['id']}) run_sink(tmp_path, handle) assert [r.url.path for r in requests] == ['/readyz', '/v1/events'] @pytest.mark.parametrize('code,receipt', [ (200, {'status':'ok'}), (202, {'status':'accepted'}), (200, {'status':'accepted','reference':'x'}), (202, {'status':'duplicate','reference':'x'}), (400, {'status':'accepted','reference':'x'}), (401, {}), (403, {}), (409, {}), (503, {}), (307, {}), ]) def test_unacknowledged_custody_never_allows_execution(tmp_path, code, receipt): def handle(request): if request.url.path == '/readyz': return httpx.Response(200, json=READY) return httpx.Response(code, json=receipt, headers={'Location':'https://untrusted.example'}) with pytest.raises(AccessFailure, match='audit_unavailable'): run_sink(tmp_path, handle) @pytest.mark.parametrize('readiness', [ {**READY, 'custody_class':'development'}, {**READY, 'durable':False}, {**READY, 'status':'unavailable'}, {**READY, 'durable':'true'}, {}, ]) def test_receiver_fallback_fails_before_post(tmp_path, readiness): def handle(request): assert request.url.path == '/readyz' return httpx.Response(200, json=readiness) with pytest.raises(AccessFailure): run_sink(tmp_path, handle) def test_rotation_is_read_each_time_and_lost_receipt_denies(tmp_path): token_file = tmp_path/'audit-token' seen = [] def handle(request): if request.url.path == '/readyz': return httpx.Response(200, json=READY) seen.append(request.headers['authorization']) if len(seen) == 2: raise httpx.ReadTimeout('sensitive private upstream details') return httpx.Response(202, json={'status':'accepted','reference':'audit:1'}) async def run(): async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as client: sink = AuditCoreSink(base_url='https://audit.example', token_file=token_file, client=client) token_file.write_text('first') await sink.append(RECORD) token_file.write_text('replacement') with pytest.raises(AccessFailure) as result: await sink.append(RECORD) assert str(result.value) == 'audit_unavailable' asyncio.run(run()) assert seen == ['Bearer first', 'Bearer replacement']