Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
005f384c82 | ||
|
|
912c0cb1fa | ||
|
|
ad90e95b10 | ||
|
|
9961c76caa | ||
|
|
61ed636df0 | ||
|
|
43900dd6da | ||
|
|
06c553219a | ||
|
|
fc73d83bd9 | ||
|
|
4cb743d952 | ||
|
|
3b6e87b060 |
@@ -85,15 +85,15 @@ jobs:
|
|||||||
secrets:
|
secrets:
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
jmeter:
|
integration:
|
||||||
needs: [ lint ]
|
needs: [ lint ]
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.12"]
|
python-version: ["3.12"]
|
||||||
uses: ./.github/workflows/jmeter.yml
|
uses: ./.github/workflows/integration.yml
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
bundle:
|
bundle:
|
||||||
needs: [ lint, test-api, test-wallets, test-unit, migration, openapi, regtest, jmeter ]
|
needs: [ lint, test-api, test-wallets, test-unit, migration, openapi, regtest, integration ]
|
||||||
uses: ./.github/workflows/bundle.yml
|
uses: ./.github/workflows/bundle.yml
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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/
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
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/
|
|
||||||
@@ -18,6 +18,8 @@ __pycache__
|
|||||||
.webassets-cache
|
.webassets-cache
|
||||||
htmlcov
|
htmlcov
|
||||||
test-reports
|
test-reports
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
tests/data/*.sqlite3
|
tests/data/*.sqlite3
|
||||||
|
|
||||||
*.swo
|
*.swo
|
||||||
|
|||||||
+1
-1
@@ -43,4 +43,4 @@ ENV LNBITS_HOST="0.0.0.0"
|
|||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
CMD ["sh", "-c", "uv --offline run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
|
CMD ["sh", "-c", "uv --offline run --no-sync lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: test
|
.PHONY: test test-integration
|
||||||
|
|
||||||
all: format check
|
all: format check
|
||||||
|
|
||||||
@@ -69,6 +69,9 @@ test-regtest:
|
|||||||
rm -rf ./tests/data \
|
rm -rf ./tests/data \
|
||||||
uv run pytest tests/regtest
|
uv run pytest tests/regtest
|
||||||
|
|
||||||
|
test-integration:
|
||||||
|
npm run test:integration
|
||||||
|
|
||||||
test-migration:
|
test-migration:
|
||||||
LNBITS_ADMIN_UI=True \
|
LNBITS_ADMIN_UI=True \
|
||||||
make test-api
|
make test-api
|
||||||
|
|||||||
+1
-1
@@ -531,7 +531,7 @@ def register_async_tasks() -> None:
|
|||||||
async def dispatch_extension_invoice_paid(payment) -> None:
|
async def dispatch_extension_invoice_paid(payment) -> None:
|
||||||
await dispatch_wasm_invoice_paid(payment)
|
await dispatch_wasm_invoice_paid(payment)
|
||||||
|
|
||||||
core_app_extra.dispatch_extension_invoice_paid = dispatch_extension_invoice_paid
|
task_manager.register_invoice_listener(dispatch_extension_invoice_paid, "core_wasm")
|
||||||
|
|
||||||
# server logs for websocket
|
# server logs for websocket
|
||||||
if settings.lnbits_admin_ui:
|
if settings.lnbits_admin_ui:
|
||||||
|
|||||||
@@ -253,13 +253,14 @@ async def get_wasm_invocation_stats(
|
|||||||
since: datetime | None = None,
|
since: datetime | None = None,
|
||||||
conn: Connection | None = None,
|
conn: Connection | None = None,
|
||||||
) -> WasmInvocationStats:
|
) -> WasmInvocationStats:
|
||||||
|
database = conn or db
|
||||||
where: list[str] = []
|
where: list[str] = []
|
||||||
values: dict = {}
|
values: dict = {}
|
||||||
if extension_id:
|
if extension_id:
|
||||||
where.append("extension_id = :extension_id")
|
where.append("extension_id = :extension_id")
|
||||||
values["extension_id"] = extension_id
|
values["extension_id"] = extension_id
|
||||||
if since:
|
if since:
|
||||||
where.append("started_at >= :since")
|
where.append(f"started_at >= {database.timestamp_placeholder('since')}")
|
||||||
values["since"] = since
|
values["since"] = since
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
@@ -314,11 +315,13 @@ async def delete_old_wasm_invocations(
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
|
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
|
||||||
result = await (conn or db).execute(
|
database = conn or db
|
||||||
"""
|
result = await database.execute(
|
||||||
|
f"""
|
||||||
DELETE FROM wasm_invocations
|
DELETE FROM wasm_invocations
|
||||||
WHERE status != 'running' AND started_at < :cutoff
|
WHERE status != 'running'
|
||||||
""",
|
AND started_at < {database.timestamp_placeholder("cutoff")}
|
||||||
|
""", # noqa: S608
|
||||||
{"cutoff": cutoff},
|
{"cutoff": cutoff},
|
||||||
)
|
)
|
||||||
return int(result.rowcount or 0)
|
return int(result.rowcount or 0)
|
||||||
@@ -327,13 +330,14 @@ async def delete_old_wasm_invocations(
|
|||||||
async def mark_stale_wasm_invocations(
|
async def mark_stale_wasm_invocations(
|
||||||
conn: Connection | None = None,
|
conn: Connection | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
await (conn or db).execute(
|
database = conn or db
|
||||||
"""
|
await database.execute(
|
||||||
|
f"""
|
||||||
UPDATE wasm_invocations
|
UPDATE wasm_invocations
|
||||||
SET status = 'abandoned',
|
SET status = 'abandoned',
|
||||||
finished_at = :finished_at,
|
finished_at = {database.timestamp_placeholder("finished_at")},
|
||||||
stop_reason = 'Server restarted before invocation finished.'
|
stop_reason = 'Server restarted before invocation finished.'
|
||||||
WHERE status = 'running'
|
WHERE status = 'running'
|
||||||
""",
|
""", # noqa: S608
|
||||||
{"finished_at": datetime.now(timezone.utc)},
|
{"finished_at": datetime.now(timezone.utc)},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -883,3 +883,10 @@ async def m048_add_wasm_runtime_limits_to_installed_extensions(db: Connection):
|
|||||||
"ALTER TABLE installed_extensions "
|
"ALTER TABLE installed_extensions "
|
||||||
"ADD COLUMN wasm_runtime_limits TEXT DEFAULT '{}'"
|
"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 '{}'")
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import shutil
|
|||||||
import zipfile
|
import zipfile
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -139,11 +141,83 @@ class UserExtensionInfo(BaseModel):
|
|||||||
payment_hash_to_enable: str | None = None
|
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):
|
class UserExtension(BaseModel):
|
||||||
user: str
|
user: str
|
||||||
extension: str
|
extension: str
|
||||||
active: bool
|
active: bool
|
||||||
extra: UserExtensionInfo | None = None
|
extra: UserExtensionInfo | None = None
|
||||||
|
permissions: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_paid(self) -> bool:
|
def is_paid(self) -> bool:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -11,17 +11,10 @@ def _do_nothing(*_):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def _do_nothing_async(_: Any) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class CoreAppExtra:
|
class CoreAppExtra:
|
||||||
register_new_ext_routes: Callable = _do_nothing
|
register_new_ext_routes: Callable = _do_nothing
|
||||||
register_new_wasm_ext_routes: Callable = _do_nothing
|
register_new_wasm_ext_routes: Callable = _do_nothing
|
||||||
register_new_ratelimiter: Callable
|
register_new_ratelimiter: Callable
|
||||||
dispatch_extension_invoice_paid: Callable[[Any], Awaitable[None]] = (
|
|
||||||
_do_nothing_async
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.wasm_extension_registry = WasmExtensionRegistry()
|
self.wasm_extension_registry = WasmExtensionRegistry()
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ from lnbits.core.crud import (
|
|||||||
)
|
)
|
||||||
from lnbits.core.crud.users import get_user
|
from lnbits.core.crud.users import get_user
|
||||||
from lnbits.core.crud.wallets import get_wallet
|
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 import Payment, Wallet
|
||||||
from lnbits.core.models.notifications import (
|
from lnbits.core.models.notifications import (
|
||||||
NOTIFICATION_TEMPLATES,
|
NOTIFICATION_TEMPLATES,
|
||||||
@@ -245,7 +244,6 @@ async def dispatch_payment_notification(payment: Payment) -> None:
|
|||||||
wallet = await get_wallet(payment.wallet_id)
|
wallet = await get_wallet(payment.wallet_id)
|
||||||
if wallet:
|
if wallet:
|
||||||
await send_payment_notification(wallet, payment)
|
await send_payment_notification(wallet, payment)
|
||||||
await core_app_extra.dispatch_extension_invoice_paid(payment)
|
|
||||||
|
|
||||||
|
|
||||||
async def dispatch_webhook(payment: Payment):
|
async def dispatch_webhook(payment: Payment):
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from lnbits.db import Connection, Filters
|
|||||||
from lnbits.decorators import check_user_extension_access
|
from lnbits.decorators import check_user_extension_access
|
||||||
from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
|
from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
|
||||||
from lnbits.fiat import get_fiat_provider
|
from lnbits.fiat import get_fiat_provider
|
||||||
from lnbits.helpers import check_callback_url
|
from lnbits.helpers import check_callback_url, daystart_timestamp
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.task_manager import task_manager
|
from lnbits.task_manager import task_manager
|
||||||
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_preimage
|
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_preimage
|
||||||
@@ -557,10 +557,9 @@ async def check_wallet_daily_withdraw_limit(
|
|||||||
raise ValueError("It is not allowed to spend funds from this server.")
|
raise ValueError("It is not allowed to spend funds from this server.")
|
||||||
|
|
||||||
payments = await get_payments(
|
payments = await get_payments(
|
||||||
since=int(time.time()) - 60 * 60 * 24,
|
since=daystart_timestamp(),
|
||||||
outgoing=True,
|
outgoing=True,
|
||||||
wallet_id=wallet_id,
|
wallet_id=wallet_id,
|
||||||
limit=1,
|
|
||||||
conn=conn,
|
conn=conn,
|
||||||
)
|
)
|
||||||
if len(payments) == 0:
|
if len(payments) == 0:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
import traceback
|
import traceback
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
@@ -9,7 +10,7 @@ from fastapi.requests import Request
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits.core.crud.extensions import get_user_extensions
|
from lnbits.core.crud.extensions import get_user_extensions
|
||||||
from lnbits.core.crud.wallets import get_wallets_ids
|
from lnbits.core.crud.wallets import get_wallet, get_wallets_ids
|
||||||
from lnbits.core.db import db
|
from lnbits.core.db import db
|
||||||
from lnbits.core.models import (
|
from lnbits.core.models import (
|
||||||
SimpleStatus,
|
SimpleStatus,
|
||||||
@@ -18,12 +19,21 @@ from lnbits.core.models.extensions import (
|
|||||||
CreateExtension,
|
CreateExtension,
|
||||||
CreateExtensionReview,
|
CreateExtensionReview,
|
||||||
Extension,
|
Extension,
|
||||||
|
ExtensionBackgroundPaymentDestinationPolicy,
|
||||||
|
ExtensionBackgroundPaymentGrant,
|
||||||
|
ExtensionBackgroundPaymentGrantRequest,
|
||||||
ExtensionConfig,
|
ExtensionConfig,
|
||||||
ExtensionMeta,
|
ExtensionMeta,
|
||||||
|
ExtensionPermissionCheckRequest,
|
||||||
|
ExtensionPermissionCheckResponse,
|
||||||
|
ExtensionPermissionCheckResult,
|
||||||
|
ExtensionPermissionsResponse,
|
||||||
ExtensionRelease,
|
ExtensionRelease,
|
||||||
ExtensionReview,
|
ExtensionReview,
|
||||||
ExtensionReviewPaymentRequest,
|
ExtensionReviewPaymentRequest,
|
||||||
ExtensionReviewsStatus,
|
ExtensionReviewsStatus,
|
||||||
|
ExtensionWalletPaymentsWatchGrant,
|
||||||
|
ExtensionWalletPaymentsWatchGrantRequest,
|
||||||
InstallableExtension,
|
InstallableExtension,
|
||||||
PayToEnableInfo,
|
PayToEnableInfo,
|
||||||
ReleasePaymentInfo,
|
ReleasePaymentInfo,
|
||||||
@@ -79,6 +89,9 @@ extension_router = APIRouter(
|
|||||||
prefix="/api/v1/extension",
|
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)])
|
@extension_router.post("", dependencies=[Depends(check_admin)])
|
||||||
async def api_install_extension(data: CreateExtension):
|
async def api_install_extension(data: CreateExtension):
|
||||||
@@ -366,6 +379,213 @@ async def api_disable_extension(
|
|||||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
|
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)])
|
@extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)])
|
||||||
async def api_activate_extension(ext_id: str) -> SimpleStatus:
|
async def api_activate_extension(ext_id: str) -> SimpleStatus:
|
||||||
try:
|
try:
|
||||||
@@ -783,3 +1003,178 @@ async def create_extension_review(
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
payment_request = resp.json()
|
payment_request = resp.json()
|
||||||
return ExtensionReviewPaymentRequest(**payment_request)
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -158,10 +158,6 @@ async def api_update_user(
|
|||||||
async def api_users_delete_user(
|
async def api_users_delete_user(
|
||||||
user_id: str, account: Account = Depends(check_admin)
|
user_id: str, account: Account = Depends(check_admin)
|
||||||
) -> SimpleStatus:
|
) -> 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:
|
if user_id == settings.super_user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
@@ -173,6 +169,11 @@ async def api_users_delete_user(
|
|||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail="Only super_user can delete admin user.",
|
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)
|
await delete_account(user_id)
|
||||||
return SimpleStatus(success=True, message="User deleted.")
|
return SimpleStatus(success=True, message="User deleted.")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
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)
|
||||||
@@ -17,6 +17,11 @@ from ..storage.crud import (
|
|||||||
storage_get_row,
|
storage_get_row,
|
||||||
storage_set_row,
|
storage_set_row,
|
||||||
)
|
)
|
||||||
|
from .background_payments import (
|
||||||
|
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION,
|
||||||
|
background_payment_extra,
|
||||||
|
invoice_amount_msat,
|
||||||
|
)
|
||||||
from .models import (
|
from .models import (
|
||||||
CreateInvoicePublicRequest,
|
CreateInvoicePublicRequest,
|
||||||
CreateInvoiceRequest,
|
CreateInvoiceRequest,
|
||||||
@@ -31,6 +36,7 @@ from .models import (
|
|||||||
NowResponse,
|
NowResponse,
|
||||||
PayInvoiceRequest,
|
PayInvoiceRequest,
|
||||||
PayInvoiceResponse,
|
PayInvoiceResponse,
|
||||||
|
PayLnurlRequest,
|
||||||
RandomIdRequest,
|
RandomIdRequest,
|
||||||
RandomIdResponse,
|
RandomIdResponse,
|
||||||
StorageDeleteRequest,
|
StorageDeleteRequest,
|
||||||
@@ -50,11 +56,6 @@ from .registry import extension_api_method
|
|||||||
logger = logging.getLogger("lnbits.extensions")
|
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:
|
class ExtensionHostAPI:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -65,7 +66,6 @@ class ExtensionHostAPI:
|
|||||||
access_token: str | None = None,
|
access_token: str | None = None,
|
||||||
context: str = "user",
|
context: str = "user",
|
||||||
owner_id: str | None = None,
|
owner_id: str | None = None,
|
||||||
wallet_id: str | None = None,
|
|
||||||
invocation_id: str | None = None,
|
invocation_id: str | None = None,
|
||||||
runtime_limits: dict[str, int] | None = None,
|
runtime_limits: dict[str, int] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -75,12 +75,15 @@ class ExtensionHostAPI:
|
|||||||
self.access_token = access_token
|
self.access_token = access_token
|
||||||
self.context = context
|
self.context = context
|
||||||
self.owner_id = sha256s(user_id) if user_id else owner_id
|
self.owner_id = sha256s(user_id) if user_id else owner_id
|
||||||
self.wallet_id = wallet_id
|
|
||||||
self.invocation_id = invocation_id
|
self.invocation_id = invocation_id
|
||||||
self.runtime_limits = runtime_limits or {}
|
self.runtime_limits = runtime_limits or {}
|
||||||
from .utils import ExtensionAPIUtils
|
from .utils import ExtensionAPIUtils
|
||||||
|
|
||||||
self.utils = ExtensionAPIUtils(self.extension_id, self.permissions)
|
self.utils = ExtensionAPIUtils(
|
||||||
|
self.extension_id,
|
||||||
|
self.permissions,
|
||||||
|
authenticated=self.has_authenticated_context(),
|
||||||
|
)
|
||||||
|
|
||||||
@extension_api_method(
|
@extension_api_method(
|
||||||
method_id="storage.get",
|
method_id="storage.get",
|
||||||
@@ -369,65 +372,143 @@ class ExtensionHostAPI:
|
|||||||
host_name="pay_invoice",
|
host_name="pay_invoice",
|
||||||
sdk_name="payInvoice",
|
sdk_name="payInvoice",
|
||||||
description="Pay a Lightning invoice from a wallet available to the user.",
|
description="Pay a Lightning invoice from a wallet available to the user.",
|
||||||
required_permission="wallet.pay_invoice",
|
|
||||||
)
|
)
|
||||||
async def wallet_pay_invoice(
|
async def wallet_pay_invoice(
|
||||||
self, request: PayInvoiceRequest
|
self, request: PayInvoiceRequest
|
||||||
) -> PayInvoiceResponse:
|
) -> PayInvoiceResponse:
|
||||||
from lnurl import LnurlResponseException
|
|
||||||
|
|
||||||
from lnbits.core.crud.wallets import get_wallet
|
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.core.services.payments import pay_invoice
|
||||||
from lnbits.exceptions import PaymentError
|
from lnbits.exceptions import PaymentError
|
||||||
|
|
||||||
wallet = await get_wallet(request.wallet_id)
|
wallet = await get_wallet(request.wallet_id)
|
||||||
if wallet is None:
|
if wallet is None:
|
||||||
raise PermissionError("Paying invoices from this wallet is not allowed.")
|
raise PermissionError("Paying invoices from this wallet is not allowed.")
|
||||||
|
|
||||||
|
try:
|
||||||
if self.user_id:
|
if self.user_id:
|
||||||
|
self.require_permission("wallet.pay_invoice")
|
||||||
if wallet.user != self.user_id:
|
if wallet.user != self.user_id:
|
||||||
raise PermissionError(
|
raise PermissionError(
|
||||||
"Paying invoices from this wallet is not allowed."
|
"Paying invoices from this wallet is not allowed."
|
||||||
)
|
)
|
||||||
elif not (self.context == "event" and request.wallet_id == self.wallet_id):
|
|
||||||
raise PermissionError(
|
|
||||||
"Paying an invoice requires an authenticated user context."
|
|
||||||
)
|
|
||||||
|
|
||||||
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(
|
payment = await pay_invoice(
|
||||||
wallet_id=request.wallet_id,
|
wallet_id=request.wallet_id,
|
||||||
payment_request=payment_request,
|
payment_request=request.payment_request,
|
||||||
max_sat=request.max_sat,
|
max_sat=request.max_sat,
|
||||||
extra={"tag": self.extension_id, **request.extra},
|
extra={"tag": self.extension_id, **request.extra},
|
||||||
description=request.description,
|
description=request.description,
|
||||||
tag=self.extension_id,
|
tag=self.extension_id,
|
||||||
)
|
)
|
||||||
except (PaymentError, ValueError, LnurlResponseException) as exc:
|
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 PayInvoiceResponse(ok=False, error=str(exc))
|
||||||
|
|
||||||
return PayInvoiceResponse(
|
return _pay_invoice_response(payment)
|
||||||
ok=True,
|
|
||||||
checking_id=payment.checking_id,
|
@extension_api_method(
|
||||||
payment_hash=payment.payment_hash,
|
method_id="wallet.pay_lnurl",
|
||||||
status=payment.status,
|
namespace="wallet",
|
||||||
amount_msat=abs(payment.amount),
|
name="Pay LNURL",
|
||||||
fee_msat=abs(payment.fee),
|
host_name="pay_lnurl",
|
||||||
pending=payment.pending,
|
sdk_name="payLnurl",
|
||||||
success=payment.success,
|
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,
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
payment = await pay_invoice(
|
||||||
|
wallet_id=request.wallet_id,
|
||||||
|
payment_request=str(action.pr),
|
||||||
|
max_sat=request.max_sat,
|
||||||
|
extra=extra,
|
||||||
|
description=request.description or lnurl_pay_response_text(res),
|
||||||
|
tag=self.extension_id,
|
||||||
|
)
|
||||||
|
except (
|
||||||
|
LnAddressError,
|
||||||
|
LnurlResponseException,
|
||||||
|
PaymentError,
|
||||||
|
PermissionError,
|
||||||
|
ValueError,
|
||||||
|
) as exc:
|
||||||
|
return PayInvoiceResponse(ok=False, error=str(exc))
|
||||||
|
|
||||||
|
return _pay_invoice_response(payment)
|
||||||
|
|
||||||
@extension_api_method(
|
@extension_api_method(
|
||||||
method_id="http.request",
|
method_id="http.request",
|
||||||
@@ -622,3 +703,16 @@ class ExtensionHostAPI:
|
|||||||
f"owner_id={self.owner_id!r}"
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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)
|
||||||
@@ -181,6 +181,17 @@ class PayInvoiceRequest(BaseModel):
|
|||||||
extra: dict[str, str] = Field(default_factory=dict)
|
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):
|
class PayInvoiceResponse(BaseModel):
|
||||||
ok: bool = True
|
ok: bool = True
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
@@ -282,6 +293,22 @@ class SatsToFiatResponse(BaseModel):
|
|||||||
amount: float
|
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):
|
class ServerHealthResponse(BaseModel):
|
||||||
server_time: int
|
server_time: int
|
||||||
up_time: str
|
up_time: str
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ from pydantic import BaseModel
|
|||||||
from .models import ExtensionAPIMethod, ExtensionAPIMethodExport
|
from .models import ExtensionAPIMethod, ExtensionAPIMethodExport
|
||||||
|
|
||||||
_EXTENSION_API_METHOD_ATTR = "__lnbits_extension_api_method__"
|
_EXTENSION_API_METHOD_ATTR = "__lnbits_extension_api_method__"
|
||||||
_EXTENSION_RUNTIME_PERMISSION_IDS = {"ui.camera.scan_qr"}
|
_EXTENSION_RUNTIME_PERMISSION_IDS = {
|
||||||
|
"ui.camera.scan_qr",
|
||||||
|
"wallet.pay_invoice",
|
||||||
|
"wallet.pay_invoice_background",
|
||||||
|
"wallet.payments.watch",
|
||||||
|
}
|
||||||
_RequestModel = TypeVar("_RequestModel", bound=BaseModel)
|
_RequestModel = TypeVar("_RequestModel", bound=BaseModel)
|
||||||
_ResponseModel = TypeVar("_ResponseModel", bound=BaseModel)
|
_ResponseModel = TypeVar("_ResponseModel", bound=BaseModel)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ from collections.abc import Iterable
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from lnurl import LnurlErrorResponse, LnurlPayResponse, LnurlResponseException
|
||||||
|
from lnurl import handle as lnurl_handle
|
||||||
|
|
||||||
from lnbits import bolt11
|
from lnbits import bolt11
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.utils.crypto import random_secret_and_hash, verify_preimage
|
from lnbits.utils.crypto import random_secret_and_hash, verify_preimage
|
||||||
@@ -15,6 +18,12 @@ from lnbits.utils.exchange_rates import (
|
|||||||
satoshis_amount_as_fiat,
|
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 (
|
from .models import (
|
||||||
Bolt11Request,
|
Bolt11Request,
|
||||||
CurrencyConvertRequest,
|
CurrencyConvertRequest,
|
||||||
@@ -30,6 +39,8 @@ from .models import (
|
|||||||
InvoiceExpiryResponse,
|
InvoiceExpiryResponse,
|
||||||
InvoiceMemoResponse,
|
InvoiceMemoResponse,
|
||||||
InvoicePaymentHashResponse,
|
InvoicePaymentHashResponse,
|
||||||
|
LnurlResolveRequest,
|
||||||
|
LnurlResolveResponse,
|
||||||
RandomSecretAndHashRequest,
|
RandomSecretAndHashRequest,
|
||||||
RandomSecretAndHashResponse,
|
RandomSecretAndHashResponse,
|
||||||
SatsToFiatRequest,
|
SatsToFiatRequest,
|
||||||
@@ -43,17 +54,39 @@ from .registry import extension_api_method
|
|||||||
|
|
||||||
|
|
||||||
class ExtensionAPIUtils:
|
class ExtensionAPIUtils:
|
||||||
def __init__(self, extension_id: str, permissions: Iterable[str]) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
extension_id: str,
|
||||||
|
permissions: Iterable[str],
|
||||||
|
*,
|
||||||
|
authenticated: bool = False,
|
||||||
|
) -> None:
|
||||||
permission_set = set(permissions)
|
permission_set = set(permissions)
|
||||||
self.currencies = ExtensionCurrencyUtils(extension_id, permission_set)
|
self.currencies = ExtensionCurrencyUtils(
|
||||||
self.server = ExtensionServerUtils(extension_id, permission_set)
|
extension_id, permission_set, authenticated=authenticated
|
||||||
self.lightning = ExtensionLightningUtils(extension_id, permission_set)
|
)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _ExtensionAPIUtilsGroup:
|
class _ExtensionAPIUtilsGroup:
|
||||||
def __init__(self, extension_id: str, permissions: Iterable[str]) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
extension_id: str,
|
||||||
|
permissions: Iterable[str],
|
||||||
|
*,
|
||||||
|
authenticated: bool = False,
|
||||||
|
) -> None:
|
||||||
self.extension_id = extension_id
|
self.extension_id = extension_id
|
||||||
self.permissions = set(permissions)
|
self.permissions = set(permissions)
|
||||||
|
self.authenticated = authenticated
|
||||||
|
|
||||||
def require_permission(self, permission: str | None) -> None:
|
def require_permission(self, permission: str | None) -> None:
|
||||||
if permission and permission not in self.permissions:
|
if permission and permission not in self.permissions:
|
||||||
@@ -62,7 +95,7 @@ class _ExtensionAPIUtilsGroup:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def has_authenticated_context(self) -> bool:
|
def has_authenticated_context(self) -> bool:
|
||||||
return False
|
return self.authenticated
|
||||||
|
|
||||||
|
|
||||||
class ExtensionCurrencyUtils(_ExtensionAPIUtilsGroup):
|
class ExtensionCurrencyUtils(_ExtensionAPIUtilsGroup):
|
||||||
@@ -193,6 +226,60 @@ 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):
|
class ExtensionLightningUtils(_ExtensionAPIUtilsGroup):
|
||||||
@extension_api_method(
|
@extension_api_method(
|
||||||
method_id="utils.lightning.decode_invoice",
|
method_id="utils.lightning.decode_invoice",
|
||||||
@@ -335,6 +422,7 @@ def extension_api_utils_method_classes() -> dict[str, type[_ExtensionAPIUtilsGro
|
|||||||
return {
|
return {
|
||||||
"utils.currencies": ExtensionCurrencyUtils,
|
"utils.currencies": ExtensionCurrencyUtils,
|
||||||
"utils.server": ExtensionServerUtils,
|
"utils.server": ExtensionServerUtils,
|
||||||
|
"utils.lnurl": ExtensionLnurlUtils,
|
||||||
"utils.lightning": ExtensionLightningUtils,
|
"utils.lightning": ExtensionLightningUtils,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from lnbits.core.crud import update_migration_version
|
|||||||
from lnbits.core.db import db as core_db
|
from lnbits.core.db import db as core_db
|
||||||
from lnbits.core.models import DbVersion
|
from lnbits.core.models import DbVersion
|
||||||
from lnbits.core.models.extensions import InstallableExtension
|
from lnbits.core.models.extensions import InstallableExtension
|
||||||
from lnbits.db import POSTGRES, SQLITE, Connection, Database
|
from lnbits.db import POSTGRES, SQLITE, Compat, Connection, Database
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
_MIGRATION_FILE_RE = re.compile(r"^(\d+)_.*\.json$")
|
_MIGRATION_FILE_RE = re.compile(r"^(\d+)_.*\.json$")
|
||||||
@@ -76,9 +76,16 @@ async def storage_set_row(
|
|||||||
) -> None:
|
) -> None:
|
||||||
table_schema = _load_table_schema(ext_id, table)
|
table_schema = _load_table_schema(ext_id, table)
|
||||||
clean_data = _data_to_db(table_schema, data, require_id=True)
|
clean_data = _data_to_db(table_schema, data, require_id=True)
|
||||||
clean_data[OWNER_ID_FIELD] = owner_id
|
|
||||||
columns = list(clean_data.keys())
|
columns = list(clean_data.keys())
|
||||||
placeholders = [f":{column}" for column in columns]
|
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}")
|
||||||
updates = [
|
updates = [
|
||||||
f"{column} = excluded.{column}"
|
f"{column} = excluded.{column}"
|
||||||
for column in columns
|
for column in columns
|
||||||
@@ -87,19 +94,19 @@ async def storage_set_row(
|
|||||||
conflict_sql = (
|
conflict_sql = (
|
||||||
"DO UPDATE SET "
|
"DO UPDATE SET "
|
||||||
+ ", ".join(updates)
|
+ ", ".join(updates)
|
||||||
+ f" WHERE {OWNER_ID_FIELD} = :{OWNER_ID_FIELD}"
|
+ f" WHERE storage_row.{OWNER_ID_FIELD} = :{OWNER_ID_FIELD}"
|
||||||
if updates
|
if updates
|
||||||
else "DO NOTHING"
|
else "DO NOTHING"
|
||||||
)
|
)
|
||||||
query = f"""
|
query = f"""
|
||||||
INSERT INTO {_table_ref_for_schema(ext_id, table)}
|
INSERT INTO {_table_ref_for_schema(ext_id, table)} AS storage_row
|
||||||
({", ".join(columns)})
|
({", ".join(columns)})
|
||||||
VALUES
|
VALUES
|
||||||
({", ".join(placeholders)})
|
({", ".join(placeholders)})
|
||||||
ON CONFLICT (id) {conflict_sql}
|
ON CONFLICT (id) {conflict_sql}
|
||||||
""" # noqa: S608
|
""" # noqa: S608
|
||||||
|
|
||||||
async with Database(f"ext_{ext_id}").connect() as conn:
|
async with database.connect() as conn:
|
||||||
await conn.execute(query, clean_data)
|
await conn.execute(query, clean_data)
|
||||||
|
|
||||||
|
|
||||||
@@ -117,7 +124,10 @@ async def storage_get_paginated_rows(
|
|||||||
offset: int,
|
offset: int,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
table_schema = _load_table_schema(ext_id, table)
|
table_schema = _load_table_schema(ext_id, table)
|
||||||
where_sql, values = _where_sql(table_schema, filters, search, search_fields)
|
database = Database(f"ext_{ext_id}")
|
||||||
|
where_sql, values = _where_sql(
|
||||||
|
database, table_schema, filters, search, search_fields
|
||||||
|
)
|
||||||
where_sql = _append_owner_where_sql(where_sql)
|
where_sql = _append_owner_where_sql(where_sql)
|
||||||
values[OWNER_ID_FIELD] = owner_id
|
values[OWNER_ID_FIELD] = owner_id
|
||||||
order_sql = _order_sql(table_schema, sort_by, descending)
|
order_sql = _order_sql(table_schema, sort_by, descending)
|
||||||
@@ -137,7 +147,7 @@ async def storage_get_paginated_rows(
|
|||||||
{where_sql}
|
{where_sql}
|
||||||
""" # noqa: S608
|
""" # noqa: S608
|
||||||
|
|
||||||
async with Database(f"ext_{ext_id}").connect() as conn:
|
async with database.connect() as conn:
|
||||||
rows = await conn.fetchall(rows_query, values)
|
rows = await conn.fetchall(rows_query, values)
|
||||||
count_row = await conn.fetchone(count_query, count_values)
|
count_row = await conn.fetchone(count_query, count_values)
|
||||||
|
|
||||||
@@ -403,14 +413,25 @@ 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(
|
def _where_sql(
|
||||||
|
db: Compat,
|
||||||
table_schema: dict[str, Any],
|
table_schema: dict[str, Any],
|
||||||
filters: dict[str, Any],
|
filters: dict[str, Any],
|
||||||
search: str | None,
|
search: str | None,
|
||||||
search_fields: list[str],
|
search_fields: list[str],
|
||||||
) -> tuple[str, dict[str, Any]]:
|
) -> tuple[str, dict[str, Any]]:
|
||||||
clean_filters = _filters_to_db(table_schema, filters)
|
clean_filters = _filters_to_db(table_schema, filters)
|
||||||
clauses = [f"{field} = :filter_{field}" for field in clean_filters]
|
fields = _fields_by_name(table_schema)
|
||||||
|
clauses = [
|
||||||
|
f"{field} = {_value_placeholder(db, fields[field], f'filter_{field}')}"
|
||||||
|
for field in clean_filters
|
||||||
|
]
|
||||||
values = {f"filter_{field}": value for field, value in clean_filters.items()}
|
values = {f"filter_{field}": value for field, value in clean_filters.items()}
|
||||||
|
|
||||||
clean_search = search.strip().lower() if search else ""
|
clean_search = search.strip().lower() if search else ""
|
||||||
|
|||||||
@@ -4,21 +4,53 @@ from typing import Any
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits.core.crud.extensions import get_installed_extension
|
from lnbits.core.crud.extensions import get_installed_extension, get_user_extensions
|
||||||
|
from lnbits.core.crud.wallets import get_wallet
|
||||||
from lnbits.core.db import core_app_extra
|
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.storage.crud import storage_get_row_owner_id
|
||||||
from lnbits.core.wasm_ext.wasm.invoke import invoke_wasm_extension_export
|
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:
|
async def dispatch_wasm_invoice_paid(payment: Any) -> None:
|
||||||
|
targets: dict[str, tuple[Any, str | None]] = {}
|
||||||
extension_id = _payment_extension_id(payment)
|
extension_id = _payment_extension_id(payment)
|
||||||
if not extension_id:
|
if extension_id:
|
||||||
return
|
|
||||||
|
|
||||||
extension = core_app_extra.wasm_extension_registry.get(extension_id)
|
extension = core_app_extra.wasm_extension_registry.get(extension_id)
|
||||||
if not extension:
|
if extension:
|
||||||
return
|
targets[extension_id] = (
|
||||||
|
extension,
|
||||||
|
await _wasm_invoice_paid_owner_id(extension, payment),
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
export_name = _wasm_invoice_paid_export(extension.config)
|
||||||
if not export_name:
|
if not export_name:
|
||||||
return
|
return
|
||||||
@@ -31,7 +63,6 @@ async def dispatch_wasm_invoice_paid(payment: Any) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
owner_id = await _wasm_invoice_paid_owner_id(extension, payment)
|
|
||||||
await invoke_wasm_extension_export(
|
await invoke_wasm_extension_export(
|
||||||
extension.id,
|
extension.id,
|
||||||
export_name,
|
export_name,
|
||||||
@@ -51,6 +82,86 @@ async def dispatch_wasm_invoice_paid(payment: Any) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def _payment_extension_id(payment: Any) -> str | None:
|
||||||
if isinstance(payment.extension, str) and payment.extension:
|
if isinstance(payment.extension, str) and payment.extension:
|
||||||
return payment.extension
|
return payment.extension
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ async def invoke_wasm_extension_export(
|
|||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
context=context,
|
context=context,
|
||||||
owner_id=owner_id,
|
owner_id=owner_id,
|
||||||
wallet_id=wallet_id,
|
|
||||||
invocation_id=invocation.id,
|
invocation_id=invocation.id,
|
||||||
runtime_limits=limits,
|
runtime_limits=limits,
|
||||||
)
|
)
|
||||||
|
|||||||
+11
-4
@@ -725,20 +725,20 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901
|
|||||||
if get_origin(outertype_) is list:
|
if get_origin(outertype_) is list:
|
||||||
_items = _safe_load_json(value) if isinstance(value, str) else value
|
_items = _safe_load_json(value) if isinstance(value, str) else value
|
||||||
_dict[key] = [
|
_dict[key] = [
|
||||||
dict_to_submodel(type_, v) if issubclass(type_, BaseModel) else v
|
dict_to_submodel(type_, v) if _is_subclass(type_, BaseModel) else v
|
||||||
for v in _items
|
for v in _items
|
||||||
]
|
]
|
||||||
continue
|
continue
|
||||||
if issubclass(type_, bool):
|
if _is_subclass(type_, bool):
|
||||||
_dict[key] = bool(value)
|
_dict[key] = bool(value)
|
||||||
continue
|
continue
|
||||||
if issubclass(type_, datetime):
|
if _is_subclass(type_, datetime):
|
||||||
if DB_TYPE == SQLITE:
|
if DB_TYPE == SQLITE:
|
||||||
_dict[key] = datetime.fromtimestamp(value, timezone.utc)
|
_dict[key] = datetime.fromtimestamp(value, timezone.utc)
|
||||||
else:
|
else:
|
||||||
_dict[key] = value.replace(tzinfo=timezone.utc)
|
_dict[key] = value.replace(tzinfo=timezone.utc)
|
||||||
continue
|
continue
|
||||||
if issubclass(type_, BaseModel):
|
if _is_subclass(type_, BaseModel):
|
||||||
_dict[key] = dict_to_submodel(type_, value)
|
_dict[key] = dict_to_submodel(type_, value)
|
||||||
continue
|
continue
|
||||||
# TODO: remove this when all sub models are migrated to Pydantic
|
# TODO: remove this when all sub models are migrated to Pydantic
|
||||||
@@ -763,6 +763,13 @@ def _safe_load_json(value: str) -> dict:
|
|||||||
return {}
|
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:
|
def _valid_sql_name(name: str) -> bool:
|
||||||
"""Check if a SQL name is valid (alphanumeric and underscores only)"""
|
"""Check if a SQL name is valid (alphanumeric and underscores only)"""
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -372,3 +372,13 @@ def sha256s(value: str) -> str:
|
|||||||
Returns the hex as a string.
|
Returns the hex as a string.
|
||||||
"""
|
"""
|
||||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
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())
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ from lnbits.settings import set_cli_settings, settings
|
|||||||
@click.option(
|
@click.option(
|
||||||
"--reload", is_flag=True, default=False, help="Enable auto-reload for development"
|
"--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(
|
def main(
|
||||||
port: int,
|
port: int,
|
||||||
host: str,
|
host: str,
|
||||||
@@ -34,6 +36,8 @@ def main(
|
|||||||
ssl_keyfile: str,
|
ssl_keyfile: str,
|
||||||
ssl_certfile: str,
|
ssl_certfile: str,
|
||||||
reload: bool,
|
reload: bool,
|
||||||
|
ws_max_queue: int,
|
||||||
|
ws_ping_timeout: float,
|
||||||
):
|
):
|
||||||
"""Launched with `uv run lnbits` at root level"""
|
"""Launched with `uv run lnbits` at root level"""
|
||||||
|
|
||||||
@@ -58,6 +62,8 @@ def main(
|
|||||||
ssl_keyfile=ssl_keyfile,
|
ssl_keyfile=ssl_keyfile,
|
||||||
ssl_certfile=ssl_certfile,
|
ssl_certfile=ssl_certfile,
|
||||||
reload=reload or False,
|
reload=reload or False,
|
||||||
|
ws_ping_timeout=ws_ping_timeout,
|
||||||
|
ws_max_queue=ws_max_queue,
|
||||||
)
|
)
|
||||||
|
|
||||||
server = uvicorn.Server(config=config)
|
server = uvicorn.Server(config=config)
|
||||||
|
|||||||
@@ -524,6 +524,11 @@ class NotificationsSettings(LNbitsSettings):
|
|||||||
and self.lnbits_telegram_notifications_access_token is not None
|
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):
|
class FakeWalletFundingSource(LNbitsSettings):
|
||||||
fake_wallet_secret: str = Field(default="ToTheMoon1")
|
fake_wallet_secret: str = Field(default="ToTheMoon1")
|
||||||
@@ -1324,6 +1329,7 @@ class PublicSettings(BaseModel):
|
|||||||
extensions_reviews_url: str = Field(alias="extensionsReviewsUrl")
|
extensions_reviews_url: str = Field(alias="extensionsReviewsUrl")
|
||||||
ext_builder: bool = Field(alias="extBuilder")
|
ext_builder: bool = Field(alias="extBuilder")
|
||||||
nostr_configured: bool = Field(alias="nostrConfigured")
|
nostr_configured: bool = Field(alias="nostrConfigured")
|
||||||
|
email_configured: bool = Field(alias="emailConfigured")
|
||||||
telegram_configured: bool = Field(alias="telegramConfigured")
|
telegram_configured: bool = Field(alias="telegramConfigured")
|
||||||
wallet_featured_button_label: str | None = Field(alias="walletFeaturedButtonLabel")
|
wallet_featured_button_label: str | None = Field(alias="walletFeaturedButtonLabel")
|
||||||
wallet_featured_button_url: str | None = Field(alias="walletFeaturedButtonUrl")
|
wallet_featured_button_url: str | None = Field(alias="walletFeaturedButtonUrl")
|
||||||
@@ -1388,6 +1394,7 @@ class PublicSettings(BaseModel):
|
|||||||
extensionsReviewsUrl=settings.lnbits_extensions_reviews_url,
|
extensionsReviewsUrl=settings.lnbits_extensions_reviews_url,
|
||||||
extBuilder=settings.lnbits_extensions_builder_activate_non_admins,
|
extBuilder=settings.lnbits_extensions_builder_activate_non_admins,
|
||||||
nostrConfigured=settings.is_nostr_notifications_configured(),
|
nostrConfigured=settings.is_nostr_notifications_configured(),
|
||||||
|
emailConfigured=settings.is_email_notifications_configured(),
|
||||||
telegramConfigured=settings.is_telegram_notifications_configured(),
|
telegramConfigured=settings.is_telegram_notifications_configured(),
|
||||||
walletFeaturedButtonLabel=settings.lnbits_wallet_featured_button_label,
|
walletFeaturedButtonLabel=settings.lnbits_wallet_featured_button_label,
|
||||||
walletFeaturedButtonUrl=settings.lnbits_wallet_featured_button_url,
|
walletFeaturedButtonUrl=settings.lnbits_wallet_featured_button_url,
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -520,6 +520,14 @@ window.localisation.en = {
|
|||||||
extension_cost: 'This release requires a payment of minimum {cost} sats.',
|
extension_cost: 'This release requires a payment of minimum {cost} sats.',
|
||||||
extension_paid_sats: 'You have already paid {paid_sats} sats.',
|
extension_paid_sats: 'You have already paid {paid_sats} sats.',
|
||||||
extension_permissions_title: 'Grant extension permissions',
|
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_grant_install: 'Grant and install',
|
||||||
extension_permissions_high_risk_warning:
|
extension_permissions_high_risk_warning:
|
||||||
'This extension requests permissions that can move funds.',
|
'This extension requests permissions that can move funds.',
|
||||||
@@ -528,6 +536,10 @@ window.localisation.en = {
|
|||||||
extension_permission_risk_high: 'High risk',
|
extension_permission_risk_high: 'High risk',
|
||||||
extension_permission_warning_wallet_pay_invoice:
|
extension_permission_warning_wallet_pay_invoice:
|
||||||
'Can spend funds from wallets available to your account.',
|
'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:
|
extension_permission_warning_extension_api_request_write:
|
||||||
'Can write data or trigger actions in approved extensions.',
|
'Can write data or trigger actions in approved extensions.',
|
||||||
extension_permission_ext_storage_read: 'Read extension storage',
|
extension_permission_ext_storage_read: 'Read extension storage',
|
||||||
@@ -542,13 +554,15 @@ window.localisation.en = {
|
|||||||
extension_permission_http_request_hosts: 'Allowed hosts',
|
extension_permission_http_request_hosts: 'Allowed hosts',
|
||||||
extension_permission_utils_basic: 'Use basic LNbits utilities',
|
extension_permission_utils_basic: 'Use basic LNbits utilities',
|
||||||
extension_permission_ui_camera_scan_qr: 'Scan QR codes',
|
extension_permission_ui_camera_scan_qr: 'Scan QR codes',
|
||||||
extension_permission_payments_watch: 'Watch payments',
|
extension_permission_wallet_payments_watch: 'Watch wallet payments',
|
||||||
extension_permission_wallet_create_invoice: 'Create invoices',
|
extension_permission_wallet_create_invoice: 'Create invoices',
|
||||||
extension_permission_wallet_create_invoice_public:
|
extension_permission_wallet_create_invoice_public:
|
||||||
'Create Lightning invoices from public pages',
|
'Create Lightning invoices from public pages',
|
||||||
extension_permission_wallet_balance_read: 'View wallet balances',
|
extension_permission_wallet_balance_read: 'View wallet balances',
|
||||||
extension_permission_wallet_list: 'List wallets',
|
extension_permission_wallet_list: 'List wallets',
|
||||||
extension_permission_wallet_pay_invoice: 'Pay invoices',
|
extension_permission_wallet_pay_invoice: 'Pay invoices',
|
||||||
|
extension_permission_wallet_pay_invoice_background:
|
||||||
|
'Make background payments',
|
||||||
create_extension: 'Create Extension',
|
create_extension: 'Create Extension',
|
||||||
release_details_error: 'Cannot get the release details.',
|
release_details_error: 'Cannot get the release details.',
|
||||||
pay_from_wallet: 'Pay from Wallet',
|
pay_from_wallet: 'Pay from Wallet',
|
||||||
|
|||||||
@@ -28,12 +28,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mediumRisk(translateFn) {
|
function mediumRisk(translateFn, warningKey) {
|
||||||
return {
|
return {
|
||||||
level: 'medium',
|
level: 'medium',
|
||||||
color: 'warning',
|
color: 'warning',
|
||||||
label: translate(translateFn, 'extension_permission_risk_medium'),
|
label: translate(translateFn, 'extension_permission_risk_medium'),
|
||||||
warning: ''
|
warning: warningKey ? translate(translateFn, warningKey) : ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,10 +77,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function permissionRiskForPermission(permission, extensions, translateFn) {
|
function permissionRiskForPermission(permission, extensions, translateFn) {
|
||||||
if (permission.id === 'wallet.pay_invoice') {
|
if (
|
||||||
|
['wallet.pay_invoice', 'wallet.pay_invoice_background'].includes(
|
||||||
|
permission.id
|
||||||
|
)
|
||||||
|
) {
|
||||||
return highRisk(
|
return highRisk(
|
||||||
translateFn,
|
translateFn,
|
||||||
'extension_permission_warning_wallet_pay_invoice'
|
permission.id === 'wallet.pay_invoice_background'
|
||||||
|
? 'extension_permission_warning_wallet_pay_invoice_background'
|
||||||
|
: 'extension_permission_warning_wallet_pay_invoice'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (permission.id === 'extension.api.request') {
|
if (permission.id === 'extension.api.request') {
|
||||||
@@ -98,13 +104,18 @@
|
|||||||
if (permission.id === 'http.request') {
|
if (permission.id === 'http.request') {
|
||||||
return mediumRisk(translateFn)
|
return mediumRisk(translateFn)
|
||||||
}
|
}
|
||||||
|
if (permission.id === 'wallet.payments.watch') {
|
||||||
|
return mediumRisk(
|
||||||
|
translateFn,
|
||||||
|
'extension_permission_warning_wallet_payments_watch'
|
||||||
|
)
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
[
|
[
|
||||||
'wallet.list',
|
'wallet.list',
|
||||||
'wallet.balance.read',
|
'wallet.balance.read',
|
||||||
'wallet.create_invoice_public',
|
'wallet.create_invoice_public',
|
||||||
'ext.storage.read_public',
|
'ext.storage.read_public'
|
||||||
'payments.watch'
|
|
||||||
].includes(permission.id)
|
].includes(permission.id)
|
||||||
) {
|
) {
|
||||||
return mediumRisk(translateFn)
|
return mediumRisk(translateFn)
|
||||||
@@ -124,6 +135,8 @@
|
|||||||
function permissionOrderIndex(permissionId) {
|
function permissionOrderIndex(permissionId) {
|
||||||
const order = [
|
const order = [
|
||||||
'wallet.pay_invoice',
|
'wallet.pay_invoice',
|
||||||
|
'wallet.pay_invoice_background',
|
||||||
|
'wallet.payments.watch',
|
||||||
'wallet.list',
|
'wallet.list',
|
||||||
'wallet.balance.read',
|
'wallet.balance.read',
|
||||||
'extension.api.request',
|
'extension.api.request',
|
||||||
|
|||||||
@@ -29,6 +29,23 @@ window.PageExtensions = {
|
|||||||
permissions: [],
|
permissions: [],
|
||||||
resolve: null
|
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,
|
uninstallAndDropDb: false,
|
||||||
maxStars: 5,
|
maxStars: 5,
|
||||||
paylinkWebsocket: null,
|
paylinkWebsocket: null,
|
||||||
@@ -98,6 +115,32 @@ window.PageExtensions = {
|
|||||||
this.filterExtensions(this.searchTerm, val)
|
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: {
|
methods: {
|
||||||
filterExtensions(term, tab) {
|
filterExtensions(term, tab) {
|
||||||
// Filter the extensions list
|
// Filter the extensions list
|
||||||
@@ -357,8 +400,18 @@ window.PageExtensions = {
|
|||||||
this.selectedExtension = extension
|
this.selectedExtension = extension
|
||||||
this.selectedRelease = null
|
this.selectedRelease = null
|
||||||
this.selectedExtensionRepos = null
|
this.selectedExtensionRepos = null
|
||||||
this.manageExtensionTab = 'releases'
|
this.resetManagedExtensionPermissions()
|
||||||
|
this.manageExtensionTab = this.g.user.admin
|
||||||
|
? 'releases'
|
||||||
|
: 'extension-permissions'
|
||||||
this.showManageExtensionDialog = true
|
this.showManageExtensionDialog = true
|
||||||
|
if (this.canManageExtensionPermissions(extension)) {
|
||||||
|
this.loadManagedExtensionPermissions(extension)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.g.user.admin) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const {data} = await LNbits.api.request(
|
const {data} = await LNbits.api.request(
|
||||||
@@ -396,6 +449,61 @@ window.PageExtensions = {
|
|||||||
extension.inProgress = false
|
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) {
|
async showExtensionDetails(extId, detailsLink) {
|
||||||
if (!detailsLink) {
|
if (!detailsLink) {
|
||||||
@@ -642,6 +750,135 @@ window.PageExtensions = {
|
|||||||
extensionOpenUrl(extension) {
|
extensionOpenUrl(extension) {
|
||||||
return extension.isWasm ? `/ext/${extension.id}` : `/${extension.id}`
|
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) {
|
async getGitHubReleaseDetails(release) {
|
||||||
if (!release.is_github_release || release.loaded) {
|
if (!release.is_github_release || release.loaded) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -48,6 +48,85 @@ window.WasmExtensionComponent = {
|
|||||||
</q-card-actions>
|
</q-card-actions>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</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>
|
</div>
|
||||||
`,
|
`,
|
||||||
data() {
|
data() {
|
||||||
@@ -68,6 +147,36 @@ window.WasmExtensionComponent = {
|
|||||||
resolve: null,
|
resolve: null,
|
||||||
show: false
|
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: '',
|
error: '',
|
||||||
extensionName: '',
|
extensionName: '',
|
||||||
frameUrl: '',
|
frameUrl: '',
|
||||||
@@ -84,6 +193,12 @@ window.WasmExtensionComponent = {
|
|||||||
unmounted() {
|
unmounted() {
|
||||||
window.removeEventListener('message', this.handleWindowMessage)
|
window.removeEventListener('message', this.handleWindowMessage)
|
||||||
this.rejectCameraPrompt('Camera scan cancelled.')
|
this.rejectCameraPrompt('Camera scan cancelled.')
|
||||||
|
this.rejectBackgroundPaymentPrompt(
|
||||||
|
'Background payment permission cancelled.'
|
||||||
|
)
|
||||||
|
this.rejectWalletPaymentWatchPrompt(
|
||||||
|
'Wallet payment watch permission cancelled.'
|
||||||
|
)
|
||||||
this.closeBridgePort()
|
this.closeBridgePort()
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -119,6 +234,20 @@ window.WasmExtensionComponent = {
|
|||||||
cameraPromptStorageKey() {
|
cameraPromptStorageKey() {
|
||||||
return `lnbits.ext.permissions.${this.bridge.extensionId}.ui.camera.scan_qr`
|
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() {
|
emptyCameraPrompt() {
|
||||||
return {
|
return {
|
||||||
extensionName: '',
|
extensionName: '',
|
||||||
@@ -127,6 +256,16 @@ window.WasmExtensionComponent = {
|
|||||||
show: false
|
show: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
emptyWalletPaymentWatchPrompt() {
|
||||||
|
return {
|
||||||
|
extensionName: '',
|
||||||
|
reject: null,
|
||||||
|
resolve: null,
|
||||||
|
show: false,
|
||||||
|
walletId: '',
|
||||||
|
walletName: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
plainValue(value) {
|
plainValue(value) {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(JSON.stringify(value))
|
return JSON.parse(JSON.stringify(value))
|
||||||
@@ -143,6 +282,12 @@ window.WasmExtensionComponent = {
|
|||||||
this.bridge = this.emptyBridge()
|
this.bridge = this.emptyBridge()
|
||||||
this.allowedPaymentHashes.clear()
|
this.allowedPaymentHashes.clear()
|
||||||
this.rejectCameraPrompt('Camera scan cancelled.')
|
this.rejectCameraPrompt('Camera scan cancelled.')
|
||||||
|
this.rejectBackgroundPaymentPrompt(
|
||||||
|
'Background payment permission cancelled.'
|
||||||
|
)
|
||||||
|
this.rejectWalletPaymentWatchPrompt(
|
||||||
|
'Wallet payment watch permission cancelled.'
|
||||||
|
)
|
||||||
this.closeBridgePort()
|
this.closeBridgePort()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -310,6 +455,320 @@ window.WasmExtensionComponent = {
|
|||||||
this.g.scanner = onScan
|
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() {
|
requireCameraScanApproval() {
|
||||||
if (this.isCameraScanRemembered()) return Promise.resolve()
|
if (this.isCameraScanRemembered()) return Promise.resolve()
|
||||||
if (this.cameraPrompt.show) {
|
if (this.cameraPrompt.show) {
|
||||||
@@ -515,6 +974,30 @@ window.WasmExtensionComponent = {
|
|||||||
return
|
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') {
|
if (message.action === 'payment.subscribe') {
|
||||||
this.subscribePayment(message)
|
this.subscribePayment(message)
|
||||||
this.sendResponse(reply, message.id, {
|
this.sendResponse(reply, message.id, {
|
||||||
|
|||||||
@@ -356,7 +356,7 @@
|
|||||||
@click="showManageExtension(extension)"
|
@click="showManageExtension(extension)"
|
||||||
flat
|
flat
|
||||||
color="primary"
|
color="primary"
|
||||||
v-if="g.user.admin"
|
v-if="canShowManageExtensionButton(extension)"
|
||||||
:label="$t('manage')"
|
:label="$t('manage')"
|
||||||
><q-tooltip
|
><q-tooltip
|
||||||
><span v-text="$t('manage_extension_details')"></span
|
><span v-text="$t('manage_extension_details')"></span
|
||||||
@@ -531,20 +531,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-card>
|
</q-card>
|
||||||
<q-card v-else class="q-pa-lg lnbits__dialog-card">
|
<q-card
|
||||||
|
v-else
|
||||||
|
class="q-pa-lg lnbits__dialog-card"
|
||||||
|
style="width: min(95vw, 920px); max-width: 95vw"
|
||||||
|
>
|
||||||
<q-tabs
|
<q-tabs
|
||||||
v-model="manageExtensionTab"
|
v-model="manageExtensionTab"
|
||||||
active-color="primary"
|
active-color="primary"
|
||||||
align="justify"
|
align="justify"
|
||||||
>
|
>
|
||||||
<q-tab
|
<q-tab
|
||||||
|
v-if="canShowAdminManageTabs()"
|
||||||
name="releases"
|
name="releases"
|
||||||
:label="$t('releases')"
|
:label="$t('releases')"
|
||||||
@update="val => (manageExtensionTab = val.name)"
|
@update="val => (manageExtensionTab = val.name)"
|
||||||
></q-tab>
|
></q-tab>
|
||||||
|
|
||||||
<q-tab
|
<q-tab
|
||||||
v-if="selectedExtension && selectedExtension.isInstalled"
|
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
|
||||||
|
"
|
||||||
name="sell"
|
name="sell"
|
||||||
:label="$t('sell')"
|
:label="$t('sell')"
|
||||||
@update="val => (manageExtensionTab = val.name)"
|
@update="val => (manageExtensionTab = val.name)"
|
||||||
@@ -554,7 +577,7 @@
|
|||||||
<div
|
<div
|
||||||
v-show="manageExtensionTab === 'releases'"
|
v-show="manageExtensionTab === 'releases'"
|
||||||
class="col-12 col-md-5 q-gutter-y-md q-mt-md"
|
class="col-12 col-md-5 q-gutter-y-md q-mt-md"
|
||||||
v-if="selectedExtensionRepos"
|
v-if="canShowAdminManageTabs() && selectedExtensionRepos"
|
||||||
>
|
>
|
||||||
<q-card
|
<q-card
|
||||||
flat
|
flat
|
||||||
@@ -815,9 +838,147 @@
|
|||||||
></q-btn>
|
></q-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<q-spinner-bars v-else color="primary" size="2.55em"></q-spinner-bars>
|
<q-spinner-bars
|
||||||
|
v-else-if="
|
||||||
|
canShowAdminManageTabs() && manageExtensionTab === 'releases'
|
||||||
|
"
|
||||||
|
color="primary"
|
||||||
|
size="2.55em"
|
||||||
|
></q-spinner-bars>
|
||||||
<div
|
<div
|
||||||
v-if="selectedExtension"
|
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-show="manageExtensionTab === 'sell'"
|
v-show="manageExtensionTab === 'sell'"
|
||||||
class="col-12 col-md-5 q-gutter-y-md q-mt-md"
|
class="col-12 col-md-5 q-gutter-y-md q-mt-md"
|
||||||
>
|
>
|
||||||
|
|||||||
Generated
+64
@@ -21,6 +21,7 @@
|
|||||||
"vuex": "4.1.0"
|
"vuex": "4.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.61.1",
|
||||||
"clean-css-cli": "^5.6.3",
|
"clean-css-cli": "^5.6.3",
|
||||||
"concat": "^1.0.3",
|
"concat": "^1.0.3",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
@@ -576,6 +577,22 @@
|
|||||||
"node": ">=0.10"
|
"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": {
|
"node_modules/@scure/base": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
||||||
@@ -1754,6 +1771,53 @@
|
|||||||
"pathe": "^2.0.3"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.14",
|
"version": "8.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "lnbits",
|
"name": "lnbits",
|
||||||
"scripts": {
|
"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",
|
"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_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))\"",
|
"vendor_json": "node -e \"require('fs').writeFileSync('./lnbits/static/vendor.json', JSON.stringify(require('./package.json').bundle))\"",
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
"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"
|
"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": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.61.1",
|
||||||
"clean-css-cli": "^5.6.3",
|
"clean-css-cli": "^5.6.3",
|
||||||
"concat": "^1.0.3",
|
"concat": "^1.0.3",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -83,7 +83,8 @@ async def test_admin_audit_monitor_and_test_email(
|
|||||||
)
|
)
|
||||||
assert monitor.status_code == 200
|
assert monitor.status_code == 200
|
||||||
task_names = [t["name"] for t in monitor.json()]
|
task_names = [t["name"] for t in monitor.json()]
|
||||||
assert any("invoice_listener" in name for name in task_names)
|
assert "core_invoice_listener" in task_names
|
||||||
|
assert "core_wasm_invoice_listener" in task_names
|
||||||
|
|
||||||
test_email = await client.get(
|
test_email = await client.get(
|
||||||
"/admin/api/v1/testemail",
|
"/admin/api/v1/testemail",
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -8,6 +11,7 @@ from starlette.requests import Request
|
|||||||
from lnbits.core.crud.db_versions import get_db_version, update_migration_version
|
from lnbits.core.crud.db_versions import get_db_version, update_migration_version
|
||||||
from lnbits.core.crud.extensions import (
|
from lnbits.core.crud.extensions import (
|
||||||
create_installed_extension,
|
create_installed_extension,
|
||||||
|
delete_installed_extension,
|
||||||
get_installed_extension,
|
get_installed_extension,
|
||||||
get_user_extension,
|
get_user_extension,
|
||||||
)
|
)
|
||||||
@@ -19,11 +23,14 @@ from lnbits.core.models.extensions import (
|
|||||||
CreateExtensionReview,
|
CreateExtensionReview,
|
||||||
Extension,
|
Extension,
|
||||||
ExtensionConfig,
|
ExtensionConfig,
|
||||||
|
ExtensionPermission,
|
||||||
ExtensionRelease,
|
ExtensionRelease,
|
||||||
InstallableExtension,
|
InstallableExtension,
|
||||||
PayToEnableInfo,
|
PayToEnableInfo,
|
||||||
ReleasePaymentInfo,
|
ReleasePaymentInfo,
|
||||||
UserExtensionInfo,
|
UserExtensionInfo,
|
||||||
|
WasmRuntimeLimitsUpdate,
|
||||||
|
wasm_extension_icon_url,
|
||||||
)
|
)
|
||||||
from lnbits.core.models.users import AccountId
|
from lnbits.core.models.users import AccountId
|
||||||
from lnbits.core.services.payments import create_wallet_invoice
|
from lnbits.core.services.payments import create_wallet_invoice
|
||||||
@@ -35,9 +42,11 @@ from lnbits.core.views.extension_api import (
|
|||||||
api_enable_extension,
|
api_enable_extension,
|
||||||
api_extension_details,
|
api_extension_details,
|
||||||
api_get_user_extensions,
|
api_get_user_extensions,
|
||||||
|
api_get_wasm_runtime_limit_extensions,
|
||||||
api_install_extension,
|
api_install_extension,
|
||||||
api_uninstall_extension,
|
api_uninstall_extension,
|
||||||
api_update_pay_to_enable,
|
api_update_pay_to_enable,
|
||||||
|
api_update_wasm_runtime_limits,
|
||||||
create_extension_review,
|
create_extension_review,
|
||||||
delete_extension_db,
|
delete_extension_db,
|
||||||
extensions,
|
extensions,
|
||||||
@@ -162,6 +171,147 @@ async def test_extension_api_install_details_and_release_endpoints(mocker):
|
|||||||
assert release_info["is_version_compatible"] is True
|
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
|
@pytest.mark.anyio
|
||||||
async def test_extension_api_pay_to_enable_and_catalog_views(mocker, admin_user):
|
async def test_extension_api_pay_to_enable_and_catalog_views(mocker, admin_user):
|
||||||
regular_user = await create_user_account(
|
regular_user = await create_user_account(
|
||||||
@@ -428,3 +578,40 @@ async def test_extension_api_review_endpoints(mocker):
|
|||||||
CreateExtensionReview(tag=ext_id, name="Alice", rating=900, comment="Great")
|
CreateExtensionReview(tag=ext_id, name="Alice", rating=900, comment="Great")
|
||||||
)
|
)
|
||||||
assert payment_request.payment_hash.startswith("hash_")
|
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"}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
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}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
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]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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')
|
||||||
|
})
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,990 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
@@ -12,6 +12,7 @@ from lnbits.core.crud.users import delete_account
|
|||||||
from lnbits.core.models import User
|
from lnbits.core.models import User
|
||||||
from lnbits.core.models.users import AccessTokenPayload
|
from lnbits.core.models.users import AccessTokenPayload
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
|
_extension_id_from_request_path,
|
||||||
access_token_payload,
|
access_token_payload,
|
||||||
check_access_token,
|
check_access_token,
|
||||||
check_admin_ui,
|
check_admin_ui,
|
||||||
@@ -225,3 +226,12 @@ async def test_check_extension_builder_requires_admin_when_disabled_for_users(
|
|||||||
admin_user = user_alan.copy(deep=True)
|
admin_user = user_alan.copy(deep=True)
|
||||||
admin_user.admin = True
|
admin_user.admin = True
|
||||||
await check_extension_builder(admin_user)
|
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"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import json
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from lnbits.core.models.extensions import ExtensionPermission
|
||||||
from lnbits.db import (
|
from lnbits.db import (
|
||||||
dict_to_model,
|
dict_to_model,
|
||||||
dict_to_submodel,
|
dict_to_submodel,
|
||||||
@@ -103,3 +104,19 @@ async def test_helpers_dict_to_model_ignores_unknown_fields():
|
|||||||
model = dict_to_model({**test_dict, "ignored": "field"}, DbTestModel3)
|
model = dict_to_model({**test_dict, "ignored": "field"}, DbTestModel3)
|
||||||
|
|
||||||
assert model == test_data
|
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"}],
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -11,6 +14,7 @@ from lnbits.core.crud import (
|
|||||||
)
|
)
|
||||||
from lnbits.core.models.extensions import (
|
from lnbits.core.models.extensions import (
|
||||||
Extension,
|
Extension,
|
||||||
|
ExtensionPermission,
|
||||||
InstallableExtension,
|
InstallableExtension,
|
||||||
ReleasePaymentInfo,
|
ReleasePaymentInfo,
|
||||||
)
|
)
|
||||||
@@ -150,6 +154,59 @@ async def test_install_extension_updates_existing_upgrade_and_preserves_payments
|
|||||||
start_mock.assert_awaited_once_with(ext_id)
|
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
|
@pytest.mark.anyio
|
||||||
async def test_uninstall_activate_and_deactivate_extensions(
|
async def test_uninstall_activate_and_deactivate_extensions(
|
||||||
tmp_path, settings: Settings, mocker: MockerFixture
|
tmp_path, settings: Settings, mocker: MockerFixture
|
||||||
@@ -204,6 +261,35 @@ async def test_uninstall_activate_and_deactivate_extensions(
|
|||||||
assert start_mock.await_count == 1
|
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
|
@pytest.mark.anyio
|
||||||
async def test_stop_extension_background_work_handles_missing_and_async_stops(
|
async def test_stop_extension_background_work_handles_missing_and_async_stops(
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
@@ -287,6 +373,72 @@ async def test_wasm_invocation_tracking_counts_and_stops(mocker: MockerFixture):
|
|||||||
assert saved.storage_call_count == 1
|
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():
|
def _reset_wasm_invocation_state():
|
||||||
with extension_services._wasm_invocation_lock:
|
with extension_services._wasm_invocation_lock:
|
||||||
extension_services._wasm_invocation_handles.clear()
|
extension_services._wasm_invocation_handles.clear()
|
||||||
@@ -294,6 +446,35 @@ def _reset_wasm_invocation_state():
|
|||||||
extension_services._wasm_invocations_last_cleanup_at = None
|
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):
|
def test_wasm_runtime_limits_merge_sparse_extension_overrides(settings: Settings):
|
||||||
original_execution_ms = settings.wasm_runtime_max_execution_ms
|
original_execution_ms = settings.wasm_runtime_max_execution_ms
|
||||||
original_memory_bytes = settings.wasm_runtime_max_memory_bytes
|
original_memory_bytes = settings.wasm_runtime_max_memory_bytes
|
||||||
@@ -489,6 +670,58 @@ async def test_wasm_invocation_host_call_limits(mocker: MockerFixture):
|
|||||||
await finish_wasm_invocation(invocation.id, status="failed")
|
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
|
@pytest.mark.anyio
|
||||||
async def test_start_extension_background_work_handles_missing_and_sync_starts(
|
async def test_start_extension_background_work_handles_missing_and_sync_starts(
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from lnbits.core.services.payments import (
|
|||||||
check_pending_payments,
|
check_pending_payments,
|
||||||
check_time_limit_between_transactions,
|
check_time_limit_between_transactions,
|
||||||
check_transaction_status,
|
check_transaction_status,
|
||||||
|
check_wallet_daily_withdraw_limit,
|
||||||
check_wallet_limits,
|
check_wallet_limits,
|
||||||
create_payment_request,
|
create_payment_request,
|
||||||
get_payments_daily_stats,
|
get_payments_daily_stats,
|
||||||
@@ -248,6 +249,23 @@ async def test_check_wallet_limits_and_time_limit(
|
|||||||
settings.lnbits_wallet_limit_secs_between_trans = original_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
|
@pytest.mark.anyio
|
||||||
async def test_calculate_fiat_amounts_handles_conversion_and_errors(
|
async def test_calculate_fiat_amounts_handles_conversion_and_errors(
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
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"}),
|
||||||
|
)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
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="",
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
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="",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
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
|
||||||
@@ -4,7 +4,22 @@ from typing import Any, cast
|
|||||||
import pytest
|
import pytest
|
||||||
from pytest_mock.plugin import MockerFixture
|
from pytest_mock.plugin import MockerFixture
|
||||||
|
|
||||||
from lnbits.core.models.extensions import ExtensionPermission
|
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.wasm_ext.api.permissions import validate_wasm_extension_permissions
|
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.events import _wasm_invoice_paid_owner_id
|
||||||
from lnbits.core.wasm_ext.wasm.invoke import _active_installed_extension
|
from lnbits.core.wasm_ext.wasm.invoke import _active_installed_extension
|
||||||
@@ -142,6 +157,190 @@ 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
|
@pytest.mark.anyio
|
||||||
async def test_invoice_paid_owner_lookup_uses_stored_granted_policies(
|
async def test_invoice_paid_owner_lookup_uses_stored_granted_policies(
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
|
|||||||
@@ -1,16 +1,34 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
|
from pathlib import Path
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import Request
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
from lnbits.core.wasm_ext.routes.api import (
|
from lnbits.core.wasm_ext.routes.api import (
|
||||||
WasmRequestBodyTooLargeError,
|
WasmRequestBodyTooLargeError,
|
||||||
_read_api_payload,
|
_read_api_payload,
|
||||||
_read_json_object_with_size,
|
_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
|
@pytest.mark.anyio
|
||||||
@@ -56,6 +74,119 @@ async def test_wasm_api_payload_records_actual_body_bytes():
|
|||||||
assert payload.request_bytes == len(body)
|
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:
|
class _FakeRequest:
|
||||||
method = "POST"
|
method = "POST"
|
||||||
|
|
||||||
@@ -79,3 +210,70 @@ class _FakeRequest:
|
|||||||
self.stream_started = True
|
self.stream_started = True
|
||||||
for chunk in self._chunks:
|
for chunk in self._chunks:
|
||||||
yield chunk
|
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": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
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
|
||||||
Reference in New Issue
Block a user