feat: partial implementation

This commit is contained in:
Vlad Stan
2026-07-16 11:35:04 +03:00
parent 9f317c5edf
commit a25cadb66d
12 changed files with 499 additions and 4 deletions
+77
View File
@@ -16,6 +16,7 @@ from ..storage.crud import (
storage_count_rows, storage_count_rows,
storage_delete_row, storage_delete_row,
storage_get_paginated_rows, storage_get_paginated_rows,
storage_get_public_paginated_rows,
storage_get_public_row, storage_get_public_row,
storage_get_row, storage_get_row,
storage_get_row_owner_id, storage_get_row_owner_id,
@@ -56,8 +57,11 @@ from .models import (
UserWalletSummary, UserWalletSummary,
WalletBalanceRequest, WalletBalanceRequest,
WalletBalanceResponse, WalletBalanceResponse,
WebsocketPublishRequest,
WebsocketPublishResponse,
) )
from .registry import extension_api_method from .registry import extension_api_method
from .websockets import scoped_websocket_item_id
logger = logging.getLogger("lnbits.extensions") logger = logging.getLogger("lnbits.extensions")
PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE = 10_000 PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE = 10_000
@@ -224,6 +228,46 @@ class ExtensionHostAPI:
total=page["total"], total=page["total"],
) )
@extension_api_method(
method_id="storage.get_public_paginated",
namespace="storage",
name="Get paginated public storage rows",
host_name="storage_get_public_paginated",
sdk_name="getPublicPaginated",
description="Get filtered, searched, sorted, paginated public storage rows.",
required_permission="ext.storage.read_public",
require_auth=False,
)
async def storage_get_public_paginated(
self, request: StoragePaginatedRequest
) -> StoragePaginatedResponse:
public_fields = self._public_storage_fields(request.table)
self._validate_public_storage_query_fields(request, public_fields)
page = await storage_get_public_paginated_rows(
self.extension_id,
request.table,
request.filters,
search=request.search,
search_fields=request.search_fields,
sort_by=request.sort_by,
descending=request.descending,
limit=request.limit,
offset=request.offset,
)
return StoragePaginatedResponse(
rows_json=json.dumps(
[
{
field_name: value
for field_name, value in row.items()
if field_name in public_fields
}
for row in page["data"]
]
),
total=page["total"],
)
@extension_api_method( @extension_api_method(
method_id="storage.delete", method_id="storage.delete",
namespace="storage", namespace="storage",
@@ -245,6 +289,25 @@ class ExtensionHostAPI:
) )
return StorageDeleteResponse() return StorageDeleteResponse()
@extension_api_method(
method_id="websocket.publish",
namespace="websocket",
name="Publish websocket message",
host_name="websocket_publish",
sdk_name="publish",
description="Publish a JSON message on an extension-local websocket channel.",
required_permission="websocket.publish",
require_auth=False,
)
async def websocket_publish(
self, request: WebsocketPublishRequest
) -> WebsocketPublishResponse:
from lnbits.core.services import websocket_manager
item_id = scoped_websocket_item_id(self.extension_id, request.item_id)
await websocket_manager.send(item_id, request.data_json)
return WebsocketPublishResponse()
@extension_api_method( @extension_api_method(
method_id="wallet.create_invoice", method_id="wallet.create_invoice",
namespace="wallet", namespace="wallet",
@@ -698,6 +761,20 @@ class ExtensionHostAPI:
raise PermissionError(f"Storage table '{table}' is not publicly readable.") raise PermissionError(f"Storage table '{table}' is not publicly readable.")
def _validate_public_storage_query_fields(
self, request: StoragePaginatedRequest, public_fields: set[str]
) -> None:
query_fields = set(request.filters)
query_fields.update(request.search_fields)
if request.sort_by:
query_fields.add(request.sort_by)
private_fields = sorted(query_fields - public_fields)
if private_fields:
raise PermissionError(
"Public storage query uses non-public fields: "
+ ", ".join(private_fields)
)
async def _public_storage_append_policy( async def _public_storage_append_policy(
self, table: str, source_id: str self, table: str, source_id: str
) -> tuple[dict[str, Any], str]: ) -> tuple[dict[str, Any], str]:
+32
View File
@@ -114,6 +114,38 @@ class StoragePaginatedResponse(BaseModel):
total: int = 0 total: int = 0
class WebsocketPublishRequest(BaseModel):
item_id: str = Field(..., min_length=1, max_length=128)
data: Any = Field(default_factory=dict)
@root_validator(pre=True)
def parse_data_json(cls, values: dict[str, Any]) -> dict[str, Any]:
data_json = values.get("data_json")
if data_json is not None and "data" not in values:
values["data"] = json.loads(data_json)
return values
@root_validator
def validate_data_size(cls, values: dict[str, Any]) -> dict[str, Any]:
data = values.get("data")
try:
encoded = json.dumps(data, separators=(",", ":"))
except TypeError as exc:
raise ValueError("websocket data must be JSON serializable.") from exc
if len(encoded.encode()) > 65536:
raise ValueError("websocket data must not exceed 65536 bytes.")
values["data"] = data
return values
@property
def data_json(self) -> str:
return json.dumps(self.data, separators=(",", ":"))
class WebsocketPublishResponse(BaseModel):
sent: bool = True
class StorageDeleteRequest(BaseModel): class StorageDeleteRequest(BaseModel):
table: str = Field(..., min_length=1, max_length=128) table: str = Field(..., min_length=1, max_length=128)
id: str = Field(..., min_length=1, max_length=512) id: str = Field(..., min_length=1, max_length=512)
+1
View File
@@ -15,6 +15,7 @@ _EXTENSION_RUNTIME_PERMISSION_IDS = {
"wallet.pay_invoice", "wallet.pay_invoice",
"wallet.pay_invoice_background", "wallet.pay_invoice_background",
"wallet.payments.watch", "wallet.payments.watch",
"websocket.subscribe",
} }
_RequestModel = TypeVar("_RequestModel", bound=BaseModel) _RequestModel = TypeVar("_RequestModel", bound=BaseModel)
_ResponseModel = TypeVar("_ResponseModel", bound=BaseModel) _ResponseModel = TypeVar("_ResponseModel", bound=BaseModel)
+17
View File
@@ -0,0 +1,17 @@
from __future__ import annotations
import re
_EXTENSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
_LOCAL_ITEM_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9:_-]{0,127}$")
def scoped_websocket_item_id(extension_id: str, item_id: str) -> str:
if not _EXTENSION_ID_RE.fullmatch(extension_id):
raise ValueError("Extension websocket namespace is invalid.")
if not _LOCAL_ITEM_ID_RE.fullmatch(item_id):
raise ValueError(
"Extension websocket item ID must be 1-128 characters and contain "
"only letters, numbers, colon, underscore, or dash."
)
return f"ext:{extension_id}:{item_id}"
+2
View File
@@ -4,6 +4,7 @@ from .crud import (
storage_count_rows, storage_count_rows,
storage_delete_row, storage_delete_row,
storage_get_paginated_rows, storage_get_paginated_rows,
storage_get_public_paginated_rows,
storage_get_public_row, storage_get_public_row,
storage_get_row, storage_get_row,
storage_get_row_owner_id, storage_get_row_owner_id,
@@ -16,6 +17,7 @@ __all__ = [
"storage_count_rows", "storage_count_rows",
"storage_delete_row", "storage_delete_row",
"storage_get_paginated_rows", "storage_get_paginated_rows",
"storage_get_public_paginated_rows",
"storage_get_public_row", "storage_get_public_row",
"storage_get_row", "storage_get_row",
"storage_get_row_owner_id", "storage_get_row_owner_id",
+44
View File
@@ -191,6 +191,50 @@ async def storage_get_paginated_rows(
} }
async def storage_get_public_paginated_rows(
ext_id: str,
table: str,
filters: dict[str, Any],
*,
search: str | None,
search_fields: list[str],
sort_by: str | None,
descending: bool,
limit: int,
offset: int,
) -> dict[str, Any]:
table_schema = _load_table_schema(ext_id, table)
database = Database(f"ext_{ext_id}")
where_sql, values = _where_sql(
database, table_schema, filters, search, search_fields
)
order_sql = _order_sql(table_schema, sort_by, descending)
count_values = dict(values)
values.update({"limit": min(limit, 1000), "offset": offset})
table_ref = _table_ref_for_schema(ext_id, table)
rows_query = f"""
SELECT * FROM {table_ref}
{where_sql}
{order_sql}
LIMIT :limit
OFFSET :offset
""" # noqa: S608
count_query = f"""
SELECT COUNT(*) AS count FROM {table_ref}
{where_sql}
""" # noqa: S608
async with database.connect() as conn:
rows = await conn.fetchall(rows_query, values)
count_row = await conn.fetchone(count_query, count_values)
return {
"data": [_row_from_db(table_schema, row) for row in rows],
"total": int(count_row["count"]) if count_row else 0,
}
async def storage_delete_row( async def storage_delete_row(
ext_id: str, ext_id: str,
table: str, table: str,
+3
View File
@@ -559,6 +559,9 @@ window.localisation.en = {
extension_permission_http_request_hosts: 'Allowed hosts', extension_permission_http_request_hosts: 'Allowed hosts',
extension_permission_utils_basic: 'Use basic LNbits utilities', extension_permission_utils_basic: 'Use basic LNbits utilities',
extension_permission_ui_camera_scan_qr: 'Scan QR codes', extension_permission_ui_camera_scan_qr: 'Scan QR codes',
extension_permission_websocket: 'Use extension websockets',
extension_permission_websocket_publish: 'Publish websocket messages',
extension_permission_websocket_subscribe: 'Subscribe to websocket messages',
extension_permission_wallet_payments_watch: 'Watch wallet payments', extension_permission_wallet_payments_watch: 'Watch wallet payments',
extension_permission_wallet_create_invoice: 'Create invoices', extension_permission_wallet_create_invoice: 'Create invoices',
extension_permission_wallet_create_invoice_public: extension_permission_wallet_create_invoice_public:
@@ -110,6 +110,9 @@
'extension_permission_warning_wallet_payments_watch' 'extension_permission_warning_wallet_payments_watch'
) )
} }
if (['websocket.publish', 'websocket.subscribe'].includes(permission.id)) {
return mediumRisk(translateFn)
}
if ( if (
[ [
'wallet.list', 'wallet.list',
@@ -143,6 +146,9 @@
'extension.api.request', 'extension.api.request',
'http.request', 'http.request',
'ui.camera.scan_qr', 'ui.camera.scan_qr',
'websocket',
'websocket.publish',
'websocket.subscribe',
'ext.storage.read', 'ext.storage.read',
'ext.storage.write', 'ext.storage.write',
'ext.storage.read_public', 'ext.storage.read_public',
@@ -233,14 +239,26 @@
permissions.length === 2 && permissions.length === 2 &&
permissions.some(permission => permission.id === 'ext.storage.read') && permissions.some(permission => permission.id === 'ext.storage.read') &&
permissions.some(permission => permission.id === 'ext.storage.write') permissions.some(permission => permission.id === 'ext.storage.write')
const isWebsocket =
permissions.every(permission =>
['websocket.publish', 'websocket.subscribe'].includes(permission.id)
) &&
permissions.some(permission => permission.id === 'websocket.publish') &&
permissions.some(permission => permission.id === 'websocket.subscribe')
const descriptions = permissions const descriptions = permissions
.map(permission => permissionManifestDescription(permission)) .map(permission => permissionManifestDescription(permission))
.filter(Boolean) .filter(Boolean)
const item = { const item = {
id: isReadWriteStorage ? 'ext.storage.read_write' : permission.id, id: isReadWriteStorage
? 'ext.storage.read_write'
: isWebsocket
? 'websocket'
: permission.id,
label: isReadWriteStorage label: isReadWriteStorage
? translate(translateFn, 'extension_permission_ext_storage_read_write') ? translate(translateFn, 'extension_permission_ext_storage_read_write')
: permissionLabel(permission, translateFn), : isWebsocket
? translate(translateFn, 'extension_permission_websocket')
: permissionLabel(permission, translateFn),
risk: permissionRisk(permissions, extensions, translateFn), risk: permissionRisk(permissions, extensions, translateFn),
badges: [], badges: [],
descriptions, descriptions,
@@ -298,7 +316,11 @@
const hasReadWriteStorage = const hasReadWriteStorage =
permissionsById.has('ext.storage.read') && permissionsById.has('ext.storage.read') &&
permissionsById.has('ext.storage.write') permissionsById.has('ext.storage.write')
const hasWebsocket =
permissionsById.has('websocket.publish') &&
permissionsById.has('websocket.subscribe')
let addedReadWriteStorage = false let addedReadWriteStorage = false
let addedWebsocket = false
return permissionList return permissionList
.map((permission, index) => { .map((permission, index) => {
@@ -317,6 +339,21 @@
] ]
} }
} }
if (
hasWebsocket &&
['websocket.publish', 'websocket.subscribe'].includes(permission.id)
) {
if (addedWebsocket) return null
addedWebsocket = true
return {
index,
orderId: 'websocket',
permissions: [
permissionsById.get('websocket.publish'),
permissionsById.get('websocket.subscribe')
]
}
}
return { return {
index, index,
orderId: permission.id, orderId: permission.id,
+129 -1
View File
@@ -183,7 +183,8 @@ window.WasmExtensionComponent = {
handleWindowMessage: null, handleWindowMessage: null,
loading: false, loading: false,
loadId: 0, loadId: 0,
paymentSubscriptions: new Map() paymentSubscriptions: new Map(),
websocketSubscriptions: new Map()
} }
}, },
created() { created() {
@@ -357,6 +358,29 @@ window.WasmExtensionComponent = {
) )
}) })
}, },
extensionRoute(path) {
let url
try {
url = new URL(String(path || ''), window.location.origin)
} catch (_error) {
throw new Error('Invalid extension route.')
}
if (url.origin !== window.location.origin) {
throw new Error('Extension route must stay on this server.')
}
const basePath = `/ext/${encodeURIComponent(this.bridge.extensionId)}`
if (
url.pathname !== basePath &&
!url.pathname.startsWith(`${basePath}/`)
) {
throw new Error('Extension route must stay inside this extension.')
}
return `${url.pathname}${url.search}${url.hash}`
},
replaceExtensionRoute(message) {
return this.$router.replace(this.extensionRoute(message.path))
},
async callApi(message) { async callApi(message) {
const method = String(message.method || 'GET').toUpperCase() const method = String(message.method || 'GET').toUpperCase()
const path = String(message.path || '') const path = String(message.path || '')
@@ -844,6 +868,18 @@ window.WasmExtensionComponent = {
isPaymentHash(value) { isPaymentHash(value) {
return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value) return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value)
}, },
isWebsocketItemId(value) {
return (
typeof value === 'string' &&
/^[A-Za-z0-9][A-Za-z0-9:_-]{0,127}$/.test(value)
)
},
scopedWebsocketItemId(itemId) {
if (!this.isWebsocketItemId(itemId)) {
throw new Error('Invalid websocket item ID.')
}
return `ext:${this.bridge.extensionId}:${itemId}`
},
websocketUrl(path) { websocketUrl(path) {
const url = new URL(window.location.href) const url = new URL(window.location.href)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
@@ -874,8 +910,24 @@ window.WasmExtensionComponent = {
this.closePaymentSubscription(subscriptionId) this.closePaymentSubscription(subscriptionId)
} }
}, },
closeWebsocketSubscription(subscriptionId) {
const subscription = this.websocketSubscriptions.get(subscriptionId)
if (!subscription) return
this.websocketSubscriptions.delete(subscriptionId)
try {
subscription.socket.close()
} catch (_error) {}
},
closeWebsocketSubscriptions() {
for (const subscriptionId of Array.from(
this.websocketSubscriptions.keys()
)) {
this.closeWebsocketSubscription(subscriptionId)
}
},
closeBridgePort() { closeBridgePort() {
this.closePaymentSubscriptions() this.closePaymentSubscriptions()
this.closeWebsocketSubscriptions()
this.bridgePort?.close() this.bridgePort?.close()
this.bridgePort = null this.bridgePort = null
}, },
@@ -937,6 +989,55 @@ window.WasmExtensionComponent = {
this.paymentSubscriptions.delete(subscriptionId) this.paymentSubscriptions.delete(subscriptionId)
}) })
}, },
subscribeWebsocket(message) {
if (!this.hasBridgePermission('websocket.subscribe')) {
throw new Error('Extension is missing websocket subscribe permission.')
}
const subscriptionId = String(message.subscriptionId || '')
const itemId = String(message.itemId || '')
if (
!subscriptionId ||
subscriptionId.length > 128 ||
!this.isWebsocketItemId(itemId)
) {
throw new Error('Invalid websocket subscription.')
}
this.closeWebsocketSubscription(subscriptionId)
const scopedItemId = this.scopedWebsocketItemId(itemId)
const socket = new WebSocket(
this.websocketUrl(`/api/v1/ws/${encodeURIComponent(scopedItemId)}`)
)
this.websocketSubscriptions.set(subscriptionId, {itemId, socket})
socket.addEventListener('message', event => {
let data = event.data
try {
data = JSON.parse(event.data)
} catch (_error) {}
this.sendBridgeEvent({
event: 'websocket.message',
subscriptionId,
itemId,
data
})
})
socket.addEventListener('error', () => {
this.sendBridgeEvent({
event: 'websocket.error',
subscriptionId,
itemId
})
this.closeWebsocketSubscription(subscriptionId)
})
socket.addEventListener('close', () => {
this.websocketSubscriptions.delete(subscriptionId)
})
},
async handleBridgeRequest(message, reply) { async handleBridgeRequest(message, reply) {
if (!message || message.type !== 'lnbits-extension:request') return if (!message || message.type !== 'lnbits-extension:request') return
@@ -966,6 +1067,15 @@ window.WasmExtensionComponent = {
return return
} }
if (message.action === 'navigation.replace') {
await this.replaceExtensionRoute(message)
this.sendResponse(reply, message.id, {
ok: true,
data: {ok: true}
})
return
}
if (message.action === 'ui.scan_qr') { if (message.action === 'ui.scan_qr') {
this.sendResponse(reply, message.id, { this.sendResponse(reply, message.id, {
ok: true, ok: true,
@@ -1016,6 +1126,24 @@ window.WasmExtensionComponent = {
return return
} }
if (message.action === 'websocket.subscribe') {
this.subscribeWebsocket(message)
this.sendResponse(reply, message.id, {
ok: true,
data: {ok: true}
})
return
}
if (message.action === 'websocket.unsubscribe') {
this.closeWebsocketSubscription(String(message.subscriptionId || ''))
this.sendResponse(reply, message.id, {
ok: true,
data: {ok: true}
})
return
}
throw new Error('Unknown extension bridge action.') throw new Error('Unknown extension bridge action.')
} catch (error) { } catch (error) {
this.sendResponse(reply, message.id, { this.sendResponse(reply, message.id, {
+8 -1
View File
@@ -14,7 +14,7 @@ def test_wasm_frontend_assets_are_registered_in_component_bundle():
assert "js/components/admin/lnbits-admin-wasm-limit-config.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(): def test_wasm_frontend_bridge_restricts_api_routes_and_realtime_actions():
bridge = (ROOT / "lnbits/static/js/wasm-extension-component.js").read_text( bridge = (ROOT / "lnbits/static/js/wasm-extension-component.js").read_text(
encoding="utf-8" encoding="utf-8"
) )
@@ -22,8 +22,15 @@ def test_wasm_frontend_bridge_restricts_api_routes_and_payment_actions():
assert "allowedApiRoute(method, path)" in bridge assert "allowedApiRoute(method, path)" in bridge
assert "url.origin !== window.location.origin" in bridge assert "url.origin !== window.location.origin" in bridge
assert "Extension API route is not allowed." in bridge assert "Extension API route is not allowed." in bridge
assert "extensionRoute(path)" in bridge
assert "Extension route must stay inside this extension." in bridge
assert "message.action === 'payment.subscribe'" in bridge assert "message.action === 'payment.subscribe'" in bridge
assert "message.action === 'payment.unsubscribe'" in bridge assert "message.action === 'payment.unsubscribe'" in bridge
assert "message.action === 'websocket.subscribe'" in bridge
assert "message.action === 'websocket.unsubscribe'" in bridge
assert "message.action === 'navigation.replace'" in bridge
assert "hasBridgePermission('websocket.subscribe')" in bridge
assert "ext:${this.bridge.extensionId}:${itemId}" in bridge
assert "message.action === 'ui.scan_qr'" in bridge assert "message.action === 'ui.scan_qr'" in bridge
+124
View File
@@ -12,7 +12,9 @@ from lnbits.core.wasm_ext.api.models import (
PayInvoiceRequest, PayInvoiceRequest,
StorageAppendPublicRequest, StorageAppendPublicRequest,
StorageGetRequest, StorageGetRequest,
StoragePaginatedRequest,
WalletBalanceRequest, WalletBalanceRequest,
WebsocketPublishRequest,
) )
from lnbits.exceptions import PaymentError from lnbits.exceptions import PaymentError
from lnbits.helpers import sha256s from lnbits.helpers import sha256s
@@ -49,6 +51,128 @@ async def test_host_api_filters_public_storage_fields(mocker: MockerFixture):
storage_mock.assert_awaited_once_with("demoext", "tips", "tip-1") storage_mock.assert_awaited_once_with("demoext", "tips", "tip-1")
@pytest.mark.anyio
async def test_host_api_filters_public_paginated_storage_rows(
mocker: MockerFixture,
):
storage_mock = mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_get_public_paginated_rows",
mocker.AsyncMock(
return_value={
"data": [
{
"id": "message-1",
"thread_id": "thread-1",
"message": "Hello",
"admin_note": "secret",
}
],
"total": 1,
}
),
)
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[
{
"table_name": "messages",
"public_fields": ["id", "thread_id", "message"],
}
],
)
],
)
response = await api.storage_get_public_paginated(
StoragePaginatedRequest(
table="messages",
filters={"thread_id": "thread-1"},
search="hello",
search_fields=["message"],
sort_by="id",
descending=False,
limit=25,
offset=0,
)
)
assert json.loads(response.rows_json) == [
{"id": "message-1", "thread_id": "thread-1", "message": "Hello"}
]
assert response.total == 1
storage_mock.assert_awaited_once()
@pytest.mark.anyio
async def test_host_api_public_paginated_storage_rejects_private_query_fields():
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[
{
"table_name": "messages",
"public_fields": ["id", "message"],
}
],
)
],
)
with pytest.raises(PermissionError, match="non-public fields"):
await api.storage_get_public_paginated(
StoragePaginatedRequest(
table="messages",
filters={"admin_note": "secret"},
search=None,
search_fields=[],
sort_by=None,
descending=False,
limit=25,
offset=0,
)
)
@pytest.mark.anyio
async def test_host_api_websocket_publish_scopes_item_id(mocker: MockerFixture):
send_mock = mocker.patch(
"lnbits.core.services.websocket_manager.send",
mocker.AsyncMock(),
)
api = ExtensionHostAPI("demoext", ["websocket.publish"])
response = await api.websocket_publish(
WebsocketPublishRequest(
item_id="conversation:abc_123",
data={"message": "Hello"},
)
)
assert response.sent is True
send_mock.assert_awaited_once_with(
"ext:demoext:conversation:abc_123",
'{"message":"Hello"}',
)
@pytest.mark.anyio
async def test_host_api_websocket_publish_rejects_invalid_item_id():
api = ExtensionHostAPI("demoext", ["websocket.publish"])
with pytest.raises(ValueError, match="item ID"):
await api.websocket_publish(
WebsocketPublishRequest(
item_id="../other",
data={"message": "Hello"},
)
)
@pytest.mark.anyio @pytest.mark.anyio
async def test_host_api_storage_requires_owner_context_and_uses_user_hash( async def test_host_api_storage_requires_owner_context_and_uses_user_hash(
mocker: MockerFixture, mocker: MockerFixture,
@@ -269,6 +269,29 @@ def test_validate_wasm_permissions_allows_wallet_payments_watch_permission():
) == [ExtensionPermission(id="wallet.payments.watch")] ) == [ExtensionPermission(id="wallet.payments.watch")]
def test_validate_wasm_permissions_allows_websocket_permissions():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config(
"demoext",
[
{"id": "websocket.publish"},
{"id": "websocket.subscribe"},
],
)
assert validate_wasm_extension_permissions(
ext_info,
[
ExtensionPermission(id="websocket.publish"),
ExtensionPermission(id="websocket.subscribe"),
],
extension_config,
) == [
ExtensionPermission(id="websocket.publish"),
ExtensionPermission(id="websocket.subscribe"),
]
def test_background_payment_grant_lookup_and_policy_coverage(): def test_background_payment_grant_lookup_and_policy_coverage():
permissions = { permissions = {
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION: [ WALLET_PAY_INVOICE_BACKGROUND_PERMISSION: [