feat: Extensions for all (#4021)
Co-authored-by: alan <alan@lnbits.com> Co-authored-by: Tiago Vasconcelos <talvasconcelos@gmail.com>
This commit is contained in:
co-authored by
alan
Tiago Vasconcelos
parent
43900dd6da
commit
61ed636df0
@@ -83,7 +83,8 @@ async def test_admin_audit_monitor_and_test_email(
|
||||
)
|
||||
assert monitor.status_code == 200
|
||||
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(
|
||||
"/admin/api/v1/testemail",
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
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.extensions import (
|
||||
create_installed_extension,
|
||||
delete_installed_extension,
|
||||
get_installed_extension,
|
||||
get_user_extension,
|
||||
)
|
||||
@@ -19,11 +23,14 @@ from lnbits.core.models.extensions import (
|
||||
CreateExtensionReview,
|
||||
Extension,
|
||||
ExtensionConfig,
|
||||
ExtensionPermission,
|
||||
ExtensionRelease,
|
||||
InstallableExtension,
|
||||
PayToEnableInfo,
|
||||
ReleasePaymentInfo,
|
||||
UserExtensionInfo,
|
||||
WasmRuntimeLimitsUpdate,
|
||||
wasm_extension_icon_url,
|
||||
)
|
||||
from lnbits.core.models.users import AccountId
|
||||
from lnbits.core.services.payments import create_wallet_invoice
|
||||
@@ -35,9 +42,11 @@ from lnbits.core.views.extension_api import (
|
||||
api_enable_extension,
|
||||
api_extension_details,
|
||||
api_get_user_extensions,
|
||||
api_get_wasm_runtime_limit_extensions,
|
||||
api_install_extension,
|
||||
api_uninstall_extension,
|
||||
api_update_pay_to_enable,
|
||||
api_update_wasm_runtime_limits,
|
||||
create_extension_review,
|
||||
delete_extension_db,
|
||||
extensions,
|
||||
@@ -162,6 +171,147 @@ async def test_extension_api_install_details_and_release_endpoints(mocker):
|
||||
assert release_info["is_version_compatible"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_extension_api_installs_wasm_with_granted_permissions(
|
||||
tmp_path,
|
||||
settings,
|
||||
mocker,
|
||||
):
|
||||
ext_id = f"wasm_{uuid4().hex[:8]}"
|
||||
release = make_extension_release(ext_id)
|
||||
granted_permissions = [
|
||||
ExtensionPermission(
|
||||
id="http.request",
|
||||
policies=[{"host": "https://api.example.com"}],
|
||||
)
|
||||
]
|
||||
create_data = CreateExtension(
|
||||
ext_id=ext_id,
|
||||
archive=release.archive,
|
||||
source_repo=release.source_repo,
|
||||
version=release.version,
|
||||
permissions=granted_permissions,
|
||||
)
|
||||
original_data_folder = settings.lnbits_data_folder
|
||||
original_extensions_path = settings.lnbits_extensions_path
|
||||
register_wasm_routes_mock = mocker.patch(
|
||||
"lnbits.core.services.extensions.core_app_extra.register_new_wasm_ext_routes"
|
||||
)
|
||||
mocker.patch.object(
|
||||
InstallableExtension,
|
||||
"get_extension_release",
|
||||
mocker.AsyncMock(return_value=release),
|
||||
)
|
||||
mocker.patch.object(
|
||||
InstallableExtension,
|
||||
"download_archive",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
|
||||
try:
|
||||
settings.lnbits_data_folder = str(tmp_path / "data")
|
||||
settings.lnbits_extensions_path = str(tmp_path / "code")
|
||||
_write_wasm_extension_archive(ext_id, release.version, settings)
|
||||
|
||||
installed = await api_install_extension(create_data)
|
||||
stored = await get_installed_extension(ext_id)
|
||||
finally:
|
||||
await delete_installed_extension(ext_id=ext_id)
|
||||
settings.lnbits_data_folder = original_data_folder
|
||||
settings.lnbits_extensions_path = original_extensions_path
|
||||
|
||||
assert installed.code == ext_id
|
||||
assert installed.is_wasm is True
|
||||
assert stored is not None
|
||||
assert stored.permissions == [
|
||||
ExtensionPermission(
|
||||
id="http.request",
|
||||
description="Call example API.",
|
||||
policies=[{"host": "https://api.example.com"}],
|
||||
)
|
||||
]
|
||||
register_wasm_routes_mock.assert_called_once_with(ext_id)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_extension_api_wasm_runtime_limits_and_catalog_use_installed_metadata(
|
||||
tmp_path,
|
||||
settings,
|
||||
mocker,
|
||||
):
|
||||
ext_id = f"wasm_{uuid4().hex[:8]}"
|
||||
py_ext_id = f"py_{uuid4().hex[:8]}"
|
||||
granted_permissions = [
|
||||
ExtensionPermission(
|
||||
id="http.request",
|
||||
description="Call example API.",
|
||||
policies=[{"host": "https://api.example.com"}],
|
||||
)
|
||||
]
|
||||
original_extensions_path = settings.lnbits_extensions_path
|
||||
|
||||
try:
|
||||
settings.lnbits_extensions_path = str(tmp_path)
|
||||
_write_installed_wasm_config(ext_id, tmp_path)
|
||||
await create_installed_extension(
|
||||
InstallableExtension(
|
||||
id=ext_id,
|
||||
name="WASM Demo",
|
||||
version="1.0.0",
|
||||
active=True,
|
||||
permissions=granted_permissions,
|
||||
wasm_runtime_limits={"wasm_runtime_max_execution_ms": 1234},
|
||||
)
|
||||
)
|
||||
await create_installed_extension(make_installable_extension(py_ext_id))
|
||||
|
||||
runtime_extensions = await api_get_wasm_runtime_limit_extensions()
|
||||
wasm_info = next(info for info in runtime_extensions if info.id == ext_id)
|
||||
|
||||
updated_info = await api_update_wasm_runtime_limits(
|
||||
ext_id,
|
||||
WasmRuntimeLimitsUpdate(
|
||||
limits={
|
||||
"wasm_runtime_max_execution_ms": "2345",
|
||||
"wasm_runtime_max_fuel": 0,
|
||||
}
|
||||
),
|
||||
)
|
||||
stored = await get_installed_extension(ext_id)
|
||||
|
||||
mocker.patch.object(
|
||||
InstallableExtension,
|
||||
"get_installable_extensions",
|
||||
mocker.AsyncMock(
|
||||
return_value=[
|
||||
make_installable_extension(ext_id),
|
||||
make_installable_extension(py_ext_id),
|
||||
]
|
||||
),
|
||||
)
|
||||
catalog = await extensions(AccountId(id=uuid4().hex))
|
||||
finally:
|
||||
await delete_installed_extension(ext_id=ext_id)
|
||||
await delete_installed_extension(ext_id=py_ext_id)
|
||||
settings.lnbits_extensions_path = original_extensions_path
|
||||
|
||||
assert wasm_info.wasm_runtime_limits == {"wasm_runtime_max_execution_ms": 1234}
|
||||
assert py_ext_id not in {info.id for info in runtime_extensions}
|
||||
assert updated_info.wasm_runtime_limits == {
|
||||
"wasm_runtime_max_execution_ms": 2345,
|
||||
"wasm_runtime_max_fuel": 0,
|
||||
}
|
||||
assert stored is not None
|
||||
assert stored.wasm_runtime_limits == updated_info.wasm_runtime_limits
|
||||
|
||||
catalog_item = next(item for item in catalog if item["id"] == ext_id)
|
||||
assert catalog_item["isWasm"] is True
|
||||
assert catalog_item["icon"] == wasm_extension_icon_url(ext_id)
|
||||
assert catalog_item["permissions"] == [
|
||||
dict(permission) for permission in granted_permissions
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_extension_api_pay_to_enable_and_catalog_views(mocker, admin_user):
|
||||
regular_user = await create_user_account(
|
||||
@@ -428,3 +578,40 @@ async def test_extension_api_review_endpoints(mocker):
|
||||
CreateExtensionReview(tag=ext_id, name="Alice", rating=900, comment="Great")
|
||||
)
|
||||
assert payment_request.payment_hash.startswith("hash_")
|
||||
|
||||
|
||||
def _write_wasm_extension_archive(ext_id: str, version: str, settings) -> None:
|
||||
zip_path = Path(settings.lnbits_data_folder, "zips", f"{ext_id}.zip")
|
||||
zip_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config = _wasm_config(ext_id)
|
||||
root = f"{ext_id}-{version}"
|
||||
with zipfile.ZipFile(zip_path, "w") as archive:
|
||||
archive.writestr(f"{root}/config.json", json.dumps(config))
|
||||
archive.writestr(f"{root}/{config['wasm']['module']}", b"\0asm")
|
||||
|
||||
|
||||
def _write_installed_wasm_config(ext_id: str, extensions_path) -> None:
|
||||
config_dir = extensions_path / "extensions" / ext_id
|
||||
config_dir.mkdir(parents=True)
|
||||
(config_dir / "config.json").write_text(
|
||||
json.dumps(_wasm_config(ext_id)),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _wasm_config(ext_id: str) -> dict:
|
||||
return {
|
||||
"id": ext_id,
|
||||
"name": "WASM Demo",
|
||||
"short_description": "WASM extension",
|
||||
"version": "1.0.0",
|
||||
"extension_type": "wasm",
|
||||
"wasm": {"module": "extension.wasm"},
|
||||
"permissions": [
|
||||
{
|
||||
"id": "http.request",
|
||||
"description": "Call example API.",
|
||||
"policies": [{"host": "https://api.example.com"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ def run_before_and_after_tests(settings: Settings):
|
||||
@pytest.fixture(scope="session")
|
||||
async def app(settings: Settings):
|
||||
app = create_app()
|
||||
async with LifespanManager(app) as manager:
|
||||
async with LifespanManager(app, startup_timeout=30) as manager:
|
||||
settings.first_install = True
|
||||
await first_install(
|
||||
UpdateSuperuserPassword(
|
||||
|
||||
@@ -12,6 +12,7 @@ from lnbits.core.crud.users import delete_account
|
||||
from lnbits.core.models import User
|
||||
from lnbits.core.models.users import AccessTokenPayload
|
||||
from lnbits.decorators import (
|
||||
_extension_id_from_request_path,
|
||||
access_token_payload,
|
||||
check_access_token,
|
||||
check_admin_ui,
|
||||
@@ -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.admin = True
|
||||
await check_extension_builder(admin_user)
|
||||
|
||||
|
||||
def test_extension_id_from_request_path_handles_wasm_routes():
|
||||
assert _extension_id_from_request_path("/ext/wasm_demo") == "wasm_demo"
|
||||
assert _extension_id_from_request_path("/ext/wasm_demo/page/1") == "wasm_demo"
|
||||
assert (
|
||||
_extension_id_from_request_path("/api/v1/ext/wasm_demo/invoices") == "wasm_demo"
|
||||
)
|
||||
assert _extension_id_from_request_path("/lnurlp/api/v1") == "lnurlp"
|
||||
|
||||
@@ -1454,6 +1454,8 @@ def test_check_revolut_signature_docs_vector(mocker: MockerFixture):
|
||||
)
|
||||
check_revolut_signature(payload, sig, timestamp, secret)
|
||||
|
||||
check_revolut_signature(payload, sig, timestamp, secret)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_fiat_service_fee(settings: Settings):
|
||||
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
|
||||
import pytest
|
||||
|
||||
from lnbits.core.models.extensions import ExtensionPermission
|
||||
from lnbits.db import (
|
||||
dict_to_model,
|
||||
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)
|
||||
|
||||
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 uuid import uuid4
|
||||
|
||||
@@ -11,17 +14,25 @@ from lnbits.core.crud import (
|
||||
)
|
||||
from lnbits.core.models.extensions import (
|
||||
Extension,
|
||||
ExtensionPermission,
|
||||
InstallableExtension,
|
||||
ReleasePaymentInfo,
|
||||
)
|
||||
from lnbits.core.services import extensions as extension_services
|
||||
from lnbits.core.services.extensions import (
|
||||
activate_extension,
|
||||
attach_wasm_invocation_runtime,
|
||||
deactivate_extension,
|
||||
finish_wasm_invocation,
|
||||
get_current_wasm_invocations,
|
||||
get_valid_extension,
|
||||
get_valid_extensions,
|
||||
install_extension,
|
||||
record_wasm_invocation_host_call,
|
||||
start_extension_background_work,
|
||||
start_wasm_invocation,
|
||||
stop_extension_background_work,
|
||||
stop_wasm_invocation,
|
||||
uninstall_extension,
|
||||
)
|
||||
from lnbits.settings import Settings
|
||||
@@ -143,6 +154,59 @@ async def test_install_extension_updates_existing_upgrade_and_preserves_payments
|
||||
start_mock.assert_awaited_once_with(ext_id)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_install_wasm_extension_requires_permissions_and_skips_background_work(
|
||||
tmp_path,
|
||||
settings: Settings,
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
ext_id = f"wasm_{uuid4().hex[:8]}"
|
||||
ext_info = make_installable_extension(ext_id)
|
||||
original_data_folder = settings.lnbits_data_folder
|
||||
original_extensions_path = settings.lnbits_extensions_path
|
||||
start_mock = mocker.patch(
|
||||
"lnbits.core.services.extensions.start_extension_background_work",
|
||||
mocker.AsyncMock(return_value=True),
|
||||
)
|
||||
|
||||
try:
|
||||
settings.lnbits_data_folder = str(tmp_path / "data")
|
||||
settings.lnbits_extensions_path = str(tmp_path / "code")
|
||||
_write_wasm_extension_archive(ext_info, _wasm_install_config(ext_id))
|
||||
|
||||
with pytest.raises(ValueError, match="requires permission approval"):
|
||||
await install_extension(ext_info, skip_download=True)
|
||||
|
||||
granted_permissions = [
|
||||
ExtensionPermission(
|
||||
id="http.request",
|
||||
policies=[{"host": "https://api.example.com"}],
|
||||
)
|
||||
]
|
||||
extension = await install_extension(
|
||||
ext_info,
|
||||
skip_download=True,
|
||||
granted_permissions=granted_permissions,
|
||||
)
|
||||
stored = await get_installed_extension(ext_id)
|
||||
finally:
|
||||
await delete_installed_extension(ext_id=ext_id)
|
||||
settings.lnbits_data_folder = original_data_folder
|
||||
settings.lnbits_extensions_path = original_extensions_path
|
||||
|
||||
assert extension.code == ext_id
|
||||
assert extension.is_wasm is True
|
||||
assert stored is not None
|
||||
assert stored.permissions == [
|
||||
ExtensionPermission(
|
||||
id="http.request",
|
||||
description="Call example API.",
|
||||
policies=[{"host": "https://api.example.com"}],
|
||||
)
|
||||
]
|
||||
start_mock.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_uninstall_activate_and_deactivate_extensions(
|
||||
tmp_path, settings: Settings, mocker: MockerFixture
|
||||
@@ -197,6 +261,35 @@ async def test_uninstall_activate_and_deactivate_extensions(
|
||||
assert start_mock.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_invocation_monitoring_marks_stale_once_and_cleans_periodically(
|
||||
settings: Settings,
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
_reset_wasm_invocation_state()
|
||||
now = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
mark_stale_mock = mocker.patch(
|
||||
"lnbits.core.services.extensions.mark_stale_wasm_invocations",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
cleanup_mock = mocker.patch(
|
||||
"lnbits.core.services.extensions.delete_old_wasm_invocations",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions._now",
|
||||
side_effect=[now, now + timedelta(minutes=1), now + timedelta(hours=2)],
|
||||
)
|
||||
|
||||
await extension_services.ensure_wasm_invocation_monitoring_ready()
|
||||
await extension_services.ensure_wasm_invocation_monitoring_ready()
|
||||
await extension_services.ensure_wasm_invocation_monitoring_ready()
|
||||
|
||||
mark_stale_mock.assert_awaited_once()
|
||||
assert cleanup_mock.await_count == 2
|
||||
cleanup_mock.assert_awaited_with(settings.lnbits_wasm_invocation_retention_days)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stop_extension_background_work_handles_missing_and_async_stops(
|
||||
mocker: MockerFixture,
|
||||
@@ -219,6 +312,416 @@ async def test_stop_extension_background_work_handles_missing_and_async_stops(
|
||||
assert called["stop"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_invocation_tracking_counts_and_stops(mocker: MockerFixture):
|
||||
_reset_wasm_invocation_state()
|
||||
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(),
|
||||
)
|
||||
|
||||
invocation = await start_wasm_invocation(
|
||||
extension_id="demoext",
|
||||
export_name="render",
|
||||
trigger_type="http",
|
||||
method="POST",
|
||||
path="/api/v1/ext/demoext/run",
|
||||
context={"origin": "https://example.com"},
|
||||
)
|
||||
store = SimpleNamespace(deadline=None)
|
||||
store.set_epoch_deadline = lambda deadline: setattr(store, "deadline", deadline)
|
||||
engine = SimpleNamespace(increments=0)
|
||||
|
||||
def increment_epoch():
|
||||
engine.increments += 1
|
||||
|
||||
engine.increment_epoch = increment_epoch
|
||||
|
||||
attach_wasm_invocation_runtime(invocation.id, engine=engine, store=store)
|
||||
record_wasm_invocation_host_call(invocation.id, "http.request")
|
||||
record_wasm_invocation_host_call(invocation.id, "storage.get")
|
||||
|
||||
assert await stop_wasm_invocation(invocation.id, reason="test stop") is True
|
||||
current = get_current_wasm_invocations()
|
||||
assert current[0].status == "stopping"
|
||||
assert store.deadline == 1
|
||||
assert engine.increments == 1
|
||||
|
||||
await finish_wasm_invocation(invocation.id, status="failed")
|
||||
assert update_mock.await_args is not None
|
||||
saved = update_mock.await_args.args[0]
|
||||
assert saved.status == "stopped"
|
||||
assert saved.stop_reason == "test stop"
|
||||
assert saved.host_call_count == 2
|
||||
assert saved.http_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():
|
||||
with extension_services._wasm_invocation_lock:
|
||||
extension_services._wasm_invocation_handles.clear()
|
||||
extension_services._wasm_invocations_marked_stale = False
|
||||
extension_services._wasm_invocations_last_cleanup_at = None
|
||||
|
||||
|
||||
def _write_wasm_extension_archive(
|
||||
ext_info: InstallableExtension,
|
||||
config: dict,
|
||||
) -> None:
|
||||
ext_info.zip_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
root = f"{ext_info.id}-{ext_info.version}"
|
||||
with zipfile.ZipFile(ext_info.zip_path, "w") as archive:
|
||||
archive.writestr(f"{root}/config.json", json.dumps(config))
|
||||
archive.writestr(f"{root}/{config['wasm']['module']}", b"\0asm")
|
||||
|
||||
|
||||
def _wasm_install_config(ext_id: str) -> dict:
|
||||
return {
|
||||
"id": ext_id,
|
||||
"name": f"WASM {ext_id}",
|
||||
"short_description": "WASM extension",
|
||||
"version": "1.0.0",
|
||||
"extension_type": "wasm",
|
||||
"wasm": {"module": "extension.wasm"},
|
||||
"permissions": [
|
||||
{
|
||||
"id": "http.request",
|
||||
"description": "Call example API.",
|
||||
"policies": [{"host": "https://api.example.com"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_wasm_runtime_limits_merge_sparse_extension_overrides(settings: Settings):
|
||||
original_execution_ms = settings.wasm_runtime_max_execution_ms
|
||||
original_memory_bytes = settings.wasm_runtime_max_memory_bytes
|
||||
try:
|
||||
settings.wasm_runtime_max_execution_ms = 5_000
|
||||
settings.wasm_runtime_max_memory_bytes = 64 * 1024 * 1024
|
||||
extension = InstallableExtension(
|
||||
id="wasm_demo",
|
||||
name="WASM Demo",
|
||||
version="1.0.0",
|
||||
wasm_runtime_limits={
|
||||
"wasm_runtime_max_execution_ms": 20_000,
|
||||
"wasm_runtime_max_fuel": 0,
|
||||
},
|
||||
)
|
||||
|
||||
limits = extension_services.resolve_wasm_runtime_limits(extension)
|
||||
|
||||
assert limits["wasm_runtime_max_execution_ms"] == 20_000
|
||||
assert limits["wasm_runtime_max_fuel"] == 0
|
||||
assert limits["wasm_runtime_max_memory_bytes"] == 64 * 1024 * 1024
|
||||
finally:
|
||||
settings.wasm_runtime_max_execution_ms = original_execution_ms
|
||||
settings.wasm_runtime_max_memory_bytes = original_memory_bytes
|
||||
|
||||
|
||||
def test_wasm_runtime_limit_override_validation():
|
||||
assert extension_services.validate_wasm_runtime_limit_overrides(
|
||||
{
|
||||
"wasm_runtime_max_execution_ms": "7000",
|
||||
"wasm_runtime_max_fuel": 0,
|
||||
"wasm_runtime_max_memory_bytes": "",
|
||||
}
|
||||
) == {
|
||||
"wasm_runtime_max_execution_ms": 7000,
|
||||
"wasm_runtime_max_fuel": 0,
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="Unknown WASM runtime limit field"):
|
||||
extension_services.validate_wasm_runtime_limit_overrides({"unknown": 1})
|
||||
|
||||
with pytest.raises(ValueError, match="cannot be negative"):
|
||||
extension_services.validate_wasm_runtime_limit_overrides(
|
||||
{"wasm_runtime_max_execution_ms": -1}
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="must be an integer"):
|
||||
extension_services.validate_wasm_runtime_limit_overrides(
|
||||
{"wasm_runtime_max_execution_ms": True}
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="must be an integer"):
|
||||
extension_services.validate_wasm_runtime_limit_overrides(
|
||||
{"wasm_runtime_max_execution_ms": 1.5}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_wasm_extension_runtime_limits_saves_sparse_overrides(
|
||||
tmp_path,
|
||||
settings: Settings,
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
ext_id = "wasm_demo"
|
||||
original_extensions_path = settings.lnbits_extensions_path
|
||||
try:
|
||||
settings.lnbits_extensions_path = str(tmp_path)
|
||||
config_dir = tmp_path / "extensions" / ext_id
|
||||
config_dir.mkdir(parents=True)
|
||||
(config_dir / "config.json").write_text(
|
||||
'{"extension_type": "wasm"}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
installed_extension = InstallableExtension(
|
||||
id=ext_id,
|
||||
name="WASM Demo",
|
||||
version="1.0.0",
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.get_installed_extension",
|
||||
mocker.AsyncMock(return_value=installed_extension),
|
||||
)
|
||||
update_mock = mocker.patch(
|
||||
"lnbits.core.services.extensions."
|
||||
"update_installed_extension_wasm_runtime_limits",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
|
||||
saved_limits = await extension_services.update_wasm_extension_runtime_limits(
|
||||
ext_id,
|
||||
{
|
||||
"wasm_runtime_max_execution_ms": "15000",
|
||||
"wasm_runtime_max_fuel": 0,
|
||||
"wasm_runtime_max_memory_bytes": "",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
settings.lnbits_extensions_path = original_extensions_path
|
||||
|
||||
assert saved_limits == {
|
||||
"wasm_runtime_max_execution_ms": 15000,
|
||||
"wasm_runtime_max_fuel": 0,
|
||||
}
|
||||
update_mock.assert_awaited_once_with(ext_id=ext_id, limits=saved_limits)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_invocation_concurrency_limits(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.update(
|
||||
{
|
||||
"wasm_runtime_max_concurrent_invocations": 1,
|
||||
"wasm_runtime_max_concurrent_invocations_per_extension": 1,
|
||||
"wasm_runtime_max_concurrent_invocations_per_user": 1,
|
||||
}
|
||||
)
|
||||
|
||||
invocation = await start_wasm_invocation(
|
||||
extension_id="demoext",
|
||||
export_name="render",
|
||||
user_id="user-id",
|
||||
runtime_limits=limits,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="too many active invocations"):
|
||||
await start_wasm_invocation(
|
||||
extension_id="demoext",
|
||||
export_name="render",
|
||||
user_id="user-id",
|
||||
runtime_limits=limits,
|
||||
)
|
||||
|
||||
await finish_wasm_invocation(invocation.id, status="completed")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_invocation_host_call_limits(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"] = 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="host call limit"):
|
||||
record_wasm_invocation_host_call(invocation.id, "storage.get")
|
||||
|
||||
await finish_wasm_invocation(invocation.id, status="failed")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_invocation_host_call_category_limits_can_be_disabled(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
_reset_wasm_invocation_state()
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.create_wasm_invocation",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.update_wasm_invocation",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.get_wasm_invocation",
|
||||
mocker.AsyncMock(return_value=None),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.mark_stale_wasm_invocations",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.delete_old_wasm_invocations",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
limits = extension_services.wasm_runtime_limit_defaults()
|
||||
limits["wasm_runtime_max_host_calls"] = 10
|
||||
limits["wasm_runtime_max_http_calls"] = 1
|
||||
|
||||
invocation = await start_wasm_invocation(
|
||||
extension_id="demoext",
|
||||
export_name="render",
|
||||
runtime_limits=limits,
|
||||
)
|
||||
record_wasm_invocation_host_call(invocation.id, "http.request")
|
||||
with pytest.raises(ValueError, match="http host call limit"):
|
||||
record_wasm_invocation_host_call(invocation.id, "extension.api.request")
|
||||
await finish_wasm_invocation(invocation.id, status="failed")
|
||||
|
||||
limits["wasm_runtime_max_host_calls"] = 0
|
||||
limits["wasm_runtime_max_http_calls"] = 0
|
||||
unlimited_invocation = await start_wasm_invocation(
|
||||
extension_id="demoext",
|
||||
export_name="render",
|
||||
runtime_limits=limits,
|
||||
)
|
||||
for _ in range(5):
|
||||
record_wasm_invocation_host_call(unlimited_invocation.id, "http.request")
|
||||
|
||||
await finish_wasm_invocation(unlimited_invocation.id, status="completed")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_start_extension_background_work_handles_missing_and_sync_starts(
|
||||
mocker: MockerFixture,
|
||||
|
||||
@@ -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="",
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,223 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from lnbits.core.models.extensions import ExtensionPermission
|
||||
from lnbits.core.models.misc import WasmExtensionRegistry
|
||||
from lnbits.core.wasm_ext.api.permissions import validate_wasm_extension_permissions
|
||||
from lnbits.core.wasm_ext.wasm.config import parse_wasm_extension_config
|
||||
from lnbits.core.wasm_ext.wasm.loader import WasmExtension, load_wasm_extension
|
||||
from lnbits.settings import Settings
|
||||
from tests.helpers import make_installable_extension
|
||||
|
||||
|
||||
def test_load_wasm_extension_rejects_missing_config_id(
|
||||
tmp_path: Path, settings: Settings
|
||||
):
|
||||
ext_id = "demoext"
|
||||
_write_wasm_extension(settings, tmp_path, ext_id, config_id=None)
|
||||
|
||||
with pytest.raises(ValueError, match="config must define id"):
|
||||
load_wasm_extension(ext_id)
|
||||
|
||||
|
||||
def test_load_wasm_extension_rejects_mismatched_config_id(
|
||||
tmp_path: Path, settings: Settings
|
||||
):
|
||||
ext_id = "demoext"
|
||||
_write_wasm_extension(settings, tmp_path, ext_id, config_id="otherext")
|
||||
|
||||
with pytest.raises(ValueError, match="id mismatch"):
|
||||
load_wasm_extension(ext_id)
|
||||
|
||||
|
||||
def test_load_wasm_extension_uses_canonical_extension_id(
|
||||
tmp_path: Path, settings: Settings
|
||||
):
|
||||
ext_id = "demoext"
|
||||
_write_wasm_extension(settings, tmp_path, ext_id, config_id=ext_id)
|
||||
|
||||
extension = load_wasm_extension(ext_id)
|
||||
|
||||
assert extension.id == ext_id
|
||||
|
||||
|
||||
def test_wasm_extension_config_ignores_unknown_fields():
|
||||
config = _wasm_config("demoext")
|
||||
config["unexpected"] = True
|
||||
|
||||
parsed = parse_wasm_extension_config("demoext", config)
|
||||
|
||||
assert not hasattr(parsed, "unexpected")
|
||||
|
||||
|
||||
def test_wasm_extension_config_rejects_coerced_scalar_types():
|
||||
config = _wasm_config("demoext")
|
||||
config["wasm"] = {"module": 123}
|
||||
|
||||
with pytest.raises(ValueError, match="str type expected"):
|
||||
parse_wasm_extension_config("demoext", config)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config_update",
|
||||
[
|
||||
{"wasm": {"module": "extension.wasm", "host_api": "custom.HostAPI"}},
|
||||
{
|
||||
"wasm": {
|
||||
"module": "extension.wasm",
|
||||
"resource_limits": {"max_response_bytes": 1024},
|
||||
}
|
||||
},
|
||||
{"build": {"source": "dev", "command": "npm run build"}},
|
||||
],
|
||||
)
|
||||
def test_wasm_extension_config_ignores_removed_extension_control_fields(
|
||||
config_update: dict[str, Any],
|
||||
):
|
||||
config = _wasm_config("demoext")
|
||||
config.update(config_update)
|
||||
|
||||
parsed = parse_wasm_extension_config("demoext", config)
|
||||
|
||||
assert parsed.wasm.module == "extension.wasm"
|
||||
assert not hasattr(parsed.wasm, "host_api")
|
||||
assert not hasattr(parsed.wasm, "resource_limits")
|
||||
assert not hasattr(parsed, "build")
|
||||
|
||||
|
||||
def test_wasm_extension_config_accepts_supported_optional_sections():
|
||||
config = _wasm_config("demoext")
|
||||
config.update(
|
||||
{
|
||||
"tile": "static/icon.png",
|
||||
"min_lnbits_version": "1.0.0",
|
||||
"max_lnbits_version": "2.0.0",
|
||||
"wasm": {
|
||||
"module": "wasm/module.wasm",
|
||||
"wit": "wasm/lnbits-extension.wit",
|
||||
"world": "lnbits-extension",
|
||||
"exports": [
|
||||
{"name": "render", "visibility": "public"},
|
||||
{"name": "on_invoice_paid", "visibility": "event"},
|
||||
],
|
||||
},
|
||||
"events": {"onInvoicePaid": "on_invoice_paid"},
|
||||
"ui": {"entrypoint": "static/index.html", "sandbox": True},
|
||||
"sdk": {"frontend_js": "static/lnbits-extension-sdk.js"},
|
||||
"ui_routes": [
|
||||
{
|
||||
"path": "/demo/{item_id}",
|
||||
"entrypoint": "static/index.html",
|
||||
"auth": "user",
|
||||
"path_params": {"item_id": "str"},
|
||||
}
|
||||
],
|
||||
"api_routes": [
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/demo/{item_id}",
|
||||
"export": "render",
|
||||
"auth": "public",
|
||||
"path_params": {"item_id": "str"},
|
||||
}
|
||||
],
|
||||
"permissions": [{"id": "utils.basic", "description": "Basic utils"}],
|
||||
}
|
||||
)
|
||||
|
||||
parsed = parse_wasm_extension_config("demoext", config)
|
||||
|
||||
assert parsed.events.on_invoice_paid == "on_invoice_paid"
|
||||
assert parsed.wasm.world == "lnbits-extension"
|
||||
|
||||
|
||||
def test_wasm_extension_config_ignores_unknown_permission_fields():
|
||||
config = _wasm_config("demoext")
|
||||
config["permissions"] = [
|
||||
{"id": "utils.basic", "label": "Basic utilities", "unknown": True}
|
||||
]
|
||||
|
||||
parsed = parse_wasm_extension_config("demoext", config)
|
||||
|
||||
assert parsed.permissions == [ExtensionPermission(id="utils.basic")]
|
||||
|
||||
|
||||
def test_install_time_permission_validation_rejects_config_id_mismatch():
|
||||
ext_info = make_installable_extension("demoext")
|
||||
extension_config = {
|
||||
"id": "otherext",
|
||||
"extension_type": "wasm",
|
||||
"permissions": [{"id": "utils.basic"}],
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="id mismatch"):
|
||||
validate_wasm_extension_permissions(
|
||||
ext_info,
|
||||
[ExtensionPermission(id="utils.basic")],
|
||||
extension_config,
|
||||
)
|
||||
|
||||
|
||||
def test_wasm_extension_registry_rejects_same_id_from_different_root(tmp_path: Path):
|
||||
registry = WasmExtensionRegistry()
|
||||
first = _wasm_extension("demoext", tmp_path / "one")
|
||||
second_same_root = _wasm_extension("demoext", tmp_path / "one")
|
||||
second_different_root = _wasm_extension("demoext", tmp_path / "two")
|
||||
|
||||
registry.register(first)
|
||||
registry.register(second_same_root)
|
||||
|
||||
with pytest.raises(ValueError, match="already registered"):
|
||||
registry.register(second_different_root)
|
||||
|
||||
|
||||
def _write_wasm_extension(
|
||||
settings: Settings,
|
||||
tmp_path: Path,
|
||||
ext_id: str,
|
||||
*,
|
||||
config_id: str | None,
|
||||
) -> None:
|
||||
settings.lnbits_extensions_path = str(tmp_path)
|
||||
ext_dir = tmp_path / "extensions" / ext_id
|
||||
ext_dir.mkdir(parents=True)
|
||||
(ext_dir / "extension.wasm").write_bytes(b"\0asm")
|
||||
config = {
|
||||
"name": "Demo",
|
||||
"short_description": "Demo extension",
|
||||
"version": "1.0.0",
|
||||
"extension_type": "wasm",
|
||||
"wasm": {"module": "extension.wasm"},
|
||||
}
|
||||
if config_id is not None:
|
||||
config["id"] = config_id
|
||||
(ext_dir / "config.json").write_text(json.dumps(config), encoding="utf-8")
|
||||
|
||||
|
||||
def _wasm_extension(ext_id: str, root_path: Path) -> WasmExtension:
|
||||
config = parse_wasm_extension_config(ext_id, _wasm_config(ext_id))
|
||||
return WasmExtension(
|
||||
id=ext_id,
|
||||
name=ext_id,
|
||||
version="1.0.0",
|
||||
root_path=root_path,
|
||||
module_path=root_path / "extension.wasm",
|
||||
wit_path=None,
|
||||
world="",
|
||||
exports=[],
|
||||
config=config,
|
||||
)
|
||||
|
||||
|
||||
def _wasm_config(ext_id: str) -> dict[str, Any]:
|
||||
return {
|
||||
"id": ext_id,
|
||||
"name": ext_id,
|
||||
"short_description": "Demo extension",
|
||||
"version": "1.0.0",
|
||||
"extension_type": "wasm",
|
||||
"wasm": {"module": "extension.wasm"},
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from pytest_mock.plugin import MockerFixture
|
||||
|
||||
from lnbits.core.models.extensions import (
|
||||
ExtensionBackgroundPaymentDestinationPolicy,
|
||||
ExtensionPermission,
|
||||
)
|
||||
from lnbits.core.views.extension_api import (
|
||||
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION,
|
||||
WALLET_PAYMENTS_WATCH_PERMISSION,
|
||||
_background_destination_policy_covers,
|
||||
_check_background_payment_permission,
|
||||
_check_wallet_payments_watch_permission,
|
||||
_find_background_payment_grant,
|
||||
_find_wallet_payments_watch_grant,
|
||||
_remove_user_permission_grant,
|
||||
_safe_user_extension_permissions,
|
||||
_user_permission_grant_id_for_wallet,
|
||||
)
|
||||
from lnbits.core.wasm_ext.api.permissions import validate_wasm_extension_permissions
|
||||
from lnbits.core.wasm_ext.wasm.events import _wasm_invoice_paid_owner_id
|
||||
from lnbits.core.wasm_ext.wasm.invoke import _active_installed_extension
|
||||
from tests.helpers import make_installable_extension
|
||||
|
||||
|
||||
def test_validate_wasm_permissions_rejects_broader_policy_grant():
|
||||
ext_info = make_installable_extension("demoext")
|
||||
extension_config = _wasm_config(
|
||||
"demoext",
|
||||
[
|
||||
{
|
||||
"id": "http.request",
|
||||
"policies": [{"host": "https://api.example.com"}],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="broader policies"):
|
||||
validate_wasm_extension_permissions(
|
||||
ext_info,
|
||||
[
|
||||
ExtensionPermission(
|
||||
id="http.request",
|
||||
policies=[
|
||||
{"host": "https://api.example.com"},
|
||||
{"host": "https://evil.example.com"},
|
||||
],
|
||||
)
|
||||
],
|
||||
extension_config,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_wasm_permissions_stores_narrower_policy_grant():
|
||||
ext_info = make_installable_extension("demoext")
|
||||
extension_config = _wasm_config(
|
||||
"demoext",
|
||||
[
|
||||
{
|
||||
"id": "ext.storage.read_public",
|
||||
"description": "Read public storage.",
|
||||
"policies": [
|
||||
{
|
||||
"table_name": "tip_jars",
|
||||
"public_fields": ["id", "title", "description"],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
permissions = validate_wasm_extension_permissions(
|
||||
ext_info,
|
||||
[
|
||||
ExtensionPermission(
|
||||
id="ext.storage.read_public",
|
||||
policies=[
|
||||
{
|
||||
"table_name": "tip_jars",
|
||||
"public_fields": ["id", "title"],
|
||||
}
|
||||
],
|
||||
)
|
||||
],
|
||||
extension_config,
|
||||
)
|
||||
|
||||
assert permissions == [
|
||||
ExtensionPermission(
|
||||
id="ext.storage.read_public",
|
||||
description="Read public storage.",
|
||||
policies=[
|
||||
{
|
||||
"table_name": "tip_jars",
|
||||
"public_fields": ["id", "title"],
|
||||
}
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_validate_wasm_permissions_rejects_broader_extension_api_access():
|
||||
ext_info = make_installable_extension("demoext")
|
||||
extension_config = _wasm_config(
|
||||
"demoext",
|
||||
[
|
||||
{
|
||||
"id": "extension.api.request",
|
||||
"policies": [{"id": "targetext", "access": ["read"]}],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="broader policies"):
|
||||
validate_wasm_extension_permissions(
|
||||
ext_info,
|
||||
[
|
||||
ExtensionPermission(
|
||||
id="extension.api.request",
|
||||
policies=[{"id": "targetext", "access": ["read", "write"]}],
|
||||
)
|
||||
],
|
||||
extension_config,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_wasm_permissions_allows_empty_grant():
|
||||
ext_info = make_installable_extension("demoext")
|
||||
extension_config = _wasm_config(
|
||||
"demoext",
|
||||
[
|
||||
{
|
||||
"id": "wallet.create_invoice_public",
|
||||
"policies": [{"table": "tip_jars", "wallet_field": "wallet_id"}],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert validate_wasm_extension_permissions(ext_info, [], extension_config) == []
|
||||
|
||||
|
||||
def test_validate_wasm_permissions_rejects_unrequested_permission_grant():
|
||||
ext_info = make_installable_extension("demoext")
|
||||
extension_config = _wasm_config("demoext", [{"id": "utils.basic"}])
|
||||
|
||||
with pytest.raises(ValueError, match="unrequested permissions"):
|
||||
validate_wasm_extension_permissions(
|
||||
ext_info,
|
||||
[
|
||||
ExtensionPermission(id="utils.basic"),
|
||||
ExtensionPermission(id="wallet.list"),
|
||||
],
|
||||
extension_config,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_wasm_permissions_allows_wallet_payments_watch_permission():
|
||||
ext_info = make_installable_extension("demoext")
|
||||
extension_config = _wasm_config(
|
||||
"demoext",
|
||||
[{"id": "wallet.payments.watch"}],
|
||||
)
|
||||
|
||||
assert validate_wasm_extension_permissions(
|
||||
ext_info,
|
||||
[ExtensionPermission(id="wallet.payments.watch")],
|
||||
extension_config,
|
||||
) == [ExtensionPermission(id="wallet.payments.watch")]
|
||||
|
||||
|
||||
def test_background_payment_grant_lookup_and_policy_coverage():
|
||||
permissions = {
|
||||
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION: [
|
||||
{
|
||||
"id": "grant-1",
|
||||
"wallet_id": "wallet-1",
|
||||
"enabled": True,
|
||||
"max_amount": 5000,
|
||||
"destination_policy": "external_allowed",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
grant = _find_background_payment_grant(permissions, "wallet-1")
|
||||
|
||||
assert grant
|
||||
assert grant.max_amount == 5000
|
||||
assert _background_destination_policy_covers(
|
||||
grant.destination_policy,
|
||||
ExtensionBackgroundPaymentDestinationPolicy.OWN_WALLETS_ONLY,
|
||||
)
|
||||
assert _background_destination_policy_covers(
|
||||
grant.destination_policy,
|
||||
ExtensionBackgroundPaymentDestinationPolicy.EXTERNAL_ALLOWED,
|
||||
)
|
||||
assert not _background_destination_policy_covers(
|
||||
ExtensionBackgroundPaymentDestinationPolicy.OWN_WALLETS_ONLY,
|
||||
ExtensionBackgroundPaymentDestinationPolicy.EXTERNAL_ALLOWED,
|
||||
)
|
||||
|
||||
|
||||
def test_wallet_payments_watch_grant_lookup_ignores_disabled_grant():
|
||||
permissions = {
|
||||
WALLET_PAYMENTS_WATCH_PERMISSION: [
|
||||
{"id": "grant-1", "wallet_id": "wallet-1", "enabled": False}
|
||||
]
|
||||
}
|
||||
|
||||
grant = _find_wallet_payments_watch_grant(permissions, "wallet-1")
|
||||
|
||||
assert grant
|
||||
assert grant.enabled is False
|
||||
|
||||
|
||||
def test_safe_user_extension_permissions_keeps_only_grants_with_ids():
|
||||
permissions = {
|
||||
WALLET_PAYMENTS_WATCH_PERMISSION: [
|
||||
{"id": "grant-1", "wallet_id": "wallet-1", "enabled": True},
|
||||
{"wallet_id": "wallet-2", "enabled": True},
|
||||
"broken",
|
||||
],
|
||||
"broken": "not-a-list",
|
||||
}
|
||||
|
||||
safe_permissions = _safe_user_extension_permissions(permissions)
|
||||
|
||||
assert safe_permissions == {
|
||||
WALLET_PAYMENTS_WATCH_PERMISSION: [
|
||||
{"id": "grant-1", "wallet_id": "wallet-1", "enabled": True}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_user_permission_grant_id_for_wallet_returns_existing_grant_id():
|
||||
permissions = {
|
||||
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION: [
|
||||
{"id": "grant-1", "wallet_id": "wallet-1", "enabled": True}
|
||||
]
|
||||
}
|
||||
|
||||
grant_id = _user_permission_grant_id_for_wallet(
|
||||
permissions, WALLET_PAY_INVOICE_BACKGROUND_PERMISSION, "wallet-1"
|
||||
)
|
||||
|
||||
assert grant_id == "grant-1"
|
||||
|
||||
|
||||
def test_remove_user_permission_grant_removes_only_matching_grant_id():
|
||||
permissions = {
|
||||
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION: [
|
||||
{"id": "grant-1", "wallet_id": "wallet-1", "enabled": True},
|
||||
{"id": "grant-2", "wallet_id": "wallet-2", "enabled": True},
|
||||
],
|
||||
WALLET_PAYMENTS_WATCH_PERMISSION: [{"id": "grant-3", "wallet_id": "wallet-1"}],
|
||||
}
|
||||
|
||||
updated_permissions = _remove_user_permission_grant(permissions, "grant-1")
|
||||
|
||||
assert updated_permissions == {
|
||||
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION: [
|
||||
{"id": "grant-2", "wallet_id": "wallet-2", "enabled": True}
|
||||
],
|
||||
WALLET_PAYMENTS_WATCH_PERMISSION: [{"id": "grant-3", "wallet_id": "wallet-1"}],
|
||||
}
|
||||
|
||||
|
||||
def test_remove_user_permission_grant_drops_empty_permission():
|
||||
permissions = {
|
||||
WALLET_PAYMENTS_WATCH_PERMISSION: [{"id": "grant-1", "wallet_id": "wallet-1"}]
|
||||
}
|
||||
|
||||
updated_permissions = _remove_user_permission_grant(permissions, "grant-1")
|
||||
|
||||
assert updated_permissions == {}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_background_payment_check_reports_approved_grant(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
permissions = {
|
||||
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION: [
|
||||
{
|
||||
"id": "grant-1",
|
||||
"wallet_id": "wallet-1",
|
||||
"enabled": True,
|
||||
"max_amount": 5000,
|
||||
"destination_policy": "external_allowed",
|
||||
}
|
||||
]
|
||||
}
|
||||
mocker.patch(
|
||||
"lnbits.core.views.extension_api.get_wallet",
|
||||
mocker.AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
user="user-1",
|
||||
is_lightning_shared_wallet=False,
|
||||
can_send_payments=True,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
result = await _check_background_payment_permission(
|
||||
"user-1",
|
||||
permissions,
|
||||
{
|
||||
"wallet_id": "wallet-1",
|
||||
"max_amount": 1000,
|
||||
"destination_policy": "own_wallets_only",
|
||||
},
|
||||
)
|
||||
|
||||
assert result.id == WALLET_PAY_INVOICE_BACKGROUND_PERMISSION
|
||||
assert result.approved is True
|
||||
assert result.grant["id"] == "grant-1"
|
||||
assert result.grant["max_amount"] == 5000
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wallet_payment_watch_check_returns_requested_unapproved_grant(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
mocker.patch(
|
||||
"lnbits.core.views.extension_api.get_wallet",
|
||||
mocker.AsyncMock(return_value=SimpleNamespace(user="user-1")),
|
||||
)
|
||||
|
||||
result = await _check_wallet_payments_watch_permission(
|
||||
"user-1",
|
||||
{},
|
||||
{"wallet_id": "wallet-1"},
|
||||
)
|
||||
|
||||
assert result.id == WALLET_PAYMENTS_WATCH_PERMISSION
|
||||
assert result.approved is False
|
||||
assert result.grant["wallet_id"] == "wallet-1"
|
||||
assert result.grant["enabled"] is True
|
||||
assert isinstance(result.grant["id"], str)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_invoice_paid_owner_lookup_uses_stored_granted_policies(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
extension = SimpleNamespace(
|
||||
id="demoext",
|
||||
config=_wasm_config(
|
||||
"demoext",
|
||||
[
|
||||
{
|
||||
"id": "wallet.create_invoice_public",
|
||||
"policies": [
|
||||
{"table": "requested_table", "wallet_field": "wallet_id"}
|
||||
],
|
||||
}
|
||||
],
|
||||
),
|
||||
)
|
||||
payment = SimpleNamespace(extra={"source_id": "source-1"})
|
||||
installed_extension = SimpleNamespace(
|
||||
permissions=[
|
||||
ExtensionPermission(
|
||||
id="wallet.create_invoice_public",
|
||||
policies=[{"table": "granted_table", "wallet_field": "wallet_id"}],
|
||||
)
|
||||
]
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.wasm.events.get_installed_extension",
|
||||
mocker.AsyncMock(return_value=installed_extension),
|
||||
)
|
||||
storage_mock = mocker.patch(
|
||||
"lnbits.core.wasm_ext.wasm.events.storage_get_row_owner_id",
|
||||
mocker.AsyncMock(return_value="owner-1"),
|
||||
)
|
||||
|
||||
owner_id = await _wasm_invoice_paid_owner_id(extension, payment)
|
||||
|
||||
assert owner_id == "owner-1"
|
||||
storage_mock.assert_awaited_once_with("demoext", "granted_table", "source-1")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_invocation_requires_installed_active_extension(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
extension = SimpleNamespace(id="demoext")
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.wasm.invoke.get_installed_extension",
|
||||
mocker.AsyncMock(return_value=None),
|
||||
)
|
||||
|
||||
with pytest.raises(PermissionError, match="deactivated"):
|
||||
await _active_installed_extension(cast(Any, extension))
|
||||
|
||||
|
||||
def _wasm_config(ext_id: str, permissions: list[dict]) -> dict:
|
||||
return {
|
||||
"id": ext_id,
|
||||
"name": ext_id,
|
||||
"short_description": "Demo extension",
|
||||
"version": "1.0.0",
|
||||
"extension_type": "wasm",
|
||||
"wasm": {"module": "extension.wasm"},
|
||||
"permissions": permissions,
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from lnbits.core.wasm_ext.routes.api import (
|
||||
WasmRequestBodyTooLargeError,
|
||||
_read_api_payload,
|
||||
_read_json_object_with_size,
|
||||
_wasm_extension_api_export,
|
||||
)
|
||||
from lnbits.core.wasm_ext.routes.assets import (
|
||||
_reject_html_like_wasm_static_asset,
|
||||
_wasm_extension_core_asset_response,
|
||||
)
|
||||
from lnbits.core.wasm_ext.routes.security import (
|
||||
consume_wasm_extension_frame_token,
|
||||
wasm_extension_frame_csp,
|
||||
wasm_extension_frame_url,
|
||||
)
|
||||
from lnbits.core.wasm_ext.routes.ui import (
|
||||
_match_wasm_extension_ui_route,
|
||||
_wasm_extension_bridge_api_routes,
|
||||
_wasm_extension_entrypoint,
|
||||
)
|
||||
from lnbits.core.wasm_ext.wasm.config import parse_wasm_extension_config
|
||||
from lnbits.core.wasm_ext.wasm.loader import WasmExtension
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_json_reader_rejects_large_content_length_without_reading():
|
||||
request = _FakeRequest([b"{}"], content_length="11")
|
||||
|
||||
with pytest.raises(WasmRequestBodyTooLargeError, match="11 bytes"):
|
||||
await _read_json_object_with_size(cast(Request, request), max_body_bytes=10)
|
||||
|
||||
assert request.stream_started is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_json_reader_rejects_large_stream_without_content_length():
|
||||
request = _FakeRequest([b'{"value":"', b"x" * 20, b'"}'])
|
||||
|
||||
with pytest.raises(WasmRequestBodyTooLargeError):
|
||||
await _read_json_object_with_size(cast(Request, request), max_body_bytes=16)
|
||||
|
||||
assert request.stream_started is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_api_payload_records_actual_body_bytes():
|
||||
body = b'{"amount":21}'
|
||||
request = _FakeRequest(
|
||||
[body],
|
||||
path_params={"invoice_id": "abc"},
|
||||
query_params={"include_paid": "true"},
|
||||
)
|
||||
|
||||
payload = await _read_api_payload(
|
||||
cast(Request, request),
|
||||
{"invoice_id": "invoiceId"},
|
||||
max_body_bytes=100,
|
||||
)
|
||||
|
||||
assert payload.data == {
|
||||
"invoiceId": "abc",
|
||||
"includePaid": "true",
|
||||
"amount": 21,
|
||||
}
|
||||
assert payload.request_bytes == len(body)
|
||||
|
||||
|
||||
def test_wasm_api_export_visibility_is_enforced(tmp_path: Path):
|
||||
extension = _wasm_extension(tmp_path)
|
||||
|
||||
assert _wasm_extension_api_export(extension, "render") == "render"
|
||||
assert _wasm_extension_api_export(extension, "private_render") == "private_render"
|
||||
with pytest.raises(PermissionError, match="not callable over HTTP"):
|
||||
_wasm_extension_api_export(extension, "on_invoice_paid")
|
||||
with pytest.raises(KeyError, match="has no export"):
|
||||
_wasm_extension_api_export(extension, "missing")
|
||||
|
||||
|
||||
def test_wasm_ui_entrypoint_rejects_escape_static_and_non_html(tmp_path: Path):
|
||||
extension = _wasm_extension(tmp_path)
|
||||
(tmp_path / "index.html").write_text("<html></html>", encoding="utf-8")
|
||||
(tmp_path / "index.txt").write_text("text", encoding="utf-8")
|
||||
(tmp_path / "static").mkdir()
|
||||
(tmp_path / "static" / "index.html").write_text("<html></html>", encoding="utf-8")
|
||||
|
||||
assert (
|
||||
_wasm_extension_entrypoint(extension, "index.html")
|
||||
== (tmp_path / "index.html").resolve()
|
||||
)
|
||||
with pytest.raises(ValueError, match="escapes extension root"):
|
||||
_wasm_extension_entrypoint(extension, "../outside.html")
|
||||
with pytest.raises(ValueError, match="must not be inside the static"):
|
||||
_wasm_extension_entrypoint(extension, "static/index.html")
|
||||
with pytest.raises(ValueError, match="must be an HTML file"):
|
||||
_wasm_extension_entrypoint(extension, "index.txt")
|
||||
|
||||
|
||||
def test_wasm_frame_token_is_one_time_and_user_bound(tmp_path: Path):
|
||||
extension = _wasm_extension(tmp_path)
|
||||
frame_path = "/ext-frame/demoext/0"
|
||||
frame_url = wasm_extension_frame_url(extension, frame_path, "user-1")
|
||||
token = frame_url.split("frame_token=", 1)[1]
|
||||
|
||||
with pytest.raises(HTTPException) as wrong_user:
|
||||
consume_wasm_extension_frame_token(
|
||||
_request_with_query(token),
|
||||
extension,
|
||||
frame_path,
|
||||
"user-2",
|
||||
)
|
||||
assert wrong_user.value.status_code == 404
|
||||
|
||||
consume_wasm_extension_frame_token(
|
||||
_request_with_query(token),
|
||||
extension,
|
||||
frame_path,
|
||||
"user-1",
|
||||
)
|
||||
with pytest.raises(HTTPException) as reused:
|
||||
consume_wasm_extension_frame_token(
|
||||
_request_with_query(token),
|
||||
extension,
|
||||
frame_path,
|
||||
"user-1",
|
||||
)
|
||||
assert reused.value.status_code == 404
|
||||
|
||||
|
||||
def test_wasm_frame_csp_is_locked_to_extension_assets(tmp_path: Path):
|
||||
csp = wasm_extension_frame_csp(
|
||||
_request_with_query("token"),
|
||||
_wasm_extension(tmp_path),
|
||||
)
|
||||
|
||||
assert "sandbox allow-scripts" in csp
|
||||
assert "default-src 'none'" in csp
|
||||
assert "connect-src 'none'" in csp
|
||||
assert "frame-ancestors 'self'" in csp
|
||||
assert "http://testserver/ext-assets/demoext/" in csp
|
||||
|
||||
|
||||
def test_wasm_ui_route_matching_and_bridge_public_api_filtering(tmp_path: Path):
|
||||
extension = _wasm_extension(tmp_path)
|
||||
|
||||
matched = _match_wasm_extension_ui_route(extension, "/ext/demo/abc")
|
||||
public_routes = _wasm_extension_bridge_api_routes(extension, public=True)
|
||||
private_routes = _wasm_extension_bridge_api_routes(extension, public=False)
|
||||
|
||||
assert matched["auth"] == "user"
|
||||
assert matched["route_params"] == {"item_id": "abc"}
|
||||
assert public_routes == [
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/ext/demoext/public/{item_id}",
|
||||
"pattern": "^/api/v1/ext/demoext/public/[^/]+$",
|
||||
}
|
||||
]
|
||||
assert {route["path"] for route in private_routes} == {
|
||||
"/api/v1/ext/demoext/public/{item_id}",
|
||||
"/api/v1/ext/demoext/private/{item_id}",
|
||||
}
|
||||
|
||||
|
||||
def test_wasm_static_core_assets_and_html_like_text_assets_are_guarded(tmp_path: Path):
|
||||
response = _wasm_extension_core_asset_response("_lnbits/material-icons.css")
|
||||
assert response.headers["X-Content-Type-Options"] == "nosniff"
|
||||
assert response.headers["Cache-Control"] == "no-store"
|
||||
|
||||
for path in ["_lnbits/../bundle.min.css", "_lnbits/missing.css"]:
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_wasm_extension_core_asset_response(path)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
script_path = tmp_path / "app.js"
|
||||
script_path.write_text("<script>alert(1)</script>", encoding="utf-8")
|
||||
with pytest.raises(HTTPException) as html_like:
|
||||
_reject_html_like_wasm_static_asset(script_path)
|
||||
assert html_like.value.status_code == 404
|
||||
|
||||
|
||||
class _FakeRequest:
|
||||
method = "POST"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
chunks: list[bytes],
|
||||
*,
|
||||
content_length: str | None = None,
|
||||
path_params: dict[str, str] | None = None,
|
||||
query_params: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
self._chunks = chunks
|
||||
self.headers: dict[str, str] = {}
|
||||
if content_length is not None:
|
||||
self.headers["content-length"] = content_length
|
||||
self.path_params = path_params or {}
|
||||
self.query_params = query_params or {}
|
||||
self.stream_started = False
|
||||
|
||||
async def stream(self) -> AsyncIterator[bytes]:
|
||||
self.stream_started = True
|
||||
for chunk in self._chunks:
|
||||
yield chunk
|
||||
|
||||
|
||||
def _wasm_extension(root_path: Path) -> WasmExtension:
|
||||
config = parse_wasm_extension_config(
|
||||
"demoext",
|
||||
{
|
||||
"id": "demoext",
|
||||
"name": "Demo",
|
||||
"short_description": "Demo extension",
|
||||
"version": "1.0.0",
|
||||
"extension_type": "wasm",
|
||||
"wasm": {
|
||||
"module": "extension.wasm",
|
||||
"exports": [
|
||||
{"name": "render", "visibility": "public"},
|
||||
{"name": "private_render", "visibility": "authenticated"},
|
||||
{"name": "on_invoice_paid", "visibility": "event"},
|
||||
],
|
||||
},
|
||||
"ui_routes": [
|
||||
{
|
||||
"path": "/demo/{item_id}",
|
||||
"entrypoint": "index.html",
|
||||
"auth": "user",
|
||||
}
|
||||
],
|
||||
"api_routes": [
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/public/{item_id}",
|
||||
"export": "render",
|
||||
"auth": "public",
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/private/{item_id}",
|
||||
"export": "private_render",
|
||||
"auth": "user",
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
return WasmExtension(
|
||||
id="demoext",
|
||||
name="Demo",
|
||||
version="1.0.0",
|
||||
root_path=root_path,
|
||||
module_path=root_path / "extension.wasm",
|
||||
wit_path=None,
|
||||
world="",
|
||||
exports=config.wasm.exports,
|
||||
config=config,
|
||||
)
|
||||
|
||||
|
||||
def _request_with_query(token: str) -> Request:
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"scheme": "http",
|
||||
"server": ("testserver", 80),
|
||||
"path": "/ext-frame/demoext/0",
|
||||
"query_string": f"frame_token={token}".encode(),
|
||||
"headers": [],
|
||||
}
|
||||
)
|
||||
@@ -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