Let supersede() reissue an expired approval given a fresh window
expired stays a terminal exit from valid (state machine unchanged), but supersede() now also accepts it as a source status when the caller supplies a validity window, linking a new successor approval to the expired original instead of requiring a caller to mint an unlinked replacement from scratch. Without a fresh window, an expired source still conflicts exactly as before. The original's status becomes superseded (not left as a bare dead-end expired) and superseded_by points at the successor, so the link is documented rather than hidden -- informed-decision's overview already renders this automatically, no changes needed there. Prompted by the operator missing infd-20260927-b01's approval window during today's key-cape login outage (APPROVAL-WP-0003). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: sonnet Assistant-Process: 169987@bnt-lap001 Assistant-Session: 322ef1ef-9048-4021-8570-b6d6f6347999
This commit is contained in:
parent
71ae259877
commit
c93628a017
6 changed files with 294 additions and 7 deletions
|
|
@ -125,6 +125,44 @@ def test_claim_after_revoke(app):
|
|||
assert claim["reason_code"] == "revoked"
|
||||
|
||||
|
||||
def test_supersede_endpoint_reissues_an_expired_approval(app):
|
||||
expired = {
|
||||
"not_before": "2026-08-29T09:00:00+00:00",
|
||||
"expires_at": "2026-08-29T10:00:00+00:00", # before the frozen test clock
|
||||
}
|
||||
_, created = call(app, "POST", "/v1/approvals", {"binding": binding(), "validity": expired})
|
||||
aid = created["id"]
|
||||
status, fetched = call(app, "GET", f"/v1/approvals/{aid}")
|
||||
assert status == 200
|
||||
assert fetched["status"] == "expired"
|
||||
|
||||
fresh = {"not_before": "2026-08-29T13:00:00+00:00", "expires_at": "2026-08-29T18:00:00+00:00"}
|
||||
status, result = call(app, "POST", f"/v1/approvals/{aid}/supersede", {"validity": fresh})
|
||||
assert status == 200
|
||||
successor_id = result["successor_id"]
|
||||
|
||||
status, original = call(app, "GET", f"/v1/approvals/{aid}")
|
||||
assert original["status"] == "superseded"
|
||||
assert original["superseded_by"] == successor_id
|
||||
|
||||
status, successor = call(app, "GET", f"/v1/approvals/{successor_id}")
|
||||
assert successor["status"] == "requested"
|
||||
assert successor["validity"] == fresh
|
||||
|
||||
|
||||
def test_supersede_endpoint_refuses_expired_without_validity(app):
|
||||
expired = {
|
||||
"not_before": "2026-08-29T09:00:00+00:00",
|
||||
"expires_at": "2026-08-29T10:00:00+00:00",
|
||||
}
|
||||
_, created = call(app, "POST", "/v1/approvals", {"binding": binding(), "validity": expired})
|
||||
aid = created["id"]
|
||||
call(app, "GET", f"/v1/approvals/{aid}") # persist the expiry
|
||||
status, body = call(app, "POST", f"/v1/approvals/{aid}/supersede", {})
|
||||
assert status == 409
|
||||
assert body["error"] == "conflict"
|
||||
|
||||
|
||||
def test_store_unavailable_is_503():
|
||||
from approval_engine.api import App
|
||||
from approval_engine.auth import Identity, StaticTokenAuthenticator
|
||||
|
|
|
|||
106
tests/test_reissue.py
Normal file
106
tests/test_reissue.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"""Reissuing an expired approval: supersede() extended to link a fresh
|
||||
successor to an already-`expired` approval, given a new validity window.
|
||||
|
||||
`expired` stays a terminal state (SCOPE.md's closed state machine is
|
||||
unchanged) -- this never reopens the original in place. It only lets a new,
|
||||
linked approval be created for the same binding, and marks the original
|
||||
`superseded` (rather than leaving it a dead-end `expired`) so the link is
|
||||
visible without deleting or overwriting the original record.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from approval_engine.errors import Conflict, Unprocessable
|
||||
from tests.conftest import FROZEN, approve, binding, validity
|
||||
|
||||
|
||||
def expired_validity():
|
||||
return {
|
||||
"not_before": "2026-08-29T09:00:00+00:00",
|
||||
"expires_at": "2026-08-29T10:00:00+00:00", # before FROZEN (12:00)
|
||||
}
|
||||
|
||||
|
||||
def fresh_validity():
|
||||
return {
|
||||
"not_before": "2026-08-29T13:00:00+00:00",
|
||||
"expires_at": "2026-08-29T18:00:00+00:00",
|
||||
}
|
||||
|
||||
|
||||
def test_reissue_links_successor_and_retires_original(engine):
|
||||
obj = engine.create(binding(), expired_validity())
|
||||
assert engine.get(obj.id).status == "expired"
|
||||
|
||||
result = engine.supersede(obj.id, validity=fresh_validity())
|
||||
successor_id = result["successor_id"]
|
||||
|
||||
original = engine.get(obj.id)
|
||||
assert original.status == "superseded" # not left as a bare dead-end "expired"
|
||||
assert original.superseded_by == successor_id # link is documented, not hidden
|
||||
|
||||
successor = engine.get(successor_id)
|
||||
assert successor.status == "requested"
|
||||
assert successor.not_before == fresh_validity()["not_before"]
|
||||
assert successor.expires_at == fresh_validity()["expires_at"]
|
||||
# Same intent carried over: actor/principal/action/purpose/target unchanged.
|
||||
assert successor.actor == original.actor
|
||||
assert successor.principal == original.principal
|
||||
assert successor.action == original.action
|
||||
assert successor.purpose == original.purpose
|
||||
assert successor.target == original.target
|
||||
assert successor.binding_digest == original.binding_digest
|
||||
|
||||
|
||||
def test_reissue_without_validity_conflicts(engine):
|
||||
obj = engine.create(binding(), expired_validity())
|
||||
assert engine.get(obj.id).status == "expired"
|
||||
with pytest.raises(Conflict):
|
||||
engine.supersede(obj.id)
|
||||
|
||||
|
||||
def test_reissue_requires_expires_after_not_before(engine):
|
||||
obj = engine.create(binding(), expired_validity())
|
||||
engine.get(obj.id)
|
||||
with pytest.raises(Unprocessable):
|
||||
engine.supersede(obj.id, validity={
|
||||
"not_before": "2026-08-29T18:00:00+00:00",
|
||||
"expires_at": "2026-08-29T13:00:00+00:00",
|
||||
})
|
||||
# A failed reissue must not have mutated the original.
|
||||
assert engine.get(obj.id).status == "expired"
|
||||
|
||||
|
||||
def test_reissue_requires_expires_at(engine):
|
||||
obj = engine.create(binding(), expired_validity())
|
||||
engine.get(obj.id)
|
||||
with pytest.raises(Unprocessable):
|
||||
engine.supersede(obj.id, validity={"not_before": "2026-08-29T13:00:00+00:00"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("terminal_verb", ["revoke"])
|
||||
def test_cannot_reissue_other_terminal_states_even_with_validity(engine, terminal_verb):
|
||||
obj = approve(engine)
|
||||
getattr(engine, terminal_verb)(obj.id)
|
||||
assert engine.get(obj.id).status == "revoked"
|
||||
with pytest.raises(Conflict):
|
||||
engine.supersede(obj.id, validity=fresh_validity())
|
||||
|
||||
|
||||
def test_cannot_reissue_consumed_even_with_validity(engine):
|
||||
obj = approve(engine)
|
||||
engine.consume(obj.id, "sha256:" + "ab" * 32)
|
||||
assert engine.get(obj.id).status == "consumed"
|
||||
with pytest.raises(Conflict):
|
||||
engine.supersede(obj.id, validity=fresh_validity())
|
||||
|
||||
|
||||
def test_ordinary_supersession_of_an_active_approval_still_copies_the_window(engine):
|
||||
"""Regression: reissue's validity override must not change the
|
||||
unrelated, already-working case of superseding a still-active approval
|
||||
(e.g. correcting a binding) -- that keeps inheriting the same window."""
|
||||
obj = approve(engine)
|
||||
result = engine.supersede(obj.id)
|
||||
successor = engine.get(result["successor_id"])
|
||||
original_window = validity()
|
||||
assert successor.not_before == original_window["not_before"]
|
||||
assert successor.expires_at == original_window["expires_at"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue