feat(audit): publish sequenced heartbeat and reconciliation evidence
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a06ec5-7e2b-7743-ac08-719e1b0f42e2
This commit is contained in:
parent
fce60099c4
commit
b9349782f4
32 changed files with 839 additions and 111 deletions
|
|
@ -1,7 +1,9 @@
|
|||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from qonto_assistant.app import create_app
|
||||
from qonto_assistant.audit import AuditLogger
|
||||
from qonto_assistant.config import Settings
|
||||
from qonto_assistant.contracts import ActorClaims
|
||||
|
|
@ -12,7 +14,9 @@ from qonto_assistant.qonto_client import QontoClient
|
|||
from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
|
||||
from qonto_assistant.service import CapabilityService
|
||||
|
||||
POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
POLICY_FILE = (
|
||||
Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
)
|
||||
|
||||
|
||||
def _settings() -> Settings:
|
||||
|
|
@ -38,6 +42,7 @@ def _settings() -> Settings:
|
|||
deny_escalation_threshold=3,
|
||||
deny_escalation_window_seconds=60,
|
||||
deny_escalation_lockout_seconds=300,
|
||||
audit_heartbeat_interval_seconds=86400,
|
||||
key_cape_jwks_url=None,
|
||||
key_cape_issuer="https://key-cape.netkingdom",
|
||||
key_cape_audience="qonto-assistant",
|
||||
|
|
@ -227,3 +232,56 @@ def test_snapshot_contract_returns_cost_run_rate_hints_for_90_day_window(monkeyp
|
|||
assert payload["summary"]["total_balance"] == 2185.94
|
||||
assert payload["cost_run_rate_hints"]["recurring_debits"][0]["label"] == "HUB31"
|
||||
assert any(event["capability"] == "snapshot_bundle" for event in events)
|
||||
|
||||
|
||||
async def test_app_lifecycle_and_reconciliation_use_the_request_audit_stream(monkeypatch) -> None:
|
||||
service, events = _service(monkeypatch)
|
||||
app = create_app(settings=_settings(), service=service)
|
||||
|
||||
async with app.router.lifespan_context(app):
|
||||
assert events[-1]["event_class"] == "audit.heartbeat"
|
||||
assert events[-1]["reason"] == "startup"
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
||||
) as client:
|
||||
response = await client.get(
|
||||
"/v1/audit/reconciliation",
|
||||
headers={"X-Actor-ID": "observer", "X-Tenant-ID": "binky"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["last_stream_sequence"] == 1
|
||||
assert response.json()["source_transition_counts"] == {
|
||||
"audit.allow": 0,
|
||||
"audit.deny": 0,
|
||||
}
|
||||
assert len(events) == 1
|
||||
|
||||
assert events[-1]["event_class"] == "audit.heartbeat"
|
||||
assert events[-1]["reason"] == "shutdown"
|
||||
assert events[-1]["stream_sequence"] == 2
|
||||
|
||||
|
||||
async def test_client_closes_when_shutdown_heartbeat_fails(monkeypatch) -> None:
|
||||
service, _ = _service(monkeypatch)
|
||||
closed = []
|
||||
monkeypatch.setattr(service.client, "close", lambda: closed.append(True))
|
||||
|
||||
def failing_shutdown(payload):
|
||||
if payload.get("reason") == "shutdown":
|
||||
raise RuntimeError("sink unavailable")
|
||||
|
||||
service.audit_logger.sink = failing_shutdown
|
||||
app = create_app(settings=_settings(), service=service)
|
||||
with pytest.raises(ExceptionGroup) as caught:
|
||||
async with app.router.lifespan_context(app):
|
||||
pass
|
||||
assert caught.group_contains(RuntimeError, match="sink unavailable")
|
||||
assert closed == [True]
|
||||
|
||||
|
||||
def test_app_rejects_split_audit_streams(monkeypatch) -> None:
|
||||
service, _ = _service(monkeypatch)
|
||||
with pytest.raises(ValueError, match="share one audit_logger"):
|
||||
create_app(settings=_settings(), service=service, audit_logger=AuditLogger())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue