feat: return buyers to the calling app after Stripe, branded QR endpoint
`_resolve_frontend_root` honours `CreateTicket.frontend_url` when its
origin is one of LNBITS_CORS_ALLOWED_ORIGINS, the LNbits base URL or
LNBITS_CUSTOM_FRONTEND_URL (400 otherwise — a silent fallback would send
the buyer to the wrong app), and falls back to request.base_url as before.
Under that root the fiat path now parameterises the hosted checkout via
`extra["checkout"]` (lnbits StripeCheckoutOptions): success_url
`/events/{id}?checkout=success&tickets=<ids>`, cancel_url
`/events/{id}?checkout=cancelled`, customer_email, an event-named line
item and event_id/quantity/ticket_ids metadata. Ticket ids are minted
before the invoice so the success URL can carry them (the payment_hash
only exists afterwards). ticket_base_url on the rows uses the same root,
so the emailed link lands in the webapp when the webapp was the client.
Purchases are gated on `effective_payment_methods(event)` (checked after
the free-ticket short-circuit, which charges nothing on any rail).
New anonymous `GET /events/api/v1/qr/{ticket_id}` returns a PNG of
`ticket://<id>` — port of upstream v1.6.8's endpoint without ticket-image
compositing — built at error-correction H with the instance QR logo
(`lnbits_qr_logo`) pasted in the centre, matching the client-side QRs.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
This commit is contained in:
parent
db708cf0da
commit
f77ad28bdd
4 changed files with 292 additions and 20 deletions
52
tests/test_frontend_root.py
Normal file
52
tests/test_frontend_root.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..models import CreateTicket
|
||||
from ..views_api import _allowed_frontend_origins, _resolve_frontend_root
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lnbits_settings(monkeypatch):
|
||||
monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/")
|
||||
monkeypatch.setattr(
|
||||
settings, "lnbits_cors_allowed_origins", ["https://app.example"], raising=False
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
settings,
|
||||
"lnbits_custom_frontend_url",
|
||||
"https://Front.Example/login",
|
||||
raising=False,
|
||||
)
|
||||
|
||||
|
||||
def _request(base_url: str = "https://lnbits.example/"):
|
||||
return SimpleNamespace(base_url=base_url)
|
||||
|
||||
|
||||
def test_allowlist_collects_every_configured_origin(lnbits_settings):
|
||||
assert _allowed_frontend_origins() == {
|
||||
"https://lnbits.example",
|
||||
"https://app.example",
|
||||
"https://front.example",
|
||||
}
|
||||
|
||||
|
||||
def test_absent_frontend_url_falls_back_to_the_request_host(lnbits_settings):
|
||||
data = CreateTicket(user_id="u1")
|
||||
assert _resolve_frontend_root(data, _request()) == "https://lnbits.example"
|
||||
|
||||
|
||||
def test_allowed_origin_is_returned_without_trailing_slash(lnbits_settings):
|
||||
data = CreateTicket(user_id="u1", frontend_url="https://app.example/events/")
|
||||
assert _resolve_frontend_root(data, _request()) == "https://app.example/events"
|
||||
|
||||
|
||||
def test_unlisted_origin_is_rejected_loudly(lnbits_settings):
|
||||
data = CreateTicket(user_id="u1", frontend_url="https://evil.example/events")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_resolve_frontend_root(data, _request())
|
||||
assert exc.value.status_code == 400
|
||||
assert "frontend_url" in exc.value.detail
|
||||
22
tests/test_ticket_qr.py
Normal file
22
tests/test_ticket_qr.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from ..views_api import make_qr_png
|
||||
|
||||
|
||||
def test_make_qr_png_renders_requested_size():
|
||||
img = make_qr_png("ticket://abc123", size=200)
|
||||
assert img.size == (200, 200)
|
||||
|
||||
|
||||
def test_make_qr_png_pastes_a_centred_logo():
|
||||
logo = Image.new("RGBA", (64, 64), (255, 0, 0, 255))
|
||||
img = make_qr_png("ticket://abc123", size=300, logo=logo)
|
||||
assert img.size == (300, 300)
|
||||
# The centre pixel is inside the pasted logo, so it is red — a plain
|
||||
# QR would only ever have black or white there.
|
||||
assert img.getpixel((150, 150))[:3] == (255, 0, 0)
|
||||
out = BytesIO()
|
||||
img.save(out, format="PNG")
|
||||
assert out.getvalue().startswith(b"\x89PNG")
|
||||
Loading…
Add table
Add a link
Reference in a new issue