boltcards/tests/test_session.py
Padreug 4e9cd78b59
Some checks failed
lint.yml / feat(lnurl): /session — one verified tap for a terminal visit (pull_request) Failing after 0s
feat(lnurl): /session — one verified tap for a terminal visit
GET /api/v1/session/{external_id}?p=&c= for bitSpire tap-to-enter. A tap
yields a single-use SUN, so a terminal that verified it at entry could not
reuse the p/c to move sats later. This verifies once (advancing the
counter like /scan), records one hit, and returns what the rest of the
visit needs: the card wallet's balance + fiat equivalent (wallet currency,
then the instance default; display only), the LUD-03 withdraw step
(callback, k1 = hit) and the LUD-06 top-up step (callback), both keyed by
the hit — the same single-use bearer /scan and /pay already hand out.
Withdraw is withheld with a reason once the daily limit is spent, as
/scan would refuse; top-up stays available.

Tests drive the real decrypt/CMAC path with a SUN encrypted under the
card's keys, and pin /verify + /pay behaviour across the helper refactor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 17:01:14 +02:00

250 lines
8.8 KiB
Python

"""
/session, and the shared tap-authentication helper it introduced, exercised
with a genuine SUN: the test encrypts the PICC data with the card's k1 and
CMACs it with k2 exactly as an NTAG424 does, so the decrypt/verify path in
`_authenticate_tap` runs for real. Storage and LNbits core are stubbed at the
module boundary.
"""
from datetime import datetime
from types import SimpleNamespace
import pytest
from Cryptodome.Cipher import AES
from .. import views_lnurl as v
from ..models import Card, Hit
from ..nxp424 import get_sun_mac
K1 = bytes.fromhex("0F1E2D3C4B5A69788796A5B4C3D2E1F0")
K2 = bytes.fromhex("F0E1D2C3B4A5968778695A4B3C2D1E0F")
UID = bytes.fromhex("04A1B2C3D4E5F6") # 7-byte NTAG424 UID
def make_sun(counter: int) -> tuple[str, str]:
"""(p, c) as the card would emit them for this read counter."""
ctr = counter.to_bytes(3, "little")
plain = b"\xc7" + UID + ctr + b"\x00" * 5 # 16 bytes: tag, uid, ctr, pad
p = AES.new(K1, AES.MODE_CBC, b"\x00" * 16).encrypt(plain).hex().upper()
c = get_sun_mac(UID, ctr, K2).hex().upper()
return p, c
def make_card(
counter: int = 5, enable: bool = True, daily_limit: int = 100_000
) -> Card:
return Card(
id="card1",
wallet="wallet1",
card_name="Alice",
uid=UID.hex().upper(),
external_id="ext123",
counter=counter,
tx_limit=50_000,
daily_limit=daily_limit,
enable=enable,
k0="00" * 16,
k1=K1.hex(),
k2=K2.hex(),
prev_k0="",
prev_k1="",
prev_k2="",
otp="",
time=datetime(2026, 1, 1),
)
def make_hit(amount: int = 0, hit_id: str = "hit1") -> Hit:
return Hit(
id=hit_id,
card_id="card1",
ip="1.2.3.4",
spent=False,
useragent="test",
old_ctr=5,
new_ctr=6,
amount=amount,
time=datetime(2026, 1, 1),
)
class FakeRequest:
def __init__(self) -> None:
self.client = SimpleNamespace(host="1.2.3.4")
self.headers = {"user-agent": "bitspire-test"}
def url_for(self, name: str, **params: str) -> str:
return f"https://lnbits.test/boltcards/{name}/{params['hit_id']}"
@pytest.fixture
def stubs(monkeypatch):
"""Stub storage + LNbits core; returns a dict the tests can tweak/inspect."""
state = {
"card": make_card(),
"hits_today": [],
"updated_counter": None,
"created_hits": [],
"wallet": SimpleNamespace(balance_msat=123_456_000, currency="USD"),
"default_currency": None,
}
async def get_card_by_external_id(external_id):
return state["card"] if external_id == state["card"].external_id else None
async def update_card_counter(ctr, card_id):
state["updated_counter"] = ctr
async def get_hits_today(card_id):
return state["hits_today"]
async def create_hit(card_id, ip, agent, old_ctr, new_ctr):
hit = make_hit(hit_id=f"hit{len(state['created_hits']) + 1}")
state["created_hits"].append((card_id, ip, agent, old_ctr, new_ctr))
return hit
async def get_wallet(wallet_id):
return state["wallet"]
async def satoshis_amount_as_fiat(amount, currency):
assert currency == "USD"
return amount * 0.0008 # 123456 sats → 98.7648
monkeypatch.setattr(v, "get_card_by_external_id", get_card_by_external_id)
monkeypatch.setattr(v, "update_card_counter", update_card_counter)
monkeypatch.setattr(v, "get_hits_today", get_hits_today)
monkeypatch.setattr(v, "create_hit", create_hit)
monkeypatch.setattr(v, "get_wallet", get_wallet)
monkeypatch.setattr(v, "satoshis_amount_as_fiat", satoshis_amount_as_fiat)
monkeypatch.setattr(
v.settings, "lnbits_default_accounting_currency", state["default_currency"]
)
return state
@pytest.mark.asyncio
async def test_session_grants_balance_and_both_callbacks(stubs):
p, c = make_sun(counter=6)
out = await v.api_session(p, c, FakeRequest(), "ext123")
assert out["authenticated"] is True
assert out["external_id"] == "ext123"
assert out["card_name"] == "Alice"
assert out["balance_msat"] == 123_456_000
assert out["currency"] == "USD"
assert out["fiat"] == pytest.approx(98.7648)
# One hit, and both second steps are keyed by it.
assert len(stubs["created_hits"]) == 1
assert out["withdraw"] == {
"callback": "https://lnbits.test/boltcards/boltcards.lnurl_callback/hit1",
"k1": "hit1",
"minWithdrawable": 1000,
"maxWithdrawable": 50_000_000,
}
assert out["withdraw_blocked_reason"] is None
assert (
out["pay"]["callback"]
== "https://lnbits.test/boltcards/boltcards.pay_callback/hit1"
)
assert out["pay"]["maxSendable"] == 50_000_000
assert out["pay"]["metadata"] == v._TOPUP_METADATA
# The SUN counter advanced, so the same p/c can't be replayed.
assert stubs["updated_counter"] == 6
@pytest.mark.asyncio
async def test_session_accepts_lowercase_p_and_c(stubs):
p, c = make_sun(counter=6)
out = await v.api_session(p.lower(), c.lower(), FakeRequest(), "ext123")
assert out["authenticated"] is True
@pytest.mark.asyncio
async def test_session_rejects_replayed_counter(stubs):
p, c = make_sun(counter=5) # card.counter is already 5
out = await v.api_session(p, c, FakeRequest(), "ext123")
assert out == {"authenticated": False, "reason": "This link is already used."}
assert stubs["updated_counter"] is None
assert stubs["created_hits"] == []
@pytest.mark.asyncio
async def test_session_rejects_bad_cmac_and_unknown_card(stubs):
p, c = make_sun(counter=6)
bad = await v.api_session(p, "00" * 8, FakeRequest(), "ext123")
assert bad == {"authenticated": False, "reason": "CMAC does not check."}
missing = await v.api_session(p, c, FakeRequest(), "nope")
assert missing == {"authenticated": False, "reason": "Card not found."}
stubs["card"] = make_card(enable=False)
disabled = await v.api_session(p, c, FakeRequest(), "ext123")
assert disabled == {"authenticated": False, "reason": "Card is disabled."}
@pytest.mark.asyncio
async def test_session_withholds_withdraw_over_daily_limit_but_keeps_pay(stubs):
stubs["card"] = make_card(daily_limit=1_000)
stubs["hits_today"] = [make_hit(amount=900), make_hit(amount=200)]
p, c = make_sun(counter=6)
out = await v.api_session(p, c, FakeRequest(), "ext123")
assert out["authenticated"] is True
assert out["withdraw"] is None
assert out["withdraw_blocked_reason"] == "Max daily limit spent."
assert out["pay"]["callback"].endswith("/hit1")
@pytest.mark.asyncio
async def test_session_without_currency_has_no_fiat(stubs):
stubs["wallet"] = SimpleNamespace(balance_msat=5_000, currency=None)
p, c = make_sun(counter=6)
out = await v.api_session(p, c, FakeRequest(), "ext123")
assert out["balance_msat"] == 5_000
assert out["currency"] is None
assert out["fiat"] is None
@pytest.mark.asyncio
async def test_session_falls_back_to_instance_default_currency(stubs, monkeypatch):
stubs["wallet"] = SimpleNamespace(balance_msat=5_000, currency=None)
monkeypatch.setattr(v.settings, "lnbits_default_accounting_currency", "USD")
p, c = make_sun(counter=6)
out = await v.api_session(p, c, FakeRequest(), "ext123")
assert out["currency"] == "USD"
assert out["fiat"] == pytest.approx(5 * 0.0008)
@pytest.mark.asyncio
async def test_session_survives_rate_failure(stubs, monkeypatch):
async def boom(amount, currency):
raise ValueError("no rate")
monkeypatch.setattr(v, "satoshis_amount_as_fiat", boom)
p, c = make_sun(counter=6)
out = await v.api_session(p, c, FakeRequest(), "ext123")
assert out["authenticated"] is True
assert out["fiat"] is None
# The refactor moved /verify and /pay onto the shared helper — pin their
# behaviour so the extraction can't have changed it.
@pytest.mark.asyncio
async def test_verify_still_authenticates_via_shared_helper(stubs):
p, c = make_sun(counter=6)
out = await v.api_verify(p, c, "ext123")
assert out == {"authenticated": True, "external_id": "ext123", "card_name": "Alice"}
assert stubs["updated_counter"] == 6
# The counter stub doesn't persist, so replay against a card already at 6.
stubs["card"] = make_card(counter=6)
replay = await v.api_verify(p, c, "ext123")
assert replay == {"authenticated": False, "reason": "This link is already used."}
@pytest.mark.asyncio
async def test_pay_still_returns_pay_request_via_shared_helper(stubs):
p, c = make_sun(counter=6)
out = await v.api_pay(p, c, FakeRequest(), "ext123")
assert str(out.callback).endswith("/boltcards.pay_callback/hit1")
assert int(out.maxSendable) == 50_000_000
bad = await v.api_pay(p, "00" * 8, FakeRequest(), "ext123")
assert bad.reason == "CMAC does not check."