Files
lnbits/tests/unit/test_wasm_extension_host_api.py
T
2026-07-16 15:29:56 +03:00

685 lines
20 KiB
Python

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,
StorageAppendPublicRequest,
StorageGetRequest,
StoragePublicPaginatedRequest,
WalletBalanceRequest,
WebsocketPublishRequest,
)
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_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",
"source_id_field": "thread_id",
"public_fields": ["id", "thread_id", "message"],
}
],
)
],
)
response = await api.storage_get_public_paginated(
StoragePublicPaginatedRequest(
table="messages",
filters={},
search="hello",
search_fields=["message"],
sort_by="id",
descending=False,
limit=25,
offset=0,
source_id="thread-1",
)
)
assert json.loads(response.rows_json) == [
{"id": "message-1", "thread_id": "thread-1", "message": "Hello"}
]
assert response.total == 1
storage_mock.assert_awaited_once_with(
"demoext",
"messages",
{"thread_id": "thread-1"},
search="hello",
search_fields=["message"],
sort_by="id",
descending=False,
limit=25,
offset=0,
)
@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",
"source_id_field": "thread_id",
"public_fields": ["id", "message"],
}
],
)
],
)
with pytest.raises(PermissionError, match="non-public fields"):
await api.storage_get_public_paginated(
StoragePublicPaginatedRequest(
table="messages",
filters={"admin_note": "secret"},
search=None,
search_fields=[],
sort_by=None,
descending=False,
limit=25,
offset=0,
source_id="thread-1",
)
)
@pytest.mark.anyio
async def test_host_api_public_paginated_storage_requires_source_policy():
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[
{
"table_name": "messages",
"public_fields": ["id", "message"],
}
],
)
],
)
with pytest.raises(PermissionError, match="source ID field policy"):
await api.storage_get_public_paginated(
StoragePublicPaginatedRequest(
table="messages",
filters={},
search=None,
search_fields=[],
sort_by=None,
descending=False,
limit=25,
offset=0,
source_id="thread-1",
)
)
@pytest.mark.anyio
async def test_host_api_public_paginated_storage_rejects_conflicting_source_filter():
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[
{
"table_name": "messages",
"source_id_field": "thread_id",
"public_fields": ["id", "message"],
}
],
)
],
)
with pytest.raises(PermissionError, match="does not match source_id"):
await api.storage_get_public_paginated(
StoragePublicPaginatedRequest(
table="messages",
filters={"thread_id": "thread-2"},
search=None,
search_fields=[],
sort_by=None,
descending=False,
limit=25,
offset=0,
source_id="thread-1",
)
)
@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
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_public_append_uses_source_owner_and_allowed_fields(
mocker: MockerFixture,
):
owner_mock = mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_get_row_owner_id",
mocker.AsyncMock(return_value="owner-1"),
)
count_mock = mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_count_rows",
mocker.AsyncMock(return_value=0),
)
append_mock = mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_append_public_row",
mocker.AsyncMock(return_value="message-1"),
)
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.append_public",
policies=[
{
"table": "messages",
"source_table": "threads",
"source_id_field": "thread_id",
"allowed_fields": ["name", "message"],
}
],
)
],
)
response = await api.storage_append_public(
StorageAppendPublicRequest(
table="messages",
source_id="thread-1",
data={"name": "Alice", "message": "Hello"},
)
)
assert response.id == "message-1"
owner_mock.assert_awaited_once_with("demoext", "threads", "thread-1")
count_mock.assert_awaited_once_with(
"demoext",
"messages",
{"thread_id": "thread-1"},
owner_id="owner-1",
)
append_mock.assert_awaited_once_with(
"demoext",
"messages",
{"name": "Alice", "message": "Hello", "thread_id": "thread-1"},
"owner-1",
)
@pytest.mark.anyio
async def test_host_api_public_append_rejects_disallowed_fields(
mocker: MockerFixture,
):
mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_get_row_owner_id",
mocker.AsyncMock(return_value="owner-1"),
)
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.append_public",
policies=[
{
"table": "messages",
"source_table": "threads",
"source_id_field": "thread_id",
"allowed_fields": ["message"],
}
],
)
],
)
with pytest.raises(PermissionError, match="disallowed fields"):
await api.storage_append_public(
StorageAppendPublicRequest(
table="messages",
source_id="thread-1",
data={"message": "Hello", "admin": True},
)
)
@pytest.mark.anyio
async def test_host_api_public_append_enforces_row_limit(mocker: MockerFixture):
mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_get_row_owner_id",
mocker.AsyncMock(return_value="owner-1"),
)
mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_count_rows",
mocker.AsyncMock(return_value=1),
)
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.append_public",
policies=[
{
"table": "messages",
"source_table": "threads",
"source_id_field": "thread_id",
"allowed_fields": ["message"],
"max_rows_per_source": 1,
}
],
)
],
)
with pytest.raises(PermissionError, match="limit reached"):
await api.storage_append_public(
StorageAppendPublicRequest(
table="messages",
source_id="thread-1",
data={"message": "Hello"},
)
)
@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="",
)
)