feat: wasm ext resource limits (#4052)
This commit is contained in:
@@ -14,14 +14,21 @@ from lnbits.core.models.extensions import (
|
||||
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
|
||||
@@ -219,6 +226,269 @@ 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
|
||||
|
||||
|
||||
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 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_start_extension_background_work_handles_missing_and_sync_starts(
|
||||
mocker: MockerFixture,
|
||||
|
||||
@@ -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,210 @@
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from pytest_mock.plugin import MockerFixture
|
||||
|
||||
from lnbits.core.models.extensions import ExtensionPermission
|
||||
from lnbits.core.wasm_ext.api.permissions import validate_wasm_extension_permissions
|
||||
from lnbits.core.wasm_ext.wasm.events import _wasm_invoice_paid_owner_id
|
||||
from lnbits.core.wasm_ext.wasm.invoke import _active_installed_extension
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
}
|
||||
Reference in New Issue
Block a user