hub-core/tests/test_access_audit.py
tegwick c9b6916dac
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / pytest-smoke (push) Failing after 1s
feat: integrate durable authorization audit and runtime composition
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
2026-09-28 12:01:42 +02:00

94 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']