95 lines
4.1 KiB
Python
95 lines
4.1 KiB
Python
|
|
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']
|