import json from datetime import timedelta import pytest from approval_engine.audit import AuditCoreSink, AuditDeliveryError, OutboxWorker from approval_engine.store import Engine from tests.conftest import FROZEN, approve class Response: def __init__(self, status): self.status = status def getcode(self): return self.status def close(self): pass def test_audit_sender_adapts_envelope_and_rereads_token(tmp_path): token = tmp_path / "token" token.write_text("first") seen = [] def open_request(request, timeout): seen.append((request, timeout)) return Response(202) engine = Engine(":memory:", clock=lambda: FROZEN) approve(engine) sink = AuditCoreSink("http://audit-core:8080", token, opener=open_request) sink(engine.undrained()[0]["payload"]) token.write_text("second") sink(engine.undrained()[0]["payload"]) first, second = (item[0] for item in seen) assert first.get_header("Authorization") == "Bearer first" assert second.get_header("Authorization") == "Bearer second" assert first.get_header("Idempotency-key") == engine.undrained()[0]["event_id"] body = json.loads(first.data) assert body["id"] == body["correlation_id"] or body["correlation_id"] assert body["type"] == "approval.issuance" assert body["source"] == "approval-engine" assert body["tenant"] == "tenant:platform" engine.close() def test_duplicate_audit_status_marks_drained(tmp_path): token = tmp_path / "token" token.write_text("token") engine = Engine(":memory:", clock=lambda: FROZEN) approve(engine) sink = AuditCoreSink( "http://audit-core:8080", token, opener=lambda *_args, **_kwargs: Response(200) ) result = engine.drain(sink) assert result == {"delivered": 1, "failed": 0} assert engine.undrained() == [] engine.close() def test_httperror_audit_status_remains_pending(tmp_path): from io import BytesIO from urllib.error import HTTPError token = tmp_path / "token" token.write_text("token") engine = Engine(":memory:", clock=lambda: FROZEN) approve(engine) def opener(request, timeout): raise HTTPError( request.full_url, 503, "unavailable", hdrs=None, fp=BytesIO(b"no") ) sink = AuditCoreSink("http://audit-core:8080", token, opener=opener) result = engine.drain(sink) assert result == {"delivered": 0, "failed": 1} assert engine.undrained()[0]["last_error"] == "AuditDeliveryError" engine.close() def test_nonaccepted_audit_status_remains_pending(tmp_path): token = tmp_path / "token" token.write_text("token") engine = Engine(":memory:", clock=lambda: FROZEN) approve(engine) sink = AuditCoreSink( "http://audit-core:8080", token, opener=lambda *_args, **_kwargs: Response(503) ) result = engine.drain(sink) assert result == {"delivered": 0, "failed": 1} assert engine.undrained()[0]["last_error"] == "AuditDeliveryError" engine.close() def test_worker_emits_due_heartbeat_and_drains(): now = [FROZEN] engine = Engine(":memory:", clock=lambda: now[0]) delivered = [] worker = OutboxWorker(engine, delivered.append, heartbeat_interval_seconds=300) from approval_engine.store import HEARTBEAT_CLASSES first = worker.run_once() # The first beat is due immediately, not one interval in: declaring a # heartbeat and never sending one is audit-core's # `no_heartbeat_since_registration` finding, not a skip. assert first["delivered"] == len(HEARTBEAT_CLASSES) assert {d["action"] for d in delivered} == {"audit-core.heartbeat"} now[0] += timedelta(seconds=301) second = worker.run_once() assert second["delivered"] == len(HEARTBEAT_CLASSES) assert len(delivered) == 2 * len(HEARTBEAT_CLASSES) engine.close() def test_sender_rejects_empty_token(tmp_path): token = tmp_path / "token" token.write_text("") sink = AuditCoreSink("http://audit-core", token) with pytest.raises(AuditDeliveryError, match="credential"): sink({}) def test_heartbeat_envelope_carries_class_and_assertion_on_data(): """audit-core reads these off `data`, not out of a producer-shaped blob.""" from approval_engine.audit import audit_envelope envelope = audit_envelope( { "event_id": "e1", "action": "audit-core.heartbeat", "source": "approval-engine", "resource": "approval-engine:heartbeat", "tenant": "tenant:platform", "observed_at": "2026-09-10T00:00:00+00:00", "schema_version": "audit-core.event.v1alpha1", "scope": "netkingdom-approvals", "outcome": "success", "details": {"class": "revocation", "assertion": "nothing-to-report"}, } ) assert envelope["type"] == "audit-core.heartbeat" assert envelope["data"]["class"] == "revocation" assert envelope["data"]["assertion"] == "nothing-to-report" def test_ordinary_event_envelope_gains_no_heartbeat_fields(): from approval_engine.audit import audit_envelope envelope = audit_envelope( { "event_id": "e2", "action": "approval.issuance", "source": "approval-engine", "resource": "approval:a1", "tenant": "tenant:platform", "observed_at": "2026-09-10T00:00:00+00:00", "schema_version": "audit-core.event.v1alpha1", "scope": "netkingdom-approvals", "outcome": "success", "details": {"class": "issuance"}, } ) assert "assertion" not in envelope["data"]