From 120cf735c8a5ce5a4cb9b553e0c40b012c541094 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 7 Jul 2026 17:25:38 +0300 Subject: [PATCH] test: more tests --- .../lnbits-wasm-test-extension/config.json | 5 +- .../lnbits-wasm-test-extension/static/app.js | 49 +++ .../static/data.json | 1 + .../static/html-like.js | 4 + tests/unit/test_wasm_extension_permissions.py | 70 +++++ tests/wasm_ext/conftest.py | 10 +- tests/wasm_ext/helpers.py | 2 + tests/wasm_ext/test_wasm_extension_e2e.py | 295 +++++++++++++++++- 8 files changed, 426 insertions(+), 10 deletions(-) create mode 100644 tests/fixtures/lnbits-wasm-test-extension/static/data.json create mode 100644 tests/fixtures/lnbits-wasm-test-extension/static/html-like.js create mode 100644 tests/unit/test_wasm_extension_permissions.py diff --git a/tests/fixtures/lnbits-wasm-test-extension/config.json b/tests/fixtures/lnbits-wasm-test-extension/config.json index 6385c73dd..0bba6e6ea 100644 --- a/tests/fixtures/lnbits-wasm-test-extension/config.json +++ b/tests/fixtures/lnbits-wasm-test-extension/config.json @@ -116,10 +116,7 @@ "policies": [ { "id": "watchonly", - "access": [ - "read", - "write" - ] + "access": ["read", "write"] } ] }, diff --git a/tests/fixtures/lnbits-wasm-test-extension/static/app.js b/tests/fixtures/lnbits-wasm-test-extension/static/app.js index f83e5ae0d..fc5003221 100644 --- a/tests/fixtures/lnbits-wasm-test-extension/static/app.js +++ b/tests/fixtures/lnbits-wasm-test-extension/static/app.js @@ -1 +1,50 @@ window.__lnbitsWasmTestExtensionLoaded = true + +;(function () { + const channel = new MessageChannel() + const pending = new Map() + let counter = 0 + + const ready = new Promise(resolve => { + channel.port1.addEventListener('message', event => { + const message = event.data || {} + + if (message.type === 'lnbits-extension:connected') { + resolve(true) + return + } + + if (message.type !== 'lnbits-extension:response') return + const callback = pending.get(message.id) + if (!callback) return + + pending.delete(message.id) + callback(message) + }) + channel.port1.start() + window.parent.postMessage( + {type: 'lnbits-extension:connect', id: 'wasm-test-extension'}, + '*', + [channel.port2] + ) + }) + + window.lnbitsWasmTestBridge = { + ready() { + return ready + }, + request(message) { + return ready.then(() => { + return new Promise(resolve => { + const id = `wasm-test-${++counter}` + pending.set(id, resolve) + channel.port1.postMessage({ + type: 'lnbits-extension:request', + id, + ...message + }) + }) + }) + } + } +})() diff --git a/tests/fixtures/lnbits-wasm-test-extension/static/data.json b/tests/fixtures/lnbits-wasm-test-extension/static/data.json new file mode 100644 index 000000000..0ea0a3af3 --- /dev/null +++ b/tests/fixtures/lnbits-wasm-test-extension/static/data.json @@ -0,0 +1 @@ +{"unsafe": true} diff --git a/tests/fixtures/lnbits-wasm-test-extension/static/html-like.js b/tests/fixtures/lnbits-wasm-test-extension/static/html-like.js new file mode 100644 index 000000000..15314f816 --- /dev/null +++ b/tests/fixtures/lnbits-wasm-test-extension/static/html-like.js @@ -0,0 +1,4 @@ + + + This must not be served as JavaScript. + diff --git a/tests/unit/test_wasm_extension_permissions.py b/tests/unit/test_wasm_extension_permissions.py new file mode 100644 index 000000000..9deffe0f2 --- /dev/null +++ b/tests/unit/test_wasm_extension_permissions.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import pytest + +from lnbits.core.models.extensions import ExtensionPermission, InstallableExtension +from lnbits.core.wasm_ext.api.permissions import ( + validate_extension_permissions, + validate_wasm_extension_permissions, +) + + +def test_validate_extension_permissions_rejects_unknown_permission() -> None: + permissions = [ + ExtensionPermission(id="wallet.list"), + ExtensionPermission(id="unknown.permission"), + ] + + with pytest.raises(ValueError, match="unknown.permission"): + validate_extension_permissions("demo", permissions) + + +def test_validate_wasm_extension_permissions_requires_grant() -> None: + extension = InstallableExtension(id="demo", name="Demo", version="0.0.1") + config = { + "extension_type": "wasm", + "permissions": [{"id": "wallet.list"}], + } + + with pytest.raises(ValueError, match="requires permission approval"): + validate_wasm_extension_permissions(extension, None, config) + + +def test_validate_wasm_extension_permissions_requires_exact_grants() -> None: + extension = InstallableExtension(id="demo", name="Demo", version="0.0.1") + config = { + "extension_type": "wasm", + "permissions": [{"id": "wallet.list"}, {"id": "utils.basic"}], + } + + with pytest.raises(ValueError, match="was not granted all requested permissions"): + validate_wasm_extension_permissions( + extension, + [ExtensionPermission(id="wallet.list")], + config, + ) + + +def test_validate_wasm_extension_permissions_returns_core_normalized_grants() -> None: + extension = InstallableExtension(id="demo", name="Demo", version="0.0.1") + config = { + "extension_type": "wasm", + "permissions": [ + { + "id": "wallet.list", + "label": "Extension supplied label", + "description": "Show wallets.", + } + ], + } + + permissions = validate_wasm_extension_permissions( + extension, + [ExtensionPermission(id="wallet.list", label="Extension supplied label")], + config, + ) + + assert len(permissions) == 1 + assert permissions[0].id == "wallet.list" + assert permissions[0].label is None + assert permissions[0].description == "Show wallets." diff --git a/tests/wasm_ext/conftest.py b/tests/wasm_ext/conftest.py index 86979b173..a2273f2c1 100644 --- a/tests/wasm_ext/conftest.py +++ b/tests/wasm_ext/conftest.py @@ -7,15 +7,17 @@ import subprocess import sys import time from collections.abc import Iterator -from pathlib import Path from typing import Any import httpx import pytest -from tests.wasm_ext.helpers import EXTENSION_ID, SERVER_HOST, LiveLNbitsServer - -REPO_ROOT = Path(__file__).resolve().parents[2] +from tests.wasm_ext.helpers import ( + EXTENSION_ID, + REPO_ROOT, + SERVER_HOST, + LiveLNbitsServer, +) @pytest.fixture(scope="session") diff --git a/tests/wasm_ext/helpers.py b/tests/wasm_ext/helpers.py index 30d6425c6..7838d5740 100644 --- a/tests/wasm_ext/helpers.py +++ b/tests/wasm_ext/helpers.py @@ -1,9 +1,11 @@ from __future__ import annotations from dataclasses import dataclass +from pathlib import Path from typing import Any EXTENSION_ID = "lnbits-wasm-test-extension" +REPO_ROOT = Path(__file__).resolve().parents[2] SERVER_HOST = "127.0.0.1" diff --git a/tests/wasm_ext/test_wasm_extension_e2e.py b/tests/wasm_ext/test_wasm_extension_e2e.py index fe8bd35c1..0665c22ec 100644 --- a/tests/wasm_ext/test_wasm_extension_e2e.py +++ b/tests/wasm_ext/test_wasm_extension_e2e.py @@ -1,11 +1,109 @@ from __future__ import annotations +import json import re from re import Pattern +from typing import Any -from playwright.sync_api import Page, expect +from playwright.sync_api import Frame, Page, expect -from tests.wasm_ext.helpers import LiveLNbitsServer +from tests.wasm_ext.helpers import EXTENSION_ID, REPO_ROOT, LiveLNbitsServer + + +def test_permission_grant_dialog_logic_is_compact_and_explicit(page: Page) -> None: + permissions = _fixture_permissions() + page.goto("about:blank") + page.add_script_tag(path=str(REPO_ROOT / "lnbits/static/js/pages/extensions.js")) + + result = page.evaluate( + """ + ({permissions, translations}) => { + const methods = window.PageExtensions.methods + const context = { + extensions: [{id: 'watchonly', name: 'Watchonly'}], + permissionGrant: {show: false, permissions: [], resolve: null}, + selectedExtension: {isWasm: true}, + selectedRelease: null, + showManageExtensionDialog: false, + $t: key => translations[key] || key + } + Object.assign(context, methods) + + const release = {extension_type: 'wasm', permissions} + const pendingGrant = context.resolveExtensionPermissionGrant(release) + const opened = + context.permissionGrant.show === true && + context.showManageExtensionDialog === true + const items = context.permissionGrantDisplayItems() + context.grantExtensionPermissions() + + return pendingGrant.then(grantedPermissions => ({ + opened, + closed: + context.permissionGrant.show === false && + context.showManageExtensionDialog === false, + grantedPermissionIds: grantedPermissions.map(permission => permission.id), + items + })) + } + """, + {"permissions": permissions, "translations": _permission_translations()}, + ) + + assert result["opened"] is True + assert result["closed"] is True + assert result["grantedPermissionIds"] == [ + permission["id"] for permission in permissions + ] + + items = result["items"] + assert [item["id"] for item in items] == [ + "wallet.pay_invoice", + "wallet.list", + "wallet.balance.read", + "extension.api.request", + "ui.camera.scan_qr", + "ext.storage.read_write", + "ext.storage.read_public", + "wallet.create_invoice_public", + "wallet.create_invoice", + "utils.basic", + ] + + by_id = {item["id"]: item for item in items} + assert by_id["wallet.pay_invoice"]["risk"]["level"] == "high" + assert by_id["extension.api.request"]["risk"]["level"] == "high" + assert by_id["ext.storage.read_write"]["risk"]["level"] == "low" + assert by_id["wallet.create_invoice"]["risk"]["level"] == "low" + assert by_id["ui.camera.scan_qr"]["risk"]["level"] == "low" + + assert by_id["ext.storage.read_write"]["label"] == ( + "Read & Write extension storage" + ) + assert by_id["ext.storage.read_public"]["badges"] == [ + {"key": "tip_jars", "label": "tip_jars"} + ] + assert by_id["ext.storage.read_public"]["fieldGroups"] == [ + { + "table": "tip_jars", + "fields": [ + "id", + "title", + "description", + "currency", + "suggested_amounts", + ], + } + ] + assert by_id["extension.api.request"]["badges"] == [ + {"key": "watchonly", "label": "Watchonly"} + ] + assert by_id["extension.api.request"]["extensionAccess"] == [ + {"id": "watchonly", "name": "Watchonly", "access": ["read", "write"]} + ] + assert by_id["wallet.create_invoice_public"]["invoicePolicies"] == [ + {"table": "tip_jars", "walletField": "wallet_id"} + ] def test_public_wasm_page_loads_sandboxed_frame( @@ -37,6 +135,145 @@ def test_public_wasm_page_loads_sandboxed_frame( ).to_contain_text("WASM Test Public") +def test_static_assets_are_strictly_whitelisted( + page: Page, + lnbits_server: LiveLNbitsServer, +) -> None: + assets_base = f"{lnbits_server.base_url}/ext-assets/{lnbits_server.extension_id}" + + script = page.request.get(f"{assets_base}/app.js") + assert script.status == 200 + assert script.headers["content-type"].startswith("text/javascript") + assert script.headers["x-content-type-options"] == "nosniff" + assert script.headers["cache-control"] == "no-store" + + core_script = page.request.get(f"{assets_base}/_lnbits/vue.global.prod.js") + assert core_script.status == 200 + assert core_script.headers["content-type"].startswith("text/javascript") + assert core_script.headers["x-content-type-options"] == "nosniff" + + unsupported_extension = page.request.get(f"{assets_base}/data.json") + assert unsupported_extension.status == 404 + + html_like_javascript = page.request.get(f"{assets_base}/html-like.js") + assert html_like_javascript.status == 404 + + +def test_frame_config_exposes_permissions_and_filters_public_routes( + authenticated_page: Page, + lnbits_server: LiveLNbitsServer, +) -> None: + public_config = authenticated_page.request.post( + _frame_config_url(lnbits_server), + data={ + "path": f"/ext/{lnbits_server.extension_id}/public/item-123", + "query": {"source_id": "abc", "empty": None}, + }, + ) + assert public_config.status == 200 + public_bridge = public_config.json()["bridge"] + assert public_bridge["public"] is True + assert public_bridge["routeParams"] == {"itemId": "item-123"} + assert public_bridge["query"] == {"sourceId": "abc"} + assert {route["path"] for route in public_bridge["apiRoutes"]} == { + f"/api/v1/ext/{lnbits_server.extension_id}/jars/{{jar_id}}", + f"/api/v1/ext/{lnbits_server.extension_id}/invoice", + } + + private_config = authenticated_page.request.post( + _frame_config_url(lnbits_server), + data={"path": f"/ext/{lnbits_server.extension_id}", "query": {}}, + ) + assert private_config.status == 200 + private_bridge = private_config.json()["bridge"] + assert private_bridge["public"] is False + assert set(private_bridge["permissions"]) == { + permission["id"] for permission in _fixture_permissions() + } + assert {route["path"] for route in private_bridge["apiRoutes"]} == { + f"/api/v1/ext/{lnbits_server.extension_id}/wallets", + f"/api/v1/ext/{lnbits_server.extension_id}/payments", + f"/api/v1/ext/{lnbits_server.extension_id}/jars/{{jar_id}}", + f"/api/v1/ext/{lnbits_server.extension_id}/invoice", + } + + +def test_private_frame_config_and_api_routes_require_auth( + page: Page, + lnbits_server: LiveLNbitsServer, +) -> None: + private_config = page.request.post( + _frame_config_url(lnbits_server), + data={"path": f"/ext/{lnbits_server.extension_id}", "query": {}}, + ) + assert private_config.status == 401 + + private_api = page.request.get( + f"{lnbits_server.base_url}/api/v1/ext/{lnbits_server.extension_id}/wallets" + ) + assert private_api.status == 401 + + public_config = page.request.post( + _frame_config_url(lnbits_server), + data={ + "path": f"/ext/{lnbits_server.extension_id}/public/item-123", + "query": {}, + }, + ) + assert public_config.status == 200 + + +def test_bridge_context_and_denied_api_request( + page: Page, + lnbits_server: LiveLNbitsServer, +) -> None: + frame_url_part = f"/ext-frame/{lnbits_server.extension_id}/1" + + with page.expect_response(lambda response: frame_url_part in response.url): + page.goto( + f"{lnbits_server.base_url}/ext/{lnbits_server.extension_id}" + "/public/item-123?source=test" + ) + + frame = _wasm_frame(page, frame_url_part) + assert frame.evaluate("() => window.lnbitsWasmTestBridge.ready()") is True + + context_response = frame.evaluate(""" + () => window.lnbitsWasmTestBridge.request({ + action: 'context' + }) + """) + assert context_response == { + "type": "lnbits-extension:response", + "id": "wasm-test-1", + "ok": True, + "data": { + "extensionId": EXTENSION_ID, + "public": True, + "routeParams": {"itemId": "item-123"}, + "query": {"source": "test"}, + }, + } + + denied_response = frame.evaluate(""" + () => window.lnbitsWasmTestBridge.request({ + action: 'api', + method: 'GET', + path: '/api/v1/wallets' + }) + """) + assert denied_response["ok"] is False + assert denied_response["error"] == "Extension API route is not allowed." + + unknown_response = frame.evaluate(""" + () => window.lnbitsWasmTestBridge.request({ + action: 'unknown' + }) + """) + assert unknown_response["ok"] is False + assert unknown_response["error"] == "Unknown extension bridge action." + + def test_frame_token_is_route_bound_required_and_single_use( page: Page, lnbits_server: LiveLNbitsServer, @@ -92,3 +329,57 @@ def test_private_wasm_page_uses_lnbits_shell_and_private_frame( def _frame_src_pattern(frame_url_part: str) -> Pattern[str]: return re.compile(f"^{re.escape(frame_url_part)}\\?frame_token=[a-f0-9]{{32}}$") + + +def _frame_config_url(lnbits_server: LiveLNbitsServer) -> str: + return ( + f"{lnbits_server.base_url}/api/v1/ext/" + f"{lnbits_server.extension_id}/_ui/frame" + ) + + +def _fixture_permissions() -> list[dict[str, Any]]: + config = json.loads( + (REPO_ROOT / "tests/fixtures" / EXTENSION_ID / "config.json").read_text() + ) + permissions = config["permissions"] + assert isinstance(permissions, list) + return permissions + + +def _permission_translations() -> dict[str, str]: + return { + "extension_permission_access_read": "Read", + "extension_permission_access_write": "Write", + "extension_permission_ext_storage_read_public": ( + "Read public extension storage" + ), + "extension_permission_ext_storage_read_write": ( + "Read & Write extension storage" + ), + "extension_permission_extension_api_request": "Use other extensions", + "extension_permission_risk_high": "High risk", + "extension_permission_risk_low": "Low risk", + "extension_permission_risk_medium": "Medium risk", + "extension_permission_ui_camera_scan_qr": "Scan QR codes", + "extension_permission_utils_basic": "Use basic LNbits utilities", + "extension_permission_wallet_balance_read": "View wallet balances", + "extension_permission_wallet_create_invoice": "Create invoices", + "extension_permission_wallet_create_invoice_public": ( + "Create Lightning invoices from public pages" + ), + "extension_permission_wallet_list": "List wallets", + "extension_permission_wallet_pay_invoice": "Pay invoices", + "extension_permission_warning_extension_api_request_write": ( + "This extension can write to another extension." + ), + "extension_permission_warning_wallet_pay_invoice": ( + "This extension can spend from selected wallets." + ), + } + + +def _wasm_frame(page: Page, frame_url_part: str) -> Frame: + frame = page.frame(url=lambda url: frame_url_part in url) + assert frame is not None + return frame