audit-core/tests/test_stream_findings.py
tegwick 146f4e1dd3
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
fix: repair chained imports and track blocked evidence dependencies
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a1046e-465c-7d43-8c4c-d10f0a1feadd
2026-10-04 11:36:06 +02:00

297 lines
12 KiB
Python

"""AUDIT-WP-0009 T04/T06/T07 — heartbeats, reconciliation, findings."""
import io
import json
from datetime import datetime, timedelta, timezone
import pytest
from audit_core.ingestion import IngestionApplication
from audit_core.senders import SenderIdentity, SenderRegistry
from audit_core.sqlite_backend import SQLiteAuditBackend
from audit_core.stream_findings import (
HEARTBEAT_ACTION,
MISSING_HEARTBEAT,
NEVER_HEARTBEAT,
evaluate,
)
def invoke(app, path, *, token="approval", method="GET", payload=None, key=None):
raw = json.dumps(payload).encode() if payload is not None else b""
environ = {
"PATH_INFO": path.split("?")[0],
"QUERY_STRING": path.split("?")[1] if "?" in path else "",
"REQUEST_METHOD": method,
"CONTENT_LENGTH": str(len(raw)),
"wsgi.input": io.BytesIO(raw),
"HTTP_AUTHORIZATION": f"Bearer {token}",
}
if key:
environ["HTTP_IDEMPOTENCY_KEY"] = key
result = {}
out = b"".join(app(environ, lambda status, headers: result.update(status=status)))
return result["status"], (json.loads(out) if out else {})
SENDER = SenderIdentity(
name="approval-engine",
tokens=("approval",),
sources=frozenset({"approval-engine"}),
tenants=frozenset({"tenant:platform"}),
may_write=True,
may_read=False,
evidence_kind="load-bearing",
heartbeat_classes=(("approval.revocation", 86400),),
)
OPERATOR = SenderIdentity(
name="operator", tokens=("operator",), sources=frozenset({"approval-engine"}),
tenants=frozenset({"*"}), may_write=False, may_read=True,
)
@pytest.fixture
def app(tmp_path):
backend = SQLiteAuditBackend(str(tmp_path / "s.db"))
return IngestionApplication(backend, SenderRegistry([SENDER, OPERATOR]))
def emit(app, event_id, action, *, data=None, tenant="tenant:platform"):
payload = {
"id": event_id, "type": action, "source": "approval-engine",
"subject": "approval:1", "tenant": tenant, "correlation_id": "c-1",
"occurred_at": "2026-09-10T00:00:00+00:00", "data": data or {"k": "v"},
}
return invoke(app, "/v1/events", method="POST", payload=payload, key=event_id)
# --- T04: heartbeats -------------------------------------------------------
def test_a_heartbeat_is_an_ordinary_event_and_is_chained(app):
"""No special table. A heartbeat outside the chain could be back-dated."""
status, _ = emit(
app, "hb-1", HEARTBEAT_ACTION,
data={"class": "approval.revocation", "assertion": "nothing-to-report"},
)
assert status.startswith("202")
status, body = invoke(app, "/v1/integrity", token="operator")
assert status.startswith("200")
assert body["intact"] is True
assert body["events"] == 1
def test_a_declared_class_with_no_heartbeat_ever_is_its_own_finding():
"""The case a naive 'compare against last seen' silently drops."""
findings = evaluate([SENDER], {})
assert [f.kind for f in findings] == [NEVER_HEARTBEAT]
assert findings[0].event_class == "approval.revocation"
assert findings[0].source == "approval-engine"
def test_a_fresh_heartbeat_produces_no_finding():
now = datetime(2026, 9, 10, 12, tzinfo=timezone.utc)
last = (now - timedelta(hours=2)).isoformat()
assert evaluate([SENDER], {("approval-engine", "approval.revocation"): last}, now=now) == []
def test_an_overdue_heartbeat_is_a_finding_with_its_lateness():
now = datetime(2026, 9, 10, 12, tzinfo=timezone.utc)
# 86400s interval, 1.5 grace -> overdue past 36h.
last = (now - timedelta(hours=40)).isoformat()
findings = evaluate([SENDER], {("approval-engine", "approval.revocation"): last}, now=now)
assert [f.kind for f in findings] == [MISSING_HEARTBEAT]
assert findings[0].overdue_seconds == int(timedelta(hours=4).total_seconds())
def test_grace_widens_the_window_but_never_removes_the_finding():
now = datetime(2026, 9, 10, 12, tzinfo=timezone.utc)
last = (now - timedelta(days=30)).isoformat()
findings = evaluate(
[SENDER], {("approval-engine", "approval.revocation"): last},
now=now, grace_factor=100.0,
)
# 86400 * 100 is ~100 days, so this one is inside the window...
assert findings == []
# ...and no grace factor makes a year-old heartbeat acceptable.
older = (now - timedelta(days=400)).isoformat()
assert evaluate(
[SENDER], {("approval-engine", "approval.revocation"): older},
now=now, grace_factor=100.0,
)
def test_a_wildcard_source_is_not_held_to_a_heartbeat():
"""No determinate set of streams to expect one from. Refused, not guessed."""
wild = SenderIdentity(
name="w", tokens=("t",), sources=frozenset({"*"}),
heartbeat_classes=(("anything", 60),),
)
assert evaluate([wild], {}) == []
def test_every_finding_says_absence_is_not_proof_of_suppression():
"""The surface invites over-reading; the bound travels on each finding."""
finding = evaluate([SENDER], {})[0].as_dict()
assert "not proof of suppression" in finding["means"].lower()
# --- T07: the findings surface ---------------------------------------------
def test_stream_findings_needs_read_and_full_tenant_scope(app):
assert invoke(app, "/v1/stream-findings")[0].startswith("403")
status, body = invoke(app, "/v1/stream-findings", token="operator")
assert status.startswith("200")
assert body["stream_findings"][0]["kind"] == NEVER_HEARTBEAT
def test_a_delivered_heartbeat_clears_the_finding(app):
emit(
app, "hb-2", HEARTBEAT_ACTION,
data={"class": "approval.revocation", "assertion": "nothing-to-report"},
)
_, body = invoke(app, "/v1/stream-findings", token="operator")
# Stored today against a 24h interval: no longer a finding.
assert body["stream_findings"] == []
# --- T06: reconciliation ---------------------------------------------------
def test_a_writer_may_count_its_own_events_without_may_read(app):
"""The §9.6 obligation would be undischargeable otherwise."""
assert SENDER.may_read is False
emit(app, "e-1", "approval.issuance")
emit(app, "e-2", "approval.issuance")
emit(app, "e-3", "approval.revocation")
status, body = invoke(
app,
"/v1/reconciliation?source=approval-engine&tenant=tenant:platform"
"&since=2026-09-01T00:00:00%2B00:00&until=2027-01-01T00:00:00%2B00:00",
)
assert status.startswith("200")
assert body["counts"] == [
{"class": "approval.issuance", "count": 2},
{"class": "approval.revocation", "count": 1},
]
def test_reconciliation_returns_counts_and_never_payloads(app):
emit(app, "e-9", "approval.issuance", data={"secret_shaped": "x", "binding": "b"})
_, body = invoke(
app,
"/v1/reconciliation?source=approval-engine&tenant=tenant:platform"
"&since=2026-09-01T00:00:00%2B00:00&until=2027-01-01T00:00:00%2B00:00",
)
assert set(body) == {"source", "tenant", "since", "until", "counts", "means"}
assert "binding" not in json.dumps(body)
def test_a_source_may_not_count_another_source(app):
status, body = invoke(
app,
"/v1/reconciliation?source=user-engine&tenant=tenant:platform"
"&since=2026-09-01T00:00:00%2B00:00&until=2027-01-01T00:00:00%2B00:00",
)
# 403, not an empty count: a zero would read as "we hold none of yours",
# which is a false answer to a question about completeness.
assert status.startswith("403")
assert body["error"] == "source_not_allowed"
def test_reconciliation_requires_an_explicit_bounded_window(app):
status, body = invoke(app, "/v1/reconciliation?source=approval-engine&tenant=tenant:platform")
assert status.startswith("400")
assert body["error"] == "since_and_until_required"
def test_a_scoped_credential_must_name_its_tenant(app):
status, body = invoke(
app,
"/v1/reconciliation?source=approval-engine"
"&since=2026-09-01T00:00:00%2B00:00&until=2027-01-01T00:00:00%2B00:00",
)
assert status.startswith("400")
assert body["error"] == "tenant_required"
def test_a_scoped_credential_may_not_count_another_tenant(app):
status, body = invoke(
app,
"/v1/reconciliation?source=approval-engine&tenant=tenant:coulomb"
"&since=2026-09-01T00:00:00%2B00:00&until=2027-01-01T00:00:00%2B00:00",
)
assert status.startswith("403")
assert body["error"] == "tenant_not_allowed"
def test_the_count_response_states_what_it_does_not_prove(app):
_, body = invoke(
app,
"/v1/reconciliation?source=approval-engine&tenant=tenant:platform"
"&since=2026-09-01T00:00:00%2B00:00&until=2027-01-01T00:00:00%2B00:00",
)
means = body["means"].lower()
assert "proves neither completeness" in means
# --- the overlay asymmetry -------------------------------------------------
def test_the_overlay_may_shorten_a_heartbeat_interval_but_not_lengthen_it(tmp_path):
scope = tmp_path / "scope.json"
base = [{
"name": "approval-engine", "tokens": ["t"], "sources": ["approval-engine"],
"heartbeat_classes": {"approval.revocation": 3600},
}]
scope.write_text(json.dumps(
[{"name": "approval-engine", "heartbeat_classes": {"approval.revocation": 600}}]
))
registry = SenderRegistry.from_env({
"AUDIT_CORE_SENDERS": json.dumps(base),
"AUDIT_CORE_SENDERS_SCOPE_PATH": str(scope),
})
assert dict(registry.identities[0].heartbeat_classes)["approval.revocation"] == 600
scope.write_text(json.dumps(
[{"name": "approval-engine", "heartbeat_classes": {"approval.revocation": 99999}}]
))
with pytest.raises(ValueError, match="lengthen"):
SenderRegistry.from_env({
"AUDIT_CORE_SENDERS": json.dumps(base),
"AUDIT_CORE_SENDERS_SCOPE_PATH": str(scope),
})
scope.write_text(json.dumps([{"name": "approval-engine", "heartbeat_classes": {}}]))
with pytest.raises(ValueError, match="remove"):
SenderRegistry.from_env({
"AUDIT_CORE_SENDERS": json.dumps(base),
"AUDIT_CORE_SENDERS_SCOPE_PATH": str(scope),
})
def test_reconciliation_separates_process_instances_and_keeps_scope_checks(app):
for event_id, instance in [('a-1', 'a'), ('a-2', 'a'), ('b-1', 'b')]:
emit(app, event_id, 'audit.deny', data={'stream_instance_id': instance, 'stream_sequence': 1})
# A delivery retry remains one accepted event.
assert emit(app, 'a-1', 'audit.deny', data={'stream_instance_id': 'a', 'stream_sequence': 1})[0].startswith('200')
base = ('/v1/reconciliation?source=approval-engine&tenant=tenant:platform'
'&since=2026-09-01T00:00:00%2B00:00&until=2027-01-01T00:00:00%2B00:00')
status, body = invoke(app, base + '&stream_instance_id=a')
assert status.startswith('200')
assert body['stream_instance_id'] == 'a'
assert body['counts'] == [{'class': 'audit.deny', 'count': 2}]
assert 'stream_sequence' not in body
assert invoke(app, base + '&stream_instance_id=absent')[1]['counts'] == []
assert invoke(app, base.replace('tenant:platform', 'tenant:other') + '&stream_instance_id=a')[0].startswith('403')
assert invoke(app, base.replace('source=approval-engine', 'source=other') + '&stream_instance_id=a')[0].startswith('403')
@pytest.mark.parametrize('suffix', ['&stream_instance_id=', '&stream_instance_id=a&stream_instance_id=b'])
def test_reconciliation_refuses_ambiguous_instance_filters(app, suffix):
status, body = invoke(app, '/v1/reconciliation?source=approval-engine&tenant=tenant:platform'
'&since=2026-09-01T00:00:00%2B00:00&until=2027-01-01T00:00:00%2B00:00' + suffix)
assert status.startswith('400')
assert body['error'] == 'invalid_stream_instance_id'