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:
Vlad Stan
2026-07-15 13:50:19 +03:00
committed by GitHub
co-authored by alan Tiago Vasconcelos
parent 43900dd6da
commit 61ed636df0
84 changed files with 14304 additions and 96 deletions
+2 -1
View File
@@ -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",
+187
View File
@@ -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
View File
@@ -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(
+10
View File
@@ -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"
+2
View File
@@ -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):
+17
View File
@@ -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"}],
)
+503
View File
@@ -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
+295
View File
@@ -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
+348
View File
@@ -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="",
)
)
+214
View File
@@ -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
+223
View File
@@ -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,
}
+279
View File
@@ -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": [],
}
)
+409
View File
@@ -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