Compare commits

..
Author SHA1 Message Date
ArcandGitHub 11e24e378f Merge branch 'extensions_for_all' into lnaddress_inc 2026-07-13 14:12:18 +01:00
Arc 8edbd2b894 Revert "make simpler"
This reverts commit 0c60ad4f3b.
2026-07-13 13:29:17 +01:00
Arc 0c60ad4f3b make simpler 2026-07-13 13:17:29 +01:00
Vlad StanandGitHub 98028bc5b5 Merge branch 'dev' into extensions_for_all 2026-07-13 10:55:05 +03:00
Arc 1537027691 make 2026-07-10 15:21:57 +01:00
Arc f64f64f6bb feat: adds lnaddress support to payments in host 2026-07-10 15:20:15 +01:00
Vlad Stan 5747bf7047 fix: stricter limits 2026-07-10 16:08:29 +03:00
Vlad StanandGitHub 7fc279b081 feat: wasm ext resource limits (#4052) 2026-07-10 14:33:04 +03:00
Vlad Stan 624f88bcf8 fix: unrelated test 2026-07-10 11:53:59 +03:00
Vlad Stan a415e38e35 chore: lint 2026-07-10 10:42:48 +03:00
Vlad Stan 752763d227 fix: merge 2026-07-10 10:39:04 +03:00
Vlad Stan 87fc693be4 chore: format 2026-07-10 10:35:37 +03:00
Vlad Stan 3b997435c0 Merge branch 'dev' into extensions_for_all 2026-07-10 10:32:25 +03:00
Vlad Stan 32379687ab fix: dependencies 2026-07-09 09:26:13 +03:00
Vlad Stan aeb7735e51 refactor: fix deps 2026-07-08 15:32:22 +03:00
Vlad Stan 2ef3894f74 refactor: fix some imports 2026-07-08 15:12:35 +03:00
Vlad Stan 73f2c863eb refactor: fix imports 2026-07-08 15:04:17 +03:00
Vlad Stan d1be79052f refactor: extract import 2026-07-08 14:54:56 +03:00
Vlad Stan 28341dedfa refactor: extract permissions component 2026-07-08 14:41:59 +03:00
Vlad Stan 0b89c75386 chore: clean-up i18n 2026-07-08 14:20:41 +03:00
Vlad Stan e1827e33ad refactor: separate function 2026-07-08 14:15:31 +03:00
Vlad Stan ff109aab87 refactor: move function 2026-07-08 14:09:52 +03:00
Vlad Stan 84dfb5b3e9 refactor: extract 2026-07-08 14:04:14 +03:00
Vlad Stan ef5ac2af64 refactor: is_upgrade_extension 2026-07-08 12:24:27 +03:00
Vlad Stan b13b3b9b92 chore: make bundle 2026-07-08 11:58:07 +03:00
Vlad Stan 053671b10f feat: http.request permissions 2026-07-08 11:55:22 +03:00
Vlad Stan bf146c82b3 chore: lint 2026-07-08 11:55:20 +03:00
Vlad Stan 44dd0e1570 refactor: remove redundant check 2026-07-08 11:55:20 +03:00
Vlad Stan 9d6069a043 fix: policies 2026-07-08 11:55:20 +03:00
Vlad Stan e0fc3c89d1 refactor: checks 2026-07-08 11:55:18 +03:00
Vlad Stan 77606a9fc1 refactor: move logic out from host.py 2026-07-08 11:55:18 +03:00
Vlad Stan 1d5c6ac601 fea: nicer permissions 2026-07-08 11:55:18 +03:00
Vlad Stan 95adf7bfbb feat: ui polish 2026-07-08 11:55:16 +03:00
Vlad Stan d13046efbf fix: allow no param 2026-07-08 11:55:15 +03:00
Vlad Stan 0d969ec624 fix: start/stop background work 2026-07-08 11:55:15 +03:00
Vlad Stan 3150ae7be1 refactor: structure 2026-07-08 11:55:15 +03:00
Vlad Stan 4b8fca43cf refactor: extract functions 2026-07-08 11:55:15 +03:00
Vlad Stan a0901a33ca refactor: extract permissions logic 2026-07-08 11:55:15 +03:00
Vlad Stan 0e30dbc2af refactor: extract function 2026-07-08 11:55:15 +03:00
Vlad Stan 5d231b0c9e chore: clean-up 2026-07-08 11:55:15 +03:00
Vlad Stan 90600da5a5 fix: lint 2026-07-08 11:55:15 +03:00
Vlad Stan 8fee8425a8 feat: add camera permissions 2026-07-08 11:55:15 +03:00
Vlad Stan 28024e0608 feat: pay_invoice 2026-07-08 11:55:13 +03:00
Vlad Stan 236f8e44b6 fix: icon 2026-07-08 11:55:13 +03:00
Vlad Stan f3cdf85f3b refactor: components 2026-07-08 11:55:11 +03:00
Vlad Stan da412a2321 refactor: better namespace 2026-07-08 11:55:08 +03:00
Vlad Stan 385b3920d4 feat: add utils 2026-07-08 11:55:08 +03:00
Vlad Stan 6887d949c7 fix: access to extensions 2026-07-08 11:55:08 +03:00
Vlad Stan ab9e68741d feat: call extensions 2026-07-08 11:55:08 +03:00
Vlad Stan 17a7b5abab Revert "feat: public page http calls"
This reverts commit e7c359c1e0e21a8aeea391fd5b2cd6316526d42c.
2026-07-08 11:55:06 +03:00
Vlad Stan 9abced4eee feat: public page http calls 2026-07-08 11:55:06 +03:00
Vlad Stan 2a8a878fea fix: user required 2026-07-08 11:55:06 +03:00
Vlad Stan d1ebfdddac fix: loop issue 2026-07-08 11:55:06 +03:00
Vlad Stan 118fbd46f3 refactor: better imports 2026-07-08 11:55:06 +03:00
Vlad Stan 233d2c5e71 refactor: split loader from routes 2026-07-08 11:55:06 +03:00
Vlad Stan daf088b4ec fix: typing 2026-07-08 11:55:06 +03:00
alanandVlad Stan 1b03d8195d chore: make bundle [skip ci] 2026-07-08 11:55:06 +03:00
Vlad Stan 84e68e0fc3 fix: simplify user check 2026-07-08 11:55:03 +03:00
Vlad Stan c3fb049fa6 chore: update poetry.lock 2026-07-08 11:55:03 +03:00
Vlad Stan 487af32ccb feat: ext http request 2026-07-08 11:55:03 +03:00
Vlad Stan f20bd17321 chore: make bundle 2026-07-08 11:55:03 +03:00
Vlad Stan 368dcf1231 refactor: move code generator to tools 2026-07-08 11:54:58 +03:00
Vlad Stan e1437c0c0f refactor: remove unused param 2026-07-08 11:54:58 +03:00
Vlad Stan 8135b15404 refactor: move out dispatch_wasm_invoice_paid from tasks 2026-07-08 11:54:58 +03:00
Vlad Stan 62c651b3f2 refactor: remove extra param 2026-07-08 11:54:58 +03:00
Vlad Stan d5ee044454 refactor: app usage 2026-07-08 11:54:58 +03:00
Vlad Stan 55f244ed4e refactor: simplify 2026-07-08 11:54:58 +03:00
Vlad Stan 727d5fec8d fix: i18n 2026-07-08 11:54:58 +03:00
Vlad Stan feb90088ec feat: extra extra 2026-07-08 11:54:58 +03:00
Vlad Stan 63050dbbbd fix: i18n 2026-07-08 11:54:58 +03:00
Vlad Stan 4cba73c183 fix: owner id 2026-07-08 11:54:58 +03:00
Vlad Stan d1b795556e fix: create row on event 2026-07-08 11:54:58 +03:00
Vlad Stan 21f88d15e8 fix: create public invoice 2026-07-08 11:54:58 +03:00
Vlad Stan 46e31e35cd feat: create invoice public 2026-07-08 11:54:57 +03:00
Vlad Stan 6cd2746078 feat: public get 2026-07-08 11:54:57 +03:00
Vlad Stan b4ebbc36fe feat: finer grain permissions 2026-07-08 11:54:57 +03:00
Vlad Stan c519d86e57 fix: permissions 2026-07-08 11:54:57 +03:00
Vlad Stan b28d2bd9b1 fix: permissions 2026-07-08 11:54:57 +03:00
Vlad Stan 2b791d1c23 chore: clean-up 2026-07-08 11:54:57 +03:00
Vlad Stan b74b75d303 fix: navigation 2026-07-08 11:54:57 +03:00
Vlad Stan f7991e5231 fix: activate wasm extension 2026-07-08 11:54:57 +03:00
Vlad Stan e5e14ca2ad fix: ui 2026-07-08 11:54:57 +03:00
Vlad Stan 97a1a0b303 feat: ask permission 2026-07-08 11:54:57 +03:00
Vlad Stan 9c0035c01d feat: permissions 2026-07-08 11:54:57 +03:00
Vlad Stan 7614c83fe8 feat: ws bridge 2026-07-08 11:54:57 +03:00
Vlad Stan 21a517e34f chore: clean-up 2026-07-08 11:54:57 +03:00
Vlad Stan f42f973032 fix: icons 2026-07-08 11:54:57 +03:00
Vlad Stan 49a0bd9a1d fix: import lnbits resources 2026-07-08 11:54:57 +03:00
Vlad Stan 8a726ddaa0 feat: pagination 2026-07-08 11:54:57 +03:00
Vlad Stan c4ee44ed4b feat: store data 2026-07-08 11:54:57 +03:00
Vlad Stan 5fe4752ebf feat: db operations 2026-07-08 11:54:57 +03:00
Vlad Stan faa2b930ba fix: wallets 2026-07-08 11:54:57 +03:00
Vlad Stan fbc39bf423 feat: create unsafe real invoice 2026-07-08 11:54:57 +03:00
Vlad Stan d6236964be fix: user bound frame_token 2026-07-08 11:54:57 +03:00
Vlad Stan 60e4952b7f fix: links 2026-07-08 11:54:57 +03:00
Vlad Stan 412736631c feat: add wallet list 2026-07-08 11:54:57 +03:00
Vlad Stan 5187731c76 fix: postMessage uses '*' 2026-07-08 11:54:57 +03:00
Vlad Stan 4a3d019660 fix: frame in frame 2026-07-08 11:54:57 +03:00
Vlad Stan 6e2099cb4f fix: stricter asset loading 2026-07-08 11:54:57 +03:00
Vlad Stan 07327a5b57 fix: better error page 2026-07-08 11:54:57 +03:00
Vlad Stan 24b76f67aa fix: allow clipboard copy 2026-07-08 11:54:57 +03:00
Vlad Stan 7a071163cc fix: allow clipboard copy 2026-07-08 11:54:57 +03:00
Vlad Stan 1a5dc0da67 fix: external iframe access 2026-07-08 11:54:57 +03:00
Vlad Stan 4444f7a897 chore: code format 2026-07-08 11:54:57 +03:00
Vlad Stan 516b01c6eb fix: stricter headers for non-iframe usage 2026-07-08 11:54:57 +03:00
Vlad Stan 73bc5ddc16 fix: iframe communication 2026-07-08 11:54:56 +03:00
Vlad Stan 814d7bf45b fix: iframe style 2026-07-08 11:54:56 +03:00
Vlad Stan 5e17e5e13d fix: link 2026-07-08 11:54:56 +03:00
Vlad Stan 6223100591 feat: serve in iframe 2026-07-08 11:54:56 +03:00
Vlad Stan 989cd08614 chore: lint 2026-07-08 11:54:56 +03:00
Vlad Stan c426c23567 perf: cache wasm component 2026-07-08 11:54:56 +03:00
Vlad Stan 23b22e89c7 fix: REST paths 2026-07-08 11:54:56 +03:00
Vlad Stan 8425e7467e feat: call ext logic 2026-07-08 11:54:56 +03:00
Vlad Stan 42afbc0576 basic extension loading 2026-07-08 11:54:56 +03:00
Vlad Stan 937b9cb5f1 refactor: reorder 2026-07-08 11:54:56 +03:00
Vlad Stan 73c4d1b409 fix: remove public context 2026-07-08 11:54:56 +03:00
Vlad Stan 04a6ed5ba1 feat: simpler permissions 2026-07-08 11:54:56 +03:00
Vlad Stan e39384b3c8 feat: dumb 2026-07-08 11:54:56 +03:00
67 changed files with 291 additions and 7836 deletions
+3 -3
View File
@@ -85,15 +85,15 @@ jobs:
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
integration:
jmeter:
needs: [ lint ]
strategy:
matrix:
python-version: ["3.12"]
uses: ./.github/workflows/integration.yml
uses: ./.github/workflows/jmeter.yml
with:
python-version: ${{ matrix.python-version }}
bundle:
needs: [ lint, test-api, test-wallets, test-unit, migration, openapi, regtest, integration ]
needs: [ lint, test-api, test-wallets, test-unit, migration, openapi, regtest, jmeter ]
uses: ./.github/workflows/bundle.yml
-45
View File
@@ -1,45 +0,0 @@
name: Extension Integration Tests
on:
workflow_call:
inputs:
python-version:
description: "Python Version"
required: true
default: "3.10"
type: string
jobs:
integration:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/prepare
with:
python-version: ${{ inputs.python-version }}
npm: "true"
- name: run Playwright integration tests
env:
EXTENSIONS_MANIFEST_URL: "https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json"
LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly,satspay,tipjar,tpos,lnurlp,withdraw"
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
AUTH_HTTPS_ONLY: false
run: npm run test:integration
- name: print lnbits log
if: ${{ always() }}
run: |
sleep 1
cat tests/data/integration/logs/debug.log || true
- name: upload integration test results
uses: actions/upload-artifact@v4
if: ${{ always() }}
with:
name: extension-integration-test-results
path: |
playwright-report/integration/
test-results/
tests/data/integration/logs/
+65
View File
@@ -0,0 +1,65 @@
name: JMeter Extension Tests
on:
workflow_call:
inputs:
python-version:
description: "Python Version"
required: true
default: "3.10"
type: string
jobs:
jmeter:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/prepare
with:
python-version: ${{ inputs.python-version }}
- name: run LNbits
env:
LNBITS_ADMIN_UI: true
AUTH_HTTPS_ONLY: false
LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly, satspay, tipjar, tpos, lnurlp, withdraw"
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
run: |
uv run lnbits &
sleep 10
- name: setup java version
run: |
update-java-alternatives --list
sudo update-java-alternatives --set /usr/lib/jvm/temurin-8-jdk-amd64
java -version
- name: clone lnbits-extensions, install jmeter and run tests
env:
JAVA_HOME: /usr/lib/jvm/temurin-8-jdk-amd64
EXTENSIONS_MANIFEST_PATH: "/lnbits/lnbits-extensions/refs/heads/main/extensions.json"
run: |
git clone https://github.com/lnbits/lnbits-extensions
cd lnbits-extensions
mkdir logs
mkdir reports
make install-jmeter
make start-mirror-server
make test
- name: print lnbits log
if: ${{ always() }}
run: |
# catch up time for lnbits
sleep 1
cat data/logs/debug.log
- name: upload jmeter test results
uses: actions/upload-artifact@v4
if: ${{ always() }}
with:
name: jmeter-extension-test-results
path: |
lnbits-extensions/reports/
lnbits-extensions/logs/
-2
View File
@@ -18,8 +18,6 @@ __pycache__
.webassets-cache
htmlcov
test-reports
playwright-report
test-results
tests/data/*.sqlite3
*.swo
+1 -1
View File
@@ -43,4 +43,4 @@ ENV LNBITS_HOST="0.0.0.0"
EXPOSE 5000
CMD ["sh", "-c", "uv --offline run --no-sync lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
CMD ["sh", "-c", "uv --offline run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
+1 -4
View File
@@ -1,4 +1,4 @@
.PHONY: test test-integration
.PHONY: test
all: format check
@@ -69,9 +69,6 @@ test-regtest:
rm -rf ./tests/data \
uv run pytest tests/regtest
test-integration:
npm run test:integration
test-migration:
LNBITS_ADMIN_UI=True \
make test-api
+1 -1
View File
@@ -531,7 +531,7 @@ def register_async_tasks() -> None:
async def dispatch_extension_invoice_paid(payment) -> None:
await dispatch_wasm_invoice_paid(payment)
task_manager.register_invoice_listener(dispatch_extension_invoice_paid, "core_wasm")
core_app_extra.dispatch_extension_invoice_paid = dispatch_extension_invoice_paid
# server logs for websocket
if settings.lnbits_admin_ui:
+9 -13
View File
@@ -253,14 +253,13 @@ async def get_wasm_invocation_stats(
since: datetime | None = None,
conn: Connection | None = None,
) -> WasmInvocationStats:
database = conn or db
where: list[str] = []
values: dict = {}
if extension_id:
where.append("extension_id = :extension_id")
values["extension_id"] = extension_id
if since:
where.append(f"started_at >= {database.timestamp_placeholder('since')}")
where.append("started_at >= :since")
values["since"] = since
query = """
@@ -315,13 +314,11 @@ async def delete_old_wasm_invocations(
return 0
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
database = conn or db
result = await database.execute(
f"""
result = await (conn or db).execute(
"""
DELETE FROM wasm_invocations
WHERE status != 'running'
AND started_at < {database.timestamp_placeholder("cutoff")}
""", # noqa: S608
WHERE status != 'running' AND started_at < :cutoff
""",
{"cutoff": cutoff},
)
return int(result.rowcount or 0)
@@ -330,14 +327,13 @@ async def delete_old_wasm_invocations(
async def mark_stale_wasm_invocations(
conn: Connection | None = None,
) -> None:
database = conn or db
await database.execute(
f"""
await (conn or db).execute(
"""
UPDATE wasm_invocations
SET status = 'abandoned',
finished_at = {database.timestamp_placeholder("finished_at")},
finished_at = :finished_at,
stop_reason = 'Server restarted before invocation finished.'
WHERE status = 'running'
""", # noqa: S608
""",
{"finished_at": datetime.now(timezone.utc)},
)
-7
View File
@@ -883,10 +883,3 @@ async def m048_add_wasm_runtime_limits_to_installed_extensions(db: Connection):
"ALTER TABLE installed_extensions "
"ADD COLUMN wasm_runtime_limits TEXT DEFAULT '{}'"
)
async def m049_add_permissions_to_user_extensions(db: Connection):
"""
Adds user-level extension permission grants.
"""
await db.execute("ALTER TABLE extensions ADD COLUMN permissions TEXT DEFAULT '{}'")
-74
View File
@@ -8,10 +8,8 @@ import shutil
import zipfile
from collections.abc import Mapping
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path, PurePosixPath
from typing import Any
from uuid import uuid4
import httpx
from loguru import logger
@@ -141,83 +139,11 @@ class UserExtensionInfo(BaseModel):
payment_hash_to_enable: str | None = None
class ExtensionBackgroundPaymentDestinationPolicy(str, Enum):
OWN_WALLETS_ONLY = "own_wallets_only"
EXTERNAL_ALLOWED = "external_allowed"
class ExtensionBackgroundPaymentGrant(BaseModel):
id: StrictStr = Field(..., min_length=1, max_length=128)
wallet_id: str = Field(..., min_length=1, max_length=128)
enabled: bool = True
max_amount: int = Field(..., gt=0)
destination_policy: ExtensionBackgroundPaymentDestinationPolicy
class ExtensionBackgroundPaymentGrantRequest(BaseModel):
wallet_id: str = Field(..., min_length=1, max_length=128)
max_amount: int = Field(..., gt=0)
destination_policy: ExtensionBackgroundPaymentDestinationPolicy
def to_grant(self, grant_id: str | None = None) -> ExtensionBackgroundPaymentGrant:
return ExtensionBackgroundPaymentGrant(
id=grant_id or str(uuid4()),
wallet_id=self.wallet_id,
enabled=True,
max_amount=self.max_amount,
destination_policy=self.destination_policy,
)
class ExtensionWalletPaymentsWatchGrant(BaseModel):
id: StrictStr = Field(..., min_length=1, max_length=128)
wallet_id: str = Field(..., min_length=1, max_length=128)
enabled: bool = True
class ExtensionWalletPaymentsWatchGrantRequest(BaseModel):
wallet_id: str = Field(..., min_length=1, max_length=128)
def to_grant(
self, grant_id: str | None = None
) -> ExtensionWalletPaymentsWatchGrant:
return ExtensionWalletPaymentsWatchGrant(
id=grant_id or str(uuid4()),
wallet_id=self.wallet_id,
enabled=True,
)
class ExtensionPermissionCheckItem(BaseModel):
id: StrictStr
grant: dict[str, Any] = Field(default_factory=dict)
class ExtensionPermissionCheckRequest(BaseModel):
permissions: list[ExtensionPermissionCheckItem] = Field(default_factory=list)
class ExtensionPermissionCheckResult(BaseModel):
id: StrictStr
approved: bool
grant: dict[str, Any] = Field(default_factory=dict)
class ExtensionPermissionCheckResponse(BaseModel):
permissions: list[ExtensionPermissionCheckResult] = Field(default_factory=list)
class ExtensionPermissionsResponse(BaseModel):
extension_permissions: list[ExtensionPermission] = Field(default_factory=list)
user_permissions: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
class UserExtension(BaseModel):
user: str
extension: str
active: bool
extra: UserExtensionInfo | None = None
permissions: dict = Field(default_factory=dict)
@property
def is_paid(self) -> bool:
+8 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any
@@ -11,10 +11,17 @@ def _do_nothing(*_):
pass
async def _do_nothing_async(_: Any) -> None:
pass
class CoreAppExtra:
register_new_ext_routes: Callable = _do_nothing
register_new_wasm_ext_routes: Callable = _do_nothing
register_new_ratelimiter: Callable
dispatch_extension_invoice_paid: Callable[[Any], Awaitable[None]] = (
_do_nothing_async
)
def __init__(self) -> None:
self.wasm_extension_registry = WasmExtensionRegistry()
+2
View File
@@ -18,6 +18,7 @@ from lnbits.core.crud import (
)
from lnbits.core.crud.users import get_user
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.db import core_app_extra
from lnbits.core.models import Payment, Wallet
from lnbits.core.models.notifications import (
NOTIFICATION_TEMPLATES,
@@ -244,6 +245,7 @@ async def dispatch_payment_notification(payment: Payment) -> None:
wallet = await get_wallet(payment.wallet_id)
if wallet:
await send_payment_notification(wallet, payment)
await core_app_extra.dispatch_extension_invoice_paid(payment)
async def dispatch_webhook(payment: Payment):
+3 -2
View File
@@ -17,7 +17,7 @@ from lnbits.db import Connection, Filters
from lnbits.decorators import check_user_extension_access
from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
from lnbits.fiat import get_fiat_provider
from lnbits.helpers import check_callback_url, daystart_timestamp
from lnbits.helpers import check_callback_url
from lnbits.settings import settings
from lnbits.task_manager import task_manager
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_preimage
@@ -557,9 +557,10 @@ async def check_wallet_daily_withdraw_limit(
raise ValueError("It is not allowed to spend funds from this server.")
payments = await get_payments(
since=daystart_timestamp(),
since=int(time.time()) - 60 * 60 * 24,
outgoing=True,
wallet_id=wallet_id,
limit=1,
conn=conn,
)
if len(payments) == 0:
+1 -396
View File
@@ -1,4 +1,3 @@
import json
import sys
import traceback
from http import HTTPStatus
@@ -10,7 +9,7 @@ from fastapi.requests import Request
from loguru import logger
from lnbits.core.crud.extensions import get_user_extensions
from lnbits.core.crud.wallets import get_wallet, get_wallets_ids
from lnbits.core.crud.wallets import get_wallets_ids
from lnbits.core.db import db
from lnbits.core.models import (
SimpleStatus,
@@ -19,21 +18,12 @@ from lnbits.core.models.extensions import (
CreateExtension,
CreateExtensionReview,
Extension,
ExtensionBackgroundPaymentDestinationPolicy,
ExtensionBackgroundPaymentGrant,
ExtensionBackgroundPaymentGrantRequest,
ExtensionConfig,
ExtensionMeta,
ExtensionPermissionCheckRequest,
ExtensionPermissionCheckResponse,
ExtensionPermissionCheckResult,
ExtensionPermissionsResponse,
ExtensionRelease,
ExtensionReview,
ExtensionReviewPaymentRequest,
ExtensionReviewsStatus,
ExtensionWalletPaymentsWatchGrant,
ExtensionWalletPaymentsWatchGrantRequest,
InstallableExtension,
PayToEnableInfo,
ReleasePaymentInfo,
@@ -89,9 +79,6 @@ extension_router = APIRouter(
prefix="/api/v1/extension",
)
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION = "wallet.pay_invoice_background"
WALLET_PAYMENTS_WATCH_PERMISSION = "wallet.payments.watch"
@extension_router.post("", dependencies=[Depends(check_admin)])
async def api_install_extension(data: CreateExtension):
@@ -379,213 +366,6 @@ async def api_disable_extension(
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
@extension_router.post("/{ext_id}/permissions/background-payment")
async def api_grant_background_payment_permission(
ext_id: str,
data: ExtensionBackgroundPaymentGrantRequest,
account_id: AccountId = Depends(check_account_id_exists),
) -> dict:
installed_ext = await get_installed_extension(ext_id)
if not installed_ext or not installed_ext.active:
raise HTTPException(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' is not active."
)
installed_permission_ids = {
permission.id for permission in installed_ext.permissions or []
}
if WALLET_PAY_INVOICE_BACKGROUND_PERMISSION not in installed_permission_ids:
raise HTTPException(
HTTPStatus.FORBIDDEN,
f"Extension '{ext_id}' cannot request background payments.",
)
user_ext = await get_user_extension(account_id.id, ext_id)
if not user_ext or not user_ext.active:
raise HTTPException(
HTTPStatus.FORBIDDEN,
f"Extension '{ext_id}' is not enabled for this user.",
)
wallet = await get_wallet(data.wallet_id)
if not wallet or wallet.user != account_id.id:
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
if wallet.is_lightning_shared_wallet:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"Background payments are not allowed from shared wallets.",
)
if not wallet.can_send_payments:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"This wallet cannot send payments.",
)
permissions = user_ext.permissions or {}
grant = data.to_grant(
_user_permission_grant_id_for_wallet(
permissions,
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION,
data.wallet_id,
)
)
background_grants = [
existing
for existing in permissions.get(WALLET_PAY_INVOICE_BACKGROUND_PERMISSION, [])
if isinstance(existing, dict) and existing.get("wallet_id") != grant.wallet_id
]
background_grants.append(json.loads(grant.json()))
permissions[WALLET_PAY_INVOICE_BACKGROUND_PERMISSION] = background_grants
user_ext.permissions = permissions
await update_user_extension(user_ext)
return {"permission": WALLET_PAY_INVOICE_BACKGROUND_PERMISSION, "grant": grant}
@extension_router.post("/{ext_id}/permissions/wallet-payments-watch")
async def api_grant_wallet_payments_watch_permission(
ext_id: str,
data: ExtensionWalletPaymentsWatchGrantRequest,
account_id: AccountId = Depends(check_account_id_exists),
) -> dict:
installed_ext = await get_installed_extension(ext_id)
if not installed_ext or not installed_ext.active:
raise HTTPException(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' is not active."
)
installed_permission_ids = {
permission.id for permission in installed_ext.permissions or []
}
if WALLET_PAYMENTS_WATCH_PERMISSION not in installed_permission_ids:
raise HTTPException(
HTTPStatus.FORBIDDEN,
f"Extension '{ext_id}' cannot request wallet payment watch access.",
)
user_ext = await get_user_extension(account_id.id, ext_id)
if not user_ext or not user_ext.active:
raise HTTPException(
HTTPStatus.FORBIDDEN,
f"Extension '{ext_id}' is not enabled for this user.",
)
wallet = await get_wallet(data.wallet_id)
if not wallet or wallet.user != account_id.id:
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
permissions = user_ext.permissions or {}
grant = data.to_grant(
_user_permission_grant_id_for_wallet(
permissions,
WALLET_PAYMENTS_WATCH_PERMISSION,
data.wallet_id,
)
)
watch_grants = [
existing
for existing in permissions.get(WALLET_PAYMENTS_WATCH_PERMISSION, [])
if isinstance(existing, dict) and existing.get("wallet_id") != grant.wallet_id
]
watch_grants.append(json.loads(grant.json()))
permissions[WALLET_PAYMENTS_WATCH_PERMISSION] = watch_grants
user_ext.permissions = permissions
await update_user_extension(user_ext)
return {"permission": WALLET_PAYMENTS_WATCH_PERMISSION, "grant": grant}
@extension_router.get("/{ext_id}/permissions")
async def api_get_extension_permissions(
ext_id: str,
account_id: AccountId = Depends(check_account_id_exists),
) -> ExtensionPermissionsResponse:
installed_ext = await _require_active_wasm_extension(ext_id)
extension_permissions = validate_extension_permissions(
installed_ext.id, installed_ext.permissions, strict=False
)
user_ext = await get_user_extension(account_id.id, ext_id)
return ExtensionPermissionsResponse(
extension_permissions=extension_permissions,
user_permissions=_safe_user_extension_permissions(
user_ext.permissions if user_ext else {}
),
)
@extension_router.delete("/{ext_id}/permissions/user/{grant_id}")
async def api_delete_user_extension_permission(
ext_id: str,
grant_id: str,
account_id: AccountId = Depends(check_account_id_exists),
) -> SimpleStatus:
await _require_active_wasm_extension(ext_id)
user_ext = await get_user_extension(account_id.id, ext_id)
if not user_ext:
return SimpleStatus(success=True, message="Permission grant removed.")
user_ext.permissions = _remove_user_permission_grant(
user_ext.permissions or {}, grant_id
)
await update_user_extension(user_ext)
return SimpleStatus(success=True, message="Permission grant removed.")
@extension_router.post("/{ext_id}/permissions/check")
async def api_check_extension_permissions(
ext_id: str,
data: ExtensionPermissionCheckRequest,
account_id: AccountId = Depends(check_account_id_exists),
) -> ExtensionPermissionCheckResponse:
installed_ext = await get_installed_extension(ext_id)
if not installed_ext or not installed_ext.active:
raise HTTPException(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' is not active."
)
installed_permission_ids = {
permission.id for permission in installed_ext.permissions or []
}
user_ext = await get_user_extension(account_id.id, ext_id)
if not user_ext or not user_ext.active:
raise HTTPException(
HTTPStatus.FORBIDDEN,
f"Extension '{ext_id}' is not enabled for this user.",
)
results: list[ExtensionPermissionCheckResult] = []
for permission in data.permissions:
if permission.id not in installed_permission_ids:
raise HTTPException(
HTTPStatus.FORBIDDEN,
f"Extension '{ext_id}' cannot request '{permission.id}'.",
)
if permission.id == WALLET_PAY_INVOICE_BACKGROUND_PERMISSION:
results.append(
await _check_background_payment_permission(
account_id.id,
user_ext.permissions or {},
permission.grant,
)
)
continue
if permission.id == WALLET_PAYMENTS_WATCH_PERMISSION:
results.append(
await _check_wallet_payments_watch_permission(
account_id.id,
user_ext.permissions or {},
permission.grant,
)
)
continue
raise HTTPException(
HTTPStatus.BAD_REQUEST,
f"Unsupported permission check '{permission.id}'.",
)
return ExtensionPermissionCheckResponse(permissions=results)
@extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)])
async def api_activate_extension(ext_id: str) -> SimpleStatus:
try:
@@ -1003,178 +783,3 @@ async def create_extension_review(
resp.raise_for_status()
payment_request = resp.json()
return ExtensionReviewPaymentRequest(**payment_request)
async def _require_active_wasm_extension(ext_id: str) -> InstallableExtension:
installed_ext = await get_installed_extension(ext_id)
if not installed_ext or not installed_ext.active:
raise HTTPException(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' is not active."
)
if not installed_ext.is_wasm:
raise HTTPException(
HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' is not a WASM extension."
)
return installed_ext
def _safe_user_extension_permissions(permissions: dict | None) -> dict:
safe_permissions: dict[str, list[dict]] = {}
for permission_id, grants in (permissions or {}).items():
if not isinstance(permission_id, str) or not isinstance(grants, list):
continue
safe_grants = [
grant
for grant in grants
if isinstance(grant, dict) and isinstance(grant.get("id"), str)
]
if safe_grants:
safe_permissions[permission_id] = safe_grants
return safe_permissions
def _user_permission_grant_id_for_wallet(
permissions: dict, permission_id: str, wallet_id: str
) -> str | None:
grants = permissions.get(permission_id)
if not isinstance(grants, list):
return None
for grant in grants:
if not isinstance(grant, dict) or grant.get("wallet_id") != wallet_id:
continue
grant_id = grant.get("id")
return grant_id if isinstance(grant_id, str) and grant_id else None
return None
def _remove_user_permission_grant(permissions: dict, grant_id: str) -> dict:
updated_permissions = dict(permissions or {})
for permission_id, grants in list(updated_permissions.items()):
if not isinstance(grants, list):
continue
remaining_grants = [
grant
for grant in grants
if not isinstance(grant, dict) or grant.get("id") != grant_id
]
if remaining_grants:
updated_permissions[permission_id] = remaining_grants
else:
updated_permissions.pop(permission_id, None)
return updated_permissions
async def _check_background_payment_permission(
account_id: str,
permissions: dict,
grant_data: dict,
) -> ExtensionPermissionCheckResult:
try:
data = ExtensionBackgroundPaymentGrantRequest.parse_obj(grant_data)
except ValueError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
wallet = await get_wallet(data.wallet_id)
if not wallet or wallet.user != account_id:
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
if wallet.is_lightning_shared_wallet:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"Background payments are not allowed from shared wallets.",
)
if not wallet.can_send_payments:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"This wallet cannot send payments.",
)
requested_grant = data.to_grant()
existing_grant = _find_background_payment_grant(
permissions, requested_grant.wallet_id
)
covered = (
existing_grant is not None
and existing_grant.enabled
and existing_grant.max_amount >= requested_grant.max_amount
and _background_destination_policy_covers(
existing_grant.destination_policy, requested_grant.destination_policy
)
)
grant = existing_grant if covered and existing_grant else requested_grant
return ExtensionPermissionCheckResult(
id=WALLET_PAY_INVOICE_BACKGROUND_PERMISSION,
approved=covered,
grant=json.loads(grant.json()),
)
async def _check_wallet_payments_watch_permission(
account_id: str,
permissions: dict,
grant_data: dict,
) -> ExtensionPermissionCheckResult:
try:
data = ExtensionWalletPaymentsWatchGrantRequest.parse_obj(grant_data)
except ValueError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
wallet = await get_wallet(data.wallet_id)
if not wallet or wallet.user != account_id:
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
existing_grant = _find_wallet_payments_watch_grant(permissions, data.wallet_id)
covered = bool(existing_grant and existing_grant.enabled)
grant = existing_grant if covered and existing_grant else data.to_grant()
return ExtensionPermissionCheckResult(
id=WALLET_PAYMENTS_WATCH_PERMISSION,
approved=covered,
grant=json.loads(grant.json()),
)
def _find_background_payment_grant(
permissions: dict, wallet_id: str
) -> ExtensionBackgroundPaymentGrant | None:
grants = permissions.get(WALLET_PAY_INVOICE_BACKGROUND_PERMISSION)
if not isinstance(grants, list):
return None
for grant_data in grants:
if not isinstance(grant_data, dict):
continue
try:
grant = ExtensionBackgroundPaymentGrant.parse_obj(grant_data)
except ValueError:
continue
if grant.wallet_id == wallet_id:
return grant
return None
def _find_wallet_payments_watch_grant(
permissions: dict, wallet_id: str
) -> ExtensionWalletPaymentsWatchGrant | None:
grants = permissions.get(WALLET_PAYMENTS_WATCH_PERMISSION)
if not isinstance(grants, list):
return None
for grant_data in grants:
if not isinstance(grant_data, dict):
continue
try:
grant = ExtensionWalletPaymentsWatchGrant.parse_obj(grant_data)
except ValueError:
continue
if grant.wallet_id == wallet_id:
return grant
return None
def _background_destination_policy_covers(
existing: ExtensionBackgroundPaymentDestinationPolicy,
requested: ExtensionBackgroundPaymentDestinationPolicy,
) -> bool:
return (
existing == requested
or existing == ExtensionBackgroundPaymentDestinationPolicy.EXTERNAL_ALLOWED
)
+4 -5
View File
@@ -158,6 +158,10 @@ async def api_update_user(
async def api_users_delete_user(
user_id: str, account: Account = Depends(check_admin)
) -> SimpleStatus:
wallets = await get_wallets(user_id, deleted=False)
for wallet in wallets:
await delete_wallet_by_id(wallet.id)
if user_id == settings.super_user:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
@@ -169,11 +173,6 @@ async def api_users_delete_user(
status_code=HTTPStatus.BAD_REQUEST,
detail="Only super_user can delete admin user.",
)
wallets = await get_wallets(user_id, deleted=False)
for wallet in wallets:
await delete_wallet_by_id(wallet.id)
await delete_account(user_id)
return SimpleStatus(success=True, message="User deleted.")
@@ -1,124 +0,0 @@
from __future__ import annotations
from typing import Any, NoReturn
from bolt11 import decode as bolt11_decode
from loguru import logger
from lnbits.core.crud.extensions import get_user_extension
from lnbits.core.crud.payments import check_internal
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models.extensions import (
ExtensionBackgroundPaymentDestinationPolicy,
ExtensionBackgroundPaymentGrant,
)
from lnbits.core.models.wallets import Wallet
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION = "wallet.pay_invoice_background"
async def background_payment_extra(
*,
extension_id: str,
wallet: Wallet,
payment_request: str,
amount_msat: int,
) -> dict[str, Any]:
grant = await _background_payment_grant(extension_id, wallet, amount_msat)
await _check_destination_policy(extension_id, wallet, grant, payment_request)
return {
"tag": extension_id,
"extension": extension_id,
"background_payment": True,
"background_permission": WALLET_PAY_INVOICE_BACKGROUND_PERMISSION,
"background_wallet_id": wallet.source_wallet_id,
"background_destination_policy": grant.destination_policy.value,
}
def invoice_amount_msat(payment_request: str) -> int:
invoice = bolt11_decode(payment_request)
amount_msat = int(invoice.amount_msat or 0)
if amount_msat <= 0:
raise PermissionError("Background payments require an invoice amount.")
return amount_msat
async def _background_payment_grant(
extension_id: str,
wallet: Wallet,
amount_msat: int,
) -> ExtensionBackgroundPaymentGrant:
if wallet.is_lightning_shared_wallet:
_deny(extension_id, wallet, amount_msat, "shared wallet")
if not wallet.can_send_payments:
_deny(extension_id, wallet, amount_msat, "wallet cannot send payments")
user_extension = await get_user_extension(wallet.user, extension_id)
if not user_extension or not user_extension.active:
_deny(extension_id, wallet, amount_msat, "extension disabled for user")
permissions = user_extension.permissions or {}
grants = permissions.get(WALLET_PAY_INVOICE_BACKGROUND_PERMISSION)
if not isinstance(grants, list):
_deny(extension_id, wallet, amount_msat, "missing background payment grant")
grant = _find_wallet_grant(grants, wallet.id)
if not grant:
_deny(extension_id, wallet, amount_msat, "missing wallet background grant")
if not grant.enabled:
_deny(extension_id, wallet, amount_msat, "background grant disabled")
if amount_msat > grant.max_amount * 1000:
_deny(extension_id, wallet, amount_msat, "payment exceeds max amount")
return grant
def _find_wallet_grant(
grants: list[Any], wallet_id: str
) -> ExtensionBackgroundPaymentGrant | None:
for grant_data in grants:
if not isinstance(grant_data, dict):
continue
try:
grant = ExtensionBackgroundPaymentGrant.parse_obj(grant_data)
except ValueError:
continue
if grant.wallet_id == wallet_id:
return grant
return None
async def _check_destination_policy(
extension_id: str,
wallet: Wallet,
grant: ExtensionBackgroundPaymentGrant,
payment_request: str,
) -> None:
if (
grant.destination_policy
== ExtensionBackgroundPaymentDestinationPolicy.EXTERNAL_ALLOWED
):
return
payment_hash = bolt11_decode(payment_request).payment_hash
internal_payment = await check_internal(payment_hash)
if not internal_payment:
_deny(extension_id, wallet, 0, "external destination not allowed")
destination_wallet = await get_wallet(internal_payment.wallet_id)
if not destination_wallet or destination_wallet.user != wallet.user:
_deny(extension_id, wallet, 0, "destination wallet is not owned by user")
def _deny(extension_id: str, wallet: Wallet, amount_msat: int, reason: str) -> NoReturn:
logger.warning(
"WASM extension '{}' denied background payment from wallet '{}', "
"user '{}', amount_msat '{}': {}.",
extension_id,
wallet.id,
wallet.user,
amount_msat,
reason,
)
raise PermissionError(reason)
+46 -140
View File
@@ -17,11 +17,6 @@ from ..storage.crud import (
storage_get_row,
storage_set_row,
)
from .background_payments import (
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION,
background_payment_extra,
invoice_amount_msat,
)
from .models import (
CreateInvoicePublicRequest,
CreateInvoiceRequest,
@@ -36,7 +31,6 @@ from .models import (
NowResponse,
PayInvoiceRequest,
PayInvoiceResponse,
PayLnurlRequest,
RandomIdRequest,
RandomIdResponse,
StorageDeleteRequest,
@@ -56,6 +50,11 @@ from .registry import extension_api_method
logger = logging.getLogger("lnbits.extensions")
def _looks_like_lnurl_pay_target(payment_request: str) -> bool:
normalized = payment_request.strip().lower()
return normalized.startswith(("lnurl", "lightning:lnurl")) or "@" in normalized
class ExtensionHostAPI:
def __init__(
self,
@@ -66,6 +65,7 @@ class ExtensionHostAPI:
access_token: str | None = None,
context: str = "user",
owner_id: str | None = None,
wallet_id: str | None = None,
invocation_id: str | None = None,
runtime_limits: dict[str, int] | None = None,
) -> None:
@@ -75,15 +75,12 @@ class ExtensionHostAPI:
self.access_token = access_token
self.context = context
self.owner_id = sha256s(user_id) if user_id else owner_id
self.wallet_id = wallet_id
self.invocation_id = invocation_id
self.runtime_limits = runtime_limits or {}
from .utils import ExtensionAPIUtils
self.utils = ExtensionAPIUtils(
self.extension_id,
self.permissions,
authenticated=self.has_authenticated_context(),
)
self.utils = ExtensionAPIUtils(self.extension_id, self.permissions)
@extension_api_method(
method_id="storage.get",
@@ -372,143 +369,65 @@ class ExtensionHostAPI:
host_name="pay_invoice",
sdk_name="payInvoice",
description="Pay a Lightning invoice from a wallet available to the user.",
required_permission="wallet.pay_invoice",
)
async def wallet_pay_invoice(
self, request: PayInvoiceRequest
) -> PayInvoiceResponse:
from lnurl import LnurlResponseException
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.services.lnurl import get_pr_from_lnurl
from lnbits.core.services.payments import pay_invoice
from lnbits.exceptions import PaymentError
wallet = await get_wallet(request.wallet_id)
if wallet is None:
raise PermissionError("Paying invoices from this wallet is not allowed.")
try:
if self.user_id:
self.require_permission("wallet.pay_invoice")
if wallet.user != self.user_id:
raise PermissionError(
"Paying invoices from this wallet is not allowed."
)
payment = await pay_invoice(
wallet_id=request.wallet_id,
payment_request=request.payment_request,
max_sat=request.max_sat,
extra={"tag": self.extension_id, **request.extra},
description=request.description,
tag=self.extension_id,
if self.user_id:
if wallet.user != self.user_id:
raise PermissionError(
"Paying invoices from this wallet is not allowed."
)
else:
self.require_permission(WALLET_PAY_INVOICE_BACKGROUND_PERMISSION)
amount_msat = invoice_amount_msat(request.payment_request)
extra = await background_payment_extra(
extension_id=self.extension_id,
wallet=wallet,
payment_request=request.payment_request,
amount_msat=amount_msat,
)
payment = await pay_invoice(
wallet_id=request.wallet_id,
payment_request=request.payment_request,
max_sat=request.max_sat,
extra={**request.extra, **extra},
description=request.description,
tag=self.extension_id,
)
except (PaymentError, PermissionError, ValueError) as exc:
return PayInvoiceResponse(ok=False, error=str(exc))
return _pay_invoice_response(payment)
@extension_api_method(
method_id="wallet.pay_lnurl",
namespace="wallet",
name="Pay LNURL",
host_name="pay_lnurl",
sdk_name="payLnurl",
description="Pay a Lightning Address or LNURL-pay request from a wallet.",
)
async def wallet_pay_lnurl(self, request: PayLnurlRequest) -> PayInvoiceResponse:
from lnurl import LnAddressError, LnurlResponseException
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models.lnurl import CreateLnurlPayment
from lnbits.core.services.lnurl import fetch_lnurl_pay_request
from lnbits.core.services.payments import pay_invoice
from lnbits.exceptions import PaymentError
from .lnurl import (
lnurl_for_core,
lnurl_pay_response_text,
lnurl_payment_amount_for_core,
lnurl_payment_unit_for_core,
)
wallet = await get_wallet(request.wallet_id)
if wallet is None:
raise PermissionError("Paying from this wallet is not allowed.")
try:
if self.user_id:
self.require_permission("wallet.pay_invoice")
if wallet.user != self.user_id:
raise PermissionError("Paying from this wallet is not allowed.")
else:
self.require_permission(WALLET_PAY_INVOICE_BACKGROUND_PERMISSION)
unit = lnurl_payment_unit_for_core(request.currency)
res, action = await fetch_lnurl_pay_request(
data=CreateLnurlPayment(
lnurl=lnurl_for_core(request.lnurl),
amount=lnurl_payment_amount_for_core(request.amount),
unit=unit,
comment=request.comment,
internal_memo=request.description or None,
),
wallet=None,
elif not (self.context == "event" and request.wallet_id == self.wallet_id):
raise PermissionError(
"Paying an invoice requires an authenticated user context."
)
extra = {"tag": self.extension_id, **request.extra}
if action.successAction:
extra["success_action"] = action.successAction.json()
if request.comment:
extra["comment"] = request.comment
if unit != "sat":
extra["fiat_currency"] = unit
extra["fiat_amount"] = str(request.amount)
if not self.user_id:
amount_msat = invoice_amount_msat(str(action.pr))
extra = {
**extra,
**(
await background_payment_extra(
extension_id=self.extension_id,
wallet=wallet,
payment_request=str(action.pr),
amount_msat=amount_msat,
)
),
}
try:
payment_request = request.payment_request
if _looks_like_lnurl_pay_target(payment_request):
if request.max_sat is None:
return PayInvoiceResponse(
ok=False,
error="max_sat is required for LNURL payments.",
)
payment_request = await get_pr_from_lnurl(
payment_request,
request.max_sat * 1000,
request.description or None,
)
payment = await pay_invoice(
wallet_id=request.wallet_id,
payment_request=str(action.pr),
payment_request=payment_request,
max_sat=request.max_sat,
extra=extra,
description=request.description or lnurl_pay_response_text(res),
extra={"tag": self.extension_id, **request.extra},
description=request.description,
tag=self.extension_id,
)
except (
LnAddressError,
LnurlResponseException,
PaymentError,
PermissionError,
ValueError,
) as exc:
except (PaymentError, ValueError, LnurlResponseException) as exc:
return PayInvoiceResponse(ok=False, error=str(exc))
return _pay_invoice_response(payment)
return PayInvoiceResponse(
ok=True,
checking_id=payment.checking_id,
payment_hash=payment.payment_hash,
status=payment.status,
amount_msat=abs(payment.amount),
fee_msat=abs(payment.fee),
pending=payment.pending,
success=payment.success,
)
@extension_api_method(
method_id="http.request",
@@ -703,16 +622,3 @@ class ExtensionHostAPI:
f"owner_id={self.owner_id!r}"
")"
)
def _pay_invoice_response(payment: Any) -> PayInvoiceResponse:
return PayInvoiceResponse(
ok=True,
checking_id=payment.checking_id,
payment_hash=payment.payment_hash,
status=payment.status,
amount_msat=abs(payment.amount),
fee_msat=abs(payment.fee),
pending=payment.pending,
success=payment.success,
)
-70
View File
@@ -1,70 +0,0 @@
from __future__ import annotations
import json
from typing import Any
from lnurl import LnAddress, Lnurl
def normalize_lnurl(value: str) -> str:
normalized = value.strip()
if normalized.lower().startswith("lightning:"):
normalized = normalized[len("lightning:") :]
if "@" in normalized:
normalized = normalized.lower()
if not normalized:
raise ValueError("LNURL is required.")
return normalized
def lnurl_for_core(value: str) -> Lnurl | LnAddress:
normalized = normalize_lnurl(value)
if "@" in normalized:
return LnAddress(normalized)
return Lnurl(normalized)
def lnurl_payment_amount_for_core(amount: float) -> int:
if amount <= 0:
raise ValueError("Amount must be greater than zero.")
return round(amount * 1000)
def lnurl_payment_unit_for_core(currency: str) -> str:
unit = currency.strip().lower()
if not unit:
raise ValueError("Currency is required.")
if unit in {"sat", "sats"}:
return "sat"
return unit.upper()
def lnurl_pay_response_metadata_json(response: Any) -> str:
metadata = getattr(response, "metadata", None)
if metadata is None:
return "[]"
metadata_list = getattr(metadata, "list", None)
try:
if callable(metadata_list):
return json.dumps(metadata_list())
return json.dumps(metadata)
except TypeError:
return json.dumps(str(metadata))
def lnurl_pay_response_text(response: Any) -> str:
description = getattr(response, "description", None)
if description is not None:
return str(description)
metadata = getattr(response, "metadata", None)
text = getattr(metadata, "text", None)
return str(text) if text is not None else ""
def lnurl_pay_response_int(response: Any, snake_name: str, camel_name: str) -> int:
value = getattr(response, snake_name, None)
if value is None:
value = getattr(response, camel_name, 0)
return int(value or 0)
-27
View File
@@ -181,17 +181,6 @@ class PayInvoiceRequest(BaseModel):
extra: dict[str, str] = Field(default_factory=dict)
class PayLnurlRequest(BaseModel):
wallet_id: str = Field(..., min_length=1, max_length=128)
lnurl: str = Field(..., min_length=1, max_length=2048)
amount: float = Field(..., gt=0)
currency: str = Field("sat", min_length=1, max_length=8)
comment: str | None = Field(None, max_length=512)
description: str = Field("", max_length=512)
max_sat: int | None = Field(None, gt=0)
extra: dict[str, str] = Field(default_factory=dict)
class PayInvoiceResponse(BaseModel):
ok: bool = True
error: str | None = None
@@ -293,22 +282,6 @@ class SatsToFiatResponse(BaseModel):
amount: float
class LnurlResolveRequest(BaseModel):
lnurl: str = Field(..., min_length=1, max_length=2048)
class LnurlResolveResponse(BaseModel):
lnurl: str
domain: str | None = None
description: str = ""
min_sendable_msat: int
max_sendable_msat: int
comment_allowed: int = 0
fixed: bool = False
image: str | None = None
metadata_json: str = "[]"
class ServerHealthResponse(BaseModel):
server_time: int
up_time: str
+1 -6
View File
@@ -10,12 +10,7 @@ from pydantic import BaseModel
from .models import ExtensionAPIMethod, ExtensionAPIMethodExport
_EXTENSION_API_METHOD_ATTR = "__lnbits_extension_api_method__"
_EXTENSION_RUNTIME_PERMISSION_IDS = {
"ui.camera.scan_qr",
"wallet.pay_invoice",
"wallet.pay_invoice_background",
"wallet.payments.watch",
}
_EXTENSION_RUNTIME_PERMISSION_IDS = {"ui.camera.scan_qr"}
_RequestModel = TypeVar("_RequestModel", bound=BaseModel)
_ResponseModel = TypeVar("_ResponseModel", bound=BaseModel)
+6 -94
View File
@@ -5,9 +5,6 @@ from collections.abc import Iterable
from datetime import datetime
from typing import Any
from lnurl import LnurlErrorResponse, LnurlPayResponse, LnurlResponseException
from lnurl import handle as lnurl_handle
from lnbits import bolt11
from lnbits.settings import settings
from lnbits.utils.crypto import random_secret_and_hash, verify_preimage
@@ -18,12 +15,6 @@ from lnbits.utils.exchange_rates import (
satoshis_amount_as_fiat,
)
from .lnurl import (
lnurl_pay_response_int,
lnurl_pay_response_metadata_json,
lnurl_pay_response_text,
normalize_lnurl,
)
from .models import (
Bolt11Request,
CurrencyConvertRequest,
@@ -39,8 +30,6 @@ from .models import (
InvoiceExpiryResponse,
InvoiceMemoResponse,
InvoicePaymentHashResponse,
LnurlResolveRequest,
LnurlResolveResponse,
RandomSecretAndHashRequest,
RandomSecretAndHashResponse,
SatsToFiatRequest,
@@ -54,39 +43,17 @@ from .registry import extension_api_method
class ExtensionAPIUtils:
def __init__(
self,
extension_id: str,
permissions: Iterable[str],
*,
authenticated: bool = False,
) -> None:
def __init__(self, extension_id: str, permissions: Iterable[str]) -> None:
permission_set = set(permissions)
self.currencies = ExtensionCurrencyUtils(
extension_id, permission_set, authenticated=authenticated
)
self.server = ExtensionServerUtils(
extension_id, permission_set, authenticated=authenticated
)
self.lightning = ExtensionLightningUtils(
extension_id, permission_set, authenticated=authenticated
)
self.lnurl = ExtensionLnurlUtils(
extension_id, permission_set, authenticated=authenticated
)
self.currencies = ExtensionCurrencyUtils(extension_id, permission_set)
self.server = ExtensionServerUtils(extension_id, permission_set)
self.lightning = ExtensionLightningUtils(extension_id, permission_set)
class _ExtensionAPIUtilsGroup:
def __init__(
self,
extension_id: str,
permissions: Iterable[str],
*,
authenticated: bool = False,
) -> None:
def __init__(self, extension_id: str, permissions: Iterable[str]) -> None:
self.extension_id = extension_id
self.permissions = set(permissions)
self.authenticated = authenticated
def require_permission(self, permission: str | None) -> None:
if permission and permission not in self.permissions:
@@ -95,7 +62,7 @@ class _ExtensionAPIUtilsGroup:
)
def has_authenticated_context(self) -> bool:
return self.authenticated
return False
class ExtensionCurrencyUtils(_ExtensionAPIUtilsGroup):
@@ -226,60 +193,6 @@ class ExtensionServerUtils(_ExtensionAPIUtilsGroup):
)
class ExtensionLnurlUtils(_ExtensionAPIUtilsGroup):
@extension_api_method(
method_id="utils.lnurl.resolve",
namespace="utils.lnurl",
name="Resolve LNURL-pay",
host_interface="utils-lnurl",
host_name="resolve",
sdk_name="resolve",
description="Resolve a Lightning Address or LNURL-pay request.",
required_permission="wallet.pay_invoice",
require_auth=True,
)
async def resolve(self, request: LnurlResolveRequest) -> LnurlResolveResponse:
normalized_lnurl = normalize_lnurl(request.lnurl)
response = await lnurl_handle(
normalized_lnurl,
user_agent=settings.user_agent,
timeout=5,
)
if isinstance(response, LnurlErrorResponse):
raise LnurlResponseException(response.reason)
if not isinstance(response, LnurlPayResponse):
raise LnurlResponseException(
"Invalid LNURL response. Expected LnurlPayResponse."
)
min_sendable_msat = lnurl_pay_response_int(
response, "min_sendable", "minSendable"
)
max_sendable_msat = lnurl_pay_response_int(
response, "max_sendable", "maxSendable"
)
image = getattr(response, "image", None)
return LnurlResolveResponse(
lnurl=normalized_lnurl,
domain=getattr(response, "domain", None),
description=lnurl_pay_response_text(response),
min_sendable_msat=min_sendable_msat,
max_sendable_msat=max_sendable_msat,
comment_allowed=lnurl_pay_response_int(
response, "comment_allowed", "commentAllowed"
),
fixed=bool(
getattr(
response,
"fixed",
min_sendable_msat == max_sendable_msat,
)
),
image=str(image) if image is not None else None,
metadata_json=lnurl_pay_response_metadata_json(response),
)
class ExtensionLightningUtils(_ExtensionAPIUtilsGroup):
@extension_api_method(
method_id="utils.lightning.decode_invoice",
@@ -422,7 +335,6 @@ def extension_api_utils_method_classes() -> dict[str, type[_ExtensionAPIUtilsGro
return {
"utils.currencies": ExtensionCurrencyUtils,
"utils.server": ExtensionServerUtils,
"utils.lnurl": ExtensionLnurlUtils,
"utils.lightning": ExtensionLightningUtils,
}
+9 -30
View File
@@ -12,7 +12,7 @@ from lnbits.core.crud import update_migration_version
from lnbits.core.db import db as core_db
from lnbits.core.models import DbVersion
from lnbits.core.models.extensions import InstallableExtension
from lnbits.db import POSTGRES, SQLITE, Compat, Connection, Database
from lnbits.db import POSTGRES, SQLITE, Connection, Database
from lnbits.settings import settings
_MIGRATION_FILE_RE = re.compile(r"^(\d+)_.*\.json$")
@@ -76,16 +76,9 @@ async def storage_set_row(
) -> None:
table_schema = _load_table_schema(ext_id, table)
clean_data = _data_to_db(table_schema, data, require_id=True)
columns = list(clean_data.keys())
database = Database(f"ext_{ext_id}")
fields = _fields_by_name(table_schema)
placeholders = [
_value_placeholder(database, fields[column], column) for column in columns
]
clean_data[OWNER_ID_FIELD] = owner_id
columns.append(OWNER_ID_FIELD)
placeholders.append(f":{OWNER_ID_FIELD}")
columns = list(clean_data.keys())
placeholders = [f":{column}" for column in columns]
updates = [
f"{column} = excluded.{column}"
for column in columns
@@ -94,19 +87,19 @@ async def storage_set_row(
conflict_sql = (
"DO UPDATE SET "
+ ", ".join(updates)
+ f" WHERE storage_row.{OWNER_ID_FIELD} = :{OWNER_ID_FIELD}"
+ f" WHERE {OWNER_ID_FIELD} = :{OWNER_ID_FIELD}"
if updates
else "DO NOTHING"
)
query = f"""
INSERT INTO {_table_ref_for_schema(ext_id, table)} AS storage_row
INSERT INTO {_table_ref_for_schema(ext_id, table)}
({", ".join(columns)})
VALUES
({", ".join(placeholders)})
ON CONFLICT (id) {conflict_sql}
""" # noqa: S608
async with database.connect() as conn:
async with Database(f"ext_{ext_id}").connect() as conn:
await conn.execute(query, clean_data)
@@ -124,10 +117,7 @@ async def storage_get_paginated_rows(
offset: int,
) -> dict[str, Any]:
table_schema = _load_table_schema(ext_id, table)
database = Database(f"ext_{ext_id}")
where_sql, values = _where_sql(
database, table_schema, filters, search, search_fields
)
where_sql, values = _where_sql(table_schema, filters, search, search_fields)
where_sql = _append_owner_where_sql(where_sql)
values[OWNER_ID_FIELD] = owner_id
order_sql = _order_sql(table_schema, sort_by, descending)
@@ -147,7 +137,7 @@ async def storage_get_paginated_rows(
{where_sql}
""" # noqa: S608
async with database.connect() as conn:
async with Database(f"ext_{ext_id}").connect() as conn:
rows = await conn.fetchall(rows_query, values)
count_row = await conn.fetchone(count_query, count_values)
@@ -413,25 +403,14 @@ def _filters_to_db(
}
def _value_placeholder(db: Compat, field: dict[str, Any], key: str) -> str:
if field.get("type") == "datetime" and not field.get("list"):
return db.timestamp_placeholder(key)
return f":{key}"
def _where_sql(
db: Compat,
table_schema: dict[str, Any],
filters: dict[str, Any],
search: str | None,
search_fields: list[str],
) -> tuple[str, dict[str, Any]]:
clean_filters = _filters_to_db(table_schema, filters)
fields = _fields_by_name(table_schema)
clauses = [
f"{field} = {_value_placeholder(db, fields[field], f'filter_{field}')}"
for field in clean_filters
]
clauses = [f"{field} = :filter_{field}" for field in clean_filters]
values = {f"filter_{field}": value for field, value in clean_filters.items()}
clean_search = search.strip().lower() if search else ""
+7 -118
View File
@@ -4,53 +4,21 @@ from typing import Any
from loguru import logger
from lnbits.core.crud.extensions import get_installed_extension, get_user_extensions
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.crud.extensions import get_installed_extension
from lnbits.core.db import core_app_extra
from lnbits.core.models.extensions import ExtensionWalletPaymentsWatchGrant
from lnbits.core.wasm_ext.storage.crud import storage_get_row_owner_id
from lnbits.core.wasm_ext.wasm.invoke import invoke_wasm_extension_export
from lnbits.helpers import sha256s
WALLET_PAYMENTS_WATCH_PERMISSION = "wallet.payments.watch"
async def dispatch_wasm_invoice_paid(payment: Any) -> None:
targets: dict[str, tuple[Any, str | None]] = {}
extension_id = _payment_extension_id(payment)
if extension_id:
extension = core_app_extra.wasm_extension_registry.get(extension_id)
if extension:
targets[extension_id] = (
extension,
await _wasm_invoice_paid_owner_id(extension, payment),
)
if not extension_id:
return
wallet = await _payment_wallet(payment)
if wallet:
wallet_owner_id = sha256s(wallet.user)
for watch_extension_id in await _wallet_watch_extension_ids(
wallet.user, wallet.id
):
extension = core_app_extra.wasm_extension_registry.get(watch_extension_id)
if not extension:
continue
if watch_extension_id in targets:
existing_extension, existing_owner_id = targets[watch_extension_id]
targets[watch_extension_id] = (
existing_extension,
existing_owner_id or wallet_owner_id,
)
continue
targets[watch_extension_id] = (extension, wallet_owner_id)
extension = core_app_extra.wasm_extension_registry.get(extension_id)
if not extension:
return
for extension, owner_id in targets.values():
await _dispatch_wasm_invoice_paid_to_extension(extension, payment, owner_id)
async def _dispatch_wasm_invoice_paid_to_extension(
extension: Any, payment: Any, owner_id: str | None
) -> None:
export_name = _wasm_invoice_paid_export(extension.config)
if not export_name:
return
@@ -63,6 +31,7 @@ async def _dispatch_wasm_invoice_paid_to_extension(
return
try:
owner_id = await _wasm_invoice_paid_owner_id(extension, payment)
await invoke_wasm_extension_export(
extension.id,
export_name,
@@ -82,86 +51,6 @@ async def _dispatch_wasm_invoice_paid_to_extension(
)
async def _payment_wallet(payment: Any) -> Any | None:
wallet_id = getattr(payment, "wallet_id", None)
if not isinstance(wallet_id, str) or not wallet_id:
return None
try:
return await get_wallet(wallet_id)
except Exception as exc:
logger.warning(f"Could not fetch wallet '{wallet_id}' for WASM event: {exc!s}")
return None
async def _wallet_watch_extension_ids(user_id: str, wallet_id: str) -> list[str]:
try:
user_extensions = await get_user_extensions(user_id)
except Exception as exc:
logger.warning(
f"Could not fetch extensions for wallet payment watch user "
f"'{user_id}': {exc!s}"
)
return []
extension_ids: list[str] = []
for user_extension in user_extensions:
if not user_extension.active:
continue
if not _has_wallet_watch_grant(user_extension, wallet_id):
continue
if not core_app_extra.wasm_extension_registry.get(user_extension.extension):
continue
try:
installed_extension = await get_installed_extension(
user_extension.extension
)
except Exception as exc:
logger.warning(
f"Could not fetch installed extension '{user_extension.extension}' "
f"for wallet payment watch: {exc!s}"
)
continue
if not installed_extension or not installed_extension.active:
continue
if not _extension_has_permission(
installed_extension, WALLET_PAYMENTS_WATCH_PERMISSION
):
continue
extension_ids.append(user_extension.extension)
return extension_ids
def _has_wallet_watch_grant(user_extension: Any, wallet_id: str) -> bool:
permissions = user_extension.permissions or {}
grants = permissions.get(WALLET_PAYMENTS_WATCH_PERMISSION)
if not isinstance(grants, list):
return False
for grant_data in grants:
if not isinstance(grant_data, dict):
continue
try:
grant = ExtensionWalletPaymentsWatchGrant.parse_obj(grant_data)
except ValueError:
continue
if grant.enabled and grant.wallet_id == wallet_id:
return True
return False
def _extension_has_permission(extension: Any, permission_id: str) -> bool:
return any(
(
permission.get("id")
if isinstance(permission, dict)
else getattr(permission, "id", None)
)
== permission_id
for permission in (extension.permissions or [])
)
def _payment_extension_id(payment: Any) -> str | None:
if isinstance(payment.extension, str) and payment.extension:
return payment.extension
+1
View File
@@ -83,6 +83,7 @@ async def invoke_wasm_extension_export(
access_token=access_token,
context=context,
owner_id=owner_id,
wallet_id=wallet_id,
invocation_id=invocation.id,
runtime_limits=limits,
)
+4 -11
View File
@@ -725,20 +725,20 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901
if get_origin(outertype_) is list:
_items = _safe_load_json(value) if isinstance(value, str) else value
_dict[key] = [
dict_to_submodel(type_, v) if _is_subclass(type_, BaseModel) else v
dict_to_submodel(type_, v) if issubclass(type_, BaseModel) else v
for v in _items
]
continue
if _is_subclass(type_, bool):
if issubclass(type_, bool):
_dict[key] = bool(value)
continue
if _is_subclass(type_, datetime):
if issubclass(type_, datetime):
if DB_TYPE == SQLITE:
_dict[key] = datetime.fromtimestamp(value, timezone.utc)
else:
_dict[key] = value.replace(tzinfo=timezone.utc)
continue
if _is_subclass(type_, BaseModel):
if issubclass(type_, BaseModel):
_dict[key] = dict_to_submodel(type_, value)
continue
# TODO: remove this when all sub models are migrated to Pydantic
@@ -763,13 +763,6 @@ def _safe_load_json(value: str) -> dict:
return {}
def _is_subclass(type_: Any, class_or_tuple: type | tuple[type, ...]) -> bool:
try:
return issubclass(type_, class_or_tuple)
except TypeError:
return False
def _valid_sql_name(name: str) -> bool:
"""Check if a SQL name is valid (alphanumeric and underscores only)"""
return (
-10
View File
@@ -372,13 +372,3 @@ def sha256s(value: str) -> str:
Returns the hex as a string.
"""
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def daystart_timestamp(dt: datetime | None = None) -> int:
"""
Returns the timestamp of the start of the day for the given
datetime (or now in UTC if not provided).
"""
dt = dt or datetime.now(timezone.utc)
day_start = dt.replace(hour=0, minute=0, second=0, microsecond=0)
return int(day_start.timestamp())
-6
View File
@@ -27,8 +27,6 @@ from lnbits.settings import set_cli_settings, settings
@click.option(
"--reload", is_flag=True, default=False, help="Enable auto-reload for development"
)
@click.option("--ws-max-queue", default=128, help="Websocket max queue size")
@click.option("--ws-ping-timeout", default=60.0, help="Websocket ping timeout")
def main(
port: int,
host: str,
@@ -36,8 +34,6 @@ def main(
ssl_keyfile: str,
ssl_certfile: str,
reload: bool,
ws_max_queue: int,
ws_ping_timeout: float,
):
"""Launched with `uv run lnbits` at root level"""
@@ -62,8 +58,6 @@ def main(
ssl_keyfile=ssl_keyfile,
ssl_certfile=ssl_certfile,
reload=reload or False,
ws_ping_timeout=ws_ping_timeout,
ws_max_queue=ws_max_queue,
)
server = uvicorn.Server(config=config)
-7
View File
@@ -524,11 +524,6 @@ class NotificationsSettings(LNbitsSettings):
and self.lnbits_telegram_notifications_access_token is not None
)
def is_email_notifications_configured(self) -> bool:
return self.lnbits_email_notifications_enabled and bool(
self.lnbits_email_notifications_email
)
class FakeWalletFundingSource(LNbitsSettings):
fake_wallet_secret: str = Field(default="ToTheMoon1")
@@ -1329,7 +1324,6 @@ class PublicSettings(BaseModel):
extensions_reviews_url: str = Field(alias="extensionsReviewsUrl")
ext_builder: bool = Field(alias="extBuilder")
nostr_configured: bool = Field(alias="nostrConfigured")
email_configured: bool = Field(alias="emailConfigured")
telegram_configured: bool = Field(alias="telegramConfigured")
wallet_featured_button_label: str | None = Field(alias="walletFeaturedButtonLabel")
wallet_featured_button_url: str | None = Field(alias="walletFeaturedButtonUrl")
@@ -1394,7 +1388,6 @@ class PublicSettings(BaseModel):
extensionsReviewsUrl=settings.lnbits_extensions_reviews_url,
extBuilder=settings.lnbits_extensions_builder_activate_non_admins,
nostrConfigured=settings.is_nostr_notifications_configured(),
emailConfigured=settings.is_email_notifications_configured(),
telegramConfigured=settings.is_telegram_notifications_configured(),
walletFeaturedButtonLabel=settings.lnbits_wallet_featured_button_label,
walletFeaturedButtonUrl=settings.lnbits_wallet_featured_button_url,
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -15
View File
@@ -520,14 +520,6 @@ window.localisation.en = {
extension_cost: 'This release requires a payment of minimum {cost} sats.',
extension_paid_sats: 'You have already paid {paid_sats} sats.',
extension_permissions_title: 'Grant extension permissions',
extension_permissions_tab: 'Extension Permissions',
user_permissions_tab: 'My Grants',
extension_permissions_none: 'This extension has no install-time permissions.',
user_permissions_none:
'You have not granted any permissions for this extension.',
user_permissions_max_amount: 'Max payment amount',
user_permissions_destination_policy: 'Allowed destinations',
user_permissions_no_editable_settings: 'This grant has no editable settings.',
extension_permissions_grant_install: 'Grant and install',
extension_permissions_high_risk_warning:
'This extension requests permissions that can move funds.',
@@ -536,10 +528,6 @@ window.localisation.en = {
extension_permission_risk_high: 'High risk',
extension_permission_warning_wallet_pay_invoice:
'Can spend funds from wallets available to your account.',
extension_permission_warning_wallet_pay_invoice_background:
'Can spend funds later from approved wallets without an active click.',
extension_permission_warning_wallet_payments_watch:
'Can read payment metadata for approved wallets.',
extension_permission_warning_extension_api_request_write:
'Can write data or trigger actions in approved extensions.',
extension_permission_ext_storage_read: 'Read extension storage',
@@ -554,15 +542,13 @@ window.localisation.en = {
extension_permission_http_request_hosts: 'Allowed hosts',
extension_permission_utils_basic: 'Use basic LNbits utilities',
extension_permission_ui_camera_scan_qr: 'Scan QR codes',
extension_permission_wallet_payments_watch: 'Watch wallet payments',
extension_permission_payments_watch: 'Watch payments',
extension_permission_wallet_create_invoice: 'Create invoices',
extension_permission_wallet_create_invoice_public:
'Create Lightning invoices from public pages',
extension_permission_wallet_balance_read: 'View wallet balances',
extension_permission_wallet_list: 'List wallets',
extension_permission_wallet_pay_invoice: 'Pay invoices',
extension_permission_wallet_pay_invoice_background:
'Make background payments',
create_extension: 'Create Extension',
release_details_error: 'Cannot get the release details.',
pay_from_wallet: 'Pay from Wallet',
@@ -28,12 +28,12 @@
}
}
function mediumRisk(translateFn, warningKey) {
function mediumRisk(translateFn) {
return {
level: 'medium',
color: 'warning',
label: translate(translateFn, 'extension_permission_risk_medium'),
warning: warningKey ? translate(translateFn, warningKey) : ''
warning: ''
}
}
@@ -77,16 +77,10 @@
}
function permissionRiskForPermission(permission, extensions, translateFn) {
if (
['wallet.pay_invoice', 'wallet.pay_invoice_background'].includes(
permission.id
)
) {
if (permission.id === 'wallet.pay_invoice') {
return highRisk(
translateFn,
permission.id === 'wallet.pay_invoice_background'
? 'extension_permission_warning_wallet_pay_invoice_background'
: 'extension_permission_warning_wallet_pay_invoice'
'extension_permission_warning_wallet_pay_invoice'
)
}
if (permission.id === 'extension.api.request') {
@@ -104,18 +98,13 @@
if (permission.id === 'http.request') {
return mediumRisk(translateFn)
}
if (permission.id === 'wallet.payments.watch') {
return mediumRisk(
translateFn,
'extension_permission_warning_wallet_payments_watch'
)
}
if (
[
'wallet.list',
'wallet.balance.read',
'wallet.create_invoice_public',
'ext.storage.read_public'
'ext.storage.read_public',
'payments.watch'
].includes(permission.id)
) {
return mediumRisk(translateFn)
@@ -135,8 +124,6 @@
function permissionOrderIndex(permissionId) {
const order = [
'wallet.pay_invoice',
'wallet.pay_invoice_background',
'wallet.payments.watch',
'wallet.list',
'wallet.balance.read',
'extension.api.request',
+1 -238
View File
@@ -29,23 +29,6 @@ window.PageExtensions = {
permissions: [],
resolve: null
},
managedExtensionPermissions: {
loading: false,
extensionPermissions: [],
userPermissions: {},
savingKey: '',
deletingKey: ''
},
backgroundPaymentDestinationOptions: [
{
label: 'Only transfers to my wallets',
value: 'own_wallets_only'
},
{
label: 'Allow external payments',
value: 'external_allowed'
}
],
uninstallAndDropDb: false,
maxStars: 5,
paylinkWebsocket: null,
@@ -115,32 +98,6 @@ window.PageExtensions = {
this.filterExtensions(this.searchTerm, val)
}
},
computed: {
managedUserPermissionRows() {
const rows = []
const userPermissions =
this.managedExtensionPermissions.userPermissions || {}
Object.entries(userPermissions).forEach(([permissionId, grants]) => {
if (!Array.isArray(grants)) return
grants.forEach(grant => {
if (!grant || typeof grant !== 'object') return
const grantId = String(grant.id || '')
const walletId = String(grant.wallet_id || '')
if (!grantId || !walletId) return
rows.push({
key: grantId,
permissionId,
label: this.permissionLabelById(permissionId),
grantId,
walletId,
walletName: this.walletName(walletId),
grant
})
})
})
return rows
}
},
methods: {
filterExtensions(term, tab) {
// Filter the extensions list
@@ -400,18 +357,8 @@ window.PageExtensions = {
this.selectedExtension = extension
this.selectedRelease = null
this.selectedExtensionRepos = null
this.resetManagedExtensionPermissions()
this.manageExtensionTab = this.g.user.admin
? 'releases'
: 'extension-permissions'
this.manageExtensionTab = 'releases'
this.showManageExtensionDialog = true
if (this.canManageExtensionPermissions(extension)) {
this.loadManagedExtensionPermissions(extension)
}
if (!this.g.user.admin) {
return
}
try {
const {data} = await LNbits.api.request(
@@ -449,61 +396,6 @@ window.PageExtensions = {
extension.inProgress = false
}
},
canShowManageExtensionButton(extension) {
return (
this.g.user.admin ||
(extension?.isWasm === true && extension?.isInstalled === true)
)
},
canManageExtensionPermissions(extension = this.selectedExtension) {
return extension?.isWasm === true && extension?.isInstalled === true
},
canShowAdminManageTabs() {
return this.g.user.admin === true
},
resetManagedExtensionPermissions() {
this.managedExtensionPermissions = {
loading: false,
extensionPermissions: [],
userPermissions: {},
savingKey: '',
deletingKey: ''
}
},
async loadManagedExtensionPermissions(extension = this.selectedExtension) {
if (!this.canManageExtensionPermissions(extension)) return
this.managedExtensionPermissions.loading = true
try {
const {data} = await LNbits.api.request(
'GET',
`/api/v1/extension/${extension.id}/permissions`
)
this.managedExtensionPermissions.extensionPermissions =
data.extension_permissions || []
this.managedExtensionPermissions.userPermissions =
this.cloneUserPermissions(data.user_permissions || {})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
} finally {
this.managedExtensionPermissions.loading = false
}
},
cloneUserPermissions(userPermissions) {
const permissions = {}
Object.entries(userPermissions || {}).forEach(
([permissionId, grants]) => {
if (!Array.isArray(grants)) return
permissions[permissionId] = grants
.filter(grant => grant && typeof grant === 'object')
.map(grant => ({
...grant,
_original: {...grant}
}))
}
)
return permissions
},
async showExtensionDetails(extId, detailsLink) {
if (!detailsLink) {
@@ -750,135 +642,6 @@ window.PageExtensions = {
extensionOpenUrl(extension) {
return extension.isWasm ? `/ext/${extension.id}` : `/${extension.id}`
},
permissionLabelById(permissionId) {
const key = `extension_permission_${String(permissionId).replace(
/[^A-Za-z0-9]/g,
'_'
)}`
const label = this.$t(key)
return label === key ? permissionId : label
},
walletName(walletId) {
const wallet = (this.g.user.wallets || []).find(
wallet => wallet.id === walletId
)
return wallet ? wallet.name || wallet.id : walletId
},
userPermissionRowCaption(row) {
return `${row.walletName} (${row.walletId.slice(0, 8)}...)`
},
isBackgroundPaymentPermission(row) {
return row.permissionId === 'wallet.pay_invoice_background'
},
userPermissionGrantPayload(row) {
return {
wallet_id: row.walletId,
max_amount: this.positiveInteger(row.grant.max_amount, 0),
destination_policy: this.backgroundPaymentDestinationPolicy(
row.grant.destination_policy
)
}
},
positiveInteger(value, fallback) {
const number = Number(value)
if (!Number.isFinite(number) || number <= 0) return fallback
return Math.floor(number)
},
backgroundPaymentDestinationPolicy(value) {
return value === 'external_allowed'
? 'external_allowed'
: 'own_wallets_only'
},
backgroundPaymentGrantIncreased(row, payload) {
const original = row.grant._original || {}
const originalAmount = this.positiveInteger(original.max_amount, 0)
const originalPolicy = this.backgroundPaymentDestinationPolicy(
original.destination_policy
)
return (
payload.max_amount > originalAmount ||
(originalPolicy === 'own_wallets_only' &&
payload.destination_policy === 'external_allowed')
)
},
confirmUserPermissionIncrease() {
return new Promise(resolve => {
let resolved = false
const finish = value => {
if (resolved) return
resolved = true
resolve(value)
}
LNbits.utils
.confirmDialog(
'This increases what the extension can do with this wallet. Continue?'
)
.onOk(() => finish(true))
.onCancel(() => finish(false))
.onDismiss(() => finish(false))
})
},
async saveUserPermissionGrant(row) {
if (!this.isBackgroundPaymentPermission(row)) return
const payload = this.userPermissionGrantPayload(row)
if (!payload.max_amount) {
Quasar.Notify.create({
type: 'negative',
message: 'Max payment amount must be greater than zero.'
})
return
}
if (
this.backgroundPaymentGrantIncreased(row, payload) &&
!(await this.confirmUserPermissionIncrease())
) {
return
}
this.managedExtensionPermissions.savingKey = row.key
try {
await LNbits.api.request(
'POST',
`/api/v1/extension/${this.selectedExtension.id}/permissions/background-payment`,
null,
payload
)
Quasar.Notify.create({
type: 'positive',
message: 'Permission updated.'
})
await this.loadManagedExtensionPermissions()
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
} finally {
this.managedExtensionPermissions.savingKey = ''
}
},
deleteUserPermissionGrant(row) {
LNbits.utils
.confirmDialog('Remove this permission grant?')
.onOk(async () => {
this.managedExtensionPermissions.deletingKey = row.key
try {
const grantId = encodeURIComponent(row.grantId)
await LNbits.api.request(
'DELETE',
`/api/v1/extension/${this.selectedExtension.id}/permissions/user/${grantId}`
)
Quasar.Notify.create({
type: 'positive',
message: 'Permission removed.'
})
await this.loadManagedExtensionPermissions()
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
} finally {
this.managedExtensionPermissions.deletingKey = ''
}
})
},
async getGitHubReleaseDetails(release) {
if (!release.is_github_release || release.loaded) {
return
@@ -48,85 +48,6 @@ window.WasmExtensionComponent = {
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="backgroundPaymentPrompt.show" persistent>
<q-card style="width: min(560px, calc(100vw - 32px)); max-width: 560px">
<q-card-section>
<div class="text-h6">Background payments</div>
</q-card-section>
<q-card-section class="q-pt-none q-gutter-md">
<div>
{{ backgroundPaymentPrompt.extensionName }} wants permission to make
background payments from
<strong>{{ backgroundPaymentPrompt.walletName }}</strong>.
</div>
<q-banner dense rounded class="bg-warning text-dark">
This permission can move funds later without an active click.
</q-banner>
<q-input
v-model.number="backgroundPaymentPrompt.form.maxAmount"
type="number"
label="Max payment amount (sats)"
min="1"
dense
outlined
></q-input>
<q-select
v-model="backgroundPaymentPrompt.form.destinationPolicy"
:options="backgroundPaymentDestinationOptions"
emit-value
map-options
label="Allowed destinations"
dense
outlined
></q-select>
</q-card-section>
<q-card-actions align="right">
<q-btn
flat
color="negative"
label="Deny"
@click="resolveBackgroundPaymentPrompt(false)"
></q-btn>
<q-btn
unelevated
color="primary"
label="Allow"
@click="resolveBackgroundPaymentPrompt(true)"
></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="walletPaymentWatchPrompt.show" persistent>
<q-card style="width: min(520px, calc(100vw - 32px)); max-width: 520px">
<q-card-section>
<div class="text-h6">Watch wallet payments</div>
</q-card-section>
<q-card-section class="q-pt-none q-gutter-md">
<div>
{{ walletPaymentWatchPrompt.extensionName }} wants permission to receive
payment notifications for
<strong>{{ walletPaymentWatchPrompt.walletName }}</strong>.
</div>
<q-banner dense rounded class="bg-warning text-dark">
This permission exposes payment metadata for this wallet to the extension.
</q-banner>
</q-card-section>
<q-card-actions align="right">
<q-btn
flat
color="negative"
label="Deny"
@click="resolveWalletPaymentWatchPrompt(false)"
></q-btn>
<q-btn
unelevated
color="primary"
label="Allow"
@click="resolveWalletPaymentWatchPrompt(true)"
></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
</div>
`,
data() {
@@ -147,36 +68,6 @@ window.WasmExtensionComponent = {
resolve: null,
show: false
},
backgroundPaymentDestinationOptions: [
{
label: 'Only transfers to my wallets',
value: 'own_wallets_only'
},
{
label: 'Allow external payments',
value: 'external_allowed'
}
],
backgroundPaymentPrompt: {
extensionName: '',
form: {
destinationPolicy: 'own_wallets_only',
maxAmount: 0
},
reject: null,
resolve: null,
show: false,
walletId: '',
walletName: ''
},
walletPaymentWatchPrompt: {
extensionName: '',
reject: null,
resolve: null,
show: false,
walletId: '',
walletName: ''
},
error: '',
extensionName: '',
frameUrl: '',
@@ -193,12 +84,6 @@ window.WasmExtensionComponent = {
unmounted() {
window.removeEventListener('message', this.handleWindowMessage)
this.rejectCameraPrompt('Camera scan cancelled.')
this.rejectBackgroundPaymentPrompt(
'Background payment permission cancelled.'
)
this.rejectWalletPaymentWatchPrompt(
'Wallet payment watch permission cancelled.'
)
this.closeBridgePort()
},
watch: {
@@ -234,20 +119,6 @@ window.WasmExtensionComponent = {
cameraPromptStorageKey() {
return `lnbits.ext.permissions.${this.bridge.extensionId}.ui.camera.scan_qr`
},
emptyBackgroundPaymentPrompt() {
return {
extensionName: '',
form: {
destinationPolicy: 'own_wallets_only',
maxAmount: 0
},
reject: null,
resolve: null,
show: false,
walletId: '',
walletName: ''
}
},
emptyCameraPrompt() {
return {
extensionName: '',
@@ -256,16 +127,6 @@ window.WasmExtensionComponent = {
show: false
}
},
emptyWalletPaymentWatchPrompt() {
return {
extensionName: '',
reject: null,
resolve: null,
show: false,
walletId: '',
walletName: ''
}
},
plainValue(value) {
try {
return JSON.parse(JSON.stringify(value))
@@ -282,12 +143,6 @@ window.WasmExtensionComponent = {
this.bridge = this.emptyBridge()
this.allowedPaymentHashes.clear()
this.rejectCameraPrompt('Camera scan cancelled.')
this.rejectBackgroundPaymentPrompt(
'Background payment permission cancelled.'
)
this.rejectWalletPaymentWatchPrompt(
'Wallet payment watch permission cancelled.'
)
this.closeBridgePort()
try {
@@ -455,320 +310,6 @@ window.WasmExtensionComponent = {
this.g.scanner = onScan
})
},
async requestBackgroundPaymentPermission(message) {
const response = await this.requestExtensionPermissions({
permissions: [
{
id: 'wallet.pay_invoice_background',
grant: message?.grant || {}
}
]
})
return response.permissions?.[0] || response
},
async requestWalletPaymentWatchPermission(message) {
const response = await this.requestExtensionPermissions({
permissions: [
{
id: 'wallet.payments.watch',
grant: message?.grant || {}
}
]
})
return response.permissions?.[0] || response
},
async requestExtensionPermissions(message) {
if (this.bridge.public) {
throw new Error('Public pages cannot request permissions.')
}
const permissions = Array.isArray(message.permissions)
? message.permissions
: []
if (!permissions.length) {
throw new Error('No permissions requested.')
}
const requestedPermissions = permissions.map(permission =>
this.normalizePermissionRequest(permission)
)
const checkResult = await this.checkExtensionPermissions(
requestedPermissions.map(permission => ({
id: permission.id,
grant: permission.grant
}))
)
const checks = Array.isArray(checkResult?.permissions)
? checkResult.permissions
: []
const approvedLabels = []
const results = []
for (const [index, permission] of requestedPermissions.entries()) {
const check = checks[index] || {}
if (check.id && check.id !== permission.id) {
throw new Error('Permission check response did not match request.')
}
if (check.approved) {
approvedLabels.push(permission.label)
results.push({
id: permission.id,
approved: true,
grant: check.grant || permission.grant
})
continue
}
const granted = await this.promptExtensionPermission(permission)
results.push({
id: permission.id,
approved: true,
grant: granted?.grant || permission.grant
})
}
this.notifyApprovedPermissionUse(approvedLabels)
return {permissions: results}
},
normalizePermissionRequest(permission) {
const id = String(permission?.id || '')
const grant = permission?.grant || {}
if (id === 'wallet.pay_invoice_background') {
return this.normalizeBackgroundPaymentPermission(grant)
}
if (id === 'wallet.payments.watch') {
return this.normalizeWalletPaymentWatchPermission(grant)
}
throw new Error(`Unsupported permission request: ${id}.`)
},
normalizeBackgroundPaymentPermission(grant) {
if (!this.hasBridgePermission('wallet.pay_invoice_background')) {
throw new Error('Extension is missing background payment permission.')
}
const walletId = String(grant.walletId || grant.wallet_id || '')
const wallet = this.walletById(walletId)
if (!wallet) {
throw new Error('Selected wallet is not available.')
}
if (wallet.walletType === 'lightning-shared') {
throw new Error(
'Background payments are not allowed from shared wallets.'
)
}
const requestedGrant = {
wallet_id: walletId,
max_amount: this.positiveInteger(
grant.maxAmount || grant.max_amount,
1000
),
destination_policy: this.backgroundPaymentDestinationPolicy(
grant.destinationPolicy || grant.destination_policy
)
}
if (!requestedGrant.max_amount) {
throw new Error('Max payment amount must be greater than zero.')
}
return {
id: 'wallet.pay_invoice_background',
grant: requestedGrant,
label: `Background payments from ${wallet.name || walletId}`,
wallet
}
},
normalizeWalletPaymentWatchPermission(grant) {
if (!this.hasBridgePermission('wallet.payments.watch')) {
throw new Error('Extension is missing wallet payment watch permission.')
}
const walletId = String(grant.walletId || grant.wallet_id || '')
const wallet = this.walletById(walletId)
if (!wallet) {
throw new Error('Selected wallet is not available.')
}
return {
id: 'wallet.payments.watch',
grant: {wallet_id: walletId},
label: `Watch wallet payments for ${wallet.name || walletId}`,
wallet
}
},
walletById(walletId) {
return (
(this.g?.user?.wallets || []).find(wallet => wallet.id === walletId) ||
null
)
},
async checkExtensionPermissions(permissions) {
return await this.postExtensionPermission(
'check',
{permissions},
'Could not check extension permissions.'
)
},
promptExtensionPermission(permission) {
if (permission.id === 'wallet.pay_invoice_background') {
return this.promptBackgroundPaymentPermission(permission)
}
if (permission.id === 'wallet.payments.watch') {
return this.promptWalletPaymentWatchPermission(permission)
}
throw new Error(`Unsupported permission request: ${permission.id}.`)
},
promptBackgroundPaymentPermission(permission) {
if (this.backgroundPaymentPrompt.show) {
throw new Error('Background payment prompt is already open.')
}
return new Promise((resolve, reject) => {
this.backgroundPaymentPrompt = {
extensionName:
this.extensionName || this.bridge.extensionId || 'This extension',
form: {
destinationPolicy: permission.grant.destination_policy,
maxAmount: permission.grant.max_amount
},
reject,
resolve,
show: true,
walletId: permission.grant.wallet_id,
walletName: permission.wallet.name || permission.grant.wallet_id
}
})
},
async resolveBackgroundPaymentPrompt(approved) {
const prompt = this.backgroundPaymentPrompt
if (!prompt.show) return
if (!approved) {
this.backgroundPaymentPrompt = this.emptyBackgroundPaymentPrompt()
prompt.reject?.(new Error('Background payment permission denied.'))
return
}
try {
const grant = {
wallet_id: prompt.walletId,
max_amount: this.positiveInteger(prompt.form.maxAmount, 0),
destination_policy: this.backgroundPaymentDestinationPolicy(
prompt.form.destinationPolicy
)
}
if (!grant.max_amount) {
throw new Error('Max payment amount must be greater than zero.')
}
const data = await this.postExtensionPermission(
'background-payment',
grant,
'Could not save permission.'
)
this.backgroundPaymentPrompt = this.emptyBackgroundPaymentPrompt()
prompt.resolve?.(data)
} catch (error) {
prompt.reject?.(error)
this.backgroundPaymentPrompt = this.emptyBackgroundPaymentPrompt()
}
},
rejectBackgroundPaymentPrompt(message) {
const reject = this.backgroundPaymentPrompt.reject
this.backgroundPaymentPrompt = this.emptyBackgroundPaymentPrompt()
reject?.(new Error(message))
},
promptWalletPaymentWatchPermission(permission) {
if (this.walletPaymentWatchPrompt.show) {
throw new Error('Wallet payment watch prompt is already open.')
}
return new Promise((resolve, reject) => {
this.walletPaymentWatchPrompt = {
extensionName:
this.extensionName || this.bridge.extensionId || 'This extension',
reject,
resolve,
show: true,
walletId: permission.grant.wallet_id,
walletName: permission.wallet.name || permission.grant.wallet_id
}
})
},
async resolveWalletPaymentWatchPrompt(approved) {
const prompt = this.walletPaymentWatchPrompt
if (!prompt.show) return
if (!approved) {
this.walletPaymentWatchPrompt = this.emptyWalletPaymentWatchPrompt()
prompt.reject?.(new Error('Wallet payment watch permission denied.'))
return
}
try {
const data = await this.postExtensionPermission(
'wallet-payments-watch',
{wallet_id: prompt.walletId},
'Could not save permission.'
)
this.walletPaymentWatchPrompt = this.emptyWalletPaymentWatchPrompt()
prompt.resolve?.(data)
} catch (error) {
prompt.reject?.(error)
this.walletPaymentWatchPrompt = this.emptyWalletPaymentWatchPrompt()
}
},
rejectWalletPaymentWatchPrompt(message) {
const reject = this.walletPaymentWatchPrompt.reject
this.walletPaymentWatchPrompt = this.emptyWalletPaymentWatchPrompt()
reject?.(new Error(message))
},
positiveInteger(value, fallback) {
const number = Number(value)
if (!Number.isFinite(number) || number <= 0) return fallback
return Math.floor(number)
},
backgroundPaymentDestinationPolicy(value) {
return value === 'external_allowed'
? 'external_allowed'
: 'own_wallets_only'
},
async postExtensionPermission(path, body, fallbackMessage) {
const response = await fetch(
`/api/v1/extension/${encodeURIComponent(
this.bridge.extensionId
)}/permissions/${path}`,
{
method: 'POST',
headers: {'content-type': 'application/json'},
credentials: 'same-origin',
body: JSON.stringify(body)
}
)
const text = await response.text()
let data = {}
if (text) {
try {
data = JSON.parse(text)
} catch (_error) {
data = {detail: text}
}
}
if (!response.ok) {
throw new Error(data?.detail || fallbackMessage)
}
return data
},
notifyApprovedPermissionUse(permissions) {
const permissionList = permissions.filter(Boolean).join(', ')
if (!permissionList) return
this.notify({
level: 'info',
message: `Using approved permissions: ${permissionList}.`
})
},
requireCameraScanApproval() {
if (this.isCameraScanRemembered()) return Promise.resolve()
if (this.cameraPrompt.show) {
@@ -974,30 +515,6 @@ window.WasmExtensionComponent = {
return
}
if (message.action === 'permissions.request') {
this.sendResponse(reply, message.id, {
ok: true,
data: await this.requestExtensionPermissions(message)
})
return
}
if (message.action === 'permissions.request_background_payment') {
this.sendResponse(reply, message.id, {
ok: true,
data: await this.requestBackgroundPaymentPermission(message)
})
return
}
if (message.action === 'permissions.request_wallet_payment_watch') {
this.sendResponse(reply, message.id, {
ok: true,
data: await this.requestWalletPaymentWatchPermission(message)
})
return
}
if (message.action === 'payment.subscribe') {
this.subscribePayment(message)
this.sendResponse(reply, message.id, {
+6 -167
View File
@@ -356,7 +356,7 @@
@click="showManageExtension(extension)"
flat
color="primary"
v-if="canShowManageExtensionButton(extension)"
v-if="g.user.admin"
:label="$t('manage')"
><q-tooltip
><span v-text="$t('manage_extension_details')"></span
@@ -531,43 +531,20 @@
</div>
</div>
</q-card>
<q-card
v-else
class="q-pa-lg lnbits__dialog-card"
style="width: min(95vw, 920px); max-width: 95vw"
>
<q-card v-else class="q-pa-lg lnbits__dialog-card">
<q-tabs
v-model="manageExtensionTab"
active-color="primary"
align="justify"
>
<q-tab
v-if="canShowAdminManageTabs()"
name="releases"
:label="$t('releases')"
@update="val => (manageExtensionTab = val.name)"
></q-tab>
<q-tab
v-if="canManageExtensionPermissions()"
name="extension-permissions"
:label="$t('extension_permissions_tab')"
@update="val => (manageExtensionTab = val.name)"
></q-tab>
<q-tab
v-if="canManageExtensionPermissions()"
name="user-permissions"
:label="$t('user_permissions_tab')"
@update="val => (manageExtensionTab = val.name)"
></q-tab>
<q-tab
v-if="
canShowAdminManageTabs() &&
selectedExtension &&
selectedExtension.isInstalled
"
v-if="selectedExtension && selectedExtension.isInstalled"
name="sell"
:label="$t('sell')"
@update="val => (manageExtensionTab = val.name)"
@@ -577,7 +554,7 @@
<div
v-show="manageExtensionTab === 'releases'"
class="col-12 col-md-5 q-gutter-y-md q-mt-md"
v-if="canShowAdminManageTabs() && selectedExtensionRepos"
v-if="selectedExtensionRepos"
>
<q-card
flat
@@ -838,147 +815,9 @@
></q-btn>
</div>
</div>
<q-spinner-bars
v-else-if="
canShowAdminManageTabs() && manageExtensionTab === 'releases'
"
color="primary"
size="2.55em"
></q-spinner-bars>
<q-spinner-bars v-else color="primary" size="2.55em"></q-spinner-bars>
<div
v-if="canManageExtensionPermissions()"
v-show="manageExtensionTab === 'extension-permissions'"
class="col-12 col-md-5 q-gutter-y-md q-mt-md"
>
<q-spinner-bars
v-if="managedExtensionPermissions.loading"
color="primary"
size="2.55em"
></q-spinner-bars>
<lnbits-extension-permissions
v-else-if="managedExtensionPermissions.extensionPermissions.length"
:permissions="managedExtensionPermissions.extensionPermissions"
:extensions="extensions"
></lnbits-extension-permissions>
<q-banner v-else rounded class="bg-grey-2 text-grey-8">
<span v-text="$t('extension_permissions_none')"></span>
</q-banner>
<div class="row q-mt-lg">
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div>
</div>
<div
v-if="canManageExtensionPermissions()"
v-show="manageExtensionTab === 'user-permissions'"
class="col-12 col-md-5 q-gutter-y-md q-mt-md"
>
<q-spinner-bars
v-if="managedExtensionPermissions.loading"
color="primary"
size="2.55em"
></q-spinner-bars>
<q-banner
v-else-if="!managedUserPermissionRows.length"
rounded
class="bg-grey-2 text-grey-8"
>
<span v-text="$t('user_permissions_none')"></span>
</q-banner>
<q-list v-else bordered separator>
<q-expansion-item
v-for="row in managedUserPermissionRows"
:key="row.key"
>
<template v-slot:header>
<q-item-section>
<q-item-label class="text-weight-medium">
<span v-text="row.label"></span>
</q-item-label>
<q-item-label caption>
<span v-text="userPermissionRowCaption(row)"></span>
</q-item-label>
</q-item-section>
<q-item-section side>
<q-btn
flat
dense
round
color="negative"
icon="delete"
:loading="managedExtensionPermissions.deletingKey === row.key"
@click.stop="deleteUserPermissionGrant(row)"
>
<q-tooltip>
<span v-text="$t('delete')"></span>
</q-tooltip>
</q-btn>
</q-item-section>
</template>
<div class="q-pa-md">
<div
v-if="isBackgroundPaymentPermission(row)"
class="row q-col-gutter-sm"
>
<div class="col-12 col-sm-5">
<q-input
dense
outlined
type="number"
min="1"
v-model.number="row.grant.max_amount"
:label="$t('user_permissions_max_amount')"
suffix="sat"
></q-input>
</div>
<div class="col-12 col-sm-7">
<q-select
dense
outlined
emit-value
map-options
v-model="row.grant.destination_policy"
:options="backgroundPaymentDestinationOptions"
:label="$t('user_permissions_destination_policy')"
></q-select>
</div>
<div class="col-12">
<q-btn
unelevated
color="primary"
icon="save"
:loading="managedExtensionPermissions.savingKey === row.key"
@click="saveUserPermissionGrant(row)"
>
<span v-text="$t('save')"></span>
</q-btn>
</div>
</div>
<q-item-label v-else caption>
<span
v-text="$t('user_permissions_no_editable_settings')"
></span>
</q-item-label>
</div>
</q-expansion-item>
</q-list>
<div class="row q-mt-lg">
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div>
</div>
<div
v-if="canShowAdminManageTabs() && selectedExtension"
v-if="selectedExtension"
v-show="manageExtensionTab === 'sell'"
class="col-12 col-md-5 q-gutter-y-md q-mt-md"
>
-64
View File
@@ -21,7 +21,6 @@
"vuex": "4.1.0"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"clean-css-cli": "^5.6.3",
"concat": "^1.0.3",
"prettier": "^3.8.3",
@@ -577,22 +576,6 @@
"node": ">=0.10"
}
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@scure/base": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
@@ -1771,53 +1754,6 @@
"pathe": "^2.0.3"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
-2
View File
@@ -1,7 +1,6 @@
{
"name": "lnbits",
"scripts": {
"test:integration": "playwright test --config=playwright.integration.config.js",
"sass": "./node_modules/.bin/sass ./lnbits/static/scss/base.scss > ./lnbits/static/css/base.css",
"vendor_copy": "node -e \"require('./package.json').vendor.forEach((file) => require('fs').copyFileSync(file, './lnbits/static/vendor/'+file.split('/').pop()))\"",
"vendor_json": "node -e \"require('fs').writeFileSync('./lnbits/static/vendor.json', JSON.stringify(require('./package.json').bundle))\"",
@@ -14,7 +13,6 @@
"bundle": "npm run sass && npm run vendor_copy && npm run vendor_json && npm run vendor_bundle_css && npm run vendor_bundle_js && npm run vendor_bundle_components && npm run vendor_minify_css && npm run vendor_minify_js && npm run vendor_minify_components"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"clean-css-cli": "^5.6.3",
"concat": "^1.0.3",
"prettier": "^3.8.3",
-31
View File
@@ -1,31 +0,0 @@
const {defineConfig} = require('@playwright/test')
const port = process.env.PORT || '5000'
const baseURL = process.env.LNBITS_BASE_URL || `http://127.0.0.1:${port}`
module.exports = defineConfig({
testDir: './tests/integration',
testMatch: '**/*.spec.js',
timeout: 120_000,
globalTimeout: 60 * 60 * 1000,
fullyParallel: false,
workers: 1,
reporter: [
['list'],
['html', {outputFolder: 'playwright-report/integration', open: 'never'}]
],
use: {
baseURL,
extraHTTPHeaders: {
Accept: 'application/json, text/plain, */*'
}
},
webServer: process.env.LNBITS_SKIP_WEB_SERVER
? undefined
: {
command: 'node tests/integration/server.cjs',
url: baseURL,
timeout: 180_000,
reuseExistingServer: !process.env.CI
}
})
+1 -2
View File
@@ -83,8 +83,7 @@ async def test_admin_audit_monitor_and_test_email(
)
assert monitor.status_code == 200
task_names = [t["name"] for t in monitor.json()]
assert "core_invoice_listener" in task_names
assert "core_wasm_invoice_listener" in task_names
assert any("invoice_listener" in name for name in task_names)
test_email = await client.get(
"/admin/api/v1/testemail",
-187
View File
@@ -1,6 +1,3 @@
import json
import zipfile
from pathlib import Path
from types import SimpleNamespace
from uuid import uuid4
@@ -11,7 +8,6 @@ from starlette.requests import Request
from lnbits.core.crud.db_versions import get_db_version, update_migration_version
from lnbits.core.crud.extensions import (
create_installed_extension,
delete_installed_extension,
get_installed_extension,
get_user_extension,
)
@@ -23,14 +19,11 @@ from lnbits.core.models.extensions import (
CreateExtensionReview,
Extension,
ExtensionConfig,
ExtensionPermission,
ExtensionRelease,
InstallableExtension,
PayToEnableInfo,
ReleasePaymentInfo,
UserExtensionInfo,
WasmRuntimeLimitsUpdate,
wasm_extension_icon_url,
)
from lnbits.core.models.users import AccountId
from lnbits.core.services.payments import create_wallet_invoice
@@ -42,11 +35,9 @@ from lnbits.core.views.extension_api import (
api_enable_extension,
api_extension_details,
api_get_user_extensions,
api_get_wasm_runtime_limit_extensions,
api_install_extension,
api_uninstall_extension,
api_update_pay_to_enable,
api_update_wasm_runtime_limits,
create_extension_review,
delete_extension_db,
extensions,
@@ -171,147 +162,6 @@ async def test_extension_api_install_details_and_release_endpoints(mocker):
assert release_info["is_version_compatible"] is True
@pytest.mark.anyio
async def test_extension_api_installs_wasm_with_granted_permissions(
tmp_path,
settings,
mocker,
):
ext_id = f"wasm_{uuid4().hex[:8]}"
release = make_extension_release(ext_id)
granted_permissions = [
ExtensionPermission(
id="http.request",
policies=[{"host": "https://api.example.com"}],
)
]
create_data = CreateExtension(
ext_id=ext_id,
archive=release.archive,
source_repo=release.source_repo,
version=release.version,
permissions=granted_permissions,
)
original_data_folder = settings.lnbits_data_folder
original_extensions_path = settings.lnbits_extensions_path
register_wasm_routes_mock = mocker.patch(
"lnbits.core.services.extensions.core_app_extra.register_new_wasm_ext_routes"
)
mocker.patch.object(
InstallableExtension,
"get_extension_release",
mocker.AsyncMock(return_value=release),
)
mocker.patch.object(
InstallableExtension,
"download_archive",
mocker.AsyncMock(),
)
try:
settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path / "code")
_write_wasm_extension_archive(ext_id, release.version, settings)
installed = await api_install_extension(create_data)
stored = await get_installed_extension(ext_id)
finally:
await delete_installed_extension(ext_id=ext_id)
settings.lnbits_data_folder = original_data_folder
settings.lnbits_extensions_path = original_extensions_path
assert installed.code == ext_id
assert installed.is_wasm is True
assert stored is not None
assert stored.permissions == [
ExtensionPermission(
id="http.request",
description="Call example API.",
policies=[{"host": "https://api.example.com"}],
)
]
register_wasm_routes_mock.assert_called_once_with(ext_id)
@pytest.mark.anyio
async def test_extension_api_wasm_runtime_limits_and_catalog_use_installed_metadata(
tmp_path,
settings,
mocker,
):
ext_id = f"wasm_{uuid4().hex[:8]}"
py_ext_id = f"py_{uuid4().hex[:8]}"
granted_permissions = [
ExtensionPermission(
id="http.request",
description="Call example API.",
policies=[{"host": "https://api.example.com"}],
)
]
original_extensions_path = settings.lnbits_extensions_path
try:
settings.lnbits_extensions_path = str(tmp_path)
_write_installed_wasm_config(ext_id, tmp_path)
await create_installed_extension(
InstallableExtension(
id=ext_id,
name="WASM Demo",
version="1.0.0",
active=True,
permissions=granted_permissions,
wasm_runtime_limits={"wasm_runtime_max_execution_ms": 1234},
)
)
await create_installed_extension(make_installable_extension(py_ext_id))
runtime_extensions = await api_get_wasm_runtime_limit_extensions()
wasm_info = next(info for info in runtime_extensions if info.id == ext_id)
updated_info = await api_update_wasm_runtime_limits(
ext_id,
WasmRuntimeLimitsUpdate(
limits={
"wasm_runtime_max_execution_ms": "2345",
"wasm_runtime_max_fuel": 0,
}
),
)
stored = await get_installed_extension(ext_id)
mocker.patch.object(
InstallableExtension,
"get_installable_extensions",
mocker.AsyncMock(
return_value=[
make_installable_extension(ext_id),
make_installable_extension(py_ext_id),
]
),
)
catalog = await extensions(AccountId(id=uuid4().hex))
finally:
await delete_installed_extension(ext_id=ext_id)
await delete_installed_extension(ext_id=py_ext_id)
settings.lnbits_extensions_path = original_extensions_path
assert wasm_info.wasm_runtime_limits == {"wasm_runtime_max_execution_ms": 1234}
assert py_ext_id not in {info.id for info in runtime_extensions}
assert updated_info.wasm_runtime_limits == {
"wasm_runtime_max_execution_ms": 2345,
"wasm_runtime_max_fuel": 0,
}
assert stored is not None
assert stored.wasm_runtime_limits == updated_info.wasm_runtime_limits
catalog_item = next(item for item in catalog if item["id"] == ext_id)
assert catalog_item["isWasm"] is True
assert catalog_item["icon"] == wasm_extension_icon_url(ext_id)
assert catalog_item["permissions"] == [
dict(permission) for permission in granted_permissions
]
@pytest.mark.anyio
async def test_extension_api_pay_to_enable_and_catalog_views(mocker, admin_user):
regular_user = await create_user_account(
@@ -578,40 +428,3 @@ async def test_extension_api_review_endpoints(mocker):
CreateExtensionReview(tag=ext_id, name="Alice", rating=900, comment="Great")
)
assert payment_request.payment_hash.startswith("hash_")
def _write_wasm_extension_archive(ext_id: str, version: str, settings) -> None:
zip_path = Path(settings.lnbits_data_folder, "zips", f"{ext_id}.zip")
zip_path.parent.mkdir(parents=True, exist_ok=True)
config = _wasm_config(ext_id)
root = f"{ext_id}-{version}"
with zipfile.ZipFile(zip_path, "w") as archive:
archive.writestr(f"{root}/config.json", json.dumps(config))
archive.writestr(f"{root}/{config['wasm']['module']}", b"\0asm")
def _write_installed_wasm_config(ext_id: str, extensions_path) -> None:
config_dir = extensions_path / "extensions" / ext_id
config_dir.mkdir(parents=True)
(config_dir / "config.json").write_text(
json.dumps(_wasm_config(ext_id)),
encoding="utf-8",
)
def _wasm_config(ext_id: str) -> dict:
return {
"id": ext_id,
"name": "WASM Demo",
"short_description": "WASM extension",
"version": "1.0.0",
"extension_type": "wasm",
"wasm": {"module": "extension.wasm"},
"permissions": [
{
"id": "http.request",
"description": "Call example API.",
"policies": [{"host": "https://api.example.com"}],
}
],
}
@@ -1,60 +0,0 @@
const {
test,
expect,
setupIntegration,
state,
pageStatus,
uninstallExtension,
getAdminSession,
installLatestExtensionViaUi,
enableExtensionViaUi,
disableExtensionViaUi
} = require('./scenario-helpers')
setupIntegration()
test('001 extensions can be installed, enabled, disabled, and re-enabled', async ({
browser
}) => {
test.setTimeout(45 * 60 * 1000)
const admin = await getAdminSession(browser)
const excluded = new Set([
'discordbot',
'usermanager',
'lnurldevice',
'scheduler',
'deezy',
'nostrrelay',
'tpos',
'webpages'
])
const extensionIds = [...new Set([...state.extensionById.keys()])]
.filter(extension => !excluded.has(extension))
.sort()
expect(extensionIds.length).toBeGreaterThan(0)
for (const extension of extensionIds) {
await test.step(`uninstall ${extension}`, async () => {
await uninstallExtension(state.adminContext, extension)
await pageStatus(state.adminContext, `/${extension}`, [200, 403, 404])
})
}
for (const extension of extensionIds) {
await test.step(`install and toggle ${extension}`, async () => {
const installed = await installLatestExtensionViaUi(admin, extension)
if (!installed) {
return
}
await pageStatus(state.adminContext, `/${extension}`, [200, 403, 404])
await enableExtensionViaUi(admin, extension)
await pageStatus(state.adminContext, `/${extension}`, [200, 403, 404])
await disableExtensionViaUi(admin, extension)
await pageStatus(state.adminContext, `/${extension}`, [200, 403, 404])
await enableExtensionViaUi(admin, extension)
await pageStatus(state.adminContext, `/${extension}`, [200, 403, 404])
})
}
})
-150
View File
@@ -1,150 +0,0 @@
const {
test,
expect,
setupIntegration,
apiKeyHeaders,
clearMirror,
createWallet,
getPayment,
jsonRequest,
lnurlScan,
pageStatus,
pollPayment,
pollWalletBalance,
newUserWithUi,
getAdminSession,
payLnurlViaUi,
withdrawLnurlViaUi,
ensureExtensionsInstalledViaUi,
enableExtensionViaUi,
createPayLink,
createWithdrawLink
} = require('./scenario-helpers')
setupIntegration()
test('002 lnurlp and withdraw scenarios', async ({browser}) => {
test.setTimeout(5 * 60 * 1000)
await clearMirror()
const admin = await getAdminSession(browser)
await ensureExtensionsInstalledViaUi(admin, ['lnurlp', 'withdraw'])
const user = await newUserWithUi(browser, 'lnurl')
await enableExtensionViaUi(user, 'lnurlp')
await pageStatus(user.context, '/lnurlp/')
const initialPayLinks = await jsonRequest(
user.context,
'get',
'/lnurlp/api/v1/links',
{
headers: apiKeyHeaders(user.inkey),
params: {all_wallets: true}
}
)
expect(initialPayLinks).toEqual([])
let userBalanceSats = 0
for (const index of [1, 2, 3]) {
const payLink = await createPayLink(user.context, user, index)
await pageStatus(user.context, `/lnurlp/link/${payLink.id}`)
const fetched = await jsonRequest(
user.context,
'get',
`/lnurlp/api/v1/links/${payLink.id}`,
{
headers: apiKeyHeaders(user.inkey),
params: {all_wallets: true}
}
)
expect(fetched.description).toBe(`receive payments ${index}`)
const lnurlResponse = await lnurlScan(
user.context,
fetched.lnurl,
user.adminkey
)
expect(lnurlResponse.tag).toBe('payRequest')
expect(lnurlResponse.commentAllowed).toBe(128)
for (let paymentIndex = 0; paymentIndex < 2; paymentIndex++) {
const payment = await payLnurlViaUi(
admin,
fetched.lnurl,
(10 + index) * 1000,
`receive payments ${index}`
)
expect(payment.payment_hash).toBeTruthy()
userBalanceSats += 10 + index
await pollWalletBalance(user.context, user.inkey, userBalanceSats * 1000)
const stored = await pollPayment(
user.context,
user.inkey,
payment.payment_hash,
result => Boolean(result?.details?.extra?.wh_response)
)
expect(stored.details.extra.wh_response).toContain('"b2":2')
expect(stored.details.extra.wh_response).toContain(payment.payment_hash)
}
}
await enableExtensionViaUi(user, 'withdraw')
await pageStatus(user.context, '/withdraw/')
const initialWithdrawLinks = await jsonRequest(
user.context,
'get',
'/withdraw/api/v1/links',
{
headers: apiKeyHeaders(user.inkey),
params: {all_wallets: true}
}
)
expect(initialWithdrawLinks.data ?? initialWithdrawLinks).toEqual([])
const receiveWallet = await createWallet(user.context, 'receive wallet')
for (const index of [1, 2, 3]) {
const withdrawLink = await createWithdrawLink(user.context, user, index)
await pageStatus(user.context, `/withdraw/${withdrawLink.id}`)
const fetched = await jsonRequest(
user.context,
'get',
`/withdraw/api/v1/links/${withdrawLink.id}`,
{
headers: apiKeyHeaders(user.inkey),
params: {all_wallets: true}
}
)
expect(fetched.title).toBe(`withdraw ${index}`)
const lnurlResponse = await lnurlScan(
user.context,
fetched.lnurl,
user.adminkey
)
expect(lnurlResponse.tag).toBe('withdrawRequest')
await user.page.waitForTimeout(2100)
for (let withdrawIndex = 0; withdrawIndex < 2; withdrawIndex++) {
const withdrawal = await withdrawLnurlViaUi(
user,
fetched.lnurl,
10,
`withdraw ${index}`,
receiveWallet.walletId
)
expect(withdrawal.payment_hash).toBeTruthy()
userBalanceSats -= 10
await pollWalletBalance(user.context, user.inkey, userBalanceSats * 1000)
await getPayment(
user.context,
receiveWallet.inkey,
withdrawal.payment_hash
)
if (withdrawIndex === 0) {
await user.page.waitForTimeout(1100)
}
}
}
await pollWalletBalance(user.context, receiveWallet.inkey, 60_000)
})
-172
View File
@@ -1,172 +0,0 @@
const {
test,
expect,
setupIntegration,
state,
apiKeyHeaders,
createWallet,
getWallet,
jsonRequest,
pageStatus,
pollWalletBalance,
newUserWithUi,
getAdminSession,
payInvoiceViaUi,
ensureExtensionsInstalledViaUi,
enableExtensionViaUi,
withdrawLnurl
} = require('./scenario-helpers')
setupIntegration()
test('003 tpos payments, tips, and ATM withdraw', async ({browser}) => {
test.setTimeout(5 * 60 * 1000)
const admin = await getAdminSession(browser)
await ensureExtensionsInstalledViaUi(admin, ['tpos'])
const user = await newUserWithUi(browser, 'tpos')
await enableExtensionViaUi(user, 'tpos')
await pageStatus(user.context, '/tpos/')
const tposs = await jsonRequest(user.context, 'get', '/tpos/api/v1/tposs', {
headers: apiKeyHeaders(user.inkey),
params: {all_wallets: true}
})
expect(tposs).toEqual([])
const tipsWallet = await createWallet(user.context, 'tips wallet')
expect(tipsWallet.userId).toBe(user.userId)
const tpos = await jsonRequest(user.context, 'post', '/tpos/api/v1/tposs', {
headers: apiKeyHeaders(user.adminkey),
data: {name: 'Test', wallet: user.walletId, currency: 'USD'},
expected: 201
})
expect(tpos.wallet).toBe(user.walletId)
const updated = await jsonRequest(
user.context,
'put',
`/tpos/api/v1/tposs/${tpos.id}`,
{
headers: apiKeyHeaders(user.adminkey),
data: {
name: 'Test',
wallet: user.walletId,
currency: 'USD',
tip_options: '[2, 5]',
tip_wallet: tipsWallet.walletId
}
}
)
expect(updated.tip_wallet).toBe(tipsWallet.walletId)
const atm = await jsonRequest(user.context, 'post', '/tpos/api/v1/tposs', {
headers: apiKeyHeaders(user.adminkey),
data: {
withdraw_between: 1,
name: 'Test ATM',
wallet: user.walletId,
currency: 'EUR',
withdraw_limit: 100000
},
expected: 201
})
expect(atm.wallet).toBe(user.walletId)
await pageStatus(user.context, `/tpos/${tpos.id}`)
await jsonRequest(user.context, 'get', '/api/v1/rate/EUR')
let expectedMerchantBalance = 0
for (const amount of [21, 42]) {
const invoice = await jsonRequest(
user.context,
'post',
`/tpos/api/v1/tposs/${tpos.id}/invoices`,
{
headers: apiKeyHeaders(user.inkey),
data: {
amount,
memo: 'TPoS test',
...(amount === 42 ? {tipAmount: 3} : {})
},
expected: 201
}
)
expect(invoice.bolt11).toBeTruthy()
await payInvoiceViaUi(admin, invoice.bolt11)
await jsonRequest(
user.context,
'get',
`/tpos/api/v1/tposs/${tpos.id}/invoices/${invoice.payment_hash}`,
{headers: apiKeyHeaders(user.inkey)}
)
expectedMerchantBalance += amount
await pollWalletBalance(
user.context,
user.inkey,
expectedMerchantBalance * 1000
)
}
await pageStatus(user.context, `/tpos/${atm.id}`)
await jsonRequest(user.context, 'get', '/api/v1/rate/EUR')
const atmInvoice = await jsonRequest(
user.context,
'post',
`/tpos/api/v1/tposs/${atm.id}/invoices`,
{
headers: apiKeyHeaders(user.inkey),
data: {amount: 11000, memo: 'TPoS test'},
expected: 201
}
)
await payInvoiceViaUi(admin, atmInvoice.bolt11)
const beforeAdmin = await getWallet(
state.adminContext,
state.adminWallet.inkey
)
const beforeUser = await getWallet(user.context, user.inkey)
const atmPin = await jsonRequest(
user.context,
'post',
`/tpos/api/v1/atm/${atm.id}/create`,
{
headers: apiKeyHeaders(user.adminkey)
}
)
expect(atmPin.id).toBeTruthy()
const withdraw = await jsonRequest(
user.context,
'get',
`/tpos/api/v1/atm/withdraw/${atmPin.id}/100`,
{
headers: apiKeyHeaders(state.adminWallet.adminkey)
}
)
const withdrawResponse = await jsonRequest(
user.context,
'get',
`/tpos/api/v1/lnurl/${withdraw.id}/100`,
{headers: apiKeyHeaders(state.adminWallet.adminkey)}
)
const payment = await withdrawLnurl(
user.context,
state.adminWallet,
withdrawResponse,
100,
'TPoS withdraw'
)
expect(payment.payment_hash).toBeTruthy()
await pollWalletBalance(
state.adminContext,
state.adminWallet.inkey,
beforeAdmin.balance + 100_000
)
await pollWalletBalance(
user.context,
user.inkey,
beforeUser.balance - 100_000
)
})
@@ -1,58 +0,0 @@
const {
test,
expect,
setupIntegration,
state,
jsonRequest,
latestRelease,
pageStatus,
topUpWallet,
newUserWithUi,
getAdminSession,
ensureExtensionsInstalledViaUi,
enableExtensionViaUi,
disableExtensionViaUi,
payToEnableExtensionViaUi
} = require('./scenario-helpers')
setupIntegration()
test('004 tpos pay-to-enable flow', async ({browser}) => {
test.setTimeout(5 * 60 * 1000)
const admin = await getAdminSession(browser)
await ensureExtensionsInstalledViaUi(admin, ['tpos'])
const user = await newUserWithUi(browser, 'pay-to-enable')
await topUpWallet(state.adminContext, user.walletId, 1000)
const release = await latestRelease(state.adminContext, 'tpos')
expect(release.version).toBeTruthy()
await enableExtensionViaUi(admin, 'tpos')
await jsonRequest(state.adminContext, 'put', '/api/v1/extension/tpos/sell', {
data: {required: true, amount: 21, wallet: state.adminWallet.walletId}
})
await pageStatus(state.adminContext, '/tpos')
await pageStatus(user.context, '/tpos', [200, 402, 403])
const lowInvoice = await jsonRequest(
user.context,
'put',
'/api/v1/extension/tpos/invoice/enable',
{data: {amount: 1}, expected: 400}
)
expect(JSON.stringify(lowInvoice)).toContain('21')
await jsonRequest(user.context, 'put', '/api/v1/extension/tpos/enable', {
expected: 402
})
await payToEnableExtensionViaUi(user, 'tpos')
await pageStatus(user.context, '/tpos')
await disableExtensionViaUi(user, 'tpos')
await enableExtensionViaUi(user, 'tpos')
await pageStatus(user.context, '/tpos')
await jsonRequest(state.adminContext, 'put', '/api/v1/extension/tpos/sell', {
data: {required: false, amount: 0, wallet: state.adminWallet.walletId}
})
})
-78
View File
@@ -1,78 +0,0 @@
const {
test,
expect,
setupIntegration,
createWallet,
getPayments,
jsonRequest,
newUserWithUi,
getAdminSession,
payLnurlViaUi,
ensureExtensionsInstalledViaUi,
enableExtensionViaUi,
createPayLink,
createWithdrawLink,
withdrawLnurl
} = require('./scenario-helpers')
setupIntegration()
test('005 lnurlw race limits successful withdrawals', async ({browser}) => {
test.setTimeout(5 * 60 * 1000)
const admin = await getAdminSession(browser)
await ensureExtensionsInstalledViaUi(admin, ['lnurlp', 'withdraw'])
const user = await newUserWithUi(browser, 'lnurl-race')
await enableExtensionViaUi(user, 'lnurlp')
await enableExtensionViaUi(user, 'withdraw')
const payLink = await createPayLink(user.context, user, 1, {
description: 'receive payments',
min: 1,
max: 100_000_000,
webhook_url: undefined,
webhook_headers: undefined,
webhook_body: undefined
})
const payResponse = await jsonRequest(
user.context,
'get',
`/lnurlp/${payLink.id}`
)
expect(payResponse.tag).toBe('payRequest')
await payLnurlViaUi(admin, payLink.lnurl, 10_000_000, 'receive payments')
const receiveWallet = await createWallet(user.context, 'race receive wallet')
const withdrawLink = await createWithdrawLink(user.context, user, 1, {
uses: 2,
max_withdrawable: 10
})
const withdrawResponse = await jsonRequest(
user.context,
'get',
`/withdraw/api/v1/lnurl/${withdrawLink.unique_hash}`
)
// The race assertion is intentionally API-driven: it needs concurrent calls,
// not serialized browser clicks.
const attempts = Array.from({length: 100}, () =>
withdrawLnurl(
user.context,
receiveWallet,
withdrawResponse,
10,
'withdraw 1'
)
)
await Promise.all(attempts)
const payments = await getPayments(user.context, receiveWallet.inkey, {
limit: 100
})
expect(payments).toHaveLength(100)
const successCount = payments.filter(
payment => payment.status === 'success'
).length
expect(successCount).toBeGreaterThan(0)
expect(successCount).toBeLessThanOrEqual(2)
})
@@ -1,233 +0,0 @@
const {
test,
expect,
setupIntegration,
apiKeyHeaders,
clearMirror,
getPayments,
jsonRequest,
mirrorUrl,
pageStatus,
pollWalletBalance,
newUserWithUi,
getAdminSession,
payInvoiceViaUi,
ensureExtensionsInstalledViaUi,
enableExtensionViaUi
} = require('./scenario-helpers')
setupIntegration()
test('006 watchonly, satspay, and tipjar scenario', async ({browser}) => {
test.setTimeout(7 * 60 * 1000)
await clearMirror()
const admin = await getAdminSession(browser)
await ensureExtensionsInstalledViaUi(admin, [
'watchonly',
'satspay',
'tipjar'
])
const user = await newUserWithUi(browser, 'watchonly-satspay-tipjar')
await enableExtensionViaUi(user, 'watchonly')
await pageStatus(user.context, `/watchonly/?usr=${user.userId}`)
await jsonRequest(user.context, 'get', '/watchonly/api/v1/config', {
headers: apiKeyHeaders(user.inkey)
})
await jsonRequest(user.context, 'get', '/watchonly/api/v1/wallet', {
headers: apiKeyHeaders(user.inkey),
params: {network: 'Mainnet'}
})
const onchain = await jsonRequest(
user.context,
'post',
'/watchonly/api/v1/wallet',
{
headers: apiKeyHeaders(user.adminkey),
data: {
title: 'segwit',
masterpub:
'zpub6rsRjqj6BTbD9DjqrY4p14tUx5kdA8ZGCTJD99wZTxD5wfvCkyXKrK3s7M3B1eFN6NbRhmbDDRDC8LF3Bn5gmxxN9rF8mDpZsGC6isGrK1g',
network: 'Mainnet',
meta: '{"accountPath":"m/84\'/0\'/0\'"}'
},
expected: [200, 201]
}
)
expect(onchain.id).toBeTruthy()
await jsonRequest(
user.context,
'get',
`/watchonly/api/v1/addresses/${onchain.id}`,
{
headers: apiKeyHeaders(user.inkey)
}
)
await jsonRequest(
user.context,
'get',
`/watchonly/api/v1/address/${onchain.id}`,
{
headers: apiKeyHeaders(user.inkey)
}
)
await enableExtensionViaUi(user, 'satspay')
await pageStatus(user.context, '/satspay/')
await jsonRequest(user.context, 'get', '/satspay/api/v1/charges', {
headers: apiKeyHeaders(user.adminkey)
})
const onchainCharge = await jsonRequest(
user.context,
'post',
'/satspay/api/v1/charge',
{
headers: apiKeyHeaders(user.adminkey),
data: {
onchain: true,
onchainwallet: onchain.id,
lnbits: false,
description: 'Onchain Charge',
time: 1111,
amount: 1111,
lnbitswallet: null
},
expected: [200, 201]
}
)
expect(onchainCharge.id).toBeTruthy()
await jsonRequest(user.context, 'post', '/satspay/api/v1/charge', {
headers: apiKeyHeaders(user.adminkey),
data: {
onchain: false,
onchainwallet: null,
lnbits: true,
description: 'lightning charge - to expire',
time: 1,
amount: 10,
lnbitswallet: user.walletId,
webhook: 'https://google.com',
completelink: 'https://twitter.com',
completelinktext: 'Have Fun'
},
expected: [200, 201]
})
let expectedBalanceSats = 0
for (let index = 1; index <= 5; index++) {
const amount = 9 + index
const charge = await jsonRequest(
user.context,
'post',
'/satspay/api/v1/charge',
{
headers: apiKeyHeaders(user.adminkey),
data: {
onchain: false,
onchainwallet: null,
lnbits: true,
description: `lightning charge [${index}]`,
time: 1220,
amount,
lnbitswallet: user.walletId,
webhook: mirrorUrl(),
completelink: 'https://twitter.com',
completelinktext: 'Have Fun'
},
expected: [200, 201]
}
)
expect(charge.payment_request).toBeTruthy()
await pageStatus(user.context, `/satspay/${charge.id}`)
await jsonRequest(
user.context,
'get',
`/satspay/api/v1/charge/balance/${charge.id}`,
{
headers: apiKeyHeaders(user.inkey)
}
)
await payInvoiceViaUi(admin, charge.payment_request)
await expect
.poll(async () => {
const updatedCharge = await jsonRequest(
user.context,
'get',
`/satspay/api/v1/charge/${charge.id}`,
{
headers: apiKeyHeaders(user.inkey)
}
)
return Boolean(updatedCharge.paid || updatedCharge.lnbitswallet)
})
.toBeTruthy()
expectedBalanceSats += amount
}
await pollWalletBalance(user.context, user.inkey, expectedBalanceSats * 1000)
const charges = await jsonRequest(
user.context,
'get',
'/satspay/api/v1/charges',
{
headers: apiKeyHeaders(user.adminkey)
}
)
expect(charges.length).toBeGreaterThanOrEqual(7)
const payments = await getPayments(user.context, user.inkey)
expect(payments.length).toBeGreaterThanOrEqual(6)
await enableExtensionViaUi(user, 'tipjar')
await pageStatus(user.context, `/tipjar/?usr=${user.userId}`)
await jsonRequest(user.context, 'get', '/tipjar/api/v1/tipjars', {
headers: apiKeyHeaders(user.inkey)
})
const tipjar = await jsonRequest(
user.context,
'post',
'/tipjar/api/v1/tipjars',
{
headers: apiKeyHeaders(user.adminkey),
data: {wallet: user.walletId, name: 'Nakamoto', webhook: mirrorUrl()},
expected: [200, 201]
}
)
expect(tipjar.id).toBeTruthy()
await pageStatus(user.context, `/tipjar/${tipjar.id}`)
for (let index = 1; index <= 5; index++) {
const tip = await jsonRequest(user.context, 'post', '/tipjar/api/v1/tips', {
headers: apiKeyHeaders(user.adminkey),
data: {
tipjar: tipjar.id,
name: 'Hal',
sats: 21,
message: `Let's go ...${index}!`
},
expected: [200, 201]
})
expect(tip.redirect_url).toBeTruthy()
const tipChargeId = tip.redirect_url.split('/').filter(Boolean).at(-1)
const charge = await jsonRequest(
user.context,
'get',
`/satspay/api/v1/charge/${tipChargeId}`,
{
headers: apiKeyHeaders(user.inkey)
}
)
expect(charge.description).toBe(`Let's go ...${index}!`)
const tips = await jsonRequest(user.context, 'get', '/tipjar/api/v1/tips', {
headers: apiKeyHeaders(user.inkey)
})
expect(tips.length).toBe(index)
await payInvoiceViaUi(admin, tip.payment_request || charge.payment_request)
expectedBalanceSats += 21
await pollWalletBalance(
user.context,
user.inkey,
expectedBalanceSats * 1000
)
}
})
-139
View File
@@ -1,139 +0,0 @@
const {
test,
expect,
setupIntegration,
jsonRequest,
pageStatus,
newUserWithUi,
getAdminSession,
createInvoiceViaUi,
payInvoiceViaUi,
ensureExtensionsInstalledViaUi,
enableExtensionViaUi,
lndhubHeaders
} = require('./scenario-helpers')
setupIntegration()
test('007 lndhub mobile wallet API scenario', async ({browser}) => {
test.setTimeout(5 * 60 * 1000)
const admin = await getAdminSession(browser)
await ensureExtensionsInstalledViaUi(admin, ['lndhub'])
const user = await newUserWithUi(browser, 'lndhub')
await enableExtensionViaUi(user, 'lndhub')
await pageStatus(user.context, '/lndhub/')
await jsonRequest(user.context, 'post', '/api/v1/auth/logout')
await jsonRequest(user.context, 'get', '/lndhub/ext/getinfo')
for (const index of [1, 2, 3]) {
const userInvoice = await createInvoiceViaUi(
user,
21,
`user invoice ${index}`
)
await payInvoiceViaUi(admin, userInvoice.bolt11)
const adminInvoice = await createInvoiceViaUi(
admin,
1,
`admin invoice ${index}`
)
await payInvoiceViaUi(user, adminInvoice.bolt11)
}
const auth = await jsonRequest(user.context, 'post', '/lndhub/ext/auth', {
data: {login: 'some_user', password: user.adminkey, refresh_token: ''}
})
expect(auth.access_token).toBeTruthy()
const authHeaders = await lndhubHeaders(auth.access_token)
const balance = await jsonRequest(
user.context,
'get',
'/lndhub/ext/balance',
{
headers: authHeaders
}
)
expect(balance.BTC.AvailableBalance).toBe(60)
const txs = await jsonRequest(user.context, 'get', '/lndhub/ext/gettxs', {
headers: authHeaders
})
expect(txs).toHaveLength(3)
expect(txs.every(tx => tx.value === -1)).toBeTruthy()
const invoices = await jsonRequest(
user.context,
'get',
'/lndhub/ext/getuserinvoices',
{
headers: authHeaders
}
)
expect(invoices).toHaveLength(3)
expect(invoices.every(invoice => invoice.amt === 21)).toBeTruthy()
const mobileInvoice = await jsonRequest(
user.context,
'post',
'/lndhub/ext/addinvoice',
{
headers: authHeaders,
data: {amt: 50, memo: '50 sats'}
}
)
await payInvoiceViaUi(admin, mobileInvoice.payment_request)
const invoicesAfter = await jsonRequest(
user.context,
'get',
'/lndhub/ext/getuserinvoices',
{
headers: authHeaders
}
)
expect(invoicesAfter).toHaveLength(4)
const adminInvoice = await createInvoiceViaUi(admin, 30, '30 sats')
const paid = await jsonRequest(
user.context,
'post',
'/lndhub/ext/payinvoice',
{
headers: authHeaders,
data: {invoice: adminInvoice.bolt11}
}
)
expect(JSON.stringify(paid)).toContain('30 sats')
const txsAfter = await jsonRequest(
user.context,
'get',
'/lndhub/ext/gettxs',
{
headers: authHeaders
}
)
expect(txsAfter).toHaveLength(4)
const balanceAfter = await jsonRequest(
user.context,
'get',
'/lndhub/ext/balance',
{
headers: authHeaders
}
)
expect(balanceAfter.BTC.AvailableBalance).toBe(80)
const invalidAuthHeaders = await lndhubHeaders('YmFkOnRva2Vu')
for (const [method, path, data] of [
['get', '/lndhub/ext/balance'],
['get', '/lndhub/ext/gettxs'],
['get', '/lndhub/ext/getuserinvoices'],
['post', '/lndhub/ext/addinvoice', {amt: 50, memo: '50 sats'}],
['post', '/lndhub/ext/payinvoice', {invoice: adminInvoice.bolt11}]
]) {
await jsonRequest(user.context, method, path, {
headers: invalidAuthHeaders,
data,
expected: [400, 404]
})
}
})
@@ -1,54 +0,0 @@
const {
test,
expect,
setupIntegration,
state,
jsonRequest,
uninstallExtension,
getAdminSession,
refreshExtensionCatalog,
installLatestExtensionViaUi,
setExtensionActiveViaUi
} = require('./scenario-helpers')
setupIntegration()
test('008 lnaddress and lnurlp redirect conflict handling', async ({
browser
}) => {
test.setTimeout(10 * 60 * 1000)
const admin = await getAdminSession(browser)
await jsonRequest(state.adminContext, 'patch', '/admin/api/v1/settings', {
data: {
lnbits_extensions_manifests: [
'https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json',
'https://raw.githubusercontent.com/lnbits/lnaddress/refs/heads/main/manifest.json'
]
}
})
await refreshExtensionCatalog(state.adminContext)
await uninstallExtension(state.adminContext, 'lnaddress')
await installLatestExtensionViaUi(admin, 'lnurlp')
await installLatestExtensionViaUi(admin, 'lnaddress')
await setExtensionActiveViaUi(admin, 'lnurlp', false)
const conflict = await setExtensionActiveViaUi(
admin,
'lnurlp',
true,
[200, 400]
)
const conflictText = JSON.stringify(conflict)
if (conflictText.includes('Already mapped')) {
expect(conflictText).toContain('Already mapped')
} else {
expect(conflict?.success).toBeTruthy()
}
await setExtensionActiveViaUi(admin, 'lnaddress', false).catch(error => {
if (!String(error.message).includes('Could not find extension card')) {
throw error
}
})
await setExtensionActiveViaUi(admin, 'lnurlp', true)
})
@@ -1,49 +0,0 @@
const {
test,
expect,
setupIntegration,
state,
jsonRequest,
textRequest,
getAdminSession,
installExtensionVersionViaUi,
enableExtensionViaUi
} = require('./scenario-helpers')
setupIntegration()
test('009 example extension can downgrade and upgrade data shape', async ({
browser
}) => {
test.setTimeout(10 * 60 * 1000)
const admin = await getAdminSession(browser)
async function installAndCheck(testVersion, extensionVersion) {
const releases = await jsonRequest(
state.adminContext,
'get',
'/api/v1/extension/example/releases'
)
const release = releases.find(item => item.version === extensionVersion)
expect(release).toBeTruthy()
await installExtensionVersionViaUi(admin, 'example', release.version)
await enableExtensionViaUi(admin, 'example')
const page = await textRequest(state.adminContext, 'get', '/example')
expect(page).toContain(
`Do not remove. Test install extension version: ${testVersion}`
)
const response = await jsonRequest(
state.adminContext,
'get',
'/example/api/v1/test/00000000'
)
expect(response.version ?? response.id).toBe(String(testVersion))
expect(response.test_id ?? response.wallet).toBe('00000000')
}
await installAndCheck(1, '1.0.1')
await installAndCheck(2, '1.0.6')
await installAndCheck(1, '1.0.1')
await installAndCheck(2, '1.0.6')
await installAndCheck(1, '1.0.1')
})
-382
View File
@@ -1,382 +0,0 @@
const {
test,
expect,
setupIntegration,
state,
apiKeyHeaders,
createWallet,
decodePayment,
getWallet,
jsonRequest,
textRequest,
newContext,
newUserWithUi,
getAdminSession,
payInvoiceViaUi,
topUpWallet,
ensureExtensionsInstalledViaUi,
enableExtensionViaUi,
createNip5Domain,
createNip5Address,
activateNip5Address,
getNostrJson,
buyNip5Address
} = require('./scenario-helpers')
setupIntegration()
test('010 nostrnip5 domain, search, pricing, and referral flow', async ({
browser
}) => {
test.setTimeout(10 * 60 * 1000)
const admin = await getAdminSession(browser)
await topUpWallet(state.adminContext, state.adminWallet.walletId, 1_000_000)
await ensureExtensionsInstalledViaUi(admin, ['nostrnip5', 'lnurlp'])
const anonymous = await newContext()
await textRequest(anonymous, 'patch', '/nostrnip5/api/v1/domain/ranking/0', {
data: 'reserved_a\r\nreserved_b',
headers: {'content-type': 'text/plain'},
expected: 401
})
await enableExtensionViaUi(admin, 'nostrnip5')
await enableExtensionViaUi(admin, 'lnurlp')
await textRequest(
state.adminContext,
'patch',
'/nostrnip5/api/v1/domain/ranking/0',
{
data: 'reserved_a\r\nreserved_b',
headers: {'content-type': 'text/plain'}
}
)
await textRequest(
state.adminContext,
'patch',
'/nostrnip5/api/v1/domain/ranking/200',
{
data: 'rank_200_a\r\nrank_200_b\r\nyyy',
headers: {'content-type': 'text/plain'}
}
)
await textRequest(
state.adminContext,
'patch',
'/nostrnip5/api/v1/domain/ranking/1000',
{
data: 'rank_1000_a\r\nrank_1000_b\r\nrank_1000_c\r\nxxx',
headers: {'content-type': 'text/plain'}
}
)
await jsonRequest(state.adminContext, 'put', '/nostrnip5/api/v1/settings', {
headers: apiKeyHeaders(state.adminWallet.adminkey),
data: {
lnaddress_api_endpoint: state.baseURL,
lnaddress_api_admin_key: state.adminWallet.adminkey
}
})
const owner = await newUserWithUi(browser, 'nostrnip5-owner')
await enableExtensionViaUi(owner, 'nostrnip5')
await enableExtensionViaUi(owner, 'lnurlp')
await textRequest(
owner.context,
'patch',
'/nostrnip5/api/v1/domain/ranking/0',
{
data: 'reserved_a\r\nreserved_b',
headers: {'content-type': 'text/plain'},
expected: 403
}
)
const domain = await createNip5Domain(owner.context, owner)
const domainId = domain.id
await jsonRequest(
owner.context,
'get',
`/nostrnip5/api/v1/domain/${domainId}`,
{
headers: apiKeyHeaders(owner.inkey)
}
)
await jsonRequest(owner.context, 'put', '/nostrnip5/api/v1/domain', {
headers: apiKeyHeaders(owner.adminkey),
data: {
cost: 100,
cost_extra: {
char_count_cost: [
{bracket: '1', amount: '1001'},
{bracket: '2', amount: '505'},
{bracket: '3', amount: '202'}
],
max_years: '5',
promotions: [
{
code: 'code_10',
buyer_discount_percent: '10',
referer_bonus_percent: '0'
},
{
code: 'code_20',
buyer_discount_percent: '20',
referer_bonus_percent: '5'
},
{
code: 'code_alan',
buyer_discount_percent: '25',
referer_bonus_percent: '20',
selected_referer: 'alan'
}
],
rank_cost: [
{bracket: 200, amount: '10000'},
{bracket: 1000, amount: '500'}
]
},
currency: 'USD',
domain: domain.domain,
id: domainId,
wallet: owner.walletId
}
})
const identifierOne = `one_${Date.now()}`
const identifierTwo = `two_${Date.now()}`
const identifierThree = `three_${Date.now()}`
const address = await createNip5Address(
owner.context,
owner,
domainId,
identifierOne,
'04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9'
)
await getNostrJson(owner.context, domainId, identifierOne, 404)
await activateNip5Address(owner.context, owner, domainId, address.id)
let nostrJson = await getNostrJson(owner.context, domainId, identifierOne)
expect(nostrJson.names[identifierOne]).toBeTruthy()
await jsonRequest(
owner.context,
'put',
`/nostrnip5/api/v1/domain/${domainId}/address/${address.id}`,
{
headers: apiKeyHeaders(owner.adminkey),
data: {
pubkey:
'04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbeeee',
relays: ['wss://relay.nostr.com', 'ws://relay.nostr.org']
}
}
)
nostrJson = await getNostrJson(owner.context, domainId, identifierOne)
expect(nostrJson.relays).toBeTruthy()
const second = await createNip5Address(
owner.context,
owner,
domainId,
identifierTwo,
'04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfaaaaa'
)
await activateNip5Address(owner.context, owner, domainId, second.id)
let addresses = await jsonRequest(
owner.context,
'get',
'/nostrnip5/api/v1/addresses/paginated',
{
headers: apiKeyHeaders(owner.inkey),
params: {
all_wallets: true,
limit: 10,
offset: 0,
sortby: 'time',
direction: 'asc'
}
}
)
expect(JSON.stringify(addresses)).toContain(identifierTwo)
await jsonRequest(
owner.context,
'delete',
`/nostrnip5/api/v1/domain/${domainId}/address/${address.id}`,
{
headers: apiKeyHeaders(owner.adminkey)
}
)
await getNostrJson(owner.context, domainId, identifierOne, 404)
const alan = await createNip5Address(
owner.context,
owner,
domainId,
'alan',
'04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfaaabb'
)
await activateNip5Address(owner.context, owner, domainId, alan.id)
const alanWallet = await createWallet(owner.context, 'Alan Wallet')
await jsonRequest(
owner.context,
'put',
`/nostrnip5/api/v1/user/domain/${domainId}/address/${alan.id}/lnaddress`,
{
headers: apiKeyHeaders(owner.adminkey),
data: {wallet: alanWallet.walletId, min: 5, max: '100005'}
}
)
for (const [query, expectedAvailable] of [
['a', true],
['aa', true],
['aaa', true],
['aaaa', true],
[identifierTwo, false],
['reserved_a', false],
['rank_200_b', true],
['rank_1000_b', true],
['yyy', true],
['xxx', true]
]) {
const result = await jsonRequest(
owner.context,
'get',
`/nostrnip5/api/v1/domain/${domainId}/search`,
{
headers: apiKeyHeaders(owner.inkey),
params: {q: query}
}
)
expect(Boolean(result.available)).toBe(expectedAvailable)
}
const client = await newUserWithUi(browser, 'nostrnip5-client')
await enableExtensionViaUi(client, 'nostrnip5')
await enableExtensionViaUi(client, 'lnurlp')
await buyNip5Address(client.context, client, domainId, 'abc1', {
pubkey: '04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfaaaaa',
years: 3
})
await buyNip5Address(client.context, client, domainId, 'a1', {
pubkey: '04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfaaaaa',
years: 3
})
const cartItems = await jsonRequest(
client.context,
'get',
'/nostrnip5/api/v1/user/addresses',
{
headers: apiKeyHeaders(client.inkey),
params: {active: false}
}
)
expect(JSON.stringify(cartItems)).toContain('abc1')
expect(JSON.stringify(cartItems)).toContain('a1')
const priceChecks = [
['abc1', 3, null, false, 300.0],
['abc1', 5, null, true, 500.0],
['abc1', 7, null, false, null, 400],
['abc1', 3, 'code_10', true, 270.0],
['abc1', 3, 'code_20', false, 240.0],
['abc1', 3, 'code_alan', true, 225.0],
['xyz', 3, null, false, 606.0],
['xyz', 5, null, true, 1010.0],
['xyz', 7, null, false, null, 400],
['xyz', 3, 'code_10', true, 545.4],
['xyz', 3, 'code_20', false, 484.8],
['xyz', 3, 'code_alan', true, 454.5]
]
for (const [
localPart,
years,
promoCode,
createInvoiceFlag,
expectedPrice,
expectedStatus
] of priceChecks) {
const result = await buyNip5Address(
client.context,
client,
domainId,
localPart,
{
pubkey:
'04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfaaaaa',
years,
promo_code: promoCode,
create_invoice: createInvoiceFlag,
expected: expectedStatus ?? 201
}
)
if (expectedPrice !== null) {
expect(result.extra.price).toBeCloseTo(expectedPrice)
}
if (createInvoiceFlag && result.payment_request) {
const decoded = await decodePayment(
client.context,
result.payment_request
)
expect(Number(result.extra.price_in_sats) * 1000).toBe(
decoded.amount_msat
)
}
}
const paidOne = await buyNip5Address(
client.context,
client,
domainId,
identifierOne,
{
pubkey:
'04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfaaaab',
years: 3,
create_invoice: true
}
)
await payInvoiceViaUi(admin, paidOne.payment_request)
await getNostrJson(client.context, domainId, identifierOne)
const quoteThree = await buyNip5Address(
client.context,
client,
domainId,
identifierThree,
{
pubkey:
'04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfaaaac',
years: 5
}
)
expect(quoteThree.extra.price).toBeCloseTo(500.0)
const paidThree = await buyNip5Address(
client.context,
client,
domainId,
identifierThree,
{
pubkey:
'04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfaaaac',
years: 5,
promo_code: 'code_alan',
create_invoice: true
}
)
await payInvoiceViaUi(admin, paidThree.payment_request)
await getNostrJson(client.context, domainId, identifierThree)
const refererBonus = Math.floor(quoteThree.extra.price_in_sats / 5)
await expect
.poll(
async () => {
const alanBalance = await getWallet(owner.context, alanWallet.inkey)
return Math.abs(Math.floor(alanBalance.balance / 1000) - refererBonus)
},
{timeout: 15_000}
)
.toBeLessThanOrEqual(100)
})
-267
View File
@@ -1,267 +0,0 @@
const {
test,
expect,
setupIntegration,
state,
apiKeyHeaders,
getPayments,
jsonRequest,
newUser,
newUserWithUi,
getAdminSession,
payInvoiceViaUi,
ensureExtensionsInstalledViaUi,
enableExtensionViaUi,
createNip5Domain,
createUserNip5Address,
activateNip5Address,
createAuctionRoom,
updateAuctionRoom,
createAuctionItem,
bidOnItem,
auctionNip5Webhooks
} = require('./scenario-helpers')
setupIntegration()
test('011 auction house transfers NIP5 addresses through bids and fixed-price sale', async ({
browser
}) => {
test.setTimeout(10 * 60 * 1000)
const admin = await getAdminSession(browser)
await ensureExtensionsInstalledViaUi(admin, [
'auction_house',
'nostrnip5',
'lnurlp'
])
await enableExtensionViaUi(admin, 'nostrnip5')
await enableExtensionViaUi(admin, 'lnurlp')
await jsonRequest(state.adminContext, 'put', '/nostrnip5/api/v1/settings', {
headers: apiKeyHeaders(state.adminWallet.adminkey),
data: {
lnaddress_api_endpoint: state.baseURL,
lnaddress_api_admin_key: state.adminWallet.adminkey
}
})
const owner = await newUserWithUi(browser, 'auction-owner')
await enableExtensionViaUi(owner, 'auction_house')
await enableExtensionViaUi(owner, 'nostrnip5')
await enableExtensionViaUi(owner, 'lnurlp')
const domain = await createNip5Domain(owner.context, owner, {
cost_extra: {transfer_secret: '1234'}
})
const domainId = domain.id
await jsonRequest(owner.context, 'put', '/nostrnip5/api/v1/settings', {
headers: apiKeyHeaders(owner.adminkey),
data: {
lnaddress_api_endpoint: state.baseURL,
lnaddress_api_admin_key: state.adminWallet.adminkey
}
})
const seller = await newUserWithUi(browser, 'auction-seller')
await enableExtensionViaUi(seller, 'auction_house')
await enableExtensionViaUi(seller, 'nostrnip5')
const createdAddresses = []
for (let index = 1; index <= 10; index++) {
const address = await createUserNip5Address(
seller.context,
seller,
domainId,
`id_${index}__${domainId}`
)
await activateNip5Address(owner.context, owner, domainId, address.id)
createdAddresses.push(address)
}
const sellRoom = await createAuctionRoom(owner.context, owner, {
name: 'nip5 sales',
description: 'Auctions for Nip5',
fee_wallet_id: owner.walletId,
currency: 'USD',
type: 'fixed_price'
})
await updateAuctionRoom(owner.context, owner, {
id: sellRoom.id,
name: 'nip5 sells',
description: 'Sells for Nip5',
currency: 'sat',
type: 'fixed_price',
room_percentage: 20,
fee_wallet_id: owner.walletId,
is_open_room: true,
extra: auctionNip5Webhooks(domainId)
})
const auctionRoom = await createAuctionRoom(owner.context, owner, {
name: 'nip5 auctions',
description: 'Auctions for Nip5',
fee_wallet_id: owner.walletId,
currency: 'USD',
type: 'auction'
})
await updateAuctionRoom(owner.context, owner, {
id: auctionRoom.id,
name: 'nip5 auctions',
description: 'Auctions for Nip5',
currency: 'sat',
type: 'auction',
min_bid_up_percentage: 5,
room_percentage: 10,
fee_wallet_id: owner.walletId,
is_open_room: true,
extra: auctionNip5Webhooks(domainId)
})
const sellerAddresses = await jsonRequest(
seller.context,
'get',
'/nostrnip5/api/v1/user/addresses',
{
headers: apiKeyHeaders(seller.inkey),
params: {active: true}
}
)
expect(Array.isArray(sellerAddresses)).toBeTruthy()
const addressForAuction = createdAddresses[0]
const transfer = await jsonRequest(
seller.context,
'get',
`/nostrnip5/api/v1/domain/${domainId}/address/${addressForAuction.id}/transfer`,
{headers: apiKeyHeaders(seller.adminkey)}
)
expect(transfer.transfer_code).toBeTruthy()
const item = await createAuctionItem(seller.context, seller, auctionRoom.id, {
name: addressForAuction.local_part,
ask_price: 10.1,
transfer_code: transfer.transfer_code
})
let winningBidder
for (let index = 1; index <= 15; index++) {
const bidder = await newUser(`auction-bidder-${index}`)
const bid = await bidOnItem(
bidder.context,
bidder,
item.id,
index * 100,
`bid index: ${index}`
)
await payInvoiceViaUi(admin, bid.payment_request)
winningBidder = bidder
}
await expect
.poll(
async () => {
const bids = await jsonRequest(
owner.context,
'get',
`/auction_house/api/v1/bids/${item.id}/paginated`,
{
headers: apiKeyHeaders(owner.inkey),
params: {limit: 20}
}
)
return bids.total
},
{timeout: 15_000}
)
.toBeGreaterThanOrEqual(15)
await getPayments(owner.context, owner.inkey)
await jsonRequest(
owner.context,
'delete',
`/auction_house/api/v1/items/${item.id}`,
{
headers: apiKeyHeaders(owner.adminkey),
params: {force_close: true}
}
)
const winnerAddresses = await jsonRequest(
winningBidder.context,
'get',
'/nostrnip5/api/v1/user/addresses',
{
headers: apiKeyHeaders(winningBidder.inkey),
params: {active: true}
}
)
expect(JSON.stringify(winnerAddresses)).toContain(
addressForAuction.local_part
)
const fixedPriceAddress = createdAddresses[1]
const fixedItems = await jsonRequest(
seller.context,
'get',
`/auction_house/api/v1/items/${auctionRoom.id}/paginated`,
{
headers: apiKeyHeaders(seller.inkey),
params: {
sortby: 'ask_price',
direction: 'asc',
name: fixedPriceAddress.local_part
}
}
)
if (fixedItems.data?.[0]?.id) {
await jsonRequest(
seller.context,
'delete',
`/auction_house/api/v1/items/${fixedItems.data[0].id}`,
{
headers: apiKeyHeaders(seller.adminkey),
params: {force_close: true}
}
)
}
const fixedTransfer = await jsonRequest(
seller.context,
'get',
`/nostrnip5/api/v1/domain/${domainId}/address/${fixedPriceAddress.id}/transfer`,
{headers: apiKeyHeaders(seller.adminkey)}
)
const sellItem = await createAuctionItem(
seller.context,
seller,
sellRoom.id,
{
name: fixedPriceAddress.local_part,
ask_price: 5000,
transfer_code: fixedTransfer.transfer_code
}
)
const buyer = await newUser('auction-buyer')
const buy = await bidOnItem(
buyer.context,
buyer,
sellItem.id,
5000,
'fixed price buy'
)
await payInvoiceViaUi(admin, buy.payment_request)
await expect
.poll(
async () => {
const buyerAddresses = await jsonRequest(
buyer.context,
'get',
'/nostrnip5/api/v1/user/addresses',
{
headers: apiKeyHeaders(buyer.inkey),
params: {active: true}
}
)
return JSON.stringify(buyerAddresses)
},
{timeout: 15_000}
)
.toContain(fixedPriceAddress.local_part)
})
-387
View File
@@ -1,387 +0,0 @@
const {expect} = require('@playwright/test')
const DEFAULT_MANIFEST_URL =
'https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json'
function apiKeyHeaders(key) {
return key ? {'X-Api-Key': key} : {}
}
async function expectStatus(response, expected, label) {
const statuses = Array.isArray(expected) ? expected : [expected]
if (!statuses.includes(response.status())) {
const body = await response.text()
throw new Error(
`${label || response.url()} expected status ${statuses.join(
'/'
)}, got ${response.status()}: ${body.slice(0, 800)}`
)
}
}
async function responseJson(response) {
const text = await response.text()
if (!text) return null
try {
return JSON.parse(text)
} catch (error) {
throw new Error(
`Expected JSON from ${response.url()}: ${text.slice(0, 800)}`
)
}
}
async function jsonRequest(context, method, url, options = {}) {
const response = await context[method](url, {
data: options.data,
form: options.form,
headers: options.headers,
params: options.params,
failOnStatusCode: false,
maxRedirects: options.maxRedirects
})
await expectStatus(
response,
options.expected ?? 200,
`${method.toUpperCase()} ${url}`
)
return responseJson(response)
}
async function textRequest(context, method, url, options = {}) {
const response = await context[method](url, {
data: options.data,
headers: options.headers,
params: options.params,
failOnStatusCode: false,
maxRedirects: options.maxRedirects
})
await expectStatus(
response,
options.expected ?? 200,
`${method.toUpperCase()} ${url}`
)
return response.text()
}
async function loginAdmin(context) {
await jsonRequest(context, 'post', '/api/v1/auth', {
data: {username: 'admin', password: 'secret1234'}
})
}
async function initServer(context) {
const home = await context.get('/', {
failOnStatusCode: false,
maxRedirects: 0
})
const location = home.headers().location || ''
if (
[301, 302, 303, 307, 308].includes(home.status()) &&
location.includes('first_install')
) {
await jsonRequest(context, 'put', '/api/v1/auth/first_install', {
data: {
username: 'admin',
password: 'secret1234',
password_repeat: 'secret1234'
}
})
}
await loginAdmin(context)
const wallets = await jsonRequest(context, 'get', '/api/v1/wallets')
expect(wallets.length).toBeGreaterThan(0)
const adminWallet = wallets[0]
await jsonRequest(context, 'put', '/admin/api/v1/settings', {
data: {lnbits_callback_url_rules: []}
})
await topUpWallet(context, adminWallet.id, 1_000_000)
return {
walletId: adminWallet.id,
inkey: adminWallet.inkey,
adminkey: adminWallet.adminkey
}
}
async function logout(context) {
await jsonRequest(context, 'post', '/api/v1/auth/logout')
}
async function createAccount(context, name) {
await textRequest(context, 'get', '/')
const wallet = await jsonRequest(context, 'post', '/api/v1/account', {
data: {name}
})
await jsonRequest(context, 'post', '/api/v1/auth/usr', {
data: {usr: wallet.user}
})
return {
userId: wallet.user,
walletId: wallet.id,
inkey: wallet.inkey,
adminkey: wallet.adminkey,
raw: wallet
}
}
async function createWallet(context, name) {
const wallet = await jsonRequest(context, 'post', '/api/v1/wallet', {
data: {name}
})
return {
userId: wallet.user,
walletId: wallet.id,
inkey: wallet.inkey,
adminkey: wallet.adminkey,
raw: wallet
}
}
async function topUpWallet(adminContext, walletId, amount) {
return jsonRequest(adminContext, 'put', '/users/api/v1/balance', {
data: {amount: String(amount), id: walletId}
})
}
async function enableExtension(context, extension) {
const enabled = await jsonRequest(
context,
'put',
`/api/v1/extension/${extension}/enable`,
{
form: {enable: extension}
}
)
if (enabled && Object.prototype.hasOwnProperty.call(enabled, 'is_enabled')) {
expect(enabled.is_enabled).toBeTruthy()
}
return enabled
}
async function disableExtension(context, extension) {
return jsonRequest(context, 'put', `/api/v1/extension/${extension}/disable`, {
form: {enable: extension}
})
}
async function deactivateExtension(context, extension) {
return jsonRequest(
context,
'put',
`/api/v1/extension/${extension}/deactivate`
)
}
async function activateExtension(context, extension, expected = 200) {
return jsonRequest(
context,
'put',
`/api/v1/extension/${extension}/activate`,
{
expected
}
)
}
async function uninstallExtension(context, extension) {
const response = await context.delete(`/api/v1/extension/${extension}`, {
failOnStatusCode: false
})
await expectStatus(
response,
[200, 204, 404],
`DELETE /api/v1/extension/${extension}`
)
}
async function pageStatus(context, path, expected = 200) {
const response = await context.get(path, {failOnStatusCode: false})
await expectStatus(response, expected, `GET ${path}`)
return response
}
async function getWallet(context, inkey) {
return jsonRequest(context, 'get', '/api/v1/wallet', {
headers: apiKeyHeaders(inkey)
})
}
async function getPayments(context, inkey, params) {
return jsonRequest(context, 'get', '/api/v1/payments', {
headers: apiKeyHeaders(inkey),
params
})
}
async function createInvoice(context, adminkey, amount, memo) {
return jsonRequest(context, 'post', '/api/v1/payments', {
headers: apiKeyHeaders(adminkey),
data: {out: false, amount, memo}
})
}
async function payInvoice(context, adminkey, bolt11, expected = 201) {
return jsonRequest(context, 'post', '/api/v1/payments', {
headers: apiKeyHeaders(adminkey),
data: {out: true, bolt11},
expected
})
}
async function getPayment(context, inkey, paymentHash) {
return jsonRequest(context, 'get', `/api/v1/payments/${paymentHash}`, {
headers: apiKeyHeaders(inkey)
})
}
async function decodePayment(context, data) {
return jsonRequest(context, 'post', '/api/v1/payments/decode', {
data: {data}
})
}
async function lnurlScan(context, lnurl, key) {
return jsonRequest(
context,
'get',
`/api/v1/lnurlscan/${encodeURIComponent(lnurl)}`,
{
headers: apiKeyHeaders(key)
}
)
}
async function clearMirror() {
const response = await fetch(mirrorUrl('/__mirror__/clear'))
if (response.status !== 204) {
throw new Error(`Expected mirror clear status 204, got ${response.status}`)
}
}
async function getMirrorRequests() {
const response = await fetch(mirrorUrl('/__mirror__/requests'))
if (!response.ok) {
throw new Error(`Cannot fetch mirror requests: ${response.status}`)
}
return response.json()
}
async function pollPayment(
context,
inkey,
paymentHash,
predicate,
timeout = 10_000
) {
let last
await expect
.poll(
async () => {
last = await getPayment(context, inkey, paymentHash)
return predicate(last)
},
{timeout}
)
.toBeTruthy()
return last
}
async function pollWalletBalance(
context,
inkey,
expectedBalance,
timeout = 10_000
) {
await expect
.poll(
async () => {
const wallet = await getWallet(context, inkey)
return wallet.balance
},
{timeout}
)
.toBe(expectedBalance)
}
async function fetchManifest() {
const manifestUrl =
process.env.EXTENSIONS_MANIFEST_URL || DEFAULT_MANIFEST_URL
const response = await fetch(manifestUrl)
if (!response.ok) {
throw new Error(
`Cannot fetch extension manifest ${manifestUrl}: ${response.status}`
)
}
return response.json()
}
async function latestRelease(context, extension) {
const releases = await jsonRequest(
context,
'get',
`/api/v1/extension/${extension}/releases`
)
expect(releases.length).toBeGreaterThan(0)
return releases
.slice()
.sort((a, b) =>
a.version.localeCompare(b.version, undefined, {numeric: true})
)
.at(-1)
}
async function installLatestExtension(context, extension) {
const release = await latestRelease(context, extension)
return jsonRequest(context, 'post', '/api/v1/extension', {
data: {
ext_id: extension,
archive: release.archive,
source_repo: release.source_repo,
version: release.version
}
})
}
function mirrorUrl(path = '') {
const port = process.env.MIRROR_PORT || '8500'
return `http://localhost:${port}${path}`
}
module.exports = {
apiKeyHeaders,
activateExtension,
clearMirror,
createAccount,
createInvoice,
createWallet,
deactivateExtension,
decodePayment,
disableExtension,
enableExtension,
expectStatus,
fetchManifest,
getMirrorRequests,
getPayment,
getPayments,
getWallet,
initServer,
installLatestExtension,
jsonRequest,
latestRelease,
lnurlScan,
loginAdmin,
logout,
mirrorUrl,
pageStatus,
payInvoice,
pollPayment,
pollWalletBalance,
responseJson,
textRequest,
topUpWallet,
uninstallExtension
}
-990
View File
@@ -1,990 +0,0 @@
const {test, expect} = require('@playwright/test')
const {
apiKeyHeaders,
clearMirror,
createAccount,
createWallet,
decodePayment,
expectStatus,
fetchManifest,
getPayment,
getPayments,
getWallet,
initServer,
jsonRequest,
latestRelease,
loginAdmin,
lnurlScan,
mirrorUrl,
pageStatus,
pollPayment,
pollWalletBalance,
responseJson,
textRequest,
topUpWallet,
uninstallExtension
} = require('./helpers')
const state = {
baseURL: null,
adminContext: null,
adminWallet: null,
adminSession: null,
extensionById: null,
contextFactory: null
}
const contexts = []
const browserContexts = []
async function newContext() {
const context = await state.contextFactory.newContext({
baseURL: state.baseURL,
extraHTTPHeaders: {
Accept: 'application/json, text/plain, */*'
}
})
contexts.push(context)
return context
}
async function newUser(name) {
const context = await newContext()
const account = await createAccount(context, `${name}-${Date.now()}`)
return {context, ...account}
}
async function newBrowserSession(browser, account) {
const uiContext = await browser.newContext({baseURL: state.baseURL})
browserContexts.push(uiContext)
if (account?.userId) {
await textRequest(uiContext.request, 'get', '/')
await jsonRequest(uiContext.request, 'post', '/api/v1/auth/usr', {
data: {usr: account.userId}
})
} else {
await loginAdmin(uiContext.request)
}
const page = await uiContext.newPage()
page.setDefaultTimeout(45_000)
return {uiContext, page}
}
async function newUserWithUi(browser, name) {
const user = await newUser(name)
return {...user, ...(await newBrowserSession(browser, user))}
}
async function getAdminSession(browser) {
if (!state.adminSession) {
state.adminSession = {
...state.adminWallet,
...(await newBrowserSession(browser))
}
}
return state.adminSession
}
function extensionName(extensionId) {
return state.extensionById?.get(extensionId)?.name || extensionId
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function extensionSearchTerms(extensionId) {
return [...new Set([extensionName(extensionId), extensionId].filter(Boolean))]
}
async function refreshExtensionCatalog(context = state.adminContext) {
const extensions = await jsonRequest(context, 'get', '/api/v1/extension')
for (const extension of extensions) {
const id = extension.id || extension.code
if (id) {
state.extensionById.set(id, {
id,
name: extension.name || extension.title || id
})
}
}
}
function responsePath(response) {
return new URL(response.url()).pathname
}
function responseMatches(response, method, path) {
return (
response.request().method() === method && responsePath(response) === path
)
}
async function clickAndCheckResponse(
page,
method,
path,
action,
expected = 200,
timeout = 45_000
) {
const responsePromise = page.waitForResponse(
response => responseMatches(response, method, path),
{timeout}
)
await action()
const response = await responsePromise
await expectStatus(response, expected, `${method} ${path}`)
return response
}
async function clickAndGetJsonResponse(
page,
method,
path,
action,
expected = 200
) {
const response = await clickAndCheckResponse(
page,
method,
path,
action,
expected
)
return responseJson(response)
}
async function clickAndMaybeGetJsonResponse(
page,
method,
path,
action,
expected = 200,
timeout = 10_000
) {
const responsePromise = page
.waitForResponse(response => responseMatches(response, method, path), {
timeout
})
.catch(() => null)
await action()
const response = await responsePromise
if (!response) return null
await expectStatus(response, expected, `${method} ${path}`)
return responseJson(response)
}
async function gotoPage(page, path, expected = 200) {
const response = await page.goto(path)
if (response) {
await expectStatus(response, expected, `GET ${path}`)
}
await page.waitForFunction(() => window.LNbits && window.g)
const credentialsButton = page.getByRole('button', {name: /i understand/i})
if (await credentialsButton.isVisible({timeout: 1000}).catch(() => false)) {
await credentialsButton.click()
}
return response
}
async function gotoWallet(session, walletId = session.walletId) {
await gotoPage(session.page, `/wallet/${walletId}`)
await expect(
session.page.getByRole('button', {name: /receive/i})
).toBeVisible()
}
async function fillQuasarField(scope, label, value) {
const field = scope
.locator('.q-field')
.filter({hasText: label})
.locator('input, textarea')
.first()
await field.fill(String(value))
}
async function createInvoiceViaUi(session, amount, memo) {
await gotoWallet(session)
await session.page.getByRole('button', {name: /receive/i}).click()
const dialog = session.page
.locator('.q-dialog')
.filter({hasText: 'Create Invoice'})
.last()
await fillQuasarField(dialog, 'Amount (sat)', amount)
await fillQuasarField(dialog, 'Memo', memo)
const invoice = await clickAndGetJsonResponse(
session.page,
'POST',
'/api/v1/payments',
() => dialog.getByRole('button', {name: /create invoice/i}).click(),
[200, 201]
)
expect(invoice.bolt11).toBeTruthy()
return invoice
}
async function payInvoiceViaUi(session, bolt11, expected = 201) {
await gotoWallet(session)
await session.page.getByRole('button', {name: /send/i}).click()
const dialog = session.page.locator('.q-dialog').last()
await fillQuasarField(dialog, 'Paste an invoice', bolt11)
await dialog.getByRole('button', {name: /read/i}).click()
await expect(dialog.getByRole('button', {name: /^pay$/i})).toBeVisible()
return clickAndGetJsonResponse(
session.page,
'POST',
'/api/v1/payments',
() => dialog.getByRole('button', {name: /^pay$/i}).click(),
expected
)
}
async function payLnurlViaUi(session, lnurl, amountMsat, comment) {
await gotoWallet(session)
await session.page.getByRole('button', {name: /send/i}).click()
const dialog = session.page.locator('.q-dialog').last()
await fillQuasarField(dialog, 'Paste an invoice', lnurl)
await clickAndCheckResponse(session.page, 'POST', '/api/v1/lnurlscan', () =>
dialog.getByRole('button', {name: /read/i}).click()
)
await fillQuasarField(dialog, 'Amount (sat)', amountMsat / 1000)
if (comment) {
await fillQuasarField(dialog, 'Comment (optional)', comment)
}
const payment = await clickAndGetJsonResponse(
session.page,
'POST',
'/api/v1/payments/lnurl',
() => dialog.getByRole('button', {name: /^send$/i}).click()
)
expect(payment.payment_hash).toBeTruthy()
return payment
}
async function withdrawLnurlViaUi(
session,
lnurl,
amount = 10,
memo = 'withdraw 1',
walletId = session.walletId
) {
await gotoWallet(session, walletId)
await session.page.getByRole('button', {name: /send/i}).click()
const scanDialog = session.page.locator('.q-dialog').last()
await fillQuasarField(scanDialog, 'Paste an invoice', lnurl)
await clickAndCheckResponse(session.page, 'POST', '/api/v1/lnurlscan', () =>
scanDialog.getByRole('button', {name: /read/i}).click()
)
const withdrawDialog = session.page
.locator('.q-dialog')
.filter({hasText: 'Withdraw from'})
.last()
await fillQuasarField(withdrawDialog, 'Amount (sat)', amount)
await fillQuasarField(withdrawDialog, 'Memo', memo)
const payment = await clickAndGetJsonResponse(
session.page,
'POST',
'/api/v1/payments',
() => withdrawDialog.getByRole('button', {name: /withdraw from/i}).click(),
[200, 201]
)
expect(payment.payment_hash).toBeTruthy()
return payment
}
async function openExtensionsPage(session, tab = 'installed') {
const extensionsLoaded = session.page
.waitForResponse(response =>
responseMatches(response, 'GET', '/api/v1/extension')
)
.catch(() => null)
await gotoPage(session.page, '/extensions')
await extensionsLoaded
await session.page.waitForLoadState('networkidle').catch(() => null)
await session.page.getByLabel(/search extensions/i).waitFor()
const tabButton = session.page.getByRole('tab', {
name: new RegExp(`^${tab}$`, 'i')
})
await tabButton.click()
await expect(tabButton).toHaveAttribute('aria-selected', 'true')
}
async function findExtensionCard(session, extensionId, tab = 'installed') {
await openExtensionsPage(session, tab)
const search = session.page.getByLabel(/search extensions/i)
const cards = session.page.locator('.q-card').filter({
visible: true,
has: session.page.getByRole('button', {
name: /manage|enable|disable|open|pay to enable/i
})
})
for (const term of extensionSearchTerms(extensionId)) {
await search.fill(term)
const matchingCard = cards
.filter({hasText: new RegExp(escapeRegExp(term), 'i')})
.first()
if (await matchingCard.isVisible({timeout: 1500}).catch(() => false)) {
return matchingCard
}
if ((await cards.count()) === 1) {
return cards.first()
}
}
throw new Error(`Could not find extension card for ${extensionId}`)
}
async function openExtensionManager(session, extensionId, tab = 'installed') {
const card = await findExtensionCard(session, extensionId, tab)
const releasesResponsePromise = session.page
.waitForResponse(
response =>
responseMatches(
response,
'GET',
`/api/v1/extension/${extensionId}/releases`
),
{timeout: 10_000}
)
.catch(() => null)
await card.getByRole('button', {name: /manage/i}).click()
const releasesResponse = await releasesResponsePromise
if (releasesResponse) {
await expectStatus(
releasesResponse,
200,
`GET /api/v1/extension/${extensionId}/releases`
)
}
const dialog = session.page.locator('.q-dialog').filter({hasText: 'Releases'})
await expect(dialog.last()).toBeVisible()
return dialog.last()
}
async function installExtensionVersionViaUi(session, extensionId, version) {
let dialog
try {
dialog = await openExtensionManager(session, extensionId, 'installed')
} catch {
dialog = await openExtensionManager(session, extensionId, 'all')
}
const versionLabels = [
...new Set([version, version.replace(/^v/i, '')].filter(Boolean))
]
let releaseVersion = null
for (const label of versionLabels) {
const candidate = dialog
.getByText(label, {exact: true})
.filter({visible: true})
.first()
if (await candidate.isVisible({timeout: 1000}).catch(() => false)) {
releaseVersion = candidate
break
}
}
if (!releaseVersion) {
const expandButton = dialog.getByRole('button', {name: /expand/i}).first()
if (!(await expandButton.isVisible({timeout: 1000}).catch(() => false))) {
const alreadyInstalled = await dialog
.getByRole('button', {name: /uninstall/i})
.isVisible({timeout: 500})
.catch(() => false)
await dialog.getByRole('button', {name: /close/i}).last().click()
return alreadyInstalled ? {alreadyInstalled: true} : null
}
await expandButton.click()
for (const label of versionLabels) {
const candidate = dialog
.getByText(label, {exact: true})
.filter({visible: true})
.first()
if (await candidate.isVisible({timeout: 1000}).catch(() => false)) {
releaseVersion = candidate
break
}
}
}
if (!releaseVersion) {
releaseVersion = dialog
.getByText(versionLabels[0], {exact: true})
.filter({visible: true})
.first()
}
await expect(releaseVersion).toBeVisible()
await releaseVersion.click()
const installButton = dialog.getByRole('button', {name: /^install$/i}).first()
if (!(await installButton.isVisible({timeout: 1000}).catch(() => false))) {
const alreadyInstalled = await dialog
.getByRole('button', {name: /uninstall/i})
.isVisible({timeout: 500})
.catch(() => false)
await dialog.getByRole('button', {name: /close/i}).last().click()
return alreadyInstalled ? {alreadyInstalled: true} : null
}
const activatePromise = session.page
.waitForResponse(response =>
responseMatches(
response,
'PUT',
`/api/v1/extension/${extensionId}/activate`
)
)
.catch(() => null)
let installResponsePromise = session.page.waitForResponse(response =>
responseMatches(response, 'POST', '/api/v1/extension')
)
await installButton.click()
const grantButton = session.page.getByRole('button', {
name: /grant and install/i
})
if (await grantButton.isVisible({timeout: 1000}).catch(() => false)) {
installResponsePromise.catch(() => null)
installResponsePromise = session.page.waitForResponse(response =>
responseMatches(response, 'POST', '/api/v1/extension')
)
await grantButton.click()
}
const installResponse = await installResponsePromise
await expectStatus(installResponse, [200, 201], 'POST /api/v1/extension')
const activateResponse = await activatePromise
if (activateResponse) {
await expectStatus(
activateResponse,
200,
`PUT /api/v1/extension/${extensionId}/activate`
)
}
return responseJson(installResponse)
}
async function installLatestExtensionViaUi(session, extensionId) {
const release = await latestRelease(session.uiContext.request, extensionId)
return installExtensionVersionViaUi(session, extensionId, release.version)
}
async function ensureExtensionsInstalledViaUi(session, extensionIds) {
for (const extensionId of extensionIds) {
const installed = await findExtensionCard(session, extensionId, 'installed')
.then(() => true)
.catch(() => false)
if (!installed) {
await installLatestExtensionViaUi(session, extensionId)
}
await setExtensionActiveViaUi(session, extensionId, true)
}
}
async function enableExtensionViaUi(session, extensionId) {
const card = await findExtensionCard(session, extensionId)
const alreadyEnabled =
(await card.getByRole('button', {name: /open/i}).isVisible()) ||
(await card.getByRole('button', {name: /^disable$/i}).isVisible())
if (alreadyEnabled) {
return null
}
const payToEnableButton = card.getByRole('button', {name: /pay to enable/i})
if (await payToEnableButton.isVisible()) {
await payToEnableButton.click()
const dialog = session.page
.locator('.q-dialog')
.filter({hasText: 'Recheck'})
await expect(dialog.last()).toBeVisible()
return clickAndGetJsonResponse(
session.page,
'PUT',
`/api/v1/extension/${extensionId}/enable`,
() => session.page.getByText(/recheck/i).click()
)
}
const enableButton = card.getByRole('button', {name: /^enable$/i})
const result = await clickAndMaybeGetJsonResponse(
session.page,
'PUT',
`/api/v1/extension/${extensionId}/enable`,
() => enableButton.click()
)
if (result) return result
const cardAfterEnable = await findExtensionCard(session, extensionId)
await expect(
cardAfterEnable.getByRole('button', {name: /^disable$/i})
).toBeVisible()
return {success: true}
}
async function disableExtensionViaUi(session, extensionId) {
const card = await findExtensionCard(session, extensionId)
if (!(await card.getByRole('button', {name: /^disable$/i}).isVisible())) {
return null
}
const disableButton = card.getByRole('button', {name: /^disable$/i})
const result = await clickAndMaybeGetJsonResponse(
session.page,
'PUT',
`/api/v1/extension/${extensionId}/disable`,
() => disableButton.click()
)
if (result) return result
const cardAfterDisable = await findExtensionCard(session, extensionId)
await expect(
cardAfterDisable.getByRole('button', {name: /^enable$/i})
).toBeVisible()
return {success: true}
}
async function setExtensionActiveViaUi(
session,
extensionId,
active,
expected = 200
) {
const card = await findExtensionCard(session, extensionId)
const action = active ? 'activate' : 'deactivate'
const toggle = card.locator('.q-toggle').first()
const alreadySet = await toggle
.innerText()
.then(text =>
text.toLowerCase().includes(active ? 'activated' : 'deactivated')
)
if (alreadySet && expected === 200) return null
const result = await clickAndMaybeGetJsonResponse(
session.page,
'PUT',
`/api/v1/extension/${extensionId}/${action}`,
() => toggle.click(),
expected
)
if (result) return result
const cardAfterToggle = await findExtensionCard(session, extensionId)
await expect(cardAfterToggle.locator('.q-toggle').first()).toContainText(
active ? 'Activated' : 'Deactivated'
)
return {success: true}
}
async function payToEnableExtensionViaUi(session, extensionId) {
const card = await findExtensionCard(session, extensionId)
await card.getByRole('button', {name: /pay to enable/i}).click()
const dialog = session.page.locator('.q-dialog').filter({hasText: 'Recheck'})
await expect(dialog.last()).toBeVisible()
const invoice = await clickAndGetJsonResponse(
session.page,
'PUT',
`/api/v1/extension/${extensionId}/invoice/enable`,
() =>
dialog
.last()
.getByRole('button', {name: /show qr/i})
.click()
)
await dialog.last().getByRole('button', {name: /close/i}).click()
await payInvoiceViaUi(session, invoice.payment_request || invoice.bolt11)
const cardAfterPayment = await findExtensionCard(session, extensionId)
if (
await cardAfterPayment.getByRole('button', {name: /^enable$/i}).isVisible()
) {
return clickAndGetJsonResponse(
session.page,
'PUT',
`/api/v1/extension/${extensionId}/enable`,
() => cardAfterPayment.getByRole('button', {name: /^enable$/i}).click()
)
}
await cardAfterPayment.getByRole('button', {name: /pay to enable/i}).click()
return clickAndGetJsonResponse(
session.page,
'PUT',
`/api/v1/extension/${extensionId}/enable`,
() => session.page.getByText(/recheck/i).click()
)
}
async function createPayLink(context, account, index, overrides = {}) {
const data = compactObject({
description: `receive payments ${index}`,
min: 11,
comment_chars: 128,
webhook_url: mirrorUrl(),
webhook_headers: '{"h1": "1"}',
webhook_body: '{"b2": 2}',
success_text: 'All goood!',
success_url: 'https://lnbits.com',
max: Number(`${index}1`),
...overrides
})
const link = await jsonRequest(context, 'post', '/lnurlp/api/v1/links', {
headers: apiKeyHeaders(account.adminkey),
data,
expected: 201
})
expect(link.id).toBeTruthy()
expect(link.wallet).toBe(account.walletId)
return link
}
async function createWithdrawLink(context, account, index, overrides = {}) {
const link = await jsonRequest(context, 'post', '/withdraw/api/v1/links', {
headers: apiKeyHeaders(account.adminkey),
data: {
is_unique: false,
use_custom: false,
title: `withdraw ${index}`,
min_withdrawable: 10,
max_withdrawable: index * 10,
uses: 5,
wait_time: 1,
webhook_url: mirrorUrl(),
webhook_headers: '{"h1": "1"}',
webhook_body: '{"b2": 2}',
custom_url: null,
...overrides
},
expected: 201
})
expect(link.id).toBeTruthy()
expect(link.wallet).toBe(account.walletId)
expect(link.title).toBe(`withdraw ${index}`)
return link
}
async function withdrawLnurl(
context,
receiveWallet,
withdrawResponse,
amount = 10,
memo = 'withdraw 1'
) {
return jsonRequest(context, 'post', '/api/v1/payments', {
headers: apiKeyHeaders(receiveWallet.inkey),
data: {
out: false,
amount,
memo,
unit: 'sat',
lnurl_withdraw: withdrawResponse
},
expected: 201
})
}
async function lndhubHeaders(accessToken) {
return {Authorization: `Bearer ${accessToken}`}
}
async function createNip5Domain(context, account, extra = {}) {
const {cost_extra: extraCost = {}, ...extraFields} = extra
return jsonRequest(context, 'post', '/nostrnip5/api/v1/domain', {
headers: apiKeyHeaders(account.adminkey),
data: {
cost_extra: {
max_years: '3',
char_count_cost: [],
rank_cost: [],
...extraCost
},
wallet: account.walletId,
currency: 'USD',
domain: `nostr-${Date.now()}.com`,
cost: '100',
...extraFields
},
expected: 201
})
}
async function createUserNip5Address(context, account, domainId, localPart) {
const address = await jsonRequest(
context,
'post',
`/nostrnip5/api/v1/user/domain/${domainId}/address`,
{
headers: apiKeyHeaders(account.adminkey),
data: {
config: {relays: ['relay.nostr.com']},
pubkey:
'04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9',
local_part: localPart,
domain_id: domainId
},
expected: 201
}
)
expect(address.id).toBeTruthy()
return address
}
async function createNip5Address(
context,
account,
domainId,
localPart,
pubkey
) {
const address = await jsonRequest(
context,
'post',
`/nostrnip5/api/v1/domain/${domainId}/address`,
{
headers: apiKeyHeaders(account.adminkey),
data: {
config: {relays: ['relay.nostr.com']},
pubkey,
local_part: localPart,
domain_id: domainId
},
expected: 201
}
)
expect(address.id).toBeTruthy()
return address
}
async function activateNip5Address(context, account, domainId, addressId) {
return jsonRequest(
context,
'put',
`/nostrnip5/api/v1/domain/${domainId}/address/${addressId}/activate`,
{
headers: apiKeyHeaders(account.adminkey)
}
)
}
async function getNostrJson(context, domainId, name, expected = 200) {
const url = `/nostrnip5/api/v1/domain/${domainId}/nostr.json`
const response = await context.get(url, {
params: {name},
failOnStatusCode: false
})
await expectStatus(
response,
expected === 404 ? [404, 200] : expected,
`GET ${url}`
)
if (response.status() === 404) return null
const data = await responseJson(response)
if (expected === 404) {
expect(data.names ?? {}).toEqual({})
expect(data.relays ?? {}).toEqual({})
}
return data
}
async function buyNip5Address(context, account, domainId, localPart, options) {
return jsonRequest(
context,
'post',
`/nostrnip5/api/v1/user/domain/${domainId}/address`,
{
headers: apiKeyHeaders(account.adminkey),
data: {
domain_id: domainId,
local_part: localPart,
pubkey: options.pubkey,
years: options.years,
promo_code: options.promo_code ?? null,
referer: options.referer ?? null,
create_invoice: options.create_invoice ?? false
},
expected: options.expected ?? 201
}
)
}
async function createAuctionRoom(context, owner, data) {
const room = await jsonRequest(
context,
'post',
'/auction_house/api/v1/auction_room',
{
headers: apiKeyHeaders(owner.adminkey),
data,
expected: 201
}
)
expect(room.id).toBeTruthy()
return room
}
async function updateAuctionRoom(context, owner, data) {
const room = await jsonRequest(
context,
'put',
'/auction_house/api/v1/auction_room',
{
headers: apiKeyHeaders(owner.adminkey),
data
}
)
expect(room.id).toBe(data.id)
return room
}
async function createAuctionItem(context, seller, roomId, data) {
const item = await jsonRequest(
context,
'post',
`/auction_house/api/v1/items/${roomId}`,
{
headers: apiKeyHeaders(seller.adminkey),
data,
expected: 201
}
)
expect(item.id).toBeTruthy()
return item
}
async function bidOnItem(context, bidder, itemId, amount, memo) {
const bid = await jsonRequest(
context,
'put',
`/auction_house/api/v1/bids/${itemId}`,
{
headers: apiKeyHeaders(bidder.adminkey),
data: {amount, ln_address: '', memo},
expected: 201
}
)
expect(bid.payment_request).toBeTruthy()
return bid
}
function setupIntegration() {
test.beforeAll(async ({playwright}, testInfo) => {
state.baseURL = testInfo.project.use.baseURL
state.contextFactory = playwright.request
state.adminSession = null
state.adminContext = await newContext()
state.adminWallet = await initServer(state.adminContext)
const manifest = await fetchManifest()
state.extensionById = new Map(
manifest.extensions.map(extension => [extension.id, extension])
)
})
test.afterAll(async () => {
await Promise.all(contexts.map(context => context.dispose()))
await Promise.all(browserContexts.map(context => context.close()))
contexts.splice(0, contexts.length)
browserContexts.splice(0, browserContexts.length)
state.baseURL = null
state.adminContext = null
state.adminWallet = null
state.adminSession = null
state.extensionById = null
state.contextFactory = null
})
}
function compactObject(value) {
return Object.fromEntries(
Object.entries(value).filter(([, entry]) => entry !== undefined)
)
}
function auctionNip5Webhooks(domainId) {
return {
duration: {days: 7, hours: 0, minutes: 0},
lock_webhook: {
method: 'PUT',
url: `${state.baseURL}/nostrnip5/api/v1/domain/${domainId}/address/lock`,
headers: '',
data: '{\n "transfer_code": "${transfer_code}"\n}'
},
unlock_webhook: {
method: 'PUT',
url: `${state.baseURL}/nostrnip5/api/v1/domain/${domainId}/address/unlock`,
headers: '',
data: '{\n "lock_code": "${lock_code}"\n}'
},
transfer_webhook: {
method: 'PUT',
url: `${state.baseURL}/nostrnip5/api/v1/domain/${domainId}/address/transfer`,
headers: '',
data: '{\n "lock_code": "${lock_code}",\n "new_owner_id": "${new_owner_id}"\n}'
}
}
}
module.exports = {
test,
expect,
setupIntegration,
state,
apiKeyHeaders,
clearMirror,
createAccount,
createWallet,
decodePayment,
expectStatus,
fetchManifest,
getPayment,
getPayments,
getWallet,
initServer,
jsonRequest,
latestRelease,
loginAdmin,
lnurlScan,
mirrorUrl,
pageStatus,
pollPayment,
pollWalletBalance,
responseJson,
textRequest,
topUpWallet,
uninstallExtension,
newContext,
newUser,
newBrowserSession,
newUserWithUi,
getAdminSession,
extensionName,
refreshExtensionCatalog,
responsePath,
responseMatches,
clickAndCheckResponse,
clickAndGetJsonResponse,
gotoPage,
gotoWallet,
fillQuasarField,
createInvoiceViaUi,
payInvoiceViaUi,
payLnurlViaUi,
withdrawLnurlViaUi,
openExtensionsPage,
findExtensionCard,
openExtensionManager,
installExtensionVersionViaUi,
installLatestExtensionViaUi,
ensureExtensionsInstalledViaUi,
enableExtensionViaUi,
disableExtensionViaUi,
setExtensionActiveViaUi,
payToEnableExtensionViaUi,
createPayLink,
createWithdrawLink,
withdrawLnurl,
lndhubHeaders,
createNip5Domain,
createUserNip5Address,
createNip5Address,
activateNip5Address,
getNostrJson,
buyNip5Address,
createAuctionRoom,
updateAuctionRoom,
createAuctionItem,
bidOnItem,
compactObject,
auctionNip5Webhooks
}
-109
View File
@@ -1,109 +0,0 @@
const fs = require('fs')
const http = require('http')
const path = require('path')
const {spawn} = require('child_process')
const root = path.resolve(__dirname, '..', '..')
const host = process.env.HOST || '127.0.0.1'
const port = process.env.PORT || '5000'
const mirrorPort = process.env.MIRROR_PORT || '8500'
const dataFolder =
process.env.LNBITS_DATA_FOLDER ||
path.join(root, 'tests', 'data', 'integration')
if (process.env.LNBITS_INTEGRATION_RESET_DATA !== 'false') {
fs.rmSync(dataFolder, {recursive: true, force: true})
}
fs.mkdirSync(dataFolder, {recursive: true})
const mirrorRequests = []
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = []
req.on('data', chunk => chunks.push(chunk))
req.on('error', reject)
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
})
}
function parseBody(body) {
if (!body) return null
try {
return JSON.parse(body)
} catch {
return body
}
}
const mirror = http.createServer(async (req, res) => {
const bodyText = await readBody(req)
const body = parseBody(bodyText)
const record = {
method: req.method,
url: req.url,
headers: req.headers,
body,
bodyText,
receivedAt: new Date().toISOString()
}
if (req.url === '/__mirror__/clear') {
mirrorRequests.splice(0, mirrorRequests.length)
res.writeHead(204)
res.end()
return
}
if (req.url === '/__mirror__/requests') {
res.writeHead(200, {'content-type': 'application/json'})
res.end(JSON.stringify(mirrorRequests))
return
}
mirrorRequests.push(record)
res.writeHead(200, {
'content-type': 'application/json',
h1: '1'
})
res.end(JSON.stringify(body ?? record))
})
mirror.listen(Number(mirrorPort), '127.0.0.1', () => {
console.log(`Mirror server listening on http://127.0.0.1:${mirrorPort}`)
})
const env = {
...process.env,
HOST: host,
PORT: port,
LNBITS_DATA_FOLDER: dataFolder,
LNBITS_ADMIN_UI: process.env.LNBITS_ADMIN_UI || 'true',
AUTH_HTTPS_ONLY: process.env.AUTH_HTTPS_ONLY || 'false',
LNBITS_BACKEND_WALLET_CLASS:
process.env.LNBITS_BACKEND_WALLET_CLASS || 'FakeWallet',
LNBITS_EXTENSIONS_DEFAULT_INSTALL:
process.env.LNBITS_EXTENSIONS_DEFAULT_INSTALL ||
'watchonly,satspay,tipjar,tpos,lnurlp,withdraw'
}
const lnbits = spawn('uv', ['run', 'lnbits', '--host', host, '--port', port], {
cwd: root,
env,
stdio: 'inherit'
})
function shutdown(signal) {
mirror.close()
if (!lnbits.killed) {
lnbits.kill(signal)
}
}
process.on('SIGINT', () => shutdown('SIGINT'))
process.on('SIGTERM', () => shutdown('SIGTERM'))
lnbits.on('exit', code => {
mirror.close()
process.exit(code ?? 0)
})
-10
View File
@@ -12,7 +12,6 @@ from lnbits.core.crud.users import delete_account
from lnbits.core.models import User
from lnbits.core.models.users import AccessTokenPayload
from lnbits.decorators import (
_extension_id_from_request_path,
access_token_payload,
check_access_token,
check_admin_ui,
@@ -226,12 +225,3 @@ async def test_check_extension_builder_requires_admin_when_disabled_for_users(
admin_user = user_alan.copy(deep=True)
admin_user.admin = True
await check_extension_builder(admin_user)
def test_extension_id_from_request_path_handles_wasm_routes():
assert _extension_id_from_request_path("/ext/wasm_demo") == "wasm_demo"
assert _extension_id_from_request_path("/ext/wasm_demo/page/1") == "wasm_demo"
assert (
_extension_id_from_request_path("/api/v1/ext/wasm_demo/invoices") == "wasm_demo"
)
assert _extension_id_from_request_path("/lnurlp/api/v1") == "lnurlp"
-17
View File
@@ -2,7 +2,6 @@ import json
import pytest
from lnbits.core.models.extensions import ExtensionPermission
from lnbits.db import (
dict_to_model,
dict_to_submodel,
@@ -104,19 +103,3 @@ async def test_helpers_dict_to_model_ignores_unknown_fields():
model = dict_to_model({**test_dict, "ignored": "field"}, DbTestModel3)
assert model == test_data
@pytest.mark.anyio
async def test_helpers_dict_to_model_handles_list_any_fields():
model = dict_to_model(
{
"id": "http.request",
"policies": '[{"host": "https://api.example.com"}]',
},
ExtensionPermission,
)
assert model == ExtensionPermission(
id="http.request",
policies=[{"host": "https://api.example.com"}],
)
-233
View File
@@ -1,6 +1,3 @@
import json
import zipfile
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from uuid import uuid4
@@ -14,7 +11,6 @@ from lnbits.core.crud import (
)
from lnbits.core.models.extensions import (
Extension,
ExtensionPermission,
InstallableExtension,
ReleasePaymentInfo,
)
@@ -154,59 +150,6 @@ async def test_install_extension_updates_existing_upgrade_and_preserves_payments
start_mock.assert_awaited_once_with(ext_id)
@pytest.mark.anyio
async def test_install_wasm_extension_requires_permissions_and_skips_background_work(
tmp_path,
settings: Settings,
mocker: MockerFixture,
):
ext_id = f"wasm_{uuid4().hex[:8]}"
ext_info = make_installable_extension(ext_id)
original_data_folder = settings.lnbits_data_folder
original_extensions_path = settings.lnbits_extensions_path
start_mock = mocker.patch(
"lnbits.core.services.extensions.start_extension_background_work",
mocker.AsyncMock(return_value=True),
)
try:
settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path / "code")
_write_wasm_extension_archive(ext_info, _wasm_install_config(ext_id))
with pytest.raises(ValueError, match="requires permission approval"):
await install_extension(ext_info, skip_download=True)
granted_permissions = [
ExtensionPermission(
id="http.request",
policies=[{"host": "https://api.example.com"}],
)
]
extension = await install_extension(
ext_info,
skip_download=True,
granted_permissions=granted_permissions,
)
stored = await get_installed_extension(ext_id)
finally:
await delete_installed_extension(ext_id=ext_id)
settings.lnbits_data_folder = original_data_folder
settings.lnbits_extensions_path = original_extensions_path
assert extension.code == ext_id
assert extension.is_wasm is True
assert stored is not None
assert stored.permissions == [
ExtensionPermission(
id="http.request",
description="Call example API.",
policies=[{"host": "https://api.example.com"}],
)
]
start_mock.assert_not_awaited()
@pytest.mark.anyio
async def test_uninstall_activate_and_deactivate_extensions(
tmp_path, settings: Settings, mocker: MockerFixture
@@ -261,35 +204,6 @@ async def test_uninstall_activate_and_deactivate_extensions(
assert start_mock.await_count == 1
@pytest.mark.anyio
async def test_wasm_invocation_monitoring_marks_stale_once_and_cleans_periodically(
settings: Settings,
mocker: MockerFixture,
):
_reset_wasm_invocation_state()
now = datetime(2026, 1, 1, tzinfo=timezone.utc)
mark_stale_mock = mocker.patch(
"lnbits.core.services.extensions.mark_stale_wasm_invocations",
mocker.AsyncMock(),
)
cleanup_mock = mocker.patch(
"lnbits.core.services.extensions.delete_old_wasm_invocations",
mocker.AsyncMock(),
)
mocker.patch(
"lnbits.core.services.extensions._now",
side_effect=[now, now + timedelta(minutes=1), now + timedelta(hours=2)],
)
await extension_services.ensure_wasm_invocation_monitoring_ready()
await extension_services.ensure_wasm_invocation_monitoring_ready()
await extension_services.ensure_wasm_invocation_monitoring_ready()
mark_stale_mock.assert_awaited_once()
assert cleanup_mock.await_count == 2
cleanup_mock.assert_awaited_with(settings.lnbits_wasm_invocation_retention_days)
@pytest.mark.anyio
async def test_stop_extension_background_work_handles_missing_and_async_stops(
mocker: MockerFixture,
@@ -373,72 +287,6 @@ async def test_wasm_invocation_tracking_counts_and_stops(mocker: MockerFixture):
assert saved.storage_call_count == 1
@pytest.mark.anyio
async def test_wasm_invocation_context_and_error_message_are_sanitized(
mocker: MockerFixture,
):
_reset_wasm_invocation_state()
create_mock = mocker.patch(
"lnbits.core.services.extensions.create_wasm_invocation",
mocker.AsyncMock(),
)
update_mock = mocker.patch(
"lnbits.core.services.extensions.update_wasm_invocation",
mocker.AsyncMock(),
)
mocker.patch(
"lnbits.core.services.extensions.get_wasm_invocation",
mocker.AsyncMock(return_value=None),
)
mocker.patch(
"lnbits.core.services.extensions.mark_stale_wasm_invocations",
mocker.AsyncMock(),
)
mocker.patch(
"lnbits.core.services.extensions.delete_old_wasm_invocations",
mocker.AsyncMock(),
)
long_key = "k" * 80
secret_hex = "a" * 64
invocation = await start_wasm_invocation(
extension_id="demoext",
export_name="render",
context={
long_key: "v" * 300,
"attempt": 2,
"enabled": True,
"nested": {"raw": "value"},
7: "ignored",
},
)
await finish_wasm_invocation(
invocation.id,
status="failed",
error_type="RuntimeError",
error_message=f"api_key=supersecret Bearer abc.def {secret_hex} " + ("x" * 600),
)
create_mock.assert_awaited_once()
assert create_mock.await_args is not None
created_invocation = create_mock.await_args.args[0]
assert created_invocation.context == {
"k" * 64: "v" * 256,
"attempt": 2,
"enabled": True,
}
update_mock.assert_awaited_once()
assert update_mock.await_args is not None
saved_invocation = update_mock.await_args.args[0]
assert saved_invocation.error_message is not None
assert "supersecret" not in saved_invocation.error_message
assert "abc.def" not in saved_invocation.error_message
assert secret_hex not in saved_invocation.error_message
assert "api_key=[redacted]" in saved_invocation.error_message
assert "Bearer [redacted]" in saved_invocation.error_message
assert "[redacted-hex]" in saved_invocation.error_message
def _reset_wasm_invocation_state():
with extension_services._wasm_invocation_lock:
extension_services._wasm_invocation_handles.clear()
@@ -446,35 +294,6 @@ def _reset_wasm_invocation_state():
extension_services._wasm_invocations_last_cleanup_at = None
def _write_wasm_extension_archive(
ext_info: InstallableExtension,
config: dict,
) -> None:
ext_info.zip_path.parent.mkdir(parents=True, exist_ok=True)
root = f"{ext_info.id}-{ext_info.version}"
with zipfile.ZipFile(ext_info.zip_path, "w") as archive:
archive.writestr(f"{root}/config.json", json.dumps(config))
archive.writestr(f"{root}/{config['wasm']['module']}", b"\0asm")
def _wasm_install_config(ext_id: str) -> dict:
return {
"id": ext_id,
"name": f"WASM {ext_id}",
"short_description": "WASM extension",
"version": "1.0.0",
"extension_type": "wasm",
"wasm": {"module": "extension.wasm"},
"permissions": [
{
"id": "http.request",
"description": "Call example API.",
"policies": [{"host": "https://api.example.com"}],
}
],
}
def test_wasm_runtime_limits_merge_sparse_extension_overrides(settings: Settings):
original_execution_ms = settings.wasm_runtime_max_execution_ms
original_memory_bytes = settings.wasm_runtime_max_memory_bytes
@@ -670,58 +489,6 @@ async def test_wasm_invocation_host_call_limits(mocker: MockerFixture):
await finish_wasm_invocation(invocation.id, status="failed")
@pytest.mark.anyio
async def test_wasm_invocation_host_call_category_limits_can_be_disabled(
mocker: MockerFixture,
):
_reset_wasm_invocation_state()
mocker.patch(
"lnbits.core.services.extensions.create_wasm_invocation",
mocker.AsyncMock(),
)
mocker.patch(
"lnbits.core.services.extensions.update_wasm_invocation",
mocker.AsyncMock(),
)
mocker.patch(
"lnbits.core.services.extensions.get_wasm_invocation",
mocker.AsyncMock(return_value=None),
)
mocker.patch(
"lnbits.core.services.extensions.mark_stale_wasm_invocations",
mocker.AsyncMock(),
)
mocker.patch(
"lnbits.core.services.extensions.delete_old_wasm_invocations",
mocker.AsyncMock(),
)
limits = extension_services.wasm_runtime_limit_defaults()
limits["wasm_runtime_max_host_calls"] = 10
limits["wasm_runtime_max_http_calls"] = 1
invocation = await start_wasm_invocation(
extension_id="demoext",
export_name="render",
runtime_limits=limits,
)
record_wasm_invocation_host_call(invocation.id, "http.request")
with pytest.raises(ValueError, match="http host call limit"):
record_wasm_invocation_host_call(invocation.id, "extension.api.request")
await finish_wasm_invocation(invocation.id, status="failed")
limits["wasm_runtime_max_host_calls"] = 0
limits["wasm_runtime_max_http_calls"] = 0
unlimited_invocation = await start_wasm_invocation(
extension_id="demoext",
export_name="render",
runtime_limits=limits,
)
for _ in range(5):
record_wasm_invocation_host_call(unlimited_invocation.id, "http.request")
await finish_wasm_invocation(unlimited_invocation.id, status="completed")
@pytest.mark.anyio
async def test_start_extension_background_work_handles_missing_and_sync_starts(
mocker: MockerFixture,
-18
View File
@@ -27,7 +27,6 @@ from lnbits.core.services.payments import (
check_pending_payments,
check_time_limit_between_transactions,
check_transaction_status,
check_wallet_daily_withdraw_limit,
check_wallet_limits,
create_payment_request,
get_payments_daily_stats,
@@ -249,23 +248,6 @@ async def test_check_wallet_limits_and_time_limit(
settings.lnbits_wallet_limit_secs_between_trans = original_limit
@pytest.mark.anyio
async def test_check_wallet_daily_limit_counts_all_daily_payments(settings: Settings):
wallet = await _create_wallet()
await _create_payment(wallet, amount_msat=-2_000, status=PaymentState.SUCCESS)
await _create_payment(wallet, amount_msat=-3_000, status=PaymentState.SUCCESS)
original_limit = settings.lnbits_wallet_limit_daily_max_withdraw
try:
settings.lnbits_wallet_limit_daily_max_withdraw = 5
with pytest.raises(
ValueError, match="Daily withdrawal limit of 5 sats reached."
):
await check_wallet_daily_withdraw_limit(wallet.id, 1_000)
finally:
settings.lnbits_wallet_limit_daily_max_withdraw = original_limit
@pytest.mark.anyio
async def test_calculate_fiat_amounts_handles_conversion_and_errors(
mocker: MockerFixture,
@@ -1,301 +0,0 @@
from types import SimpleNamespace
from typing import cast
import httpx
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.core.wasm_ext.api.models import ExtensionApiRequest
from lnbits.core.wasm_ext.client import extensions as extension_client
from lnbits.settings import Settings
def test_wasm_extension_api_path_validation():
assert (
extension_client._extension_api_path("/api/v1/payments?limit=1")
== "/api/v1/payments?limit=1"
)
for path, message in [
("https://example.com/api/v1/payments", "relative"),
("/wallet", "start with '/api/'"),
("/api/v1/payments#frag", "fragment"),
("/api/v1/../admin", "traverse"),
("/api/v1/%2e%2e/admin", "traverse"),
("/api//v1/payments", "invalid"),
]:
with pytest.raises(PermissionError, match=message):
extension_client._extension_api_path(path)
def test_wasm_extension_api_target_and_access_validation():
assert extension_client._target_extension_id(" target_ext ") == "target_ext"
for extension_id in ["", "../admin", "bad.ext"]:
with pytest.raises(PermissionError, match="invalid target"):
extension_client._target_extension_id(extension_id)
assert extension_client._target_extension_access(["target"], "target") == {"read"}
assert extension_client._target_extension_access(
[{"id": "target", "access": ["read", "write", "ignored"]}],
"target",
) == {"read", "write"}
read_request = ExtensionApiRequest(
extension_id="target",
method="GET",
path="/api/v1/demo",
body=None,
)
write_request = ExtensionApiRequest(
extension_id="target",
method="POST",
path="/api/v1/demo",
body="{}",
)
extension_client._require_method_access(
"caller",
"target",
{"read"},
read_request,
)
with pytest.raises(PermissionError, match="cannot write"):
extension_client._require_method_access(
"caller",
"target",
{"read"},
write_request,
)
@pytest.mark.anyio
async def test_wasm_extension_api_request_enforces_auth_policy_and_user_enablement(
settings: Settings,
mocker: MockerFixture,
):
settings.host = "127.0.0.1"
settings.port = 5000
mocker.patch(
"lnbits.core.wasm_ext.client.extensions.get_installed_extension",
mocker.AsyncMock(return_value=SimpleNamespace(active=True)),
)
mocker.patch(
"lnbits.core.wasm_ext.client.extensions.get_user_active_extensions_ids",
mocker.AsyncMock(return_value=["target"]),
)
client = _FakeAsyncClient(
_FakeStreamResponse(
status_code=202,
headers={"set-cookie": "secret", "x-result": "ok"},
chunks=[b'{"accepted":true}'],
)
)
mocker.patch(
"lnbits.core.wasm_ext.client.extensions.httpx.AsyncClient",
client.factory,
)
response = await extension_client.send_extension_api_request(
"caller",
[{"id": "target", "access": ["write"]}],
"user-id",
"access-token",
ExtensionApiRequest(
extension_id="target",
method="POST",
path="/api/v1/run?value=1",
body="{}",
),
timeout_ms=750,
max_response_bytes=100,
)
assert response.status_code == 202
assert response.body == '{"accepted":true}'
assert response.headers == {"x-result": "ok"}
assert client.kwargs["follow_redirects"] is False
assert client.kwargs["trust_env"] is False
assert client.kwargs["timeout"] == 0.75
assert (
client.stream_kwargs["url"] == "http://127.0.0.1:5000/target/api/v1/run?value=1"
)
assert client.stream_kwargs["headers"] == {"Authorization": "Bearer access-token"}
@pytest.mark.anyio
async def test_wasm_extension_api_request_rejects_missing_auth_and_disabled_targets(
mocker: MockerFixture,
):
request = ExtensionApiRequest(
extension_id="target",
method="GET",
path="/api/v1/run",
body=None,
)
with pytest.raises(PermissionError, match="authentication"):
await extension_client.send_extension_api_request(
"caller",
["target"],
None,
"access-token",
request,
)
with pytest.raises(PermissionError, match="access token"):
await extension_client.send_extension_api_request(
"caller",
["target"],
"user-id",
None,
request,
)
mocker.patch(
"lnbits.core.wasm_ext.client.extensions.get_installed_extension",
mocker.AsyncMock(return_value=SimpleNamespace(active=False)),
)
with pytest.raises(PermissionError, match="not installed or enabled"):
await extension_client.send_extension_api_request(
"caller",
["target"],
"user-id",
"access-token",
request,
)
mocker.patch(
"lnbits.core.wasm_ext.client.extensions.get_installed_extension",
mocker.AsyncMock(return_value=SimpleNamespace(active=True)),
)
mocker.patch(
"lnbits.core.wasm_ext.client.extensions.get_user_active_extensions_ids",
mocker.AsyncMock(return_value=[]),
)
with pytest.raises(PermissionError, match="not active for this user"):
await extension_client.send_extension_api_request(
"caller",
["target"],
"user-id",
"access-token",
request,
)
@pytest.mark.anyio
async def test_wasm_extension_api_request_rejects_oversized_body_and_response(
mocker: MockerFixture,
):
mocker.patch(
"lnbits.core.wasm_ext.client.extensions.get_installed_extension",
mocker.AsyncMock(return_value=SimpleNamespace(active=True)),
)
mocker.patch(
"lnbits.core.wasm_ext.client.extensions.get_user_active_extensions_ids",
mocker.AsyncMock(return_value=["target"]),
)
with pytest.raises(ValueError, match="body is too large"):
await extension_client.send_extension_api_request(
"caller",
[{"id": "target", "access": ["write"]}],
"user-id",
"access-token",
ExtensionApiRequest.construct(
extension_id="target",
method="POST",
path="/api/v1/run",
body="x" * 65_537,
),
)
with pytest.raises(ValueError, match="response is too large"):
await extension_client._read_limited_response(
cast(httpx.Response, _FakeStreamResponse(chunks=[b"12345", b"67890"])),
max_response_bytes=8,
)
@pytest.mark.anyio
async def test_wasm_extension_api_request_hides_transport_errors(
mocker: MockerFixture,
):
mocker.patch(
"lnbits.core.wasm_ext.client.extensions.get_installed_extension",
mocker.AsyncMock(return_value=SimpleNamespace(active=True)),
)
mocker.patch(
"lnbits.core.wasm_ext.client.extensions.get_user_active_extensions_ids",
mocker.AsyncMock(return_value=["target"]),
)
client = _FakeAsyncClient(_FakeStreamError())
mocker.patch(
"lnbits.core.wasm_ext.client.extensions.httpx.AsyncClient",
client.factory,
)
with pytest.raises(ValueError, match="Extension API request failed"):
await extension_client.send_extension_api_request(
"caller",
["target"],
"user-id",
"access-token",
ExtensionApiRequest(
extension_id="target",
method="GET",
path="/api/v1/run",
body=None,
),
)
class _FakeAsyncClient:
def __init__(self, response):
self.response = response
self.kwargs = {}
self.stream_kwargs = {}
def factory(self, **kwargs):
self.kwargs = kwargs
return self
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return False
def stream(self, method, url, **kwargs):
self.stream_kwargs = {"method": method, "url": url, **kwargs}
return self.response
class _FakeStreamResponse:
def __init__(
self,
*,
status_code: int = 200,
headers: dict[str, str] | None = None,
chunks: list[bytes] | None = None,
):
self.status_code = status_code
self.headers = headers or {}
self.encoding = "utf-8"
self._chunks = chunks or []
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return False
async def aiter_bytes(self):
for chunk in self._chunks:
yield chunk
class _FakeStreamError:
async def __aenter__(self):
raise httpx.RequestError(
"network failed",
request=httpx.Request("GET", "http://127.0.0.1"),
)
async def __aexit__(self, *_args):
return False
-295
View File
@@ -1,295 +0,0 @@
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.core.db import core_app_extra
from lnbits.core.models.extensions import ExtensionPermission
from lnbits.core.wasm_ext.wasm.config import parse_wasm_extension_config
from lnbits.core.wasm_ext.wasm.events import (
_payment_extension_id,
_payment_source_id,
_wasm_public_invoice_source_tables_from_permissions,
dispatch_wasm_invoice_paid,
)
from lnbits.core.wasm_ext.wasm.loader import WasmExtension
from lnbits.helpers import sha256s
def test_wasm_invoice_paid_helpers_extract_extension_and_source_tables():
payment = SimpleNamespace(
extension="",
extra={"tag": "demoext", "source_id": "row-1"},
tag="fallback",
)
permissions = [
ExtensionPermission(
id="wallet.create_invoice_public",
policies=[
{"table": "tip_jars", "wallet_field": "wallet_id"},
{"table": "", "wallet_field": "wallet_id"},
],
),
ExtensionPermission(id="http.request"),
]
assert _payment_extension_id(payment) == "demoext"
assert _payment_source_id(payment) == "row-1"
assert _wasm_public_invoice_source_tables_from_permissions(permissions) == [
"tip_jars"
]
@pytest.mark.anyio
async def test_dispatch_wasm_invoice_paid_invokes_registered_event_export_with_owner(
mocker: MockerFixture,
):
ext_id = "demo_event_ext"
extension = _wasm_extension(ext_id)
registry = core_app_extra.wasm_extension_registry
registry.register(extension)
installed_extension = SimpleNamespace(
permissions=[
ExtensionPermission(
id="wallet.create_invoice_public",
policies=[{"table": "tip_jars", "wallet_field": "wallet_id"}],
)
]
)
mocker.patch(
"lnbits.core.wasm_ext.wasm.events.get_installed_extension",
mocker.AsyncMock(return_value=installed_extension),
)
mocker.patch(
"lnbits.core.wasm_ext.wasm.events.get_wallet",
mocker.AsyncMock(return_value=None),
)
storage_mock = mocker.patch(
"lnbits.core.wasm_ext.wasm.events.storage_get_row_owner_id",
mocker.AsyncMock(return_value="owner-1"),
)
invoke_mock = mocker.patch(
"lnbits.core.wasm_ext.wasm.events.invoke_wasm_extension_export",
mocker.AsyncMock(),
)
payment = _payment(ext_id)
try:
await dispatch_wasm_invoice_paid(payment)
finally:
registry._extensions.pop(ext_id, None)
storage_mock.assert_awaited_once_with(ext_id, "tip_jars", "row-1")
invoke_mock.assert_awaited_once()
assert invoke_mock.await_args is not None
args = invoke_mock.await_args.args
kwargs = invoke_mock.await_args.kwargs
assert args[0] == ext_id
assert args[1] == "on_invoice_paid"
assert args[2]["paymentHash"] == "payment-hash"
assert args[2]["payment"] == {"id": "payment-row"}
assert kwargs["context"] == "event"
assert kwargs["owner_id"] == "owner-1"
assert kwargs["trigger_type"] == "event"
assert kwargs["event_type"] == "invoice_paid"
assert kwargs["wallet_id"] == "wallet-1"
assert kwargs["payment_hash"] == "payment-hash"
assert kwargs["checking_id"] == "checking-id"
@pytest.mark.anyio
async def test_dispatch_wasm_invoice_paid_skips_invalid_event_export_visibility(
mocker: MockerFixture,
):
ext_id = "demo_public_event_ext"
extension = _wasm_extension(ext_id, visibility="public")
registry = core_app_extra.wasm_extension_registry
registry.register(extension)
invoke_mock = mocker.patch(
"lnbits.core.wasm_ext.wasm.events.invoke_wasm_extension_export",
mocker.AsyncMock(),
)
mocker.patch(
"lnbits.core.wasm_ext.wasm.events.get_wallet",
mocker.AsyncMock(return_value=None),
)
try:
await dispatch_wasm_invoice_paid(_payment(ext_id))
finally:
registry._extensions.pop(ext_id, None)
invoke_mock.assert_not_awaited()
@pytest.mark.anyio
async def test_dispatch_wasm_invoice_paid_invokes_wallet_watch_grant(
mocker: MockerFixture,
):
ext_id = "demo_wallet_watch_ext"
extension = _wasm_extension(ext_id)
registry = core_app_extra.wasm_extension_registry
registry.register(extension)
mocker.patch(
"lnbits.core.wasm_ext.wasm.events.get_wallet",
mocker.AsyncMock(return_value=SimpleNamespace(id="wallet-1", user="user-1")),
)
mocker.patch(
"lnbits.core.wasm_ext.wasm.events.get_user_extensions",
mocker.AsyncMock(
return_value=[
SimpleNamespace(
active=True,
extension=ext_id,
permissions={
"wallet.payments.watch": [
{
"id": "grant-1",
"wallet_id": "wallet-1",
"enabled": True,
}
]
},
)
]
),
)
mocker.patch(
"lnbits.core.wasm_ext.wasm.events.get_installed_extension",
mocker.AsyncMock(
return_value=SimpleNamespace(
active=True,
permissions=[ExtensionPermission(id="wallet.payments.watch")],
)
),
)
invoke_mock = mocker.patch(
"lnbits.core.wasm_ext.wasm.events.invoke_wasm_extension_export",
mocker.AsyncMock(),
)
try:
await dispatch_wasm_invoice_paid(_payment(""))
finally:
registry._extensions.pop(ext_id, None)
invoke_mock.assert_awaited_once()
assert invoke_mock.await_args is not None
args = invoke_mock.await_args.args
kwargs = invoke_mock.await_args.kwargs
assert args[0] == ext_id
assert args[1] == "on_invoice_paid"
assert args[2]["paymentHash"] == "payment-hash"
assert kwargs["context"] == "event"
assert kwargs["owner_id"] == sha256s("user-1")
@pytest.mark.anyio
async def test_dispatch_wasm_invoice_paid_dedupes_tagged_wallet_watch_grant(
mocker: MockerFixture,
):
ext_id = "demo_dedupe_event_ext"
extension = _wasm_extension(ext_id)
registry = core_app_extra.wasm_extension_registry
registry.register(extension)
mocker.patch(
"lnbits.core.wasm_ext.wasm.events.get_wallet",
mocker.AsyncMock(return_value=SimpleNamespace(id="wallet-1", user="user-1")),
)
mocker.patch(
"lnbits.core.wasm_ext.wasm.events.get_user_extensions",
mocker.AsyncMock(
return_value=[
SimpleNamespace(
active=True,
extension=ext_id,
permissions={
"wallet.payments.watch": [
{
"id": "grant-1",
"wallet_id": "wallet-1",
"enabled": True,
}
]
},
)
]
),
)
mocker.patch(
"lnbits.core.wasm_ext.wasm.events.get_installed_extension",
mocker.AsyncMock(
return_value=SimpleNamespace(
active=True,
permissions=[ExtensionPermission(id="wallet.payments.watch")],
)
),
)
invoke_mock = mocker.patch(
"lnbits.core.wasm_ext.wasm.events.invoke_wasm_extension_export",
mocker.AsyncMock(),
)
try:
await dispatch_wasm_invoice_paid(_payment(ext_id, extra={}))
finally:
registry._extensions.pop(ext_id, None)
invoke_mock.assert_awaited_once()
assert invoke_mock.await_args is not None
assert invoke_mock.await_args.kwargs["owner_id"] == sha256s("user-1")
def _wasm_extension(ext_id: str, *, visibility: str = "event") -> WasmExtension:
config = parse_wasm_extension_config(
ext_id,
{
"id": ext_id,
"name": "Demo event extension",
"short_description": "Demo",
"version": "1.0.0",
"extension_type": "wasm",
"wasm": {
"module": "extension.wasm",
"exports": [
{
"name": "on_invoice_paid",
"visibility": visibility,
}
],
},
"events": {"onInvoicePaid": "on_invoice_paid"},
},
)
root_path = Path(__file__).resolve().parent / ext_id
return WasmExtension(
id=ext_id,
name=config.name,
version=config.version,
root_path=root_path,
module_path=root_path / "extension.wasm",
wit_path=None,
world=config.wasm.world,
exports=config.wasm.exports,
config=config,
)
def _payment(ext_id: str, *, extra: dict | None = None) -> SimpleNamespace:
return SimpleNamespace(
extension=ext_id,
extra={"source_id": "row-1"} if extra is None else extra,
tag=None,
wallet_id="wallet-1",
payment_hash="payment-hash",
checking_id="checking-id",
amount=1000,
fee=0,
bolt11="lnbc1",
memo="memo",
pending=False,
status="success",
json=lambda: json.dumps({"id": "payment-row"}),
)
@@ -1,57 +0,0 @@
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_wasm_frontend_assets_are_registered_in_component_bundle():
package_json = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))
components = package_json["bundle"]["components"]
assert "js/wasm-extension-component.js" in components
assert "js/components/lnbits-extension-permissions.js" in components
assert "js/components/admin/lnbits-admin-wasm-runtime.js" in components
assert "js/components/admin/lnbits-admin-wasm-limit-config.js" in components
def test_wasm_frontend_bridge_restricts_api_routes_and_payment_actions():
bridge = (ROOT / "lnbits/static/js/wasm-extension-component.js").read_text(
encoding="utf-8"
)
assert "allowedApiRoute(method, path)" in bridge
assert "url.origin !== window.location.origin" in bridge
assert "Extension API route is not allowed." in bridge
assert "message.action === 'payment.subscribe'" in bridge
assert "message.action === 'payment.unsubscribe'" in bridge
assert "message.action === 'ui.scan_qr'" in bridge
def test_wasm_extension_install_ui_requests_permissions_before_install_paths():
extensions_page = (ROOT / "lnbits/static/js/pages/extensions.js").read_text(
encoding="utf-8"
)
wasm_bulk_update_skip_message = (
"Skipping ${ext.id}; this extension update requires permission approval."
)
assert "await this.resolveExtensionPermissionGrant(release)" in extensions_page
assert "permissions: grantedPermissions" in extensions_page
assert "release.extension_type === 'wasm'" in extensions_page
assert "this.selectedExtension?.isWasm === true" in extensions_page
assert wasm_bulk_update_skip_message in extensions_page
def test_wasm_admin_frontend_calls_runtime_limit_and_invocation_endpoints():
runtime = (
ROOT / "lnbits/static/js/components/admin/lnbits-admin-wasm-runtime.js"
).read_text(encoding="utf-8")
limits = (
ROOT / "lnbits/static/js/components/admin/lnbits-admin-wasm-limit-config.js"
).read_text(encoding="utf-8")
assert "/api/v1/extension/wasm/invocations/current" in runtime
assert "/api/v1/extension/wasm/invocations?" in runtime
assert "/api/v1/extension/wasm/invocations/stats?" in runtime
assert "/api/v1/extension/wasm/runtime-limits/extensions" in limits
assert "/api/v1/extension/wasm/runtime-limits/${encodeURIComponent(" in limits
-348
View File
@@ -1,348 +0,0 @@
import json
from types import SimpleNamespace
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.core.models.extensions import ExtensionPermission
from lnbits.core.wasm_ext.api.host import ExtensionHostAPI
from lnbits.core.wasm_ext.api.models import (
CreateInvoicePublicRequest,
EmptyRequest,
PayInvoiceRequest,
StorageGetRequest,
WalletBalanceRequest,
)
from lnbits.exceptions import PaymentError
from lnbits.helpers import sha256s
@pytest.mark.anyio
async def test_host_api_filters_public_storage_fields(mocker: MockerFixture):
storage_mock = mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_get_public_row",
mocker.AsyncMock(
return_value={
"id": "tip-1",
"title": "Tip jar",
"wallet_id": "secret-wallet",
}
),
)
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[{"table_name": "tips", "public_fields": ["id", "title"]}],
)
],
)
response = await api.storage_get_public(StorageGetRequest(table="tips", id="tip-1"))
assert json.loads(response.data_json or "{}") == {
"id": "tip-1",
"title": "Tip jar",
}
storage_mock.assert_awaited_once_with("demoext", "tips", "tip-1")
@pytest.mark.anyio
async def test_host_api_storage_requires_owner_context_and_uses_user_hash(
mocker: MockerFixture,
):
api_without_owner = ExtensionHostAPI(
"demoext",
["ext.storage.read"],
context="event",
)
with pytest.raises(PermissionError, match="owner context"):
await api_without_owner.storage_get(StorageGetRequest(table="notes", id="1"))
storage_mock = mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_get_row",
mocker.AsyncMock(return_value={"id": "1", "title": "Note"}),
)
api = ExtensionHostAPI("demoext", ["ext.storage.read"], user_id="user-1")
response = await api.storage_get(StorageGetRequest(table="notes", id="1"))
assert json.loads(response.data_json or "{}") == {"id": "1", "title": "Note"}
storage_mock.assert_awaited_once_with(
"demoext",
"notes",
"1",
sha256s("user-1"),
)
@pytest.mark.anyio
async def test_host_api_wallet_methods_require_permissions_and_user_wallets(
mocker: MockerFixture,
):
api_without_permission = ExtensionHostAPI("demoext", [], user_id="user-1")
with pytest.raises(PermissionError, match="wallet.list"):
await api_without_permission.wallet_list_user_wallets(EmptyRequest())
wallet = SimpleNamespace(id="wallet-1", name="Wallet", currency="USD")
mocker.patch(
"lnbits.core.crud.wallets.get_wallets",
mocker.AsyncMock(return_value=[wallet]),
)
api = ExtensionHostAPI("demoext", ["wallet.list"], user_id="user-1")
response = await api.wallet_list_user_wallets(EmptyRequest())
assert response.wallets[0].id == "wallet-1"
assert response.wallets[0].name == "Wallet"
@pytest.mark.anyio
async def test_host_api_wallet_balance_rejects_other_users_wallets(
mocker: MockerFixture,
):
mocker.patch(
"lnbits.core.crud.wallets.get_wallet",
mocker.AsyncMock(return_value=SimpleNamespace(id="wallet-1", user="other")),
)
api = ExtensionHostAPI(
"demoext",
["wallet.balance.read"],
user_id="user-1",
)
with pytest.raises(PermissionError, match="not allowed"):
await api.wallet_balance(WalletBalanceRequest(wallet_id="wallet-1"))
@pytest.mark.anyio
async def test_host_api_wallet_balance_returns_safe_summary(mocker: MockerFixture):
wallet = SimpleNamespace(
id="wallet-1",
user="user-1",
name="Wallet",
currency="USD",
balance_msat=10_000,
balance=10,
withdrawable_balance=8_000,
can_send_payments=True,
)
mocker.patch(
"lnbits.core.crud.wallets.get_wallet",
mocker.AsyncMock(return_value=wallet),
)
api = ExtensionHostAPI(
"demoext",
["wallet.balance.read"],
user_id="user-1",
)
response = await api.wallet_balance(WalletBalanceRequest(wallet_id="wallet-1"))
assert response.wallet_id == "wallet-1"
assert response.balance_msat == 10_000
assert response.withdrawable_msat == 8_000
assert response.fee_reserve_msat == 2_000
assert response.can_send_payments is True
@pytest.mark.anyio
async def test_host_api_pay_invoice_checks_wallet_owner_and_returns_payment_errors(
mocker: MockerFixture,
):
wallet = SimpleNamespace(id="wallet-1", user="user-1")
mocker.patch(
"lnbits.core.crud.wallets.get_wallet",
mocker.AsyncMock(return_value=wallet),
)
mocker.patch(
"lnbits.core.services.payments.pay_invoice",
mocker.AsyncMock(side_effect=PaymentError("insufficient balance")),
)
api = ExtensionHostAPI("demoext", ["wallet.pay_invoice"], user_id="user-1")
response = await api.wallet_pay_invoice(
PayInvoiceRequest(
wallet_id="wallet-1",
payment_request="lnbc1demo",
max_sat=None,
description="Demo",
extra={"source": "test"},
)
)
assert response.ok is False
assert response.error == "insufficient balance"
@pytest.mark.anyio
async def test_host_api_background_pay_invoice_requires_background_permission(
mocker: MockerFixture,
):
wallet = SimpleNamespace(id="wallet-1", user="user-1")
mocker.patch(
"lnbits.core.crud.wallets.get_wallet",
mocker.AsyncMock(return_value=wallet),
)
api = ExtensionHostAPI("demoext", [], context="event")
response = await api.wallet_pay_invoice(
PayInvoiceRequest(
wallet_id="wallet-1",
payment_request="lnbc1demo",
max_sat=None,
description="Demo",
extra={},
)
)
assert response.ok is False
assert "wallet.pay_invoice_background" in (response.error or "")
@pytest.mark.anyio
async def test_host_api_background_pay_invoice_uses_background_grant_metadata(
mocker: MockerFixture,
):
wallet = SimpleNamespace(id="wallet-1", user="user-1")
mocker.patch(
"lnbits.core.crud.wallets.get_wallet",
mocker.AsyncMock(return_value=wallet),
)
mocker.patch(
"lnbits.core.wasm_ext.api.host.invoice_amount_msat",
return_value=21_000,
)
background_extra_mock = mocker.patch(
"lnbits.core.wasm_ext.api.host.background_payment_extra",
mocker.AsyncMock(
return_value={
"tag": "demoext",
"background_payment": True,
"background_permission": "wallet.pay_invoice_background",
"background_wallet_id": "wallet-1",
}
),
)
payment = SimpleNamespace(
checking_id="checking-id",
payment_hash="payment-hash",
status="success",
amount=-21_000,
fee=0,
pending=False,
success=True,
)
pay_mock = mocker.patch(
"lnbits.core.services.payments.pay_invoice",
mocker.AsyncMock(return_value=payment),
)
api = ExtensionHostAPI(
"demoext",
["wallet.pay_invoice_background"],
context="event",
)
response = await api.wallet_pay_invoice(
PayInvoiceRequest(
wallet_id="wallet-1",
payment_request="lnbc1demo",
max_sat=None,
description="Demo",
extra={"source": "test"},
)
)
assert response.ok is True
background_extra_mock.assert_awaited_once()
pay_mock.assert_awaited_once()
assert pay_mock.await_args is not None
assert pay_mock.await_args.kwargs["extra"] == {
"source": "test",
"tag": "demoext",
"background_payment": True,
"background_permission": "wallet.pay_invoice_background",
"background_wallet_id": "wallet-1",
}
@pytest.mark.anyio
async def test_host_api_public_invoice_uses_granted_source_policy(
mocker: MockerFixture,
):
storage_mock = mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_get_public_row",
mocker.AsyncMock(return_value={"id": "tip-1", "wallet_id": "wallet-1"}),
)
payment = SimpleNamespace(
payment_hash="hash",
payment_request="lnbc1demo",
bolt11="lnbc1fallback",
checking_id="checking-id",
)
create_mock = mocker.patch(
"lnbits.core.services.payments.create_payment_request",
mocker.AsyncMock(return_value=payment),
)
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="wallet.create_invoice_public",
policies=[{"table": "tips", "wallet_field": "wallet_id"}],
)
],
)
response = await api.wallet_create_invoice_public(
CreateInvoicePublicRequest(
source_id="tip-1",
amount=21,
currency="sat",
memo="Tip",
extra={"note": "thanks"},
)
)
assert response.payment_hash == "hash"
storage_mock.assert_awaited_once_with("demoext", "tips", "tip-1")
create_mock.assert_awaited_once()
assert create_mock.await_args is not None
wallet_id, invoice = create_mock.await_args.args
assert wallet_id == "wallet-1"
assert invoice.extra == {
"tag": "demoext",
"source_id": "tip-1",
"extra_demoext": {"note": "thanks"},
}
assert invoice.extension == "demoext"
@pytest.mark.anyio
async def test_host_api_public_invoice_rejects_missing_source_wallet(
mocker: MockerFixture,
):
mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_get_public_row",
mocker.AsyncMock(return_value={"id": "tip-1"}),
)
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="wallet.create_invoice_public",
policies=[{"table": "tips", "wallet_field": "wallet_id"}],
)
],
)
with pytest.raises(PermissionError, match="no valid wallet"):
await api.wallet_create_invoice_public(
CreateInvoicePublicRequest(
source_id="tip-1",
amount=21,
currency="sat",
memo="",
)
)
@@ -0,0 +1,100 @@
from types import SimpleNamespace
import pytest
from lnbits.core.wasm_ext.api.host import ExtensionHostAPI
from lnbits.core.wasm_ext.api.models import PayInvoiceRequest
@pytest.mark.anyio
async def test_wasm_wallet_pay_invoice_resolves_ln_address(mocker):
calls = {}
async def get_wallet(wallet_id: str):
calls["get_wallet"] = wallet_id
return SimpleNamespace(user="user1")
async def get_pr_from_lnurl(lnurl: str, amount_msat: int, comment: str | None):
calls["lnurl"] = (lnurl, amount_msat, comment)
return "lnbc1resolved"
async def pay_invoice(**kwargs):
calls["pay_invoice"] = kwargs
return SimpleNamespace(
checking_id="checking",
payment_hash="hash",
status="success",
amount=-21_000,
fee=-10,
pending=False,
success=True,
)
mocker.patch("lnbits.core.crud.wallets.get_wallet", get_wallet)
mocker.patch("lnbits.core.services.lnurl.get_pr_from_lnurl", get_pr_from_lnurl)
mocker.patch("lnbits.core.services.payments.pay_invoice", pay_invoice)
api = ExtensionHostAPI("demoext", ["wallet.pay_invoice"], user_id="user1")
response = await api.wallet_pay_invoice(
PayInvoiceRequest(
wallet_id="wallet1",
payment_request="alice@example.com",
max_sat=21,
description="winner",
)
)
assert response.ok is True
assert response.checking_id == "checking"
assert calls["get_wallet"] == "wallet1"
assert calls["lnurl"] == ("alice@example.com", 21_000, "winner")
assert calls["pay_invoice"]["payment_request"] == "lnbc1resolved"
assert calls["pay_invoice"]["max_sat"] == 21
@pytest.mark.anyio
async def test_wasm_wallet_pay_invoice_allows_event_wallet_only(mocker):
async def get_wallet(wallet_id: str):
return SimpleNamespace(user="other-user")
async def pay_invoice(**kwargs):
return SimpleNamespace(
checking_id="checking",
payment_hash="hash",
status="success",
amount=-1_000,
fee=0,
pending=False,
success=True,
)
mocker.patch("lnbits.core.crud.wallets.get_wallet", get_wallet)
mocker.patch("lnbits.core.services.payments.pay_invoice", pay_invoice)
api = ExtensionHostAPI(
"demoext",
["wallet.pay_invoice"],
context="event",
owner_id="owner",
wallet_id="wallet1",
)
allowed = await api.wallet_pay_invoice(
PayInvoiceRequest(
wallet_id="wallet1",
payment_request="lnbc1invoice",
max_sat=None,
description="",
)
)
assert allowed.ok is True
with pytest.raises(PermissionError, match="authenticated user context"):
await api.wallet_pay_invoice(
PayInvoiceRequest(
wallet_id="wallet2",
payment_request="lnbc1invoice",
max_sat=None,
description="",
)
)
-214
View File
@@ -1,214 +0,0 @@
import ipaddress
from typing import cast
import httpx
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.core.wasm_ext.api.models import HttpRequest
from lnbits.core.wasm_ext.client import http as wasm_http
def test_wasm_http_origin_normalization_and_url_rejections():
assert (
wasm_http._request_origin("https://EXAMPLE.com/path") == "https://example.com"
)
assert (
wasm_http._request_origin("https://example.com:8443/path")
== "https://example.com:8443"
)
for url, message in [
("http://example.com", "https URLs"),
("https://user:pass@example.com", "credentials"),
("https:///missing-host", "hostname"),
("https://example.com:bad", "invalid port"),
]:
with pytest.raises(PermissionError, match=message):
wasm_http._request_origin(url)
@pytest.mark.anyio
async def test_wasm_http_rejects_internal_hosts(mocker: MockerFixture):
with pytest.raises(PermissionError, match="localhost"):
await wasm_http._reject_internal_host("https://localhost/api")
with pytest.raises(PermissionError, match="internal network"):
await wasm_http._reject_internal_host("https://127.0.0.1/api")
mocker.patch(
"lnbits.core.wasm_ext.client.http._resolve_host",
mocker.AsyncMock(return_value=[ipaddress.ip_address("10.0.0.1")]),
)
with pytest.raises(PermissionError, match="internal network"):
await wasm_http._reject_internal_host("https://api.example.com")
def test_wasm_http_strips_forbidden_headers_and_normalizes_timeout():
assert wasm_http._request_headers(
{
" Host ": "evil.example",
"Content-Length": "10",
"X-Trace": "ok",
"Cookie": "session=secret",
}
) == {"X-Trace": "ok"}
assert wasm_http._response_headers(
{
"content-length": "10",
"set-cookie": "session=secret",
"x-safe": "ok",
}
) == {"x-safe": "ok"}
assert wasm_http._timeout_seconds(None, 10.0) == 10.0
assert wasm_http._timeout_seconds(2500, 10.0) == 2.5
assert wasm_http._timeout_seconds(0, 10.0) is None
@pytest.mark.anyio
async def test_wasm_http_request_enforces_policies_and_response_bounds(
mocker: MockerFixture,
):
mocker.patch(
"lnbits.core.wasm_ext.client.http._reject_internal_host",
mocker.AsyncMock(),
)
client = _FakeAsyncClient(
_FakeStreamResponse(
status_code=201,
headers={
"content-type": "application/json",
"set-cookie": "session=secret",
},
chunks=[b'{"ok":true}'],
)
)
mocker.patch("lnbits.core.wasm_ext.client.http.httpx.AsyncClient", client.factory)
response = await wasm_http.send_extension_http_request(
"demoext",
[{"host": "https://api.example.com"}],
HttpRequest(
method="POST",
url="https://api.example.com/path",
headers={"Host": "evil.example", "X-Test": "yes"},
body="{}",
),
timeout_ms=500,
max_response_bytes=100,
)
assert response.status_code == 201
assert response.body == '{"ok":true}'
assert response.headers == {"content-type": "application/json"}
assert client.kwargs["follow_redirects"] is False
assert client.kwargs["trust_env"] is False
assert client.kwargs["timeout"] == 0.5
assert client.stream_kwargs["headers"] == {"X-Test": "yes"}
with pytest.raises(PermissionError, match="not allowed"):
await wasm_http.send_extension_http_request(
"demoext",
[{"host": "https://api.example.com"}],
HttpRequest(url="https://other.example.com/path", body=None),
)
@pytest.mark.anyio
async def test_wasm_http_request_rejects_oversized_bodies_and_responses(
mocker: MockerFixture,
):
mocker.patch(
"lnbits.core.wasm_ext.client.http._reject_internal_host",
mocker.AsyncMock(),
)
with pytest.raises(ValueError, match="body is too large"):
await wasm_http.send_extension_http_request(
"demoext",
[{"host": "https://api.example.com"}],
HttpRequest.construct(
method="GET",
url="https://api.example.com/path",
headers={},
body="x" * 65_537,
),
)
with pytest.raises(ValueError, match="response is too large"):
await wasm_http._read_limited_response(
cast(httpx.Response, _FakeStreamResponse(chunks=[b"12345", b"67890"])),
max_response_bytes=8,
)
@pytest.mark.anyio
async def test_wasm_http_request_hides_transport_errors(mocker: MockerFixture):
mocker.patch(
"lnbits.core.wasm_ext.client.http._reject_internal_host",
mocker.AsyncMock(),
)
client = _FakeAsyncClient(_FakeStreamError())
mocker.patch("lnbits.core.wasm_ext.client.http.httpx.AsyncClient", client.factory)
with pytest.raises(ValueError, match="HTTP request failed"):
await wasm_http.send_extension_http_request(
"demoext",
[{"host": "https://api.example.com"}],
HttpRequest(url="https://api.example.com/path", body=None),
)
class _FakeAsyncClient:
def __init__(self, response):
self.response = response
self.kwargs = {}
self.stream_kwargs = {}
def factory(self, **kwargs):
self.kwargs = kwargs
return self
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return False
def stream(self, method, url, **kwargs):
self.stream_kwargs = {"method": method, "url": url, **kwargs}
return self.response
class _FakeStreamResponse:
def __init__(
self,
*,
status_code: int = 200,
headers: dict[str, str] | None = None,
chunks: list[bytes] | None = None,
):
self.status_code = status_code
self.headers = headers or {}
self.encoding = "utf-8"
self._chunks = chunks or []
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return False
async def aiter_bytes(self):
for chunk in self._chunks:
yield chunk
class _FakeStreamError:
async def __aenter__(self):
raise httpx.RequestError(
"network failed",
request=httpx.Request("GET", "https://api.example.com"),
)
async def __aexit__(self, *_args):
return False
+1 -200
View File
@@ -4,22 +4,7 @@ from typing import Any, cast
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.core.models.extensions import (
ExtensionBackgroundPaymentDestinationPolicy,
ExtensionPermission,
)
from lnbits.core.views.extension_api import (
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION,
WALLET_PAYMENTS_WATCH_PERMISSION,
_background_destination_policy_covers,
_check_background_payment_permission,
_check_wallet_payments_watch_permission,
_find_background_payment_grant,
_find_wallet_payments_watch_grant,
_remove_user_permission_grant,
_safe_user_extension_permissions,
_user_permission_grant_id_for_wallet,
)
from lnbits.core.models.extensions import ExtensionPermission
from lnbits.core.wasm_ext.api.permissions import validate_wasm_extension_permissions
from lnbits.core.wasm_ext.wasm.events import _wasm_invoice_paid_owner_id
from lnbits.core.wasm_ext.wasm.invoke import _active_installed_extension
@@ -157,190 +142,6 @@ def test_validate_wasm_permissions_rejects_unrequested_permission_grant():
)
def test_validate_wasm_permissions_allows_wallet_payments_watch_permission():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config(
"demoext",
[{"id": "wallet.payments.watch"}],
)
assert validate_wasm_extension_permissions(
ext_info,
[ExtensionPermission(id="wallet.payments.watch")],
extension_config,
) == [ExtensionPermission(id="wallet.payments.watch")]
def test_background_payment_grant_lookup_and_policy_coverage():
permissions = {
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION: [
{
"id": "grant-1",
"wallet_id": "wallet-1",
"enabled": True,
"max_amount": 5000,
"destination_policy": "external_allowed",
}
]
}
grant = _find_background_payment_grant(permissions, "wallet-1")
assert grant
assert grant.max_amount == 5000
assert _background_destination_policy_covers(
grant.destination_policy,
ExtensionBackgroundPaymentDestinationPolicy.OWN_WALLETS_ONLY,
)
assert _background_destination_policy_covers(
grant.destination_policy,
ExtensionBackgroundPaymentDestinationPolicy.EXTERNAL_ALLOWED,
)
assert not _background_destination_policy_covers(
ExtensionBackgroundPaymentDestinationPolicy.OWN_WALLETS_ONLY,
ExtensionBackgroundPaymentDestinationPolicy.EXTERNAL_ALLOWED,
)
def test_wallet_payments_watch_grant_lookup_ignores_disabled_grant():
permissions = {
WALLET_PAYMENTS_WATCH_PERMISSION: [
{"id": "grant-1", "wallet_id": "wallet-1", "enabled": False}
]
}
grant = _find_wallet_payments_watch_grant(permissions, "wallet-1")
assert grant
assert grant.enabled is False
def test_safe_user_extension_permissions_keeps_only_grants_with_ids():
permissions = {
WALLET_PAYMENTS_WATCH_PERMISSION: [
{"id": "grant-1", "wallet_id": "wallet-1", "enabled": True},
{"wallet_id": "wallet-2", "enabled": True},
"broken",
],
"broken": "not-a-list",
}
safe_permissions = _safe_user_extension_permissions(permissions)
assert safe_permissions == {
WALLET_PAYMENTS_WATCH_PERMISSION: [
{"id": "grant-1", "wallet_id": "wallet-1", "enabled": True}
]
}
def test_user_permission_grant_id_for_wallet_returns_existing_grant_id():
permissions = {
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION: [
{"id": "grant-1", "wallet_id": "wallet-1", "enabled": True}
]
}
grant_id = _user_permission_grant_id_for_wallet(
permissions, WALLET_PAY_INVOICE_BACKGROUND_PERMISSION, "wallet-1"
)
assert grant_id == "grant-1"
def test_remove_user_permission_grant_removes_only_matching_grant_id():
permissions = {
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION: [
{"id": "grant-1", "wallet_id": "wallet-1", "enabled": True},
{"id": "grant-2", "wallet_id": "wallet-2", "enabled": True},
],
WALLET_PAYMENTS_WATCH_PERMISSION: [{"id": "grant-3", "wallet_id": "wallet-1"}],
}
updated_permissions = _remove_user_permission_grant(permissions, "grant-1")
assert updated_permissions == {
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION: [
{"id": "grant-2", "wallet_id": "wallet-2", "enabled": True}
],
WALLET_PAYMENTS_WATCH_PERMISSION: [{"id": "grant-3", "wallet_id": "wallet-1"}],
}
def test_remove_user_permission_grant_drops_empty_permission():
permissions = {
WALLET_PAYMENTS_WATCH_PERMISSION: [{"id": "grant-1", "wallet_id": "wallet-1"}]
}
updated_permissions = _remove_user_permission_grant(permissions, "grant-1")
assert updated_permissions == {}
@pytest.mark.anyio
async def test_background_payment_check_reports_approved_grant(
mocker: MockerFixture,
):
permissions = {
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION: [
{
"id": "grant-1",
"wallet_id": "wallet-1",
"enabled": True,
"max_amount": 5000,
"destination_policy": "external_allowed",
}
]
}
mocker.patch(
"lnbits.core.views.extension_api.get_wallet",
mocker.AsyncMock(
return_value=SimpleNamespace(
user="user-1",
is_lightning_shared_wallet=False,
can_send_payments=True,
)
),
)
result = await _check_background_payment_permission(
"user-1",
permissions,
{
"wallet_id": "wallet-1",
"max_amount": 1000,
"destination_policy": "own_wallets_only",
},
)
assert result.id == WALLET_PAY_INVOICE_BACKGROUND_PERMISSION
assert result.approved is True
assert result.grant["id"] == "grant-1"
assert result.grant["max_amount"] == 5000
@pytest.mark.anyio
async def test_wallet_payment_watch_check_returns_requested_unapproved_grant(
mocker: MockerFixture,
):
mocker.patch(
"lnbits.core.views.extension_api.get_wallet",
mocker.AsyncMock(return_value=SimpleNamespace(user="user-1")),
)
result = await _check_wallet_payments_watch_permission(
"user-1",
{},
{"wallet_id": "wallet-1"},
)
assert result.id == WALLET_PAYMENTS_WATCH_PERMISSION
assert result.approved is False
assert result.grant["wallet_id"] == "wallet-1"
assert result.grant["enabled"] is True
assert isinstance(result.grant["id"], str)
@pytest.mark.anyio
async def test_invoice_paid_owner_lookup_uses_stored_granted_policies(
mocker: MockerFixture,
+1 -199
View File
@@ -1,34 +1,16 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from pathlib import Path
from typing import cast
import pytest
from fastapi import HTTPException, Request
from fastapi import Request
from lnbits.core.wasm_ext.routes.api import (
WasmRequestBodyTooLargeError,
_read_api_payload,
_read_json_object_with_size,
_wasm_extension_api_export,
)
from lnbits.core.wasm_ext.routes.assets import (
_reject_html_like_wasm_static_asset,
_wasm_extension_core_asset_response,
)
from lnbits.core.wasm_ext.routes.security import (
consume_wasm_extension_frame_token,
wasm_extension_frame_csp,
wasm_extension_frame_url,
)
from lnbits.core.wasm_ext.routes.ui import (
_match_wasm_extension_ui_route,
_wasm_extension_bridge_api_routes,
_wasm_extension_entrypoint,
)
from lnbits.core.wasm_ext.wasm.config import parse_wasm_extension_config
from lnbits.core.wasm_ext.wasm.loader import WasmExtension
@pytest.mark.anyio
@@ -74,119 +56,6 @@ async def test_wasm_api_payload_records_actual_body_bytes():
assert payload.request_bytes == len(body)
def test_wasm_api_export_visibility_is_enforced(tmp_path: Path):
extension = _wasm_extension(tmp_path)
assert _wasm_extension_api_export(extension, "render") == "render"
assert _wasm_extension_api_export(extension, "private_render") == "private_render"
with pytest.raises(PermissionError, match="not callable over HTTP"):
_wasm_extension_api_export(extension, "on_invoice_paid")
with pytest.raises(KeyError, match="has no export"):
_wasm_extension_api_export(extension, "missing")
def test_wasm_ui_entrypoint_rejects_escape_static_and_non_html(tmp_path: Path):
extension = _wasm_extension(tmp_path)
(tmp_path / "index.html").write_text("<html></html>", encoding="utf-8")
(tmp_path / "index.txt").write_text("text", encoding="utf-8")
(tmp_path / "static").mkdir()
(tmp_path / "static" / "index.html").write_text("<html></html>", encoding="utf-8")
assert (
_wasm_extension_entrypoint(extension, "index.html")
== (tmp_path / "index.html").resolve()
)
with pytest.raises(ValueError, match="escapes extension root"):
_wasm_extension_entrypoint(extension, "../outside.html")
with pytest.raises(ValueError, match="must not be inside the static"):
_wasm_extension_entrypoint(extension, "static/index.html")
with pytest.raises(ValueError, match="must be an HTML file"):
_wasm_extension_entrypoint(extension, "index.txt")
def test_wasm_frame_token_is_one_time_and_user_bound(tmp_path: Path):
extension = _wasm_extension(tmp_path)
frame_path = "/ext-frame/demoext/0"
frame_url = wasm_extension_frame_url(extension, frame_path, "user-1")
token = frame_url.split("frame_token=", 1)[1]
with pytest.raises(HTTPException) as wrong_user:
consume_wasm_extension_frame_token(
_request_with_query(token),
extension,
frame_path,
"user-2",
)
assert wrong_user.value.status_code == 404
consume_wasm_extension_frame_token(
_request_with_query(token),
extension,
frame_path,
"user-1",
)
with pytest.raises(HTTPException) as reused:
consume_wasm_extension_frame_token(
_request_with_query(token),
extension,
frame_path,
"user-1",
)
assert reused.value.status_code == 404
def test_wasm_frame_csp_is_locked_to_extension_assets(tmp_path: Path):
csp = wasm_extension_frame_csp(
_request_with_query("token"),
_wasm_extension(tmp_path),
)
assert "sandbox allow-scripts" in csp
assert "default-src 'none'" in csp
assert "connect-src 'none'" in csp
assert "frame-ancestors 'self'" in csp
assert "http://testserver/ext-assets/demoext/" in csp
def test_wasm_ui_route_matching_and_bridge_public_api_filtering(tmp_path: Path):
extension = _wasm_extension(tmp_path)
matched = _match_wasm_extension_ui_route(extension, "/ext/demo/abc")
public_routes = _wasm_extension_bridge_api_routes(extension, public=True)
private_routes = _wasm_extension_bridge_api_routes(extension, public=False)
assert matched["auth"] == "user"
assert matched["route_params"] == {"item_id": "abc"}
assert public_routes == [
{
"method": "GET",
"path": "/api/v1/ext/demoext/public/{item_id}",
"pattern": "^/api/v1/ext/demoext/public/[^/]+$",
}
]
assert {route["path"] for route in private_routes} == {
"/api/v1/ext/demoext/public/{item_id}",
"/api/v1/ext/demoext/private/{item_id}",
}
def test_wasm_static_core_assets_and_html_like_text_assets_are_guarded(tmp_path: Path):
response = _wasm_extension_core_asset_response("_lnbits/material-icons.css")
assert response.headers["X-Content-Type-Options"] == "nosniff"
assert response.headers["Cache-Control"] == "no-store"
for path in ["_lnbits/../bundle.min.css", "_lnbits/missing.css"]:
with pytest.raises(HTTPException) as exc_info:
_wasm_extension_core_asset_response(path)
assert exc_info.value.status_code == 404
script_path = tmp_path / "app.js"
script_path.write_text("<script>alert(1)</script>", encoding="utf-8")
with pytest.raises(HTTPException) as html_like:
_reject_html_like_wasm_static_asset(script_path)
assert html_like.value.status_code == 404
class _FakeRequest:
method = "POST"
@@ -210,70 +79,3 @@ class _FakeRequest:
self.stream_started = True
for chunk in self._chunks:
yield chunk
def _wasm_extension(root_path: Path) -> WasmExtension:
config = parse_wasm_extension_config(
"demoext",
{
"id": "demoext",
"name": "Demo",
"short_description": "Demo extension",
"version": "1.0.0",
"extension_type": "wasm",
"wasm": {
"module": "extension.wasm",
"exports": [
{"name": "render", "visibility": "public"},
{"name": "private_render", "visibility": "authenticated"},
{"name": "on_invoice_paid", "visibility": "event"},
],
},
"ui_routes": [
{
"path": "/demo/{item_id}",
"entrypoint": "index.html",
"auth": "user",
}
],
"api_routes": [
{
"method": "GET",
"path": "/public/{item_id}",
"export": "render",
"auth": "public",
},
{
"method": "POST",
"path": "/private/{item_id}",
"export": "private_render",
"auth": "user",
},
],
},
)
return WasmExtension(
id="demoext",
name="Demo",
version="1.0.0",
root_path=root_path,
module_path=root_path / "extension.wasm",
wit_path=None,
world="",
exports=config.wasm.exports,
config=config,
)
def _request_with_query(token: str) -> Request:
return Request(
{
"type": "http",
"method": "GET",
"scheme": "http",
"server": ("testserver", 80),
"path": "/ext-frame/demoext/0",
"query_string": f"frame_token={token}".encode(),
"headers": [],
}
)
-409
View File
@@ -1,409 +0,0 @@
import json
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace
from typing import cast
from uuid import uuid4
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.core.crud import extensions as extension_crud
from lnbits.core.crud import update_migration_version
from lnbits.core.db import db as core_db
from lnbits.core.migrations import (
m010_create_installed_extensions_table,
m046_add_permissions_to_installed_extensions,
m047_create_wasm_invocations_table,
m048_add_wasm_runtime_limits_to_installed_extensions,
)
from lnbits.core.models.extensions import ExtensionPermission, WasmInvocation
from lnbits.core.wasm_ext.storage import crud as storage_crud
from lnbits.core.wasm_ext.storage.crud import (
OWNER_ID_FIELD,
migrate_wasm_extension_database,
storage_delete_row,
storage_get_paginated_rows,
storage_get_public_row,
storage_get_row,
storage_set_row,
)
from lnbits.db import DB_TYPE, SQLITE, Compat, Connection, Database
from lnbits.settings import Settings
from tests.helpers import make_installable_extension
@pytest.mark.anyio
async def test_core_wasm_migrations_create_persistent_columns(
tmp_path: Path, settings: Settings
):
if DB_TYPE != SQLITE:
pytest.skip("temporary core databases are SQLite-only")
db = _temporary_database(tmp_path, settings, "wasm_core_migrations")
async with db.connect() as conn:
await m010_create_installed_extensions_table(conn)
await m046_add_permissions_to_installed_extensions(conn)
await m047_create_wasm_invocations_table(conn)
await m048_add_wasm_runtime_limits_to_installed_extensions(conn)
installed_columns = {
row["name"]
for row in await conn.fetchall("PRAGMA table_info(installed_extensions)")
}
invocation_columns = {
row["name"]
for row in await conn.fetchall("PRAGMA table_info(wasm_invocations)")
}
assert {"permissions", "wasm_runtime_limits"}.issubset(installed_columns)
assert {
"extension_id",
"status",
"request_bytes",
"response_bytes",
"host_call_count",
"http_call_count",
"storage_call_count",
"wallet_call_count",
"context",
}.issubset(invocation_columns)
@pytest.mark.anyio
async def test_installed_extension_permissions_and_wasm_limits_round_trip(
app,
tmp_path: Path,
settings: Settings,
mocker: MockerFixture,
):
db = await _temporary_core_crud_database(tmp_path, settings)
mocker.patch.object(extension_crud, "db", db)
ext_id = f"wasmlimits_{uuid4().hex[:8]}"
extension = make_installable_extension(ext_id)
extension.permissions = [ExtensionPermission(id="utils.basic")]
extension.wasm_runtime_limits = {"wasm_runtime_max_execution_ms": 1234}
await extension_crud.create_installed_extension(extension)
stored = await extension_crud.get_installed_extension(ext_id)
assert stored is not None
assert stored.permissions == [ExtensionPermission(id="utils.basic")]
assert stored.wasm_runtime_limits == {"wasm_runtime_max_execution_ms": 1234}
stored.wasm_runtime_limits = {"wasm_runtime_max_fuel": 0}
await extension_crud.update_installed_extension(stored)
updated = await extension_crud.get_installed_extension(ext_id)
assert updated is not None
assert updated.wasm_runtime_limits == {"wasm_runtime_max_fuel": 0}
@pytest.mark.anyio
async def test_wasm_invocation_crud_stats_and_cleanup_are_isolated(
app,
tmp_path: Path,
settings: Settings,
mocker: MockerFixture,
):
db = await _temporary_core_crud_database(tmp_path, settings)
mocker.patch.object(extension_crud, "db", db)
now = datetime.now(timezone.utc)
old = now - timedelta(days=10)
ext_id = f"wasminv_{uuid4().hex[:8]}"
other_ext_id = f"wasminv_{uuid4().hex[:8]}"
invocations = [
WasmInvocation(
id=f"inv_{uuid4().hex}",
extension_id=ext_id,
export_name="render",
status="completed",
started_at=old,
finished_at=old + timedelta(milliseconds=20),
duration_ms=20,
host_call_count=2,
http_call_count=1,
),
WasmInvocation(
id=f"inv_{uuid4().hex}",
extension_id=ext_id,
export_name="render",
status="failed",
started_at=now,
finished_at=now,
duration_ms=40,
host_call_count=3,
storage_call_count=2,
),
WasmInvocation(
id=f"inv_{uuid4().hex}",
extension_id=ext_id,
export_name="render",
status="running",
started_at=old,
),
WasmInvocation(
id=f"inv_{uuid4().hex}",
extension_id=other_ext_id,
export_name="render",
status="completed",
started_at=now,
duration_ms=100,
),
]
for invocation in invocations:
await extension_crud.create_wasm_invocation(invocation)
failed = await extension_crud.get_wasm_invocations(
extension_id=ext_id,
status="failed",
)
stats = await extension_crud.get_wasm_invocation_stats(
extension_id=ext_id,
since=now - timedelta(days=30),
)
deleted = await extension_crud.delete_old_wasm_invocations(retention_days=7)
remaining_running = await extension_crud.get_wasm_invocations(
extension_id=ext_id,
status="running",
)
assert [invocation.id for invocation in failed] == [invocations[1].id]
assert stats.total == 3
assert stats.completed == 1
assert stats.failed == 1
assert stats.running == 1
assert stats.host_call_count == 5
assert stats.http_call_count == 1
assert stats.storage_call_count == 2
assert deleted == 1
assert [invocation.id for invocation in remaining_running] == [invocations[2].id]
@pytest.mark.anyio
async def test_wasm_datetime_queries_use_postgres_placeholders(
mocker: MockerFixture,
):
db = mocker.Mock()
db.timestamp_placeholder.side_effect = lambda key: f"to_timestamp(:{key})"
db.fetchone = mocker.AsyncMock(return_value=None)
db.execute = mocker.AsyncMock(return_value=SimpleNamespace(rowcount=0))
field = {"name": "created_at", "type": "datetime"}
assert (
storage_crud._value_placeholder(cast(Compat, db), field, "created_at")
== "to_timestamp(:created_at)"
)
where_sql, _ = storage_crud._where_sql(
cast(Compat, db), {"fields": [field]}, {"created_at": 0}, None, []
)
assert "created_at = to_timestamp(:filter_created_at)" in where_sql
conn = cast(Connection, db)
await extension_crud.get_wasm_invocation_stats(
since=datetime.now(timezone.utc), conn=conn
)
await extension_crud.delete_old_wasm_invocations(1, conn=conn)
await extension_crud.mark_stale_wasm_invocations(conn=conn)
queries = [
db.fetchone.call_args.args[0],
*[c.args[0] for c in db.execute.call_args_list],
]
assert all("to_timestamp(:" in query for query in queries)
@pytest.mark.anyio
async def test_wasm_storage_migration_and_owner_scoped_crud(
tmp_path: Path,
settings: Settings,
):
ext_id = f"wasmstore_{uuid4().hex[:8]}"
original_extensions_path = settings.lnbits_extensions_path
original_data_folder = settings.lnbits_data_folder
try:
settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path / "code")
Path(settings.lnbits_data_folder).mkdir(parents=True)
ext_dir = _write_storage_extension(settings, ext_id)
await migrate_wasm_extension_database(make_installable_extension(ext_id))
await storage_set_row(
ext_id,
"notes",
{
"id": "note-1",
"title": "First",
"count": 1,
"published": True,
"tags": ["alpha", "beta"],
"created_at": 1_700_000_000,
},
"owner-1",
)
await storage_set_row(
ext_id,
"notes",
{
"id": "note-1",
"title": "Other owner attempt",
"count": 2,
"published": False,
"tags": ["gamma"],
"created_at": 1_700_000_001,
},
"owner-2",
)
owner_row = await storage_get_row(ext_id, "notes", "note-1", "owner-1")
other_owner_row = await storage_get_row(ext_id, "notes", "note-1", "owner-2")
public_row = await storage_get_public_row(ext_id, "notes", "note-1")
page = await storage_get_paginated_rows(
ext_id,
"notes",
{"published": True},
owner_id="owner-1",
search="fir",
search_fields=["title"],
sort_by="count",
descending=True,
limit=50,
offset=0,
)
await storage_delete_row(ext_id, "notes", "note-1", "owner-2")
still_owned = await storage_get_row(ext_id, "notes", "note-1", "owner-1")
await storage_delete_row(ext_id, "notes", "note-1", "owner-1")
deleted = await storage_get_row(ext_id, "notes", "note-1", "owner-1")
finally:
settings.lnbits_extensions_path = original_extensions_path
settings.lnbits_data_folder = original_data_folder
assert ext_dir.is_dir()
assert owner_row is not None
assert owner_row["title"] == "First"
assert owner_row["tags"] == ["alpha", "beta"]
assert owner_row["created_at"] == 1_700_000_000
assert other_owner_row is None
assert public_row is not None
assert public_row["title"] == "First"
assert page["total"] == 1
assert page["data"][0]["id"] == "note-1"
assert still_owned is not None
assert deleted is None
@pytest.mark.anyio
async def test_wasm_storage_rejects_reserved_fields_and_invalid_identifiers(
tmp_path: Path,
settings: Settings,
):
ext_id = f"wasmstore_{uuid4().hex[:8]}"
original_extensions_path = settings.lnbits_extensions_path
original_data_folder = settings.lnbits_data_folder
try:
settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path / "code")
Path(settings.lnbits_data_folder).mkdir(parents=True)
_write_storage_extension(settings, ext_id)
with pytest.raises(ValueError, match="Invalid WASM storage SQL identifier"):
await storage_get_row(ext_id, "notes;DROP", "note-1", "owner-1")
with pytest.raises(ValueError, match="reserved owner field"):
await storage_set_row(
ext_id,
"notes",
{"id": "note-1", OWNER_ID_FIELD: "owner-2"},
"owner-1",
)
schema_path = (
Path(settings.lnbits_extensions_path)
/ "extensions"
/ ext_id
/ "storage"
/ "schema.json"
)
schema = json.loads(schema_path.read_text(encoding="utf-8"))
schema["tables"]["notes"]["fields"].append(
{"name": OWNER_ID_FIELD, "type": "string"}
)
schema_path.write_text(json.dumps(schema), encoding="utf-8")
with pytest.raises(ValueError, match="reserved field"):
await storage_get_row(ext_id, "notes", "note-1", "owner-1")
finally:
settings.lnbits_extensions_path = original_extensions_path
settings.lnbits_data_folder = original_data_folder
def _temporary_database(
tmp_path: Path,
settings: Settings,
name: str,
) -> Database:
settings.lnbits_data_folder = str(tmp_path / "data")
Path(settings.lnbits_data_folder).mkdir(parents=True, exist_ok=True)
return Database(name)
async def _temporary_core_crud_database(
tmp_path: Path,
settings: Settings,
) -> Database:
if DB_TYPE != SQLITE:
return core_db
db = _temporary_database(tmp_path, settings, f"core_{uuid4().hex[:8]}")
async with db.connect() as conn:
await conn.execute("""
CREATE TABLE dbversions (
db TEXT PRIMARY KEY,
version INT NOT NULL
)
""")
await update_migration_version(conn, "core", 48)
await m010_create_installed_extensions_table(conn)
await m046_add_permissions_to_installed_extensions(conn)
await m047_create_wasm_invocations_table(conn)
await m048_add_wasm_runtime_limits_to_installed_extensions(conn)
return db
def _write_storage_extension(settings: Settings, ext_id: str) -> Path:
ext_dir = Path(settings.lnbits_extensions_path) / "extensions" / ext_id
storage_dir = ext_dir / "storage"
migrations_dir = storage_dir / "migrations"
migrations_dir.mkdir(parents=True)
schema = {
"tables": {
"notes": {
"fields": [
{"name": "id", "type": "string"},
{"name": "title", "type": "string"},
{"name": "count", "type": "integer", "default": 0},
{"name": "published", "type": "boolean", "default": False},
{"name": "tags", "type": "string", "list": True},
{"name": "created_at", "type": "datetime"},
]
}
}
}
migration = {
"operations": [
{
"op": "create_table",
"table": "notes",
"fields": schema["tables"]["notes"]["fields"],
}
]
}
(storage_dir / "schema.json").write_text(json.dumps(schema), encoding="utf-8")
(migrations_dir / "001_init.json").write_text(
json.dumps(migration),
encoding="utf-8",
)
return ext_dir