Finish approval engine spine
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a05e2e-805b-7042-a750-71f473bceea2
This commit is contained in:
parent
e7c210bf56
commit
c3f1dfbc07
18 changed files with 526 additions and 75 deletions
|
|
@ -27,16 +27,86 @@ def test_create_entry_claim_roundtrip(app):
|
|||
assert "decision" not in claim
|
||||
|
||||
|
||||
def test_no_check_or_authorize_or_consume(app):
|
||||
def test_no_check_or_authorize(app):
|
||||
for path in (
|
||||
"/v1/check",
|
||||
"/authorize",
|
||||
"/v1/approvals/00000000-0000-0000-0000-000000000001/consume",
|
||||
"/v1/approvals/abc/consume",
|
||||
):
|
||||
status, body = call(app, "POST", path, {})
|
||||
assert status == 404
|
||||
assert "consume is not implemented" in body.get("message", "") or body["error"] == "not_found"
|
||||
assert body["error"] == "not_found"
|
||||
|
||||
|
||||
def test_consume_endpoint_is_a_mutation_not_a_decision(app):
|
||||
_, created = call(
|
||||
app,
|
||||
"POST",
|
||||
"/v1/approvals",
|
||||
{"binding": binding(), "validity": validity()},
|
||||
)
|
||||
aid = created["id"]
|
||||
call(app, "POST", f"/v1/approvals/{aid}/entries", {"subject_id": "user:alice"})
|
||||
digest = "sha256:" + "12" * 32
|
||||
status, result = call(
|
||||
app,
|
||||
"POST",
|
||||
f"/v1/approvals/{aid}/consume",
|
||||
{"request_digest": digest, "decision_id": "decision:123"},
|
||||
)
|
||||
assert status == 200
|
||||
assert result == {
|
||||
"approval_id": aid,
|
||||
"status": "consumed",
|
||||
"request_digest": digest,
|
||||
"decision_id": "decision:123",
|
||||
"consumed_at": "2026-08-29T12:00:00+00:00",
|
||||
"idempotent": False,
|
||||
}
|
||||
assert not ({"effect", "decision", "allow", "deny"} & set(result))
|
||||
|
||||
|
||||
def test_consume_requires_request_digest(app):
|
||||
_, created = call(
|
||||
app,
|
||||
"POST",
|
||||
"/v1/approvals",
|
||||
{"binding": binding(), "validity": validity()},
|
||||
)
|
||||
aid = created["id"]
|
||||
call(app, "POST", f"/v1/approvals/{aid}/entries", {"subject_id": "user:alice"})
|
||||
status, body = call(app, "POST", f"/v1/approvals/{aid}/consume", {})
|
||||
assert status == 422
|
||||
assert body["error"] == "unprocessable"
|
||||
|
||||
status, body = call(
|
||||
app,
|
||||
"POST",
|
||||
f"/v1/approvals/{aid}/consume",
|
||||
{"request_digest": ["not", "a", "digest"]},
|
||||
)
|
||||
assert status == 422
|
||||
assert body["error"] == "unprocessable"
|
||||
|
||||
|
||||
def test_consume_endpoint_rejects_different_digest_replay(app):
|
||||
_, created = call(
|
||||
app,
|
||||
"POST",
|
||||
"/v1/approvals",
|
||||
{"binding": binding(), "validity": validity()},
|
||||
)
|
||||
aid = created["id"]
|
||||
call(app, "POST", f"/v1/approvals/{aid}/entries", {"subject_id": "user:alice"})
|
||||
first = "sha256:" + "45" * 32
|
||||
second = "sha256:" + "67" * 32
|
||||
assert call(
|
||||
app, "POST", f"/v1/approvals/{aid}/consume", {"request_digest": first}
|
||||
)[0] == 200
|
||||
status, body = call(
|
||||
app, "POST", f"/v1/approvals/{aid}/consume", {"request_digest": second}
|
||||
)
|
||||
assert status == 409
|
||||
assert body["error"] == "conflict"
|
||||
|
||||
|
||||
def test_claim_after_revoke(app):
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import tempfile
|
||||
import threading
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from approval_engine.errors import Conflict
|
||||
from approval_engine.store import Engine
|
||||
from tests.conftest import approve
|
||||
from tests.conftest import FROZEN, approve
|
||||
|
||||
|
||||
def test_second_supersession_loses(engine):
|
||||
|
|
@ -25,7 +26,7 @@ def test_second_supersession_loses(engine):
|
|||
def test_concurrent_supersessions_one_winner():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "a.sqlite"
|
||||
setup = Engine(path)
|
||||
setup = Engine(path, clock=lambda: FROZEN)
|
||||
obj = approve(setup)
|
||||
setup.close()
|
||||
|
||||
|
|
@ -34,7 +35,7 @@ def test_concurrent_supersessions_one_winner():
|
|||
barrier = threading.Barrier(2)
|
||||
|
||||
def race():
|
||||
eng = Engine(path)
|
||||
eng = Engine(path, clock=lambda: FROZEN)
|
||||
barrier.wait()
|
||||
try:
|
||||
result = eng.supersede(obj.id)
|
||||
|
|
@ -51,20 +52,87 @@ def test_concurrent_supersessions_one_winner():
|
|||
t.join()
|
||||
assert len(winners) == 1
|
||||
assert len(errors) == 1
|
||||
check = Engine(path)
|
||||
check = Engine(path, clock=lambda: FROZEN)
|
||||
assert check.get(obj.id).status == "superseded"
|
||||
check.close()
|
||||
|
||||
|
||||
def test_internal_consume_cas_once(engine):
|
||||
def test_consume_same_digest_is_idempotent(engine):
|
||||
obj = approve(engine)
|
||||
engine._cas_consume(obj.id)
|
||||
try:
|
||||
engine._cas_consume(obj.id)
|
||||
raise AssertionError("double consume must conflict")
|
||||
except Conflict:
|
||||
pass
|
||||
digest = "sha256:" + "ab" * 32
|
||||
first = engine.consume(obj.id, digest, decision_id="decision:first")
|
||||
second = engine.consume(obj.id, digest, decision_id="decision:retry")
|
||||
assert first["idempotent"] is False
|
||||
assert second["idempotent"] is True
|
||||
assert second["decision_id"] == "decision:first"
|
||||
assert [item["class"] for item in engine.undrained()].count("use") == 1
|
||||
claim = engine.claim(obj.id)
|
||||
assert claim["consumed"] is True
|
||||
assert claim["valid_now"] is False
|
||||
assert claim["reason_code"] == "consumed"
|
||||
|
||||
|
||||
def test_consume_different_digest_conflicts(engine):
|
||||
obj = approve(engine)
|
||||
engine.consume(obj.id, "sha256:" + "ab" * 32)
|
||||
try:
|
||||
engine.consume(obj.id, "sha256:" + "cd" * 32)
|
||||
raise AssertionError("different request digest must conflict")
|
||||
except Conflict:
|
||||
pass
|
||||
|
||||
|
||||
def test_concurrent_same_digest_consume_is_one_use_event():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "consume.sqlite"
|
||||
setup = Engine(path, clock=lambda: FROZEN)
|
||||
obj = approve(setup)
|
||||
setup.close()
|
||||
digest = "sha256:" + "ef" * 32
|
||||
results: list[bool] = []
|
||||
barrier = threading.Barrier(2)
|
||||
|
||||
def race():
|
||||
eng = Engine(path, clock=lambda: FROZEN)
|
||||
barrier.wait()
|
||||
try:
|
||||
results.append(eng.consume(obj.id, digest)["idempotent"])
|
||||
finally:
|
||||
eng.close()
|
||||
|
||||
threads = [threading.Thread(target=race) for _ in range(2)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
assert sorted(results) == [False, True]
|
||||
check = Engine(path, clock=lambda: FROZEN)
|
||||
assert [item["class"] for item in check.undrained()].count("use") == 1
|
||||
check.close()
|
||||
|
||||
|
||||
def test_existing_database_migrates_consumption_columns():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "legacy.sqlite"
|
||||
conn = sqlite3.connect(path)
|
||||
conn.execute(
|
||||
"""CREATE TABLE approvals (
|
||||
id TEXT PRIMARY KEY, status TEXT NOT NULL,
|
||||
binding_json TEXT NOT NULL, binding_digest TEXT NOT NULL,
|
||||
pdp_digest TEXT, actor TEXT NOT NULL, principal TEXT NOT NULL,
|
||||
action TEXT NOT NULL, purpose TEXT NOT NULL,
|
||||
target_json TEXT NOT NULL, not_before TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL, required_count INTEGER NOT NULL,
|
||||
superseded_by TEXT, created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)"""
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
eng = Engine(path, clock=lambda: FROZEN)
|
||||
columns = {
|
||||
row["name"]
|
||||
for row in eng._conn().execute("PRAGMA table_info(approvals)").fetchall()
|
||||
}
|
||||
assert {"consumed_digest", "consumed_decision_id", "consumed_at"} <= columns
|
||||
eng.close()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from approval_engine.errors import StoreUnavailable
|
||||
from approval_engine.store import Engine
|
||||
from tests.conftest import approve, binding, validity
|
||||
from tests.conftest import FROZEN, approve, binding, validity
|
||||
|
||||
|
||||
def test_issuance_queued_in_same_commit(engine):
|
||||
|
|
@ -16,7 +16,7 @@ def test_issuance_queued_in_same_commit(engine):
|
|||
|
||||
|
||||
def test_failed_outbox_rolls_back_mutation():
|
||||
eng = Engine(":memory:", fail_outbox=True)
|
||||
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")
|
||||
|
|
@ -70,3 +70,15 @@ def test_revocation_event_class(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())
|
||||
|
|
|
|||
|
|
@ -4,13 +4,14 @@ Reuse a previously valid approval artifact for a different target, parameter
|
|||
set, or later time. Pass: parameter binding, expiry, or replay protection
|
||||
rejects the request.
|
||||
|
||||
Consume-side replay (use twice) waits on GH-WP-0002-T06. This suite covers
|
||||
the object and claim side: wrong binding, expiry, revocation, supersession.
|
||||
Consume-side replay follows GH-DEC-2026-003: same-digest retries are
|
||||
idempotent; a different digest against a consumed object conflicts.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from approval_engine.binding import binding_digest
|
||||
from approval_engine.errors import Conflict
|
||||
from approval_engine.store import Engine
|
||||
from tests.conftest import approve, binding, validity
|
||||
|
||||
|
|
@ -65,3 +66,22 @@ def test_t06_superseded_rejected(engine):
|
|||
obj = approve(engine)
|
||||
engine.supersede(obj.id)
|
||||
assert _consumer_accepts(engine.claim(obj.id), binding()) is False
|
||||
|
||||
|
||||
def test_t06_consumed_approval_rejects_different_request_digest(engine):
|
||||
obj = approve(engine)
|
||||
first = "sha256:" + "56" * 32
|
||||
engine.consume(obj.id, first)
|
||||
try:
|
||||
engine.consume(obj.id, "sha256:" + "78" * 32)
|
||||
raise AssertionError("consume-side replay must conflict")
|
||||
except Conflict:
|
||||
pass
|
||||
|
||||
|
||||
def test_t06_consumed_approval_allows_same_request_retry(engine):
|
||||
obj = approve(engine)
|
||||
digest = "sha256:" + "9a" * 32
|
||||
engine.consume(obj.id, digest)
|
||||
retry = engine.consume(obj.id, digest)
|
||||
assert retry["idempotent"] is True
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue