mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
fix: persist and propagate key constraints from Lightning invoices
LightningInvoice had no columns for balance_limit, balance_limit_reset, or validity_date. SQLModel silently dropped these constructor kwargs, so create_api_key_from_invoice always produced an unconstrained key. Add the three columns to LightningInvoice with a migration, and wire them through to the ApiKey in create_api_key_from_invoice, matching the pattern already used in the child key creation path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
23e206c93a
commit
feb76bc89d
@@ -0,0 +1,26 @@
|
|||||||
|
"""Add balance_limit, balance_limit_reset, validity_date to lightning_invoices
|
||||||
|
|
||||||
|
Revision ID: a2b3c4d5e6f7
|
||||||
|
Revises: f1a2b3c4d5e6
|
||||||
|
Create Date: 2026-06-03 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "a2b3c4d5e6f7"
|
||||||
|
down_revision = "f1a2b3c4d5e6"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("lightning_invoices", sa.Column("balance_limit", sa.Integer(), nullable=True))
|
||||||
|
op.add_column("lightning_invoices", sa.Column("balance_limit_reset", sa.String(), nullable=True))
|
||||||
|
op.add_column("lightning_invoices", sa.Column("validity_date", sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("lightning_invoices", "validity_date")
|
||||||
|
op.drop_column("lightning_invoices", "balance_limit_reset")
|
||||||
|
op.drop_column("lightning_invoices", "balance_limit")
|
||||||
@@ -140,6 +140,18 @@ class LightningInvoice(SQLModel, table=True): # type: ignore
|
|||||||
)
|
)
|
||||||
expires_at: int = Field(description="Unix timestamp when invoice expires")
|
expires_at: int = Field(description="Unix timestamp when invoice expires")
|
||||||
paid_at: int | None = Field(default=None, description="Unix timestamp when paid")
|
paid_at: int | None = Field(default=None, description="Unix timestamp when paid")
|
||||||
|
balance_limit: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Max spendable msats for the created key",
|
||||||
|
)
|
||||||
|
balance_limit_reset: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Reset policy for balance limit (daily, weekly, monthly)",
|
||||||
|
)
|
||||||
|
validity_date: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Unix timestamp after which the created key expires",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CashuTransaction(SQLModel, table=True): # type: ignore
|
class CashuTransaction(SQLModel, table=True): # type: ignore
|
||||||
|
|||||||
@@ -269,6 +269,9 @@ async def create_api_key_from_invoice(
|
|||||||
balance=invoice.amount_sats * 1000, # Convert to msats
|
balance=invoice.amount_sats * 1000, # Convert to msats
|
||||||
refund_currency="sat",
|
refund_currency="sat",
|
||||||
refund_mint_url=settings.primary_mint,
|
refund_mint_url=settings.primary_mint,
|
||||||
|
balance_limit=invoice.balance_limit,
|
||||||
|
balance_limit_reset=invoice.balance_limit_reset,
|
||||||
|
validity_date=invoice.validity_date,
|
||||||
)
|
)
|
||||||
|
|
||||||
session.add(api_key)
|
session.add(api_key)
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""Integration tests for Lightning invoice key constraint fields.
|
||||||
|
|
||||||
|
Covers two things:
|
||||||
|
- The three constraint fields (balance_limit, balance_limit_reset, validity_date)
|
||||||
|
are persisted on LightningInvoice and survive a DB round-trip.
|
||||||
|
- create_api_key_from_invoice propagates those fields to the created ApiKey,
|
||||||
|
so the constraints are actually enforced when the key is used.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from routstr.core.db import ApiKey, LightningInvoice
|
||||||
|
from routstr.lightning import create_api_key_from_invoice
|
||||||
|
|
||||||
|
|
||||||
|
def _make_invoice(**kwargs: object) -> LightningInvoice:
|
||||||
|
base = dict(
|
||||||
|
id="inv_test_001",
|
||||||
|
bolt11="lnbc1000n1test",
|
||||||
|
amount_sats=1000,
|
||||||
|
description="test invoice",
|
||||||
|
payment_hash="deadbeef" * 8,
|
||||||
|
status="paid",
|
||||||
|
purpose="create",
|
||||||
|
expires_at=int(time.time()) + 3600,
|
||||||
|
paid_at=int(time.time()),
|
||||||
|
)
|
||||||
|
base.update(kwargs)
|
||||||
|
return LightningInvoice(**base) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_wallet_mint() -> object:
|
||||||
|
with patch("routstr.lightning.get_wallet") as mock_get_wallet:
|
||||||
|
wallet = AsyncMock()
|
||||||
|
wallet.mint = AsyncMock(return_value=[])
|
||||||
|
mock_get_wallet.return_value = wallet
|
||||||
|
yield mock_get_wallet
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Persistence
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invoice_persists_balance_limit(
|
||||||
|
integration_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
invoice = _make_invoice(balance_limit=5000)
|
||||||
|
integration_session.add(invoice)
|
||||||
|
await integration_session.commit()
|
||||||
|
|
||||||
|
stored = await integration_session.get(LightningInvoice, invoice.id)
|
||||||
|
assert stored is not None
|
||||||
|
assert stored.balance_limit == 5000
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invoice_persists_balance_limit_reset(
|
||||||
|
integration_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
invoice = _make_invoice(balance_limit=5000, balance_limit_reset="daily")
|
||||||
|
integration_session.add(invoice)
|
||||||
|
await integration_session.commit()
|
||||||
|
|
||||||
|
stored = await integration_session.get(LightningInvoice, invoice.id)
|
||||||
|
assert stored is not None
|
||||||
|
assert stored.balance_limit_reset == "daily"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invoice_persists_validity_date(
|
||||||
|
integration_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
expiry = int(time.time()) + 86400
|
||||||
|
invoice = _make_invoice(validity_date=expiry)
|
||||||
|
integration_session.add(invoice)
|
||||||
|
await integration_session.commit()
|
||||||
|
|
||||||
|
stored = await integration_session.get(LightningInvoice, invoice.id)
|
||||||
|
assert stored is not None
|
||||||
|
assert stored.validity_date == expiry
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Propagation to ApiKey
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_created_key_receives_balance_limit(
|
||||||
|
integration_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
invoice = _make_invoice(balance_limit=8000)
|
||||||
|
integration_session.add(invoice)
|
||||||
|
await integration_session.flush()
|
||||||
|
|
||||||
|
api_key = await create_api_key_from_invoice(invoice, integration_session)
|
||||||
|
await integration_session.commit()
|
||||||
|
|
||||||
|
stored_key = await integration_session.get(ApiKey, api_key.hashed_key)
|
||||||
|
assert stored_key is not None
|
||||||
|
assert stored_key.balance_limit == 8000
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_created_key_receives_balance_limit_reset(
|
||||||
|
integration_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
invoice = _make_invoice(balance_limit=8000, balance_limit_reset="monthly")
|
||||||
|
integration_session.add(invoice)
|
||||||
|
await integration_session.flush()
|
||||||
|
|
||||||
|
api_key = await create_api_key_from_invoice(invoice, integration_session)
|
||||||
|
await integration_session.commit()
|
||||||
|
|
||||||
|
stored_key = await integration_session.get(ApiKey, api_key.hashed_key)
|
||||||
|
assert stored_key is not None
|
||||||
|
assert stored_key.balance_limit_reset == "monthly"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_created_key_receives_validity_date(
|
||||||
|
integration_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
expiry = int(time.time()) + 86400
|
||||||
|
invoice = _make_invoice(validity_date=expiry)
|
||||||
|
integration_session.add(invoice)
|
||||||
|
await integration_session.flush()
|
||||||
|
|
||||||
|
api_key = await create_api_key_from_invoice(invoice, integration_session)
|
||||||
|
await integration_session.commit()
|
||||||
|
|
||||||
|
stored_key = await integration_session.get(ApiKey, api_key.hashed_key)
|
||||||
|
assert stored_key is not None
|
||||||
|
assert stored_key.validity_date == expiry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_created_key_without_constraints_has_none_fields(
|
||||||
|
integration_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
invoice = _make_invoice()
|
||||||
|
integration_session.add(invoice)
|
||||||
|
await integration_session.flush()
|
||||||
|
|
||||||
|
api_key = await create_api_key_from_invoice(invoice, integration_session)
|
||||||
|
await integration_session.commit()
|
||||||
|
|
||||||
|
stored_key = await integration_session.get(ApiKey, api_key.hashed_key)
|
||||||
|
assert stored_key is not None
|
||||||
|
assert stored_key.balance_limit is None
|
||||||
|
assert stored_key.balance_limit_reset is None
|
||||||
|
assert stored_key.validity_date is None
|
||||||
Reference in New Issue
Block a user