test: more tests

This commit is contained in:
Vlad Stan
2026-07-07 18:32:54 +03:00
parent 4f072a6247
commit 120cf735c8
8 changed files with 426 additions and 10 deletions
+1 -4
View File
@@ -116,10 +116,7 @@
"policies": [ "policies": [
{ {
"id": "watchonly", "id": "watchonly",
"access": [ "access": ["read", "write"]
"read",
"write"
]
} }
] ]
}, },
+49
View File
@@ -1 +1,50 @@
window.__lnbitsWasmTestExtensionLoaded = true 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
})
})
})
}
}
})()
@@ -0,0 +1 @@
{"unsafe": true}
@@ -0,0 +1,4 @@
<!doctype html>
<html>
<body>This must not be served as JavaScript.</body>
</html>
@@ -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."
+6 -4
View File
@@ -7,15 +7,17 @@ import subprocess
import sys import sys
import time import time
from collections.abc import Iterator from collections.abc import Iterator
from pathlib import Path
from typing import Any from typing import Any
import httpx import httpx
import pytest import pytest
from tests.wasm_ext.helpers import EXTENSION_ID, SERVER_HOST, LiveLNbitsServer from tests.wasm_ext.helpers import (
EXTENSION_ID,
REPO_ROOT = Path(__file__).resolve().parents[2] REPO_ROOT,
SERVER_HOST,
LiveLNbitsServer,
)
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
+2
View File
@@ -1,9 +1,11 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from typing import Any from typing import Any
EXTENSION_ID = "lnbits-wasm-test-extension" EXTENSION_ID = "lnbits-wasm-test-extension"
REPO_ROOT = Path(__file__).resolve().parents[2]
SERVER_HOST = "127.0.0.1" SERVER_HOST = "127.0.0.1"
+293 -2
View File
@@ -1,11 +1,109 @@
from __future__ import annotations from __future__ import annotations
import json
import re import re
from re import Pattern 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( 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") ).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( def test_frame_token_is_route_bound_required_and_single_use(
page: Page, page: Page,
lnbits_server: LiveLNbitsServer, 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]: 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}}$") 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