feat(transport): get_machine_config RPC — server-delivered machine config (#41)
Some checks failed
ci.yml / feat(transport): get_machine_config RPC — server-delivered machine config (#41) (pull_request) Failing after 0s
Some checks failed
ci.yml / feat(transport): get_machine_config RPC — server-delivered machine config (#41) (pull_request) Failing after 0s
A paired ATM pulls its operator pubkey + fee config over the already-
authenticated kind-21000 transport, instead of the operator pubkey being
provisioned into the machine's .env and the fee config learned only from an
operator-signed kind-30078 broadcast. This lets a seed-only machine (blank
.env, scanned spire-seed) leave "awaiting configuration" with zero per-machine
provisioning — closing the gap surfaced by bitspire#70. Client half: bitspire#71.
The transport is already per-machine authenticated (the ATM signs with its
bunker-minted spire key == dca_machines.machine_npub), so the handler resolves
the exact machine → operator from the verified request.sender_pubkey — same
lookup as cashin_transport, no client-supplied trust — and returns only the
caller's own config. Returns {operator_pubkey, fee_config, fiat_code,
machine_npub, wallet_id, created_at}; fee_config is None until the operator has
a super-config. Reuses build_fee_payload + get_account; AUTH_ACCOUNT; registers
soft-failing (older lnbits) like the roster hook. kind-30078 push stays
dual-run for live mid-run updates.
6 tests; full suite 235 pass; black + ruff clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
eefadb6c20
commit
1f5652b425
3 changed files with 226 additions and 0 deletions
117
tests/test_machine_config_transport.py
Normal file
117
tests/test_machine_config_transport.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"""Tests for the get_machine_config nostr-transport RPC handler
|
||||
(spirekeeper#41 / bitspire#70 P1). The handler resolves the machine from the
|
||||
verified request.sender_pubkey and returns operator pubkey + fee config; the
|
||||
crud/account lookups are monkeypatched (no DB)."""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from .. import machine_config_transport
|
||||
from ..machine_config_transport import handle_get_machine_config
|
||||
from ..models import Machine, SuperConfig
|
||||
|
||||
_NOW = datetime(2026, 7, 2, 12, 0, 0)
|
||||
_LATER = datetime(2026, 7, 3, 12, 0, 0)
|
||||
_ATM_HEX = "b1dbceccc6e717f9fb2283bee389080220d719b0e95cc0ab6bf709c23caae6fa"
|
||||
_OP_HEX = "8eb610540021d3773606068a2077821e9233736d1b6dcd4e4f9a3de59b2eddcdc3"
|
||||
|
||||
|
||||
def _machine(updated_at: datetime = _NOW) -> Machine:
|
||||
return Machine(
|
||||
id="m1",
|
||||
operator_user_id="op1",
|
||||
machine_npub=_ATM_HEX,
|
||||
wallet_id="w1",
|
||||
name="sintra",
|
||||
location=None,
|
||||
fiat_code="EUR",
|
||||
is_active=True,
|
||||
operator_cash_in_fee_fraction=0.05,
|
||||
operator_cash_out_fee_fraction=0.05,
|
||||
created_at=_NOW,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _super(updated_at: datetime = _NOW) -> SuperConfig:
|
||||
return SuperConfig(
|
||||
id="default",
|
||||
super_cash_in_fee_fraction=0.03,
|
||||
super_cash_out_fee_fraction=0.03,
|
||||
super_fee_wallet_id="super-wallet",
|
||||
updated_at=updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _wire(monkeypatch, *, machine, pubkey: object = _OP_HEX, super_config):
|
||||
async def fake_get_machine(_hex):
|
||||
return machine
|
||||
|
||||
async def fake_get_account(_uid):
|
||||
return None if pubkey is _MISSING else SimpleNamespace(pubkey=pubkey)
|
||||
|
||||
async def fake_get_super():
|
||||
return super_config
|
||||
|
||||
monkeypatch.setattr(
|
||||
machine_config_transport, "get_machine_by_atm_pubkey_hex", fake_get_machine
|
||||
)
|
||||
monkeypatch.setattr(machine_config_transport, "get_account", fake_get_account)
|
||||
monkeypatch.setattr(machine_config_transport, "get_super_config", fake_get_super)
|
||||
|
||||
|
||||
_MISSING = object() # sentinel: account with no pubkey
|
||||
|
||||
|
||||
def _call(sender: str | None = _ATM_HEX):
|
||||
req = SimpleNamespace(sender_pubkey=sender)
|
||||
return asyncio.run(handle_get_machine_config(None, req))
|
||||
|
||||
|
||||
def test_returns_operator_pubkey_and_fee_config(monkeypatch):
|
||||
_wire(monkeypatch, machine=_machine(), super_config=_super())
|
||||
result = _call()
|
||||
assert result["operator_pubkey"] == _OP_HEX
|
||||
assert result["fee_config"] is not None
|
||||
assert result["fee_config"]["cash_in_fee_fraction"] == 0.08 # 0.03 + 0.05
|
||||
assert result["fee_config"]["cash_out_fee_fraction"] == 0.08
|
||||
assert result["fiat_code"] == "EUR"
|
||||
assert result["machine_npub"] == _ATM_HEX
|
||||
assert result["wallet_id"] == "w1"
|
||||
|
||||
|
||||
def test_watermark_is_max_of_machine_and_super(monkeypatch):
|
||||
# machine updated after super → watermark tracks the later one
|
||||
_wire(monkeypatch, machine=_machine(updated_at=_LATER), super_config=_super())
|
||||
assert _call()["created_at"] == int(_LATER.timestamp())
|
||||
|
||||
|
||||
def test_no_super_config_still_returns_operator_pubkey_no_fees(monkeypatch):
|
||||
# Delivering the operator pubkey re-enables the ATM's fees subscription even
|
||||
# before the operator has a super-config; fee_config is None until then.
|
||||
_wire(monkeypatch, machine=_machine(), super_config=None)
|
||||
result = _call()
|
||||
assert result["operator_pubkey"] == _OP_HEX
|
||||
assert result["fee_config"] is None
|
||||
assert result["created_at"] == int(_NOW.timestamp()) # machine.updated_at only
|
||||
|
||||
|
||||
def test_unpaired_sender_raises(monkeypatch):
|
||||
_wire(monkeypatch, machine=None, super_config=_super())
|
||||
with pytest.raises(ValueError, match="not a paired machine"):
|
||||
_call()
|
||||
|
||||
|
||||
def test_operator_without_pubkey_raises(monkeypatch):
|
||||
_wire(monkeypatch, machine=_machine(), pubkey=_MISSING, super_config=_super())
|
||||
with pytest.raises(ValueError, match="no Nostr pubkey"):
|
||||
_call()
|
||||
|
||||
|
||||
def test_missing_sender_pubkey_raises(monkeypatch):
|
||||
_wire(monkeypatch, machine=_machine(), super_config=_super())
|
||||
with pytest.raises(ValueError, match="sender_pubkey"):
|
||||
_call(sender=None)
|
||||
Loading…
Add table
Add a link
Reference in a new issue