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="", ) )