approval-engine/tests/test_outbox.py
tegwick 87e55e2bca Carry threshold evidence on issuance and use events
GH-DEC-2026-005 moved the distinct-approver check off the PEP onto this
engine's valid_now. secrets-engine has implemented the split and reports
it no longer verifies the threshold independently. Gate House accepted
that as correct on layering AND as a genuine reduction in defence in
depth, and named the compensating control: not a second check at the PEP,
which is the duplication the split removes, but reconstructability at the
issuer under §9.6.

The emitted events could not support that. approval.issuance carried
required_count but never who satisfied it, and approval.use carried no
threshold evidence at all, so an auditor replaying the stream could not
recompute the evaluation without reading live rows -- rows that may since
have been superseded, revoked, or expired.

Both events now carry a threshold object: required_count,
distinct_approver_count, threshold_met, and approvers with approved_at
plus assurance and evidence_ref when recorded. Tests prove reconstruction
from the use row alone, and that the claim still discloses no approver
identities -- they are evidence for audit-core, not consumer-facing, and
the claim keeps disclosing the least it can.

Writing the tests showed distinctness is already a storage invariant:
entries is UNIQUE on (approval_id, subject_id), so a repeat approver is
refused at insert and a separate entry_count could never differ from the
distinct count. Dropped that field rather than ship a number that cannot
vary, and the test now asserts the refusal instead.

88 tests pass (4 new).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvyJPAaVCGsVheVhcCwNND

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 411227@bnt-lap001
Assistant-Session: d566f6d3-bcaf-43c3-bc5e-3ddd0f64b535
2026-09-06 08:10:21 +02:00

164 lines
5.7 KiB
Python

import json
import pytest
from approval_engine.errors import DuplicateApprover, StoreUnavailable
from approval_engine.store import Engine
from tests.conftest import FROZEN, approve, binding, validity
def test_issuance_queued_in_same_commit(engine):
obj = approve(engine)
pending = engine.undrained()
classes = [p["class"] for p in pending]
assert classes == ["issuance"]
assert pending[0]["approval_id"] == obj.id
payload = pending[0]["payload"]
assert payload["source"] == "approval-engine"
assert payload["action"] == "approval.issuance"
assert payload["event_id"] == pending[0]["event_id"]
def test_failed_outbox_rolls_back_mutation():
eng = Engine(":memory:", clock=lambda: FROZEN, fail_outbox=True)
obj = eng.create(binding(), validity(), required_count=1)
try:
eng.add_entry(obj.id, "user:alice")
raise AssertionError("must fail")
except StoreUnavailable:
pass
obj = eng.get(obj.id)
assert obj.status == "requested"
assert obj.entries == []
assert eng.undrained() == []
eng.close()
def test_revoke_succeeds_when_drain_sink_is_down(engine):
obj = approve(engine)
engine.revoke(obj.id)
assert engine.get(obj.id).status == "revoked"
def down(_payload):
raise ConnectionError("audit-core unreachable")
result = engine.drain(down)
assert result["failed"] >= 1
assert engine.get(obj.id).status == "revoked"
assert any(p["class"] == "revocation" for p in engine.undrained())
def test_drain_marks_delivered(engine):
approve(engine)
sink: list[dict] = []
result = engine.drain(sink.append)
assert result["delivered"] == 1
assert result["failed"] == 0
assert engine.undrained() == []
assert sink[0]["action"] == "approval.issuance"
def test_heartbeat_is_positive_claim(engine):
approve(engine)
beat = engine.emit_heartbeat()
assert beat["assertion"] == "nothing-to-report"
assert beat["counts"]["issuance"] == 1
assert beat["counts"]["heartbeat"] == 1
pending = [p for p in engine.undrained() if p["class"] == "heartbeat"]
assert len(pending) == 1
assert pending[0]["payload"]["details"]["assertion"] == "nothing-to-report"
def test_revocation_event_class(engine):
obj = approve(engine)
engine.revoke(obj.id)
classes = [p["class"] for p in engine.undrained()]
assert "revocation" in classes
def test_failed_outbox_rolls_back_consume(engine):
obj = approve(engine)
engine.fail_outbox = True
try:
engine.consume(obj.id, "sha256:" + "34" * 32)
raise AssertionError("must fail")
except StoreUnavailable:
pass
assert engine.get(obj.id).status == "approved"
assert all(item["class"] != "use" for item in engine.undrained())
def test_drain_failure_records_bounded_attempt_state(engine):
approve(engine)
class SensitiveFailure(Exception):
pass
result = engine.drain(lambda _payload: (_ for _ in ()).throw(SensitiveFailure("secret")))
assert result["failed"] == 1
pending = engine.undrained()
assert pending[0]["attempts"] == 1
assert pending[0]["last_error"] == "SensitiveFailure"
stats = engine.outbox_stats()
assert stats["failed_pending"] == 1
assert stats["attempts"] == 1
def _event(engine, cls):
return next(p["payload"] for p in engine.undrained() if p["class"] == cls)
def test_issuance_carries_the_threshold_evaluation(engine):
"""GH-DEC-2026-005 §9.6: the PEP no longer counts approvers, so the
evaluation must be recoverable from what this engine emitted."""
approve(engine, required=2)
threshold = _event(engine, "issuance")["details"]["threshold"]
assert threshold["required_count"] == 2
assert threshold["distinct_approver_count"] == 2
assert threshold["threshold_met"] is True
assert [a["subject_id"] for a in threshold["approvers"]] == [
"user:approver-0",
"user:approver-1",
]
assert all(a["approved_at"] for a in threshold["approvers"])
def test_use_event_reconstructs_the_threshold_without_live_rows(engine):
"""An auditor holding only the use row must be able to recompute it."""
obj = approve(engine, required=2)
engine.consume(obj.id, obj.binding_digest)
threshold = _event(engine, "use")["details"]["threshold"]
assert threshold["required_count"] == 2
assert threshold["distinct_approver_count"] == 2
assert threshold["threshold_met"] is True
approvers = [a["subject_id"] for a in threshold["approvers"]]
assert approvers == ["user:approver-0", "user:approver-1"]
# the recomputation an auditor performs
assert len(set(approvers)) >= threshold["required_count"]
def test_duplicate_approver_is_refused_so_distinctness_is_an_invariant(engine):
"""Dual control is enforced at storage, not recomputed from evidence."""
obj = engine.create(binding(), validity(), required_count=2)
engine.add_entry(obj.id, "user:alice")
with pytest.raises(DuplicateApprover):
engine.add_entry(obj.id, "user:alice")
assert engine.get(obj.id).status == "requested"
engine.add_entry(obj.id, "user:bob")
threshold = _event(engine, "issuance")["details"]["threshold"]
assert threshold["distinct_approver_count"] == 2
assert threshold["threshold_met"] is True
assert [a["subject_id"] for a in threshold["approvers"]] == [
"user:alice",
"user:bob",
]
def test_claim_still_discloses_no_approver_identities(engine):
"""Identities are evidence for audit-core, never consumer-facing."""
obj = approve(engine, required=2)
claim = engine.claim(obj.id)
assert "threshold" not in claim
assert "approvers" not in claim
assert "user:approver-0" not in json.dumps(claim)
assert claim["valid_now"] is True