import json from pathlib import Path from approval_engine.binding import binding_digest from tests.conftest import approve, binding, validity ROOT = Path(__file__).resolve().parents[1] def test_schema_forbids_decision_keys(): schema = json.loads((ROOT / "schemas/approval_claim.schema.json").read_text()) assert "not" in schema kinds = [item["required"][0] for item in schema["not"]["anyOf"]] assert set(kinds) >= {"effect", "decision", "allow", "deny"} def test_claim_has_issuer_digest_freshness(engine): obj = approve(engine) claim = engine.claim(obj.id) assert claim["kind"] == "approval-claim" assert claim["issuer"] == "approval-engine" assert claim["approval_id"] == obj.id assert claim["valid_now"] is True assert claim["reason_code"] == "ok" assert claim["binding"]["digest"] == binding_digest(binding()) assert claim["freshness"]["ttl_seconds"] == 30 assert claim["freshness"]["not_after"] == "2026-08-29T12:00:30+00:00" assert "effect" not in claim assert "decision" not in claim assert "yields_to" in claim def test_wrong_target_changes_digest(): a = binding() b = binding(target={"id": "other-lane", "stage": "prod"}) assert binding_digest(a) != binding_digest(b) def test_wrong_action_changes_digest(): assert binding_digest(binding()) != binding_digest(binding(action="secrets.kv.read")) def test_pdp_digest_is_recorded_not_recomputed(engine): pdp = "sha256:" + "ab" * 32 obj = approve(engine, pdp_digest=pdp) claim = engine.claim(obj.id) assert claim["binding"]["pdp_digest"] == pdp assert claim["binding"]["digest"] == binding_digest(binding()) assert claim["binding"]["digest"] != pdp def test_examples_are_claim_shaped(): for name in ("claim.valid.json", "claim.revoked.json"): data = json.loads((ROOT / "examples" / name).read_text()) assert data["kind"] == "approval-claim" assert data["issuer"] == "approval-engine" for forbidden in ("effect", "decision", "allow", "deny"): assert forbidden not in data def test_presentation_changes_cannot_change_the_approved_act(engine): """GH-DEC-2026-015: presentation cannot enter the native digest preimage.""" act = binding() original = approve(engine, extra_binding=act) original_claim = engine.claim(original.id) presentation = { "view_hash": "sha256:" + "12" * 32, "brief": "First explanation", "packet": {"sections": ["summary"]}, "highlights": ["target"], "locale": "de-DE", "ui_release": "surface-v1", "presentation": {"binding_digest": original_claim["binding"]["digest"]}, } first = approve(engine, extra_binding={**act, **presentation}) changed = {key: {"changed": value} for key, value in presentation.items()} second = approve(engine, extra_binding={**act, **changed}) for obj in (first, second): assert engine.claim(obj.id)["binding"]["digest"] == original_claim["binding"]["digest"] assert obj.binding == original.binding # The invariant is about presentation, not permission to ignore act changes. for field, replacement in { "action": "secrets.kv.read", "actor": "different-agent", "principal": "different-principal", "purpose": "different-purpose", "target": {"id": "different-lane", "stage": "prod"}, }.items(): other = approve(engine, extra_binding={**act, **presentation, field: replacement}) assert engine.claim(other.id)["binding"]["digest"] != original_claim["binding"]["digest"] def test_approver_identity_is_not_in_the_act_digest(engine): """`binding.principal` is the party on whose behalf, never the approver. Answers `informed-decision`'s `principal_role_overlap` question. The approver is recorded as an entry, so two approvals of the same act bound by different people share one digest — which is why a presentation hash that must commit to *who was shown this* cannot get that from our digest. """ act = binding() first = engine.create(act, validity(), required_count=1) first = engine.add_entry(first.id, "user:approver-a") second = engine.create(act, validity(), required_count=1) second = engine.add_entry(second.id, "user:approver-b") expected = binding_digest(act) assert engine.claim(first.id)["binding"]["digest"] == expected assert engine.claim(second.id)["binding"]["digest"] == expected # The approver is still distinguishable — recorded, not hashed into the act. assert [e["subject_id"] for e in first.entries] == ["user:approver-a"] assert [e["subject_id"] for e in second.entries] == ["user:approver-b"] # And changing who the act is *for* does change the digest. assert binding_digest(binding(principal="someone-else")) != expected