feat(pairing): optional token TTL + revoke endpoint (#9/#12, #22)
Some checks failed
ci.yml / feat(pairing): optional token TTL + revoke endpoint (#9/#12, #22) (pull_request) Failing after 0s

Builds on the seed-URL pairing in #21 (stacked).

(b) TTL — PairMachineData.duration_hours (validated > 0) threads through
    pair_spire -> create_new_token (lnbits#55). None = non-expiring.

(c) Revoke — POST /machines/{id}/revoke -> revoke_spire ->
    admin_client.revoke_key_user(spire-<id>). Per spirekeeper#22, revoke
    MUST go through KeyUser.revokedAt (revoke_key_user), NOT token revoke:
    lnbits eager-binds (redeems) the connect token at provision, so
    nsecbunkerd has materialised the policy into per-KeyUser grants its
    ACL checks BEFORE the Token.revokedAt filter -> token revoke is a
    silent no-op. Returns RevokeResult{revoked_count}: >=1 = cut, 0 =
    never bound. set_machine_unpaired clears paired_at (keeps npub +
    bunker_spire_key_name for audit / re-pair).

7 new tests (duration threading + default-None; revoke routes to
revoke_key_user and never token-revoke + error mapping; endpoint wiring
revoke happy/zero/502). 210 green; new code black/ruff-clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-18 18:51:54 +02:00
commit a5efdf22a1
6 changed files with 223 additions and 10 deletions

View file

@ -66,7 +66,7 @@ def _wire(monkeypatch, *, pair="ok"):
async def fake_owned(machine_id, user_id):
return _machine()
async def fake_pair(machine, *, relays, admin_client):
async def fake_pair(machine, *, relays, admin_client, duration_hours=None):
if pair == "error":
raise PairingError("boom")
return _result()
@ -118,3 +118,50 @@ def test_pair_failure_maps_to_bad_gateway(monkeypatch):
assert ei.value.status_code == 502
# nothing persisted on failure
assert state["persisted"] is None
def _wire_revoke(monkeypatch, *, revoke="ok", count=2):
state = {"unpaired": None}
async def fake_owned(machine_id, user_id):
return _machine()
async def fake_revoke(machine, *, admin_client):
if revoke == "error":
raise PairingError("boom")
return count
async def fake_unpaired(machine_id):
state["unpaired"] = machine_id
return _machine()
monkeypatch.setattr(views_api, "_machine_owned_by", fake_owned)
monkeypatch.setattr(views_api, "NsecBunkerAdminClient", _FakeAdmin)
monkeypatch.setattr(views_api, "revoke_spire", fake_revoke)
monkeypatch.setattr(views_api, "set_machine_unpaired", fake_unpaired)
return state
def _call_revoke():
user = SimpleNamespace(id="op1")
return asyncio.run(views_api.api_revoke_machine("m1", user))
def test_revoke_cuts_access_and_marks_unpaired(monkeypatch):
state = _wire_revoke(monkeypatch, count=2)
result = _call_revoke()
assert result.revoked_count == 2
assert state["unpaired"] == "m1"
def test_revoke_zero_when_nothing_bound(monkeypatch):
_wire_revoke(monkeypatch, count=0)
assert _call_revoke().revoked_count == 0
def test_revoke_failure_maps_to_bad_gateway(monkeypatch):
state = _wire_revoke(monkeypatch, revoke="error")
with pytest.raises(HTTPException) as ei:
_call_revoke()
assert ei.value.status_code == 502
assert state["unpaired"] is None # not persisted on failure