diff --git a/Makefile b/Makefile index ff0effae0..283caf9db 100644 --- a/Makefile +++ b/Makefile @@ -66,6 +66,7 @@ test-regtest: LNBITS_DATA_FOLDER="./tests/data" \ PYTHONUNBUFFERED=1 \ DEBUG=true \ + rm -rf ./tests/data \ uv run pytest tests/regtest test-migration: diff --git a/lnbits/core/models/tinyurl.py b/lnbits/core/models/tinyurl.py index a9e4cdff5..af96b60f8 100644 --- a/lnbits/core/models/tinyurl.py +++ b/lnbits/core/models/tinyurl.py @@ -1,4 +1,6 @@ -from pydantic import BaseModel +from datetime import datetime, timezone + +from pydantic import BaseModel, Field class TinyURL(BaseModel): @@ -6,4 +8,4 @@ class TinyURL(BaseModel): url: str endless: bool wallet: str - time: float + time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) diff --git a/tests/api/test_admin_api.py b/tests/api/test_admin_api.py index 8e8dfa03c..29de36fc4 100644 --- a/tests/api/test_admin_api.py +++ b/tests/api/test_admin_api.py @@ -1,6 +1,9 @@ +from pathlib import Path + import pytest from httpx import AsyncClient +from lnbits.server import server_restart from lnbits.settings import Settings @@ -49,3 +52,109 @@ async def test_admin_update_noneditable_settings( headers={"Authorization": f"Bearer {superuser_token}"}, ) assert response.status_code == 400 + + +@pytest.mark.anyio +async def test_admin_audit_monitor_and_test_email( + client: AsyncClient, superuser_token: str, mocker +): + mocker.patch( + "lnbits.core.views.admin_api.get_balance_delta", + mocker.AsyncMock( + return_value={"lnbits_balance_sats": 21, "node_balance_sats": 13} + ), + ) + mocker.patch( + "lnbits.core.views.admin_api.send_email_notification", + mocker.AsyncMock(return_value={"status": "queued"}), + ) + + audit = await client.get( + "/admin/api/v1/audit", + headers={"Authorization": f"Bearer {superuser_token}"}, + ) + assert audit.status_code == 200 + assert audit.json()["lnbits_balance_sats"] == 21 + + monitor = await client.get( + "/admin/api/v1/monitor", + headers={"Authorization": f"Bearer {superuser_token}"}, + ) + assert monitor.status_code == 200 + assert "invoice_listeners" in monitor.json() + + test_email = await client.get( + "/admin/api/v1/testemail", + headers={"Authorization": f"Bearer {superuser_token}"}, + ) + assert test_email.status_code == 200 + assert test_email.json()["status"] == "queued" + + +@pytest.mark.anyio +async def test_admin_partial_reset_restart_and_backup( + client: AsyncClient, + superuser_token: str, + settings: Settings, + tmp_path, +): + response = await client.patch( + "/admin/api/v1/settings", + json={"lnbits_site_title": "PATCHED TITLE"}, + headers={"Authorization": f"Bearer {superuser_token}"}, + ) + assert response.status_code == 200 + assert response.json()["status"] == "Success" + + default_value = await client.get( + "/admin/api/v1/settings/default", + params={"field_name": "lnbits_site_title"}, + headers={"Authorization": f"Bearer {superuser_token}"}, + ) + assert default_value.status_code == 200 + assert "default_value" in default_value.json() + + backup_path = Path("lnbits-backup.zip") + original_data_folder = settings.lnbits_data_folder + + try: + data_folder = tmp_path / "backup_data" + data_folder.mkdir(parents=True, exist_ok=True) + (data_folder / "sample.txt").write_text("backup me") + settings.lnbits_data_folder = str(data_folder) + + backup = await client.get( + "/admin/api/v1/backup", + headers={"Authorization": f"Bearer {superuser_token}"}, + ) + assert backup.status_code == 200 + assert backup.headers["content-type"] == "application/zip" + assert backup.content.startswith(b"PK") + assert backup_path.is_file() + finally: + settings.lnbits_data_folder = original_data_folder + backup_path.unlink(missing_ok=True) + + server_restart.clear() + restart = await client.get( + "/admin/api/v1/restart", + headers={"Authorization": f"Bearer {superuser_token}"}, + ) + assert restart.status_code == 200 + assert restart.json()["status"] == "Success" + assert server_restart.is_set() is True + server_restart.clear() + + +@pytest.mark.anyio +async def test_admin_delete_settings_requires_superuser( + client: AsyncClient, superuser_token: str +): + server_restart.clear() + response = await client.delete( + "/admin/api/v1/settings", + headers={"Authorization": f"Bearer {superuser_token}"}, + ) + assert response.status_code == 200 + assert server_restart.is_set() is True + server_restart.clear() diff --git a/tests/api/test_api.py b/tests/api/test_api.py index a8b80dcfe..2e111360f 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -507,9 +507,9 @@ async def test_api_payment_without_key(invoice: Payment): # check api_payment() internal function call (NOT API): payment status @pytest.mark.anyio -async def test_api_payment_with_key(invoice: Payment, inkey_headers_from): +async def test_api_payment_with_key(invoice: Payment, inkey_headers_to): # check the payment status - response = await api_payment(invoice.payment_hash, inkey_headers_from["X-Api-Key"]) + response = await api_payment(invoice.payment_hash, inkey_headers_to["X-Api-Key"]) assert isinstance(response, dict) assert response["paid"] is True assert "details" in response diff --git a/tests/api/test_asset_api.py b/tests/api/test_asset_api.py new file mode 100644 index 000000000..7707ad92e --- /dev/null +++ b/tests/api/test_asset_api.py @@ -0,0 +1,135 @@ +from uuid import uuid4 + +import pytest +from httpx import AsyncClient + +from lnbits.core.crud.assets import get_user_asset +from lnbits.core.services.assets import create_user_asset +from tests.helpers import get_png_bytes, get_user_token_headers, make_upload_file + + +@pytest.mark.anyio +async def test_asset_api_upload_list_update_and_delete( + client: AsyncClient, + user_headers_from: dict[str, str], +): + upload = await client.post( + "/api/v1/assets?public_asset=false", + headers={"Authorization": user_headers_from["Authorization"]}, + files={"file": ("note.txt", b"hello world", "text/plain")}, + ) + assert upload.status_code == 200 + asset = upload.json() + assert asset["name"] == "note.txt" + assert asset["is_public"] is False + + page = await client.get("/api/v1/assets/paginated", headers=user_headers_from) + assert page.status_code == 200 + assert any(item["id"] == asset["id"] for item in page.json()["data"]) + + info = await client.get(f"/api/v1/assets/{asset['id']}", headers=user_headers_from) + assert info.status_code == 200 + assert info.json()["name"] == "note.txt" + + data = await client.get( + f"/api/v1/assets/{asset['id']}/data", headers=user_headers_from + ) + assert data.status_code == 200 + assert data.content == b"hello world" + assert data.headers["content-disposition"] == 'inline; filename="note.txt"' + + updated = await client.put( + f"/api/v1/assets/{asset['id']}", + headers=user_headers_from, + json={"name": "renamed.txt", "is_public": True}, + ) + assert updated.status_code == 200 + assert updated.json()["name"] == "renamed.txt" + assert updated.json()["is_public"] is True + + public_data = await client.get(f"/api/v1/assets/{asset['id']}/data") + assert public_data.status_code == 200 + assert public_data.content == b"hello world" + + deleted = await client.delete( + f"/api/v1/assets/{asset['id']}", headers=user_headers_from + ) + assert deleted.status_code == 200 + assert deleted.json()["success"] is True + + missing = await client.get( + f"/api/v1/assets/{asset['id']}", headers=user_headers_from + ) + assert missing.status_code == 404 + + +@pytest.mark.anyio +async def test_asset_api_enforces_visibility_and_supports_admin_updates( + client: AsyncClient, + from_user, + to_user, + superuser_token: str, +): + private_asset = await create_user_asset( + from_user.id, + make_upload_file( + get_png_bytes(), + filename=f"private_{uuid4().hex[:8]}.png", + content_type="image/png", + ), + is_public=False, + ) + other_user_headers = await get_user_token_headers(client, to_user.id) + + anonymous = await client.get(f"/api/v1/assets/{private_asset.id}/data") + assert anonymous.status_code == 404 + + wrong_user = await client.get( + f"/api/v1/assets/{private_asset.id}/data", headers=other_user_headers + ) + assert wrong_user.status_code == 404 + + admin_updated = await client.put( + f"/api/v1/assets/{private_asset.id}", + headers={"Authorization": f"Bearer {superuser_token}"}, + json={"is_public": True, "name": "admin-visible.png"}, + ) + assert admin_updated.status_code == 200 + assert admin_updated.json()["is_public"] is True + assert admin_updated.json()["name"] == "admin-visible.png" + + thumbnail = await client.get(f"/api/v1/assets/{private_asset.id}/thumbnail") + assert thumbnail.status_code == 200 + assert thumbnail.content + assert thumbnail.headers["content-type"] == "image/png" + + +@pytest.mark.anyio +async def test_asset_api_validates_uploads_and_missing_assets( + client: AsyncClient, + user_headers_from: dict[str, str], +): + invalid = await client.post( + "/api/v1/assets", + headers={"Authorization": user_headers_from["Authorization"]}, + files={"file": ("payload.exe", b"boom", "application/x-msdownload")}, + ) + assert invalid.status_code == 400 + assert "not allowed" in invalid.json()["detail"] + + missing = await client.delete( + f"/api/v1/assets/{uuid4().hex}", + headers=user_headers_from, + ) + assert missing.status_code == 404 + + missing_thumb = await client.get(f"/api/v1/assets/{uuid4().hex}/thumbnail") + assert missing_thumb.status_code == 404 + + stored = await create_user_asset( + "missing-user-check", + make_upload_file(b"content", filename="content.txt", content_type="text/plain"), + is_public=True, + ) + fetched = await get_user_asset("missing-user-check", stored.id) + assert fetched is not None diff --git a/tests/api/test_audit_api.py b/tests/api/test_audit_api.py new file mode 100644 index 000000000..23080a68b --- /dev/null +++ b/tests/api/test_audit_api.py @@ -0,0 +1,64 @@ +from datetime import datetime, timezone +from uuid import uuid4 + +import pytest +from httpx import AsyncClient + +from lnbits.core.crud.audit import create_audit_entry +from lnbits.core.models import AuditEntry + + +@pytest.mark.anyio +async def test_audit_api_requires_admin(client: AsyncClient, user_headers_from): + response = await client.get("/audit/api/v1", headers=user_headers_from) + assert response.status_code == 403 + + +@pytest.mark.anyio +async def test_audit_api_returns_entries_and_stats( + client: AsyncClient, + superuser_token: str, +): + component = f"audit_component_{uuid4().hex[:8]}" + await create_audit_entry( + AuditEntry( + component=component, + ip_address="127.0.0.1", + user_id=uuid4().hex, + path="/api/v1/test", + request_method="GET", + response_code="200", + duration=0.12, + created_at=datetime.now(timezone.utc), + ) + ) + await create_audit_entry( + AuditEntry( + component=component, + ip_address="127.0.0.2", + user_id=uuid4().hex, + path="/api/v1/test", + request_method="POST", + response_code="400", + duration=2.5, + created_at=datetime.now(timezone.utc), + ) + ) + headers = {"Authorization": f"Bearer {superuser_token}"} + + page = await client.get(f"/audit/api/v1?component={component}", headers=headers) + assert page.status_code == 200 + page_data = page.json() + assert page_data["total"] == 2 + assert {item["request_method"] for item in page_data["data"]} == {"GET", "POST"} + + stats = await client.get( + f"/audit/api/v1/stats?component={component}", + headers=headers, + ) + assert stats.status_code == 200 + payload = stats.json() + assert {item["field"] for item in payload["request_method"]} == {"GET", "POST"} + assert {item["field"] for item in payload["response_code"]} == {"200", "400"} + assert payload["component"][0]["field"] == component + assert payload["long_duration"][0]["field"] == "/api/v1/test" diff --git a/tests/api/test_auth_api.py b/tests/api/test_auth_api.py new file mode 100644 index 000000000..416c91940 --- /dev/null +++ b/tests/api/test_auth_api.py @@ -0,0 +1,161 @@ +from types import SimpleNamespace +from uuid import uuid4 + +import pytest +from fastapi.responses import RedirectResponse +from httpx import AsyncClient + +from lnbits.core.crud.users import get_account, update_account +from lnbits.core.models.users import Account +from lnbits.core.services.users import create_user_account +from lnbits.core.views.auth_api import get_account_by_email +from lnbits.settings import Settings + + +class _FakeSSO: + def __init__(self, userinfo: object | None = None, state: str = ""): + self.userinfo = userinfo + self.state = state + self.redirect_uri: str | None = None + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + async def get_login_redirect(self, state: str): + self.state = state + return RedirectResponse("https://example.com/sso/login") + + async def verify_and_process(self, _request): + return self.userinfo + + +@pytest.mark.anyio +async def test_auth_api_logout_and_update_ui_customization( + http_client: AsyncClient, +): + user = await create_user_account( + Account( + id=uuid4().hex, + username=f"user_{uuid4().hex[:8]}", + email=f"user_{uuid4().hex[:8]}@lnbits.com", + ) + ) + + response = await http_client.patch( + f"/api/v1/auth/ui?usr={user.id}", + json={"theme": "amber", "walletLayout": "grid"}, + ) + assert response.status_code == 200 + assert response.json()["ui_customization"]["theme"] == "amber" + assert response.json()["ui_customization"]["walletLayout"] == "grid" + + logout = await http_client.post("/api/v1/auth/logout") + assert logout.status_code == 200 + assert logout.json()["status"] == "success" + assert "cookie_access_token=" in logout.headers["set-cookie"] + + +@pytest.mark.anyio +async def test_auth_api_sso_login_and_callback(http_client: AsyncClient, mocker): + user = await create_user_account( + Account( + id=uuid4().hex, + username=f"user_{uuid4().hex[:8]}", + email=f"user_{uuid4().hex[:8]}@lnbits.com", + ) + ) + + provider = "github" + login_sso = _FakeSSO() + mocker.patch("lnbits.core.views.auth_api._new_sso", return_value=login_sso) + + response = await http_client.get( + f"/api/v1/auth/{provider}", params={"user_id": user.id} + ) + assert response.status_code == 307 + assert response.headers["location"] == "https://example.com/sso/login" + assert login_sso.redirect_uri == f"{http_client.base_url}/api/v1/auth/github/token" + assert login_sso.state + + email = f"sso_{uuid4().hex[:8]}@lnbits.com" + callback_sso = _FakeSSO(userinfo=SimpleNamespace(email=email), state="") + mocker.patch("lnbits.core.views.auth_api._new_sso", return_value=callback_sso) + + callback = await http_client.get(f"/api/v1/auth/{provider}/token") + assert callback.status_code == 307 + assert callback.headers["location"] == "/wallet" + + account = await get_account_by_email(email, active_only=False) + assert account is not None + assert account.email == email + assert account.extra.email_verified is True + + +@pytest.mark.anyio +async def test_auth_api_first_install_success_and_validation( + http_client: AsyncClient, settings: Settings +): + superuser = await get_account(settings.super_user, active_only=False) + assert superuser is not None + + original_username = superuser.username + original_password_hash = superuser.password_hash + original_first_install = settings.first_install + original_first_install_token = settings.first_install_token + + first_install_token = f"install_{uuid4().hex[:8]}" + new_username = f"reinstall_{uuid4().hex[:8]}" + + try: + settings.first_install = True + settings.first_install_token = first_install_token + + missing_token = await http_client.put( + "/api/v1/auth/first_install", + json={ + "username": new_username, + "password": "secret1234", + "password_repeat": "secret1234", + }, + ) + assert missing_token.status_code == 401 + assert missing_token.json()["detail"] == "Missing first_install_token." + + success = await http_client.put( + "/api/v1/auth/first_install", + json={ + "username": new_username, + "password": "secret1234", + "password_repeat": "secret1234", + "first_install_token": first_install_token, + }, + ) + assert success.status_code == 200 + assert success.json()["access_token"] + + updated_superuser = await get_account(settings.super_user, active_only=False) + assert updated_superuser is not None + assert updated_superuser.username == new_username + assert settings.first_install is False + + forbidden = await http_client.put( + "/api/v1/auth/first_install", + json={ + "username": f"blocked_{uuid4().hex[:8]}", + "password": "secret1234", + "password_repeat": "secret1234", + }, + ) + assert forbidden.status_code == 403 + assert forbidden.json()["detail"] == "This is not your first install" + finally: + restored_superuser = await get_account(settings.super_user, active_only=False) + assert restored_superuser is not None + restored_superuser.username = original_username + restored_superuser.password_hash = original_password_hash + await update_account(restored_superuser) + settings.first_install = original_first_install + settings.first_install_token = original_first_install_token diff --git a/tests/api/test_callback_api.py b/tests/api/test_callback_api.py new file mode 100644 index 000000000..afe5227c9 --- /dev/null +++ b/tests/api/test_callback_api.py @@ -0,0 +1,175 @@ +import json +from uuid import uuid4 + +import pytest +from httpx import AsyncClient + +from lnbits.core.models import Account, CreateInvoice +from lnbits.core.services.payments import create_wallet_invoice +from lnbits.core.services.users import create_user_account +from lnbits.core.views.callback_api import ( + handle_paypal_event, + handle_stripe_event, +) + + +@pytest.mark.anyio +async def test_callback_api_generic_webhook_handler_routes_providers( + http_client: AsyncClient, mocker +): + stripe_mock = mocker.patch( + "lnbits.core.views.callback_api.handle_stripe_event", mocker.AsyncMock() + ) + paypal_mock = mocker.patch( + "lnbits.core.views.callback_api.handle_paypal_event", mocker.AsyncMock() + ) + mocker.patch("lnbits.core.views.callback_api.check_stripe_signature") + mocker.patch( + "lnbits.core.views.callback_api.verify_paypal_webhook", mocker.AsyncMock() + ) + + stripe = await http_client.post( + "/api/v1/callback/stripe", + headers={"Stripe-Signature": "sig"}, + json={"id": "evt_1", "type": "payment_intent.succeeded"}, + ) + assert stripe.status_code == 200 + assert stripe.json()["success"] is True + stripe_mock.assert_awaited_once() + + paypal = await http_client.post( + "/api/v1/callback/paypal", + json={"id": "evt_2", "event_type": "CHECKOUT.ORDER.APPROVED"}, + ) + assert paypal.status_code == 200 + assert paypal.json()["success"] is True + paypal_mock.assert_awaited_once() + + unknown = await http_client.post("/api/v1/callback/unknown", json={"id": "evt_3"}) + assert unknown.status_code == 200 + assert unknown.json()["success"] is False + + +@pytest.mark.anyio +async def test_callback_api_handles_paid_events_with_real_payments(mocker): + user = await create_user_account( + Account( + id=uuid4().hex, + username=f"user_{uuid4().hex[:8]}", + email=f"user_{uuid4().hex[:8]}@lnbits.com", + ) + ) + wallet = user.wallets[0] + payment = await create_wallet_invoice( + wallet.id, CreateInvoice(out=False, amount=11, memo="fiat callback") + ) + + fiat_status_mock = mocker.patch( + "lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock() + ) + + await handle_stripe_event( + { + "id": "evt_stripe", + "type": "payment_intent.succeeded", + "data": { + "object": { + "object": "payment_intent", + "metadata": {"payment_hash": payment.payment_hash}, + } + }, + } + ) + await handle_paypal_event( + { + "id": "evt_paypal", + "event_type": "CHECKOUT.ORDER.APPROVED", + "resource": { + "purchase_units": [{"invoice_id": payment.payment_hash}], + }, + } + ) + await handle_stripe_event({"id": "evt_unhandled", "type": "customer.created"}) + + assert fiat_status_mock.await_count == 2 + + +@pytest.mark.anyio +async def test_callback_api_handles_subscription_flows_and_validation(mocker): + user = await create_user_account( + Account( + id=uuid4().hex, + username=f"user_{uuid4().hex[:8]}", + email=f"user_{uuid4().hex[:8]}@lnbits.com", + ) + ) + wallet = user.wallets[0] + payment = await create_wallet_invoice( + wallet.id, CreateInvoice(out=False, amount=15, memo="subscription") + ) + + create_fiat_invoice_mock = mocker.patch( + "lnbits.core.views.callback_api.create_fiat_invoice", + mocker.AsyncMock(return_value=payment), + ) + fiat_status_mock = mocker.patch( + "lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock() + ) + + await handle_stripe_event( + { + "id": "evt_invoice_paid", + "type": "invoice.paid", + "data": { + "object": { + "id": "invoice_1", + "currency": "usd", + "amount_paid": 500, + "hosted_invoice_url": "https://stripe.example/invoice", + "customer_email": "alice@example.com", + "lines": {"data": [{"description": "Gold Plan"}]}, + "parent": { + "type": "subscription_details", + "subscription_details": { + "metadata": { + "alan_action": "subscription", + "wallet_id": wallet.id, + "tag": "gold", + "memo": "Monthly Gold", + "extra": json.dumps({"plan": "gold"}), + } + }, + }, + } + }, + } + ) + create_fiat_invoice_mock.assert_awaited() + fiat_status_mock.assert_awaited() + + await handle_paypal_event( + { + "id": "evt_sale_completed", + "event_type": "PAYMENT.SALE.COMPLETED", + "resource": { + "id": "sale_1", + "billing_agreement_id": "agreement_1", + "amount": {"currency": "USD", "total": "7.50"}, + "custom_id": json.dumps( + [wallet.id, "vip", "subscription_1", "link-1", "VIP Plan"] + ), + }, + } + ) + assert create_fiat_invoice_mock.await_count == 2 + + with pytest.raises( + ValueError, match="PayPal subscription event missing custom metadata." + ): + await handle_paypal_event( + { + "id": "evt_bad_sale", + "event_type": "PAYMENT.SALE.COMPLETED", + "resource": {"amount": {"currency": "USD", "total": "5.00"}}, + } + ) diff --git a/tests/api/test_extension_api.py b/tests/api/test_extension_api.py new file mode 100644 index 000000000..c3d64ffaa --- /dev/null +++ b/tests/api/test_extension_api.py @@ -0,0 +1,430 @@ +from types import SimpleNamespace +from uuid import uuid4 + +import pytest +from fastapi import HTTPException +from starlette.requests import Request + +from lnbits.core.crud.db_versions import get_db_version, update_migration_version +from lnbits.core.crud.extensions import ( + create_installed_extension, + get_installed_extension, + get_user_extension, +) +from lnbits.core.crud.users import get_account +from lnbits.core.crud.wallets import create_wallet +from lnbits.core.models import Account, CreateInvoice +from lnbits.core.models.extensions import ( + CreateExtension, + CreateExtensionReview, + Extension, + ExtensionConfig, + ExtensionRelease, + InstallableExtension, + PayToEnableInfo, + ReleasePaymentInfo, + UserExtensionInfo, +) +from lnbits.core.models.users import AccountId +from lnbits.core.services.payments import create_wallet_invoice +from lnbits.core.services.users import create_user_account +from lnbits.core.views.extension_api import ( + api_activate_extension, + api_deactivate_extension, + api_disable_extension, + api_enable_extension, + api_extension_details, + api_get_user_extensions, + api_install_extension, + api_uninstall_extension, + api_update_pay_to_enable, + create_extension_review, + delete_extension_db, + extensions, + get_extension_release, + get_extension_releases, + get_extension_reviews, + get_extension_reviews_tags, + get_pay_to_enable_invoice, + get_pay_to_install_invoice, +) +from tests.helpers import make_extension_release, make_installable_extension + + +class _MockHTTPResponse: + def __init__( + self, + *, + json_data=None, + text: str = "", + status_code: int = 200, + is_error: bool = False, + ): + self._json_data = json_data + self.text = text + self.status_code = status_code + self.is_error = is_error + + def json(self): + return self._json_data + + def raise_for_status(self): + if self.status_code >= 400: + raise ValueError(self.text or "request failed") + + +class _MockHTTPClient: + def __init__(self, responses: dict[str, _MockHTTPResponse]): + self.responses = responses + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return False + + async def get(self, url: str): + return self.responses[url] + + async def post(self, url: str, json=None): + return self.responses[url] + + +@pytest.mark.anyio +async def test_extension_api_install_details_and_release_endpoints(mocker): + ext_id = f"ext_{uuid4().hex[:8]}" + release = make_extension_release(ext_id) + create_data = CreateExtension( + ext_id=ext_id, + archive=release.archive, + source_repo=release.source_repo, + version=release.version, + ) + + mocker.patch.object( + InstallableExtension, + "get_extension_release", + mocker.AsyncMock(return_value=release), + ) + mocker.patch( + "lnbits.core.views.extension_api.install_extension", + mocker.AsyncMock(return_value=Extension(code=ext_id, is_valid=True)), + ) + activate_mock = mocker.patch( + "lnbits.core.views.extension_api.activate_extension", mocker.AsyncMock() + ) + + installed = await api_install_extension(create_data) + assert installed.code == ext_id + activate_mock.assert_awaited_once() + + mocker.patch.object( + InstallableExtension, + "get_extension_releases", + mocker.AsyncMock(return_value=[release]), + ) + mocker.patch.object( + ExtensionRelease, + "fetch_release_details", + mocker.AsyncMock(return_value={"description": "Extension details"}), + ) + details = await api_extension_details(ext_id, release.details_link or "") + assert details["description"] == "Extension details" + assert details["icon"] == release.icon + assert details["repo"] == release.repo + + installed_ext = make_installable_extension( + ext_id, + payments=[ + ReleasePaymentInfo( + amount=55, + pay_link=release.pay_link, + payment_hash=f"payment_{uuid4().hex[:8]}", + ) + ], + ) + await create_installed_extension(installed_ext) + releases = await get_extension_releases(ext_id) + assert releases[0].paid_sats == 55 + + config = ExtensionConfig( + name=ext_id, + short_description="Config", + min_lnbits_version="0.1.0", + max_lnbits_version=None, + ) + mocker.patch.object( + ExtensionConfig, + "fetch_github_release_config", + mocker.AsyncMock(return_value=config), + ) + release_info = await get_extension_release("org", ext_id, "v1.0.0") + assert release_info["is_version_compatible"] is True + + +@pytest.mark.anyio +async def test_extension_api_pay_to_enable_and_catalog_views(mocker, admin_user): + regular_user = await create_user_account( + Account( + id=uuid4().hex, + username=f"user_{uuid4().hex[:8]}", + email=f"user_{uuid4().hex[:8]}@lnbits.com", + ) + ) + admin_account = await get_account(admin_user.id) + assert admin_account is not None + admin_wallet = await create_wallet( + user_id=admin_account.id, wallet_name="extension sales" + ) + + ext_id = f"paid_{uuid4().hex[:8]}" + await create_installed_extension( + make_installable_extension( + ext_id, + pay_to_enable=PayToEnableInfo( + required=True, amount=10, wallet=admin_wallet.id + ), + ) + ) + + updated = await api_update_pay_to_enable( + ext_id, + PayToEnableInfo(required=True, amount=21, wallet=admin_wallet.id), + account=admin_account, + ) + assert updated.success is True + stored_extension = await get_installed_extension(ext_id) + assert stored_extension is not None + assert stored_extension.meta is not None + assert stored_extension.meta.pay_to_enable is not None + assert stored_extension.meta.pay_to_enable.amount == 21 + + enable_invoice = await create_wallet_invoice( + admin_wallet.id, CreateInvoice(out=False, amount=21, memo="enable extension") + ) + mocker.patch( + "lnbits.core.views.extension_api.create_invoice", + mocker.AsyncMock(return_value=enable_invoice), + ) + invoice_response = await get_pay_to_enable_invoice( + ext_id, + PayToEnableInfo(amount=21), + account_id=AccountId(id=regular_user.id), + ) + assert invoice_response["payment_hash"] == enable_invoice.payment_hash + + user_ext = await get_user_extension(regular_user.id, ext_id) + assert user_ext is not None + assert user_ext.extra is not None + assert user_ext.extra.payment_hash_to_enable == enable_invoice.payment_hash + + mocker.patch( + "lnbits.core.views.extension_api.get_valid_extensions", + mocker.AsyncMock(return_value=[Extension(code=ext_id, is_valid=True)]), + ) + mocker.patch( + "lnbits.core.views.extension_api.check_transaction_status", + mocker.AsyncMock(return_value=SimpleNamespace(paid=True)), + ) + + enabled = await api_enable_extension(ext_id, AccountId(id=regular_user.id)) + assert enabled.success is True + user_ext = await get_user_extension(regular_user.id, ext_id) + assert user_ext is not None + assert user_ext.active is True + assert user_ext.extra == UserExtensionInfo( + payment_hash_to_enable=enable_invoice.payment_hash, + paid_to_enable=True, + ) + + disabled = await api_disable_extension(ext_id, AccountId(id=regular_user.id)) + assert disabled.success is True + disabled_again = await api_disable_extension(ext_id, AccountId(id=regular_user.id)) + assert disabled_again.success is True + assert "already disabled" in disabled_again.message + + mocker.patch( + "lnbits.core.views.extension_api.get_valid_extensions", + mocker.AsyncMock( + return_value=[ + Extension(code=ext_id, is_valid=True, name="Paid Extension"), + Extension(code="other", is_valid=True), + ] + ), + ) + visible_extensions = await api_get_user_extensions(AccountId(id=regular_user.id)) + assert [ext.code for ext in visible_extensions] == [ext_id] + + catalog_entry = make_installable_extension( + ext_id, + pay_to_enable=PayToEnableInfo(required=True, amount=21, wallet=admin_wallet.id), + ) + mocker.patch.object( + InstallableExtension, + "get_installable_extensions", + mocker.AsyncMock(return_value=[catalog_entry]), + ) + catalog = await extensions(AccountId(id=regular_user.id)) + catalog_item = next(item for item in catalog if item["id"] == ext_id) + assert catalog_item["payToEnable"]["wallet"] is None + + +@pytest.mark.anyio +async def test_extension_api_activate_uninstall_install_invoice_and_cleanup(mocker): + base_ext = f"base_{uuid4().hex[:8]}" + dependent_ext = f"dependent_{uuid4().hex[:8]}" + uninstall_ext = f"uninstall_{uuid4().hex[:8]}" + db_ext = f"db_{uuid4().hex[:8]}" + + await create_installed_extension(make_installable_extension(base_ext)) + await create_installed_extension( + make_installable_extension(dependent_ext, dependencies=[base_ext]) + ) + await create_installed_extension(make_installable_extension(uninstall_ext)) + + mocker.patch( + "lnbits.core.views.extension_api.get_valid_extensions", + mocker.AsyncMock( + return_value=[ + Extension(code=base_ext, is_valid=True, name="Base"), + Extension(code=dependent_ext, is_valid=True, name="Dependent"), + Extension(code=uninstall_ext, is_valid=True, name="Remove"), + ] + ), + ) + + with pytest.raises(HTTPException, match="depends on this one"): + await api_uninstall_extension(base_ext) + + uninstall_mock = mocker.patch( + "lnbits.core.views.extension_api.uninstall_extension", mocker.AsyncMock() + ) + uninstalled = await api_uninstall_extension(uninstall_ext) + assert uninstalled.success is True + uninstall_mock.assert_awaited_once_with(uninstall_ext) + + mocker.patch( + "lnbits.core.views.extension_api.get_valid_extension", + mocker.AsyncMock(return_value=Extension(code=base_ext, is_valid=True)), + ) + activate_mock = mocker.patch( + "lnbits.core.views.extension_api.activate_extension", mocker.AsyncMock() + ) + deactivate_mock = mocker.patch( + "lnbits.core.views.extension_api.deactivate_extension", mocker.AsyncMock() + ) + activated = await api_activate_extension(base_ext) + assert activated.success is True + deactivated = await api_deactivate_extension(base_ext) + assert deactivated.success is True + activate_mock.assert_awaited_once() + deactivate_mock.assert_awaited_once() + + owner = await create_user_account( + Account( + id=uuid4().hex, + username=f"user_{uuid4().hex[:8]}", + email=f"user_{uuid4().hex[:8]}@lnbits.com", + ) + ) + wallet = owner.wallets[0] + install_invoice = await create_wallet_invoice( + wallet.id, CreateInvoice(out=False, amount=33, memo="install extension") + ) + release = make_extension_release(base_ext, version="2.0.0") + payment_info = ReleasePaymentInfo( + amount=33, + pay_link=release.pay_link, + payment_hash=install_invoice.payment_hash, + payment_request=install_invoice.bolt11, + ) + mocker.patch.object( + InstallableExtension, + "get_extension_release", + mocker.AsyncMock(return_value=release), + ) + mocker.patch.object( + ExtensionRelease, + "fetch_release_payment_info", + mocker.AsyncMock(return_value=payment_info), + ) + invoice = await get_pay_to_install_invoice( + base_ext, + CreateExtension( + ext_id=base_ext, + archive=release.archive, + source_repo=release.source_repo, + version=release.version, + cost_sats=33, + ), + ) + assert invoice.payment_hash == install_invoice.payment_hash + + await update_migration_version(None, db_ext, 1) + drop_mock = mocker.patch( + "lnbits.core.views.extension_api.drop_extension_db", mocker.AsyncMock() + ) + deleted = await delete_extension_db(db_ext) + assert deleted.success is True + drop_mock.assert_awaited_once_with(ext_id=db_ext) + assert await get_db_version(db_ext) is None + + +@pytest.mark.anyio +async def test_extension_api_review_endpoints(mocker): + ext_id = f"review_{uuid4().hex[:8]}" + reviews_base = "https://demo.lnbits.com/paidreviews/api/v1/AdFzLjzuKFLsdk4Bcnff6r" + tags_url = f"{reviews_base}/tags" + reviews_url = f"{reviews_base}/reviews/{ext_id}?offset=0&limit=5" + create_review_url = f"{reviews_base}/reviews" + request = Request( + { + "type": "http", + "method": "GET", + "path": f"/api/v1/extension/reviews/{ext_id}", + "query_string": b"offset=0&limit=5", + "headers": [], + } + ) + mock_client = _MockHTTPClient( + { + tags_url: _MockHTTPResponse( + json_data=[{"tag": "good", "avg_rating": 900, "review_count": 3}] + ), + reviews_url: _MockHTTPResponse( + json_data={ + "data": [ + { + "id": "1", + "name": "Alice", + "tag": "good", + "rating": 950, + "comment": "solid", + } + ], + "total": 1, + } + ), + create_review_url: _MockHTTPResponse( + json_data={ + "payment_hash": f"hash_{uuid4().hex[:8]}", + "payment_request": "lnbc1review", + } + ), + } + ) + mocker.patch( + "lnbits.core.views.extension_api.httpx.AsyncClient", return_value=mock_client + ) + + tags = await get_extension_reviews_tags() + assert tags[0].tag == "good" + + reviews = await get_extension_reviews(ext_id, request) + assert reviews.total == 1 + assert reviews.data[0].comment == "solid" + + payment_request = await create_extension_review( + CreateExtensionReview(tag=ext_id, name="Alice", rating=900, comment="Great") + ) + assert payment_request.payment_hash.startswith("hash_") diff --git a/tests/api/test_extensions_builder_api.py b/tests/api/test_extensions_builder_api.py new file mode 100644 index 000000000..80e1cf620 --- /dev/null +++ b/tests/api/test_extensions_builder_api.py @@ -0,0 +1,109 @@ +from pathlib import Path +from uuid import uuid4 + +import pytest + +from lnbits.core.crud.extensions import create_user_extension, get_user_extension +from lnbits.core.crud.users import get_account +from lnbits.core.models.extensions import ( + Extension, + UserExtension, +) +from lnbits.core.models.users import AccountId +from lnbits.core.views.extensions_builder_api import ( + api_build_extension, + api_delete_extension_builder_data, + api_deploy_extension, + api_preview_extension, +) +from lnbits.settings import Settings +from tests.helpers import make_extension_data, make_extension_release + + +@pytest.mark.anyio +async def test_extensions_builder_api_build_preview_and_cleanup( + tmp_path, settings: Settings, mocker, from_user +): + ext_id = f"builder_{uuid4().hex[:8]}" + data = make_extension_data(ext_id) + build_dir = tmp_path / "build" + build_dir.mkdir(parents=True, exist_ok=True) + (build_dir / "index.txt").write_text("hello") + + original_data_folder = settings.lnbits_data_folder + build_mock = mocker.patch( + "lnbits.core.views.extensions_builder_api.build_extension_from_data", + mocker.AsyncMock( + return_value=(make_extension_release(ext_id, "0.1.0"), build_dir) + ), + ) + clean_mock = mocker.patch( + "lnbits.core.views.extensions_builder_api.clean_extension_builder_data" + ) + + try: + settings.lnbits_data_folder = str(tmp_path) + + build_response = await api_build_extension(data) + assert Path(build_response.path).is_file() + assert build_response.filename == f"{ext_id}.zip" + + preview = await api_preview_extension(data, AccountId(id=from_user.id)) + assert preview.success is True + assert ext_id in preview.message + + cleaned = await api_delete_extension_builder_data() + assert cleaned.success is True + clean_mock.assert_called_once() + assert build_mock.await_count == 2 + finally: + settings.lnbits_data_folder = original_data_folder + + +@pytest.mark.anyio +async def test_extensions_builder_api_deploy_updates_user_extension( + tmp_path, settings: Settings, mocker, admin_user +): + ext_id = f"deploy_{uuid4().hex[:8]}" + data = make_extension_data(ext_id) + account = await get_account(admin_user.id) + assert account is not None + + build_root = tmp_path / "deploy-root" / ext_id + build_root.mkdir(parents=True, exist_ok=True) + (build_root / "manifest.json").write_text("{}") + + original_data_folder = settings.lnbits_data_folder + await create_user_extension( + UserExtension(user=account.id, extension=ext_id, active=False) + ) + + mocker.patch( + "lnbits.core.views.extensions_builder_api.build_extension_from_data", + mocker.AsyncMock( + return_value=(make_extension_release(ext_id, "0.1.0"), build_root) + ), + ) + install_mock = mocker.patch( + "lnbits.core.views.extensions_builder_api.install_extension", + mocker.AsyncMock(return_value=Extension(code=ext_id, is_valid=True)), + ) + activate_mock = mocker.patch( + "lnbits.core.views.extensions_builder_api.activate_extension", + mocker.AsyncMock(), + ) + + try: + settings.lnbits_data_folder = str(tmp_path) + deployed = await api_deploy_extension(data, account=account) + finally: + settings.lnbits_data_folder = original_data_folder + + assert deployed.success is True + assert ext_id in deployed.message + install_mock.assert_awaited_once() + activate_mock.assert_awaited_once() + + user_ext = await get_user_extension(account.id, ext_id) + assert user_ext is not None + assert user_ext.active is True diff --git a/tests/api/test_fiat_api.py b/tests/api/test_fiat_api.py new file mode 100644 index 000000000..ba3dadbe1 --- /dev/null +++ b/tests/api/test_fiat_api.py @@ -0,0 +1,146 @@ +import pytest +from httpx import AsyncClient +from pytest_mock.plugin import MockerFixture + +from lnbits.core.models.misc import SimpleStatus +from lnbits.fiat.base import FiatSubscriptionResponse + + +class _UnsetSecret: + pass + + +UNSET_SECRET = _UnsetSecret() + + +class FakeStripeWallet: + def __init__(self, secret: str | None | _UnsetSecret = UNSET_SECRET): + self._secret = ( + "connection-token" if isinstance(secret, _UnsetSecret) else secret + ) + + async def create_terminal_connection_token(self) -> dict[str, str]: + if self._secret is None: + return {} + return {"secret": self._secret} + + +@pytest.mark.anyio +async def test_fiat_api_test_provider_and_subscription_lifecycle( + client: AsyncClient, + superuser_token: str, + adminkey_headers_from: dict[str, str], + from_wallet, + mocker: MockerFixture, +): + test_connection = mocker.patch( + "lnbits.core.views.fiat_api.test_connection", + mocker.AsyncMock(return_value=SimpleStatus(success=True, message="ok")), + ) + response = await client.put( + "/api/v1/fiat/check/stripe", + headers={"Authorization": f"Bearer {superuser_token}"}, + ) + assert response.status_code == 200 + assert response.json()["success"] is True + test_connection.assert_awaited_once_with("stripe") + + provider = mocker.Mock() + provider.create_subscription = mocker.AsyncMock( + return_value=FiatSubscriptionResponse( + ok=True, + subscription_request_id="sub-1", + checkout_session_url="https://stripe.example/checkout", + ) + ) + provider.cancel_subscription = mocker.AsyncMock( + return_value=FiatSubscriptionResponse(ok=True, subscription_request_id="sub-1") + ) + get_provider = mocker.patch( + "lnbits.core.views.fiat_api.get_fiat_provider", + mocker.AsyncMock(return_value=provider), + ) + + mismatch = await client.post( + "/api/v1/fiat/stripe/subscription", + headers=adminkey_headers_from, + json={ + "subscription_id": "sub-1", + "quantity": 2, + "payment_options": {"wallet_id": "wrong-wallet"}, + }, + ) + assert mismatch.status_code == 403 + + created = await client.post( + "/api/v1/fiat/stripe/subscription", + headers=adminkey_headers_from, + json={ + "subscription_id": "sub-1", + "quantity": 2, + "payment_options": {"memo": "hello", "wallet_id": from_wallet.id}, + }, + ) + assert created.status_code == 200 + assert created.json()["checkout_session_url"] == "https://stripe.example/checkout" + provider.create_subscription.assert_awaited_once() + assert provider.create_subscription.await_args is not None + assert provider.create_subscription.await_args.args[2].wallet_id == from_wallet.id + + cancelled = await client.delete( + "/api/v1/fiat/stripe/subscription/sub-1", + headers=adminkey_headers_from, + ) + assert cancelled.status_code == 200 + provider.cancel_subscription.assert_awaited_once_with("sub-1", from_wallet.id) + assert get_provider.await_count == 3 + + +@pytest.mark.anyio +async def test_fiat_api_connection_token_validates_provider_configuration( + client: AsyncClient, + superuser_token: str, + mocker: MockerFixture, +): + headers = {"Authorization": f"Bearer {superuser_token}"} + + not_found = mocker.patch( + "lnbits.core.views.fiat_api.get_fiat_provider", + mocker.AsyncMock(return_value=None), + ) + missing = await client.post("/api/v1/fiat/stripe/connection_token", headers=headers) + assert missing.status_code == 404 + assert not_found.await_count == 1 + + unsupported_provider = mocker.patch( + "lnbits.core.views.fiat_api.get_fiat_provider", + mocker.AsyncMock(return_value=object()), + ) + unsupported = await client.post( + "/api/v1/fiat/paypal/connection_token", headers=headers + ) + assert unsupported.status_code == 400 + assert unsupported_provider.await_count == 1 + + mocker.patch("lnbits.core.views.fiat_api.StripeWallet", FakeStripeWallet) + bad_wallet = FakeStripeWallet(secret=None) + bad_provider = mocker.patch( + "lnbits.core.views.fiat_api.get_fiat_provider", + mocker.AsyncMock(return_value=bad_wallet), + ) + no_secret = await client.post( + "/api/v1/fiat/stripe/connection_token", headers=headers + ) + assert no_secret.status_code == 500 + assert no_secret.json()["detail"] == "Failed to create connection token" + assert bad_provider.await_count == 1 + + good_wallet = FakeStripeWallet(secret="tok_live") + good_provider = mocker.patch( + "lnbits.core.views.fiat_api.get_fiat_provider", + mocker.AsyncMock(return_value=good_wallet), + ) + ok = await client.post("/api/v1/fiat/stripe/connection_token", headers=headers) + assert ok.status_code == 200 + assert ok.json() == {"secret": "tok_live"} + assert good_provider.await_count == 1 diff --git a/tests/api/test_lnurl_api.py b/tests/api/test_lnurl_api.py new file mode 100644 index 000000000..5cbdba40a --- /dev/null +++ b/tests/api/test_lnurl_api.py @@ -0,0 +1,142 @@ +from uuid import uuid4 + +import pytest +from fastapi import HTTPException +from lnurl import ( + LnAddress, + LnurlAuthResponse, + LnurlErrorResponse, + LnurlException, + LnurlPayActionResponse, + LnurlPayResponse, + LnurlResponseException, +) +from lnurl.models import MessageAction +from lnurl.types import CallbackUrl, LightningInvoice +from pydantic import parse_obj_as + +from lnbits.core.models import Account, CreateInvoice +from lnbits.core.models.lnurl import CreateLnurlPayment, LnurlScan +from lnbits.core.models.wallets import KeyType, WalletTypeInfo +from lnbits.core.services.payments import create_wallet_invoice +from lnbits.core.services.users import create_user_account +from lnbits.core.views.lnurl_api import ( + api_lnurlscan, + api_lnurlscan_post, + api_payments_pay_lnurl, + api_perform_lnurlauth, +) +from tests.helpers import make_lnurl_pay_response + +TEST_BOLT11 = ( + "lnbc1pnsu5z3pp57getmdaxhg5kc9yh2a2qsh7cjf4gnccgkw0qenm8vsqv50w7s" + "ygqdqj0fjhymeqv9kk7atwwscqzzsxqyz5vqsp5e2yyqcp0a3ujeesp24ya0glej" + "srh703md8mrx0g2lyvjxy5w27ss9qxpqysgqyjreasng8a086kpkczv48er5c6l5" + "73aym6ynrdl9nkzqnag49vt3sjjn8qdfq5cr6ha0vrdz5c5r3v4aghndly0hplmv" + "6hjxepwp93cq398l3s" +) + + +@pytest.mark.anyio +async def test_lnurl_api_scan_routes_validate_and_forward(mocker): + pay_response = make_lnurl_pay_response() + mocker.patch( + "lnbits.core.views.lnurl_api.lnurl_handle", + mocker.AsyncMock(return_value=pay_response), + ) + + scanned = await api_lnurlscan("lnurl1example") + assert isinstance(scanned, LnurlPayResponse) + assert scanned.callback == pay_response.callback + + scanned_post = await api_lnurlscan_post( + scan=LnurlScan(lnurl=LnAddress("alice@example.com")) + ) + assert isinstance(scanned_post, LnurlPayResponse) + assert scanned_post.callback == pay_response.callback + + mocker.patch( + "lnbits.core.views.lnurl_api.lnurl_handle", + mocker.AsyncMock(return_value=LnurlErrorResponse(reason="blocked callback")), + ) + with pytest.raises(HTTPException, match="blocked callback"): + await api_lnurlscan("lnurl1blocked") + + mocker.patch( + "lnbits.core.views.lnurl_api.lnurl_handle", + mocker.AsyncMock(side_effect=LnurlException("invalid lnurl")), + ) + with pytest.raises(HTTPException, match="invalid lnurl"): + await api_lnurlscan("lnurl1invalid") + + +@pytest.mark.anyio +async def test_lnurl_api_auth_and_pay_flow(mocker): + user = await create_user_account( + Account( + id=uuid4().hex, + username=f"user_{uuid4().hex[:8]}", + email=f"user_{uuid4().hex[:8]}@lnbits.com", + ) + ) + wallet = user.wallets[0] + wallet_info = WalletTypeInfo(key_type=KeyType.admin, wallet=wallet) + pay_response = make_lnurl_pay_response() + payment = await create_wallet_invoice( + wallet.id, CreateInvoice(out=False, amount=21, memo="lnurl") + ) + + auth_response = LnurlAuthResponse( + callback=parse_obj_as(CallbackUrl, "https://example.com/auth"), + k1="k1-value", + ) + mocker.patch( + "lnbits.core.views.lnurl_api.lnurlauth", + mocker.AsyncMock(return_value=auth_response), + ) + authenticated = await api_perform_lnurlauth(auth_response, wallet_info) + assert isinstance(authenticated, LnurlAuthResponse) + assert authenticated.k1 == "k1-value" + + mocker.patch( + "lnbits.core.views.lnurl_api.lnurlauth", + mocker.AsyncMock(side_effect=LnurlResponseException("denied")), + ) + with pytest.raises(HTTPException, match="denied"): + await api_perform_lnurlauth(auth_response, wallet_info) + + action_response = LnurlPayActionResponse( + pr=LightningInvoice(TEST_BOLT11), + disposable=False, + successAction=parse_obj_as(MessageAction, {"message": "paid"}), + ) + fetch_mock = mocker.patch( + "lnbits.core.views.lnurl_api.fetch_lnurl_pay_request", + mocker.AsyncMock(return_value=(pay_response, action_response)), + ) + pay_mock = mocker.patch( + "lnbits.core.views.lnurl_api.pay_invoice", + mocker.AsyncMock(return_value=payment), + ) + + paid = await api_payments_pay_lnurl( + CreateLnurlPayment( + res=pay_response, amount=2_000, unit="USD", comment="thanks" + ), + wallet_info, + ) + assert paid.payment_hash == payment.payment_hash + fetch_mock.assert_awaited_once() + pay_mock.assert_awaited_once() + assert pay_mock.await_args is not None + assert action_response.successAction is not None + assert pay_mock.await_args.kwargs["extra"] == { + "stored": True, + "success_action": action_response.successAction.json(), + "comment": "thanks", + "fiat_currency": "USD", + "fiat_amount": 2.0, + } + + with pytest.raises(HTTPException, match="Missing LNURL or LnurlPayResponse data."): + await api_payments_pay_lnurl(CreateLnurlPayment(amount=1), wallet_info) diff --git a/tests/api/test_node_api.py b/tests/api/test_node_api.py new file mode 100644 index 000000000..158c2db14 --- /dev/null +++ b/tests/api/test_node_api.py @@ -0,0 +1,366 @@ +from collections.abc import Callable +from typing import Any, cast +from uuid import uuid4 + +import httpx +import pytest +from fastapi import HTTPException +from pytest_mock.plugin import MockerFixture + +from lnbits.core.views import node_api +from lnbits.db import Filters, Page +from lnbits.nodes.base import ( + ChannelBalance, + ChannelPoint, + ChannelState, + ChannelStats, + Node, + NodeChannel, + NodeFees, + NodeInfoResponse, + NodeInvoice, + NodePayment, + NodePeerInfo, + PublicNodeInfo, +) +from lnbits.settings import Settings +from lnbits.wallets.base import Feature + + +class FakeNode: + def __init__(self): + self.channel = NodeChannel( + id="chan-1", + short_id="123x1x0", + peer_id="peer-1", + name="Peer One", + color="#ffffff", + state=ChannelState.ACTIVE, + balance=ChannelBalance(local_msat=1000, remote_msat=2000, total_msat=3000), + point=ChannelPoint(funding_txid="ab" * 32, output_index=1), + fee_ppm=10, + fee_base_msat=1000, + ) + self.peer = NodePeerInfo(id="peer-1", alias="Peer One", addresses=["127.0.0.1"]) + self.info = NodeInfoResponse( + id="node-id", + backend_name="FakeNode", + alias="Fake Alias", + color="#ffffff", + num_peers=1, + blockheight=1, + channel_stats=ChannelStats( + counts={ChannelState.ACTIVE: 1}, + avg_size=3000, + biggest_size=3000, + smallest_size=3000, + total_capacity=3000, + ), + addresses=["127.0.0.1:9735"], + onchain_balance_sat=1, + onchain_confirmed_sat=1, + fees=NodeFees(total_msat=0), + balance_msat=3000, + ) + self.fees_updated: tuple[str, int | None, int | None] | None = None + + async def get_public_info(self) -> PublicNodeInfo: + return PublicNodeInfo(**self.info.dict()) + + async def get_info(self) -> NodeInfoResponse: + return self.info + + async def get_channels(self) -> list[NodeChannel]: + return [self.channel] + + async def get_channel(self, channel_id: str) -> NodeChannel | None: + return self.channel if channel_id == self.channel.id else None + + async def open_channel( + self, + peer_id: str, + funding_amount: int, + push_amount: int | None = None, + fee_rate: int | None = None, + ) -> ChannelPoint: + assert peer_id == "peer-1" + assert funding_amount == 10_000 + assert push_amount == 100 + assert fee_rate == 5 + return ChannelPoint(funding_txid="cd" * 32, output_index=0) + + async def close_channel( + self, + short_id: str | None = None, + point: ChannelPoint | None = None, + force: bool = False, + ) -> list[NodeChannel]: + assert short_id == self.channel.short_id + assert point is None + assert force is True + return [self.channel] + + async def set_channel_fee( + self, channel_id: str, fee_base_msat: int | None, fee_ppm: int | None + ) -> None: + self.fees_updated = (channel_id, fee_base_msat, fee_ppm) + + async def get_payments(self, filters: Filters[Any]) -> Page[NodePayment]: + return Page( + data=[ + NodePayment( + pending=False, + amount=1, + fee=0, + memo="payment", + time=1, + preimage="11" * 32, + payment_hash="22" * 32, + ) + ], + total=1, + ) + + async def get_invoices(self, filters: Filters[Any]) -> Page[NodeInvoice]: + return Page( + data=[ + NodeInvoice( + pending=False, + amount=1, + memo="invoice", + bolt11="lnbc1dummy", + preimage="11" * 32, + payment_hash="33" * 32, + ) + ], + total=1, + ) + + async def get_peers(self) -> list[NodePeerInfo]: + return [self.peer] + + async def connect_peer(self, uri: str) -> dict[str, str]: + return {"uri": uri} + + async def disconnect_peer(self, peer_id: str) -> dict[str, str]: + return {"peer_id": peer_id} + + async def get_id(self) -> str: + return "fake-node-id" + + +class FakeFundingSource: + def __init__( + self, + features: list[Feature], + node_factory: Callable[[Any], Any] | None, + ): + self.features = features + self.__node_cls__ = node_factory + + +class MockHTTPResponse: + def __init__( + self, json_data: dict[str, Any], status_error: Exception | None = None + ): + self._json_data = json_data + self._status_error = status_error + + def raise_for_status(self) -> None: + if self._status_error: + raise self._status_error + + def json(self) -> dict[str, Any]: + return self._json_data + + +class MockHTTPClient: + def __init__(self, response: MockHTTPResponse): + self.response = response + self.calls: list[str] = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def get(self, url: str, timeout: int): + self.calls.append(url) + return self.response + + +@pytest.mark.anyio +async def test_node_api_dependency_guards(settings: Settings, mocker: MockerFixture): + original_node_ui = settings.lnbits_node_ui + original_public = settings.lnbits_public_node_ui + try: + settings.lnbits_node_ui = True + funding_source = FakeFundingSource([], None) + mocker.patch( + "lnbits.core.views.node_api.get_funding_source", + return_value=funding_source, + ) + with pytest.raises(HTTPException) as excinfo: + node_api.require_node() + assert excinfo.value.status_code == 501 + + node_enabled_source = FakeFundingSource( + [Feature.nodemanager], lambda wallet: "fake-node" + ) + mocker.patch( + "lnbits.core.views.node_api.get_funding_source", + return_value=node_enabled_source, + ) + settings.lnbits_node_ui = False + with pytest.raises(HTTPException) as disabled: + node_api.require_node() + assert disabled.value.status_code == 503 + + settings.lnbits_node_ui = True + assert node_api.require_node() == "fake-node" + + settings.lnbits_public_node_ui = False + with pytest.raises(HTTPException) as public_disabled: + node_api.check_public() + assert public_disabled.value.status_code == 503 + finally: + settings.lnbits_node_ui = original_node_ui + settings.lnbits_public_node_ui = original_public + + +@pytest.mark.anyio +async def test_node_api_route_functions_with_fake_node( + settings: Settings, + mocker: MockerFixture, +): + fake_node = FakeNode() + node = cast(Node, fake_node) + original_transactions = settings.lnbits_node_ui_transactions + settings.lnbits_node_ui_transactions = True + rank_response = MockHTTPResponse( + { + "noderank": { + "capacity": 1, + "channelcount": 2, + "age": 3, + "growth": 4, + "availability": 5, + } + } + ) + mocker.patch( + "lnbits.core.views.node_api.httpx.AsyncClient", + return_value=MockHTTPClient(rank_response), + ) + + try: + assert await node_api.api_get_ok() is None + + public_info = await node_api.api_get_public_info(node=node) + assert public_info.backend_name == "FakeNode" + + info = await node_api.api_get_info(node=node) + assert info is not None + assert info.id == "node-id" + + channels = await node_api.api_get_channels(node=node) + assert channels is not None + assert channels[0].id == "chan-1" + + channel = await node_api.api_get_channel("chan-1", node=node) + assert channel is not None + assert channel.peer_id == "peer-1" + + created = await node_api.api_create_channel( + node=node, + peer_id="peer-1", + funding_amount=10_000, + push_amount=100, + fee_rate=5, + ) + assert created.output_index == 0 + + deleted = await node_api.api_delete_channel( + short_id="123x1x0", + funding_txid=None, + output_index=None, + force=True, + node=node, + ) + assert deleted is not None + assert deleted[0].id == "chan-1" + + await node_api.api_set_channel_fees( + "chan-1", + node=node, + fee_ppm=42, + fee_base_msat=7, + ) + assert fake_node.fees_updated == ("chan-1", 7, 42) + + payments = await node_api.api_get_payments(node=node, filters=Filters()) + assert payments is not None + assert payments.total == 1 + + invoices = await node_api.api_get_invoices(node=node, filters=Filters()) + assert invoices is not None + assert invoices.total == 1 + + peers = await node_api.api_get_peers(node=node) + assert peers[0].id == "peer-1" + + connect = await node_api.api_connect_peer( + uri="peer-1@127.0.0.1:9735", node=node + ) + assert connect["uri"] == "peer-1@127.0.0.1:9735" + + disconnect = await node_api.api_disconnect_peer("peer-1", node=node) + assert disconnect["peer_id"] == "peer-1" + + rank = await node_api.api_get_1ml_stats(node=node) + assert rank is not None + rank_data = node_api.NodeRank.parse_obj(rank) + assert rank_data.channelcount == 2 + finally: + settings.lnbits_node_ui_transactions = original_transactions + + +@pytest.mark.anyio +async def test_node_api_transactions_and_rank_errors( + settings: Settings, + mocker: MockerFixture, +): + fake_node = FakeNode() + node = cast(Node, fake_node) + original_transactions = settings.lnbits_node_ui_transactions + settings.lnbits_node_ui_transactions = False + + request = httpx.Request("GET", f"https://1ml.com/node/{uuid4().hex}/json") + mocker.patch( + "lnbits.core.views.node_api.httpx.AsyncClient", + return_value=MockHTTPClient( + MockHTTPResponse( + {}, + status_error=httpx.HTTPStatusError( + "not found", request=request, response=httpx.Response(404) + ), + ) + ), + ) + + try: + with pytest.raises(HTTPException) as payments: + await node_api.api_get_payments(node=node, filters=Filters()) + assert payments.value.status_code == 503 + + with pytest.raises(HTTPException) as invoices: + await node_api.api_get_invoices(node=node, filters=Filters()) + assert invoices.value.status_code == 503 + + with pytest.raises(HTTPException) as rank: + await node_api.api_get_1ml_stats(node=node) + assert rank.value.status_code == 404 + assert rank.value.detail == "Node not found on 1ml.com" + finally: + settings.lnbits_node_ui_transactions = original_transactions diff --git a/tests/api/test_payment_api.py b/tests/api/test_payment_api.py new file mode 100644 index 000000000..1371b47cc --- /dev/null +++ b/tests/api/test_payment_api.py @@ -0,0 +1,187 @@ +import json +from hashlib import sha256 +from uuid import uuid4 + +import pytest +from fastapi import HTTPException + +from lnbits.core.crud.payments import create_payment +from lnbits.core.models import Account, CreateInvoice, PaymentState +from lnbits.core.models.payments import CancelInvoice, CreatePayment, SettleInvoice +from lnbits.core.models.users import AccountId +from lnbits.core.models.wallets import KeyType, WalletTypeInfo +from lnbits.core.services.payments import create_wallet_invoice +from lnbits.core.services.users import create_user_account +from lnbits.core.views.payment_api import ( + api_all_payments_paginated, + api_payments_cancel, + api_payments_counting_stats, + api_payments_daily_stats, + api_payments_fee_reserve, + api_payments_settle, + api_payments_wallets_stats, +) +from lnbits.db import Filters +from lnbits.wallets.base import InvoiceResponse + +ZERO_AMOUNT_INVOICE = ( + "lnbc1pnsu5z3pp57getmdaxhg5kc9yh2a2qsh7cjf4gnccgkw0qenm8vsqv50w7s" + "ygqdqj0fjhymeqv9kk7atwwscqzzsxqyz5vqsp5e2yyqcp0a3ujeesp24ya0glej" + "srh703md8mrx0g2lyvjxy5w27ss9qxpqysgqyjreasng8a086kpkczv48er5c6l5" + "73aym6ynrdl9nkzqnag49vt3sjjn8qdfq5cr6ha0vrdz5c5r3v4aghndly0hplmv" + "6hjxepwp93cq398l3s" +) + + +@pytest.mark.anyio +async def test_payment_api_stats_and_all_paginated(admin_user): + first_user = await create_user_account( + Account( + id=uuid4().hex, + username=f"user_{uuid4().hex[:8]}", + email=f"user_{uuid4().hex[:8]}@lnbits.com", + ) + ) + second_user = await create_user_account( + Account( + id=uuid4().hex, + username=f"user_{uuid4().hex[:8]}", + email=f"user_{uuid4().hex[:8]}@lnbits.com", + ) + ) + first_wallet = first_user.wallets[0] + second_wallet = second_user.wallets[0] + + await _create_payment(first_wallet.id, amount_msat=2_000, tag="coffee") + await _create_payment(first_wallet.id, amount_msat=-1_000, tag="coffee") + await _create_payment(second_wallet.id, amount_msat=5_000, tag="books") + + count_stats = await api_payments_counting_stats( + count_by="tag", + filters=Filters(limit=20), + account_id=AccountId(id=first_user.id), + ) + assert any(item.field == "coffee" for item in count_stats) + assert all(item.field != "books" for item in count_stats) + + wallet_stats = await api_payments_wallets_stats( + filters=Filters(limit=20), account_id=AccountId(id=first_user.id) + ) + assert any(item.wallet_id == first_wallet.id for item in wallet_stats) + assert all(item.wallet_id != second_wallet.id for item in wallet_stats) + + daily_stats = await api_payments_daily_stats( + account_id=AccountId(id=first_user.id), + filters=Filters(limit=20), + ) + assert daily_stats + assert daily_stats[0].payments_count >= 1 + + regular_page = await api_all_payments_paginated( + filters=Filters(limit=20), account_id=AccountId(id=first_user.id) + ) + assert regular_page.total >= 2 + assert all(payment.wallet_id == first_wallet.id for payment in regular_page.data) + + admin_page = await api_all_payments_paginated( + filters=Filters(limit=50), account_id=AccountId(id=admin_user.id) + ) + wallet_ids = {payment.wallet_id for payment in admin_page.data} + assert first_wallet.id in wallet_ids + assert second_wallet.id in wallet_ids + + +@pytest.mark.anyio +async def test_payment_api_fee_reserve_and_hold_invoice_actions(mocker): + user = await create_user_account( + Account( + id=uuid4().hex, + username=f"user_{uuid4().hex[:8]}", + email=f"user_{uuid4().hex[:8]}@lnbits.com", + ) + ) + wallet = user.wallets[0] + + invoice = await create_wallet_invoice( + wallet.id, CreateInvoice(out=False, amount=42, memo="reserve") + ) + reserve = await api_payments_fee_reserve(invoice.bolt11) + assert json.loads(reserve.body)["fee_reserve"] >= 0 + + with pytest.raises(HTTPException, match="Invoice has no amount."): + await api_payments_fee_reserve(ZERO_AMOUNT_INVOICE) + + preimage = "11" * 32 + payment_hash = sha256(bytes.fromhex(preimage)).hexdigest() + await _create_payment( + wallet.id, + amount_msat=1_000, + payment_hash=payment_hash, + status=PaymentState.PENDING, + ) + settle_mock = mocker.patch( + "lnbits.core.views.payment_api.settle_hold_invoice", + mocker.AsyncMock( + return_value=InvoiceResponse( + ok=True, + checking_id="settled", + preimage=preimage, + ) + ), + ) + + settled = await api_payments_settle( + SettleInvoice(preimage=preimage), + WalletTypeInfo(key_type=KeyType.admin, wallet=wallet), + ) + assert settled.success is True + settle_mock.assert_awaited_once() + + cancel_hash = (uuid4().hex * 2)[:64] + await _create_payment( + wallet.id, + amount_msat=2_000, + payment_hash=cancel_hash, + status=PaymentState.PENDING, + ) + cancel_mock = mocker.patch( + "lnbits.core.views.payment_api.cancel_hold_invoice", + mocker.AsyncMock( + return_value=InvoiceResponse( + ok=False, + checking_id="cancelled", + error_message="cancelled", + ) + ), + ) + + cancelled = await api_payments_cancel( + CancelInvoice(payment_hash=cancel_hash), + WalletTypeInfo(key_type=KeyType.admin, wallet=wallet), + ) + assert cancelled.failed is True + cancel_mock.assert_awaited_once() + + +async def _create_payment( + wallet_id: str, + *, + amount_msat: int, + status: PaymentState = PaymentState.SUCCESS, + payment_hash: str | None = None, + tag: str | None = None, +) -> str: + checking_id = f"checking_{uuid4().hex[:8]}" + await create_payment( + checking_id=checking_id, + data=CreatePayment( + wallet_id=wallet_id, + payment_hash=payment_hash or uuid4().hex, + bolt11=f"bolt11_{checking_id}", + amount_msat=amount_msat, + memo=f"payment_{checking_id}", + extra={"tag": tag} if tag else {}, + ), + status=status, + ) + return checking_id diff --git a/tests/api/test_tinyurl_api.py b/tests/api/test_tinyurl_api.py new file mode 100644 index 000000000..931ccf5ef --- /dev/null +++ b/tests/api/test_tinyurl_api.py @@ -0,0 +1,71 @@ +from http import HTTPStatus + +import pytest +from httpx import AsyncClient + + +@pytest.mark.anyio +async def test_tinyurl_api_create_get_redirect_and_delete( + client: AsyncClient, + adminkey_headers_from: dict[str, str], + inkey_headers_from: dict[str, str], + inkey_headers_to: dict[str, str], +): + created = await client.post( + "/api/v1/tinyurl", + params={"url": "https://example.com/landing", "endless": "true"}, + headers=adminkey_headers_from, + ) + assert created.status_code == HTTPStatus.OK + tinyurl = created.json() + assert tinyurl["url"] == "https://example.com/landing" + assert tinyurl["endless"] is True + + fetched = await client.get( + f"/api/v1/tinyurl/{tinyurl['id']}", + headers=inkey_headers_from, + ) + assert fetched.status_code == HTTPStatus.OK + assert fetched.json()["id"] == tinyurl["id"] + + wrong_wallet = await client.get( + f"/api/v1/tinyurl/{tinyurl['id']}", + headers=inkey_headers_to, + ) + assert wrong_wallet.status_code == HTTPStatus.NOT_FOUND + assert wrong_wallet.json()["detail"] == "Unable to fetch tinyurl" + + redirect = await client.get(f"/t/{tinyurl['id']}") + assert redirect.status_code == HTTPStatus.TEMPORARY_REDIRECT + assert redirect.headers["location"] == "https://example.com/landing" + + deleted = await client.delete( + f"/api/v1/tinyurl/{tinyurl['id']}", + headers=adminkey_headers_from, + ) + assert deleted.status_code == HTTPStatus.OK + assert deleted.json()["deleted"] is True + + missing_redirect = await client.get(f"/t/{tinyurl['id']}") + assert missing_redirect.status_code == HTTPStatus.NOT_FOUND + + +@pytest.mark.anyio +async def test_tinyurl_api_reuses_existing_entries_for_same_wallet( + client: AsyncClient, + adminkey_headers_from: dict[str, str], +): + first = await client.post( + "/api/v1/tinyurl", + params={"url": "https://example.com/reused"}, + headers=adminkey_headers_from, + ) + second = await client.post( + "/api/v1/tinyurl", + params={"url": "https://example.com/reused"}, + headers=adminkey_headers_from, + ) + + assert first.status_code == HTTPStatus.OK + assert second.status_code == HTTPStatus.OK + assert first.json()["id"] == second.json()["id"] diff --git a/tests/api/test_user_api.py b/tests/api/test_user_api.py new file mode 100644 index 000000000..e181b04c3 --- /dev/null +++ b/tests/api/test_user_api.py @@ -0,0 +1,102 @@ +from uuid import uuid4 + +import pytest +from httpx import AsyncClient + +from lnbits.core.crud.wallets import create_wallet, get_wallet, get_wallets +from lnbits.core.models import UpdateBalance +from lnbits.core.models.users import Account +from lnbits.core.services.users import create_user_account +from lnbits.core.views.user_api import api_users_create_user_wallet +from lnbits.settings import settings + + +@pytest.mark.anyio +async def test_user_api_toggle_admin_and_update_balance( + http_client: AsyncClient, superuser_token: str +): + user = await create_user_account( + Account( + id=uuid4().hex, + username=f"user_{uuid4().hex[:8]}", + email=f"user_{uuid4().hex[:8]}@lnbits.com", + ) + ) + wallet = user.wallets[0] + + promote = await http_client.put( + f"/users/api/v1/user/{user.id}/admin", + headers={"Authorization": f"Bearer {superuser_token}"}, + ) + assert promote.status_code == 200 + assert settings.is_admin_user(user.id) is True + + demote = await http_client.put( + f"/users/api/v1/user/{user.id}/admin", + headers={"Authorization": f"Bearer {superuser_token}"}, + ) + assert demote.status_code == 200 + assert settings.is_admin_user(user.id) is False + + balance = await http_client.put( + "/users/api/v1/balance", + headers={"Authorization": f"Bearer {superuser_token}"}, + json=UpdateBalance(id=wallet.id, amount=7).dict(), + ) + assert balance.status_code == 200 + assert balance.json()["success"] is True + + updated_wallet = await get_wallet(wallet.id) + assert updated_wallet is not None + assert updated_wallet.balance == 7 + + +@pytest.mark.anyio +async def test_user_api_get_wallets_and_delete_all_wallets( + http_client: AsyncClient, superuser_token: str +): + user = await create_user_account( + Account( + id=uuid4().hex, + username=f"user_{uuid4().hex[:8]}", + email=f"user_{uuid4().hex[:8]}@lnbits.com", + ) + ) + extra_wallet = await create_wallet(user_id=user.id, wallet_name="spare") + + wallets = await http_client.get( + f"/users/api/v1/user/{user.id}/wallet", + headers={"Authorization": f"Bearer {superuser_token}"}, + ) + assert wallets.status_code == 200 + wallet_ids = {wallet["id"] for wallet in wallets.json()} + assert extra_wallet.id in wallet_ids + + deleted = await http_client.delete( + f"/users/api/v1/user/{user.id}/wallets", + headers={"Authorization": f"Bearer {superuser_token}"}, + ) + assert deleted.status_code == 200 + assert deleted.json()["success"] is True + + active_wallets = await get_wallets(user.id, deleted=False) + assert active_wallets == [] + + +@pytest.mark.anyio +async def test_user_api_create_wallet_validates_currency(): + user = await create_user_account( + Account( + id=uuid4().hex, + username=f"user_{uuid4().hex[:8]}", + email=f"user_{uuid4().hex[:8]}@lnbits.com", + ) + ) + + with pytest.raises(ValueError, match="Currency 'INVALID' not allowed."): + await api_users_create_user_wallet(user.id, name="invalid", currency="INVALID") + + wallet = await api_users_create_user_wallet( + user.id, name="eur wallet", currency="EUR" + ) + assert wallet.currency == "EUR" diff --git a/tests/api/test_wallet_api.py b/tests/api/test_wallet_api.py new file mode 100644 index 000000000..9ec2d560d --- /dev/null +++ b/tests/api/test_wallet_api.py @@ -0,0 +1,190 @@ +from uuid import uuid4 + +import pytest +from httpx import AsyncClient + +from lnbits.core.crud.wallets import create_wallet, get_wallet +from lnbits.core.models.users import Account +from lnbits.core.services.users import create_user_account + + +@pytest.mark.anyio +async def test_wallet_api_share_invite_reject_accept_and_delete( + http_client: AsyncClient, +): + owner = await create_user_account( + Account( + id=uuid4().hex, + username=f"owner_{uuid4().hex[:8]}", + email=f"owner_{uuid4().hex[:8]}@lnbits.com", + ) + ) + invited = await create_user_account( + Account( + id=uuid4().hex, + username=f"invited_{uuid4().hex[:8]}", + email=f"invited_{uuid4().hex[:8]}@lnbits.com", + ) + ) + source_wallet = owner.wallets[0] + owner_headers = _admin_headers(source_wallet.adminkey) + + invite = await http_client.put( + "/api/v1/wallet/share/invite", + headers=owner_headers, + json={ + "username": invited.username, + "permissions": ["view-payments"], + "status": "invite_sent", + }, + ) + assert invite.status_code == 200 + share_request = invite.json() + assert share_request["request_id"] + + reject = await http_client.delete( + f"/api/v1/wallet/share/invite/{share_request['request_id']}?usr={invited.id}" + ) + assert reject.status_code == 200 + assert reject.json()["success"] is True + + removed_share = await http_client.delete( + f"/api/v1/wallet/share/{share_request['request_id']}", + headers=owner_headers, + ) + assert removed_share.status_code == 200 + assert removed_share.json()["success"] is True + + invite = await http_client.put( + "/api/v1/wallet/share/invite", + headers=owner_headers, + json={ + "username": invited.username, + "permissions": ["view-payments", "receive-payments"], + "status": "invite_sent", + }, + ) + assert invite.status_code == 200 + share_request = invite.json() + + create_shared = await http_client.post( + f"/api/v1/wallet?usr={invited.id}", + json={ + "name": "shared", + "wallet_type": "lightning-shared", + "shared_wallet_id": source_wallet.id, + }, + ) + assert create_shared.status_code == 200 + mirror_wallet = create_shared.json() + assert mirror_wallet["shared_wallet_id"] == source_wallet.id + + approve = await http_client.put( + "/api/v1/wallet/share", + headers=owner_headers, + json={ + "username": invited.username, + "shared_with_wallet_id": mirror_wallet["id"], + "permissions": ["view-payments", "receive-payments"], + "status": "approved", + }, + ) + assert approve.status_code == 200 + assert approve.json()["status"] == "approved" + + delete_share = await http_client.delete( + f"/api/v1/wallet/share/{share_request['request_id']}", + headers=owner_headers, + ) + assert delete_share.status_code == 200 + assert delete_share.json()["success"] is True + assert await get_wallet(mirror_wallet["id"]) is None + + +@pytest.mark.anyio +async def test_wallet_api_paginated_update_reset_and_store_paylinks( + http_client: AsyncClient, +): + user = await create_user_account( + Account( + id=uuid4().hex, + username=f"user_{uuid4().hex[:8]}", + email=f"user_{uuid4().hex[:8]}@lnbits.com", + ) + ) + extra_wallet = await create_wallet(user_id=user.id, wallet_name="second") + first_wallet = user.wallets[0] + + page = await http_client.get(f"/api/v1/wallet/paginated?usr={user.id}&limit=10") + assert page.status_code == 200 + assert page.json()["total"] >= 2 + + renamed = await http_client.put( + "/api/v1/wallet/renamed-wallet", + headers=_admin_headers(first_wallet.adminkey), + ) + assert renamed.status_code == 200 + assert renamed.json()["name"] == "renamed-wallet" + + original_admin_key = extra_wallet.adminkey + reset = await http_client.put( + f"/api/v1/wallet/reset/{extra_wallet.id}?usr={user.id}" + ) + assert reset.status_code == 200 + assert reset.json()["adminkey"] != original_admin_key + + stored = await http_client.put( + f"/api/v1/wallet/stored_paylinks/{extra_wallet.id}", + headers=_admin_headers(reset.json()["adminkey"]), + json={ + "links": [ + { + "lnurl": "alice@example.com", + "label": "Alice", + } + ] + }, + ) + assert stored.status_code == 200 + assert stored.json()[0]["lnurl"] == "alice@example.com" + + forbidden = await http_client.put( + f"/api/v1/wallet/stored_paylinks/{extra_wallet.id}", + headers=_admin_headers(first_wallet.adminkey), + json={"links": []}, + ) + assert forbidden.status_code == 403 + + updated = await http_client.patch( + "/api/v1/wallet", + headers=_admin_headers(first_wallet.adminkey), + json={"icon": "bolt", "color": "amber", "pinned": True}, + ) + assert updated.status_code == 200 + assert updated.json()["extra"]["icon"] == "bolt" + assert updated.json()["extra"]["color"] == "amber" + assert updated.json()["extra"]["pinned"] is True + + +@pytest.mark.anyio +async def test_wallet_api_shared_wallet_requires_source_id(http_client: AsyncClient): + user = await create_user_account( + Account( + id=uuid4().hex, + username=f"user_{uuid4().hex[:8]}", + email=f"user_{uuid4().hex[:8]}@lnbits.com", + ) + ) + + response = await http_client.post( + f"/api/v1/wallet?usr={user.id}", + json={"wallet_type": "lightning-shared"}, + ) + assert response.status_code == 400 + assert ( + response.json()["detail"] == "Shared wallet ID is required for shared wallets." + ) + + +def _admin_headers(adminkey: str) -> dict[str, str]: + return {"X-Api-Key": adminkey, "Content-type": "application/json"} diff --git a/tests/api/test_websocket_api.py b/tests/api/test_websocket_api.py new file mode 100644 index 000000000..fdd14ced2 --- /dev/null +++ b/tests/api/test_websocket_api.py @@ -0,0 +1,31 @@ +from unittest.mock import AsyncMock + +from pytest_mock.plugin import MockerFixture + + +def test_websocket_api_connects_and_updates(test_client): + with test_client.websocket_connect("/api/v1/ws/demo-item") as websocket: + response = test_client.post("/api/v1/ws/demo-item", params={"data": "hello"}) + assert response.status_code == 200 + assert response.json() == {"sent": True, "data": "hello"} + assert websocket.receive_text() == "hello" + + response = test_client.get("/api/v1/ws/demo-item/world") + assert response.status_code == 200 + assert response.json() == {"sent": True, "data": "world"} + assert websocket.receive_text() == "world" + + +def test_websocket_api_reports_send_failures(test_client, mocker: MockerFixture): + mocker.patch( + "lnbits.core.views.websocket_api.websocket_manager.send", + AsyncMock(side_effect=RuntimeError("boom")), + ) + + post_response = test_client.post("/api/v1/ws/demo-item", params={"data": "oops"}) + assert post_response.status_code == 200 + assert post_response.json() == {"sent": False, "data": "oops"} + + get_response = test_client.get("/api/v1/ws/demo-item/oops") + assert get_response.status_code == 200 + assert get_response.json() == {"sent": False, "data": "oops"} diff --git a/tests/conftest.py b/tests/conftest.py index 5a2999059..de56fec78 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,7 +40,9 @@ from tests.helpers import ( asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) ADMIN_USER_ID = uuid4().hex -_PURE_SETTINGS = Settings() +# Snapshot the initialized module settings instead of a fresh Settings() instance. +# The module settings include runtime-populated values like `version`. +_PURE_SETTINGS = copy.deepcopy(lnbits_settings) _PURE_SETTINGS_FIELDS = tuple( sorted( { diff --git a/tests/docker-compose.yaml b/tests/docker-compose.yaml new file mode 100644 index 000000000..814d9fd2c --- /dev/null +++ b/tests/docker-compose.yaml @@ -0,0 +1,24 @@ +networks: + lnbits-net: + +services: + postgres-db: + container_name: postgres_container_test + image: postgres:14 + environment: + POSTGRES_DB: lnbits + POSTGRES_USER: lnbits + POSTGRES_PASSWORD: lnbits + PGDATA: /lnbits/data/postgres + volumes: + - ./data/postgres_test:/lnbits/data/postgres + ports: + - '5444:5432' + restart: unless-stopped + healthcheck: + test: ['CMD-SHELL', 'pg_isready -d lnbits_db'] + interval: 5s + timeout: 5s + retries: 5 + networks: + - lnbits-net diff --git a/tests/helpers.py b/tests/helpers.py index 45e7b2b0d..393fcddb5 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,8 +1,33 @@ import random import string +from io import BytesIO -from pydantic import BaseModel +from bolt11.types import MilliSatoshi +from fastapi import UploadFile +from httpx import AsyncClient +from lnurl import LnurlPayResponse +from lnurl.types import CallbackUrl, LnurlPayMetadata +from PIL import Image +from pydantic import BaseModel, parse_obj_as +from starlette.datastructures import Headers +from lnbits.core.models.extensions import ( + ExtensionMeta, + ExtensionRelease, + InstallableExtension, + PayToEnableInfo, + ReleasePaymentInfo, +) +from lnbits.core.models.extensions_builder import ( + ActionFields, + ClientDataFields, + DataField, + DataFields, + ExtensionData, + OwnerDataFields, + PublicPageFields, + SettingsFields, +) from lnbits.wallets import get_funding_source, set_funding_source @@ -40,7 +65,125 @@ async def get_random_invoice_data(): return {"out": False, "amount": 10, "memo": f"test_memo_{get_random_string(10)}"} +def get_png_bytes(*, color: str = "blue", size: tuple[int, int] = (32, 32)) -> bytes: + image = Image.new("RGB", size, color=color) + buffer = BytesIO() + image.save(buffer, format="PNG") + return buffer.getvalue() + + +def make_upload_file( + contents: bytes, + *, + filename: str, + content_type: str | None, +) -> UploadFile: + headers = ( + Headers({"content-type": content_type}) if content_type is not None else None + ) + return UploadFile(BytesIO(contents), filename=filename, headers=headers) + + +async def get_user_token_headers(client: AsyncClient, user_id: str) -> dict[str, str]: + response = await client.post("/api/v1/auth/usr", json={"usr": user_id}) + client.cookies.clear() + return { + "Authorization": f"Bearer {response.json()['access_token']}", + "Content-type": "application/json", + } + + +def make_extension_data(ext_id: str = "demoext") -> ExtensionData: + return ExtensionData( + id=ext_id, + name="Demo Extension", + stub_version="0.1.0", + short_description="Generated extension", + owner_data=DataFields( + name="OwnerData", + fields=[DataField(name="wallet_id", type="wallet")], + ), + client_data=DataFields( + name="ClientData", + fields=[DataField(name="amount", type="int")], + ), + settings_data=SettingsFields(name="SettingsData", fields=[]), + public_page=PublicPageFields( + owner_data_fields=OwnerDataFields(), + client_data_fields=ClientDataFields(), + action_fields=ActionFields(), + ), + ) + + +def make_extension_release(ext_id: str, version: str = "1.0.0") -> ExtensionRelease: + return ExtensionRelease( + name=ext_id, + version=version, + archive=f"https://example.com/{ext_id}.zip", + source_repo="org/repo", + hash=f"hash-{ext_id}", + details_link=f"https://example.com/{ext_id}/details.json", + repo=f"https://github.com/org/{ext_id}", + icon=f"/{ext_id}/static/icon.png", + pay_link=f"https://pay.example/{ext_id}", + is_github_release=False, + is_version_compatible=True, + ) + + +def make_installable_extension( + ext_id: str, + *, + version: str = "1.0.0", + compatible: bool = True, + active: bool = True, + pay_to_enable: PayToEnableInfo | None = None, + dependencies: list[str] | None = None, + payments: list[ReleasePaymentInfo] | None = None, +) -> InstallableExtension: + release = make_extension_release(ext_id, version) + release.is_version_compatible = compatible + return InstallableExtension( + id=ext_id, + name=f"Extension {ext_id}", + version=version, + active=active, + short_description="Demo extension", + icon=release.icon, + meta=ExtensionMeta( + installed_release=release, + pay_to_enable=pay_to_enable, + dependencies=dependencies or [], + payments=payments or [], + ), + ) + + +def make_lnurl_pay_response( + *, + min_sendable_msat: int = 1_000, + max_sendable_msat: int = 10_000, + text: str = "Test payment", + identifier: str = "alice@example.com", + callback: str = "https://example.com/callback", +) -> LnurlPayResponse: + return LnurlPayResponse( + callback=parse_obj_as(CallbackUrl, callback), + minSendable=MilliSatoshi(min_sendable_msat), + maxSendable=MilliSatoshi(max_sendable_msat), + metadata=LnurlPayMetadata( + f"[[" + f'"text/plain","{text}"' + f"],[" + f'"text/identifier","{identifier}"' + f"]]" + ), + ) + + set_funding_source() + funding_source = get_funding_source() is_fake: bool = funding_source.__class__.__name__ == "FakeWallet" is_regtest: bool = not is_fake diff --git a/tests/unit/test_cache.py b/tests/unit/test_cache.py index f7ec4a906..2f33ffe1d 100644 --- a/tests/unit/test_cache.py +++ b/tests/unit/test_cache.py @@ -1,8 +1,11 @@ import asyncio +from time import time import pytest +from pytest_mock.plugin import MockerFixture -from lnbits.utils.cache import Cache +from lnbits.settings import Settings +from lnbits.utils.cache import Cache, Cached key = "foo" value = "bar" @@ -59,3 +62,55 @@ async def test_cache_coro(cache): await cache.save_result(test, key="test") result = await cache.save_result(test, key="test") assert result == called == 1 + + +def test_cached_older_than(): + cached = Cached(value="value", expiry=time() - 5) + + assert cached.older_than(1) is True + assert cached.older_than(10) is False + + +@pytest.mark.anyio +async def test_cache_value_returns_cached_metadata(cache): + cache.set(key, value, expiry=1) + + cached = cache.value(key) + + assert cached is not None + assert cached.value == value + assert cached.expiry > time() + + +@pytest.mark.anyio +async def test_cache_pop_expired_returns_default(cache): + cache.set(key, value, expiry=0.01) + await asyncio.sleep(0.02) + + assert cache.pop(key, default="fallback") == "fallback" + + +@pytest.mark.anyio +async def test_invalidate_forever_logs_and_recovers_from_errors( + settings: Settings, mocker: MockerFixture +): + test_cache = Cache(interval=0) + logger_error = mocker.patch("lnbits.utils.cache.logger.error") + original_running = settings.lnbits_running + calls = 0 + + async def fake_sleep(_interval): + nonlocal calls + calls += 1 + if calls == 1: + raise RuntimeError("boom") + settings.lnbits_running = False + + try: + settings.lnbits_running = True + mocker.patch("lnbits.utils.cache.asyncio.sleep", side_effect=fake_sleep) + await test_cache.invalidate_forever() + finally: + settings.lnbits_running = original_running + + logger_error.assert_called_once_with("Error invalidating cache") diff --git a/tests/unit/test_crypto_aes.py b/tests/unit/test_crypto_aes.py index 3e098afde..bd0b7ae5d 100644 --- a/tests/unit/test_crypto_aes.py +++ b/tests/unit/test_crypto_aes.py @@ -1,6 +1,15 @@ -import pytest +from base64 import b64encode +from hashlib import sha256 -from lnbits.utils.crypto import AESCipher +import pytest +from pytest_mock.plugin import MockerFixture + +from lnbits.utils.crypto import ( + AESCipher, + fake_privkey, + random_secret_and_hash, + verify_preimage, +) @pytest.mark.anyio @@ -18,3 +27,83 @@ async def test_aes_encrypt_decrypt(key): encrypted_text = aes.encrypt(original_text.encode()) decrypted_text = aes.decrypt(encrypted_text) assert original_text == decrypted_text + + +def test_random_secret_and_hash(): + secret, payment_hash = random_secret_and_hash(16) + + assert len(secret) == 32 + assert payment_hash == sha256(bytes.fromhex(secret)).hexdigest() + + +def test_fake_privkey_is_deterministic(): + assert fake_privkey("secret") == fake_privkey("secret") + assert fake_privkey("secret") != fake_privkey("other-secret") + + +def test_verify_preimage_success_and_failure(): + preimage = "00" * 32 + payment_hash = sha256(bytes.fromhex(preimage)).hexdigest() + + assert verify_preimage(preimage, payment_hash) is True + assert verify_preimage(preimage, "0" * 64) is False + + +@pytest.mark.anyio +async def test_aes_urlsafe_encrypt_decrypt(): + aes = AESCipher("normal_string") + + encrypted_text = aes.encrypt(b"url-safe", urlsafe=True) + + assert aes.decrypt(encrypted_text, urlsafe=True) == "url-safe" + + +def test_aes_derive_iv_and_key_requires_eight_byte_salt(): + aes = AESCipher("normal_string") + + with pytest.raises(ValueError, match="Salt must be 8 bytes"): + aes.derive_iv_and_key(b"short") + + +def test_aes_decrypt_rejects_invalid_salt_prefix(): + aes = AESCipher("normal_string") + encrypted_text = b64encode(b"NotSalted__12345678ciphertext").decode() + + with pytest.raises(ValueError, match="Invalid salt."): + aes.decrypt(encrypted_text) + + +def test_aes_decrypt_raises_for_cipher_errors(mocker: MockerFixture): + aes = AESCipher("normal_string") + fake_cipher = mocker.Mock() + fake_cipher.decrypt.side_effect = RuntimeError("boom") + + mocker.patch.object( + aes, + "derive_iv_and_key", + return_value=(b"0" * aes.block_size, b"1" * 32), + ) + mocker.patch("lnbits.utils.crypto.AES.new", return_value=fake_cipher) + + encrypted_text = b64encode(b"Salted__12345678ciphertext").decode() + + with pytest.raises(ValueError, match="Could not decrypt payload"): + aes.decrypt(encrypted_text) + + +def test_aes_decrypt_raises_for_invalid_utf8_output(mocker: MockerFixture): + aes = AESCipher("normal_string") + fake_cipher = mocker.Mock() + fake_cipher.decrypt.return_value = b"\xff\x01" + + mocker.patch.object( + aes, + "derive_iv_and_key", + return_value=(b"0" * aes.block_size, b"1" * 32), + ) + mocker.patch("lnbits.utils.crypto.AES.new", return_value=fake_cipher) + + encrypted_text = b64encode(b"Salted__12345678ciphertext").decode() + + with pytest.raises(ValueError, match="invalid UTF-8 data"): + aes.decrypt(encrypted_text) diff --git a/tests/unit/test_decorators.py b/tests/unit/test_decorators.py index 5aa425e58..304aebabf 100644 --- a/tests/unit/test_decorators.py +++ b/tests/unit/test_decorators.py @@ -11,7 +11,16 @@ from pydantic.types import UUID4 from lnbits.core.crud.users import delete_account from lnbits.core.models import User from lnbits.core.models.users import AccessTokenPayload -from lnbits.decorators import check_user_exists +from lnbits.decorators import ( + access_token_payload, + check_access_token, + check_admin_ui, + check_extension_builder, + check_first_install, + check_user_exists, + optional_user_id, +) +from lnbits.helpers import create_access_token from lnbits.settings import AuthMethods, Settings, settings @@ -136,3 +145,83 @@ async def test_check_user_exists_with_user_id_only_not_allowed(user_alan: User): await check_user_exists(request, access_token=None, usr=UUID4(user_alan.id)) assert exc_info.value.status_code == 401 assert exc_info.value.detail == "Missing user ID or access token." + + +@pytest.mark.anyio +async def test_check_access_token_prefers_available_source(): + assert await check_access_token("header", "cookie", "bearer") == "header" + assert await check_access_token(None, "cookie", "bearer") == "cookie" + assert await check_access_token(None, None, "bearer") == "bearer" + + +@pytest.mark.anyio +async def test_access_token_payload_success_and_missing(settings: Settings): + token = create_access_token({"sub": "alice", "usr": "user-id"}) + + payload = await access_token_payload(token) + + assert isinstance(payload, AccessTokenPayload) + assert payload.sub == "alice" + assert payload.usr == "user-id" + + with pytest.raises(HTTPException, match="Missing access token."): + await access_token_payload(None) + + +@pytest.mark.anyio +async def test_optional_user_id_uses_user_id_or_access_token( + user_alan: User, settings: Settings +): + settings.auth_allowed_methods = [AuthMethods.user_id_only.value] + request = Request({"type": "http", "path": "/wallet", "method": "GET"}) + + assert ( + await optional_user_id(request, access_token=None, usr=UUID4(user_alan.id)) + == user_alan.id + ) + + settings.auth_allowed_methods = [] + token = create_access_token({"sub": user_alan.username, "usr": user_alan.id}) + assert await optional_user_id(request, access_token=token, usr=None) == user_alan.id + assert await optional_user_id(request, access_token=None, usr=None) is None + + +@pytest.mark.anyio +async def test_check_admin_ui_and_first_install(settings: Settings): + original_admin_ui = settings.lnbits_admin_ui + original_first_install = settings.first_install + try: + settings.lnbits_admin_ui = False + with pytest.raises(HTTPException, match="Admin UI is disabled."): + await check_admin_ui() + + settings.lnbits_admin_ui = True + await check_admin_ui() + + settings.first_install = False + with pytest.raises( + HTTPException, match="Super user account has already been configured." + ): + await check_first_install() + + settings.first_install = True + await check_first_install() + finally: + settings.lnbits_admin_ui = original_admin_ui + settings.first_install = original_first_install + + +@pytest.mark.anyio +async def test_check_extension_builder_requires_admin_when_disabled_for_users( + settings: Settings, user_alan: User +): + settings.lnbits_extensions_builder_activate_non_admins = False + + with pytest.raises( + HTTPException, match="Extension Builder is disabled for non admin users." + ): + await check_extension_builder(user_alan) + + admin_user = user_alan.copy(deep=True) + admin_user.admin = True + await check_extension_builder(admin_user) diff --git a/tests/unit/test_exchange_rates.py b/tests/unit/test_exchange_rates.py index 9b3263b49..a0aeb3b11 100644 --- a/tests/unit/test_exchange_rates.py +++ b/tests/unit/test_exchange_rates.py @@ -1,8 +1,54 @@ +from unittest.mock import AsyncMock + +import httpx +import pytest +from pytest_mock.plugin import MockerFixture + +from lnbits.settings import ExchangeRateProvider, Settings from lnbits.utils.exchange_rates import ( + allowed_currencies, apply_trimmed_mean_filter, + btc_price, + btc_rates, + fiat_amount_as_satoshis, + get_fiat_rate_and_price_satoshis, + get_fiat_rate_satoshis, + satoshis_amount_as_fiat, ) +class MockResponse: + def __init__( + self, *, text: str = "", json_data=None, error: Exception | None = None + ): + self.text = text + self._json_data = json_data or {} + self._error = error + + def raise_for_status(self): + if self._error: + raise self._error + + def json(self): + return self._json_data + + +class MockAsyncClient: + def __init__(self, response: MockResponse): + self.response = response + self.calls: list[tuple[str, int]] = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def get(self, url: str, timeout: int = 3): + self.calls.append((url, timeout)) + return self.response + + class TestApplyTrimmedMeanFilter: """Test the trimmed mean filtering function""" @@ -123,3 +169,164 @@ class TestApplyTrimmedMeanFilter: # Should keep the rate at exactly 1% deviation assert len(result) == 2 assert result == rates + + +def test_allowed_currencies_returns_full_list_by_default(settings: Settings): + original_allowed_currencies = settings.lnbits_allowed_currencies + try: + settings.lnbits_allowed_currencies = [] + + currencies = allowed_currencies() + + assert "USD" in currencies + assert "EUR" in currencies + finally: + settings.lnbits_allowed_currencies = original_allowed_currencies + + +def test_allowed_currencies_respects_allow_list(settings: Settings): + original_allowed_currencies = settings.lnbits_allowed_currencies + try: + settings.lnbits_allowed_currencies = ["USD", "EUR"] + + assert allowed_currencies() == ["EUR", "USD"] + finally: + settings.lnbits_allowed_currencies = original_allowed_currencies + + +@pytest.mark.anyio +async def test_btc_rates_rejects_disallowed_currency(settings: Settings): + original_allowed_currencies = settings.lnbits_allowed_currencies + try: + settings.lnbits_allowed_currencies = ["EUR"] + + with pytest.raises(ValueError, match="Currency 'usd' not allowed."): + await btc_rates("usd") + finally: + settings.lnbits_allowed_currencies = original_allowed_currencies + + +@pytest.mark.anyio +async def test_btc_rates_parses_plain_text_response( + settings: Settings, mocker: MockerFixture +): + provider = ExchangeRateProvider( + name="PlainText", + api_url="https://plain.test/{TO}", + path="", + ) + client = MockAsyncClient(MockResponse(text="12,345.67")) + mocker.patch.object(settings, "lnbits_allowed_currencies", []) + mocker.patch.object(settings, "lnbits_exchange_rate_providers", [provider]) + mocker.patch("lnbits.utils.exchange_rates.httpx.AsyncClient", return_value=client) + + rates = await btc_rates("usd") + + assert rates == [("PlainText", 12345.67)] + assert client.calls == [("https://plain.test/USD", 3)] + + +@pytest.mark.anyio +async def test_btc_rates_parses_json_path_response( + settings: Settings, mocker: MockerFixture +): + provider = ExchangeRateProvider( + name="JsonProvider", + api_url="https://json.test/{TO}", + path="$.data.rates.{TO}", + ) + client = MockAsyncClient( + MockResponse(json_data={"data": {"rates": {"USD": "54321.0"}}}) + ) + mocker.patch.object(settings, "lnbits_allowed_currencies", []) + mocker.patch.object(settings, "lnbits_exchange_rate_providers", [provider]) + mocker.patch("lnbits.utils.exchange_rates.httpx.AsyncClient", return_value=client) + + rates = await btc_rates("usd") + + assert rates == [("JsonProvider", 54321.0)] + assert client.calls == [("https://json.test/USD", 3)] + + +@pytest.mark.anyio +async def test_btc_rates_skips_unsupported_and_failing_providers( + settings: Settings, mocker: MockerFixture +): + unsupported = ExchangeRateProvider( + name="Unsupported", + api_url="https://unsupported.test/{TO}", + path="$.price", + exclude_to=["usd"], + ) + failing = ExchangeRateProvider( + name="Failing", + api_url="https://failing.test/{TO}", + path="$.price", + ) + client = MockAsyncClient(MockResponse(error=httpx.HTTPError("boom"))) + mocker.patch.object(settings, "lnbits_allowed_currencies", []) + mocker.patch.object( + settings, "lnbits_exchange_rate_providers", [unsupported, failing] + ) + mocker.patch("lnbits.utils.exchange_rates.httpx.AsyncClient", return_value=client) + + assert await btc_rates("usd") == [] + + +@pytest.mark.anyio +async def test_btc_price_handles_empty_single_and_multiple_rates(mocker: MockerFixture): + mocker.patch("lnbits.utils.exchange_rates.btc_rates", AsyncMock(return_value=[])) + assert await btc_price("usd") == 0.0 + + mocker.patch( + "lnbits.utils.exchange_rates.btc_rates", + AsyncMock(return_value=[("Only", 50000.0)]), + ) + assert await btc_price("usd") == 50000.0 + + mocker.patch( + "lnbits.utils.exchange_rates.btc_rates", + AsyncMock(return_value=[("A", 40000.0), ("B", 50000.0)]), + ) + assert await btc_price("usd") == 45000.0 + + +@pytest.mark.anyio +async def test_rate_and_amount_conversion_helpers(mocker: MockerFixture): + cache_result = AsyncMock(return_value=50000.0) + mocker.patch("lnbits.utils.exchange_rates.cache.save_result", cache_result) + + rate, price = await get_fiat_rate_and_price_satoshis("usd") + + assert price == 50000.0 + assert rate == 2000.0 + cache_result.assert_awaited_once() + + mocker.patch( + "lnbits.utils.exchange_rates.get_fiat_rate_and_price_satoshis", + AsyncMock(return_value=(1250.0, 80000.0)), + ) + assert await get_fiat_rate_satoshis("usd") == 1250.0 + + mocker.patch( + "lnbits.utils.exchange_rates.get_fiat_rate_satoshis", + AsyncMock(return_value=100.0), + ) + assert await fiat_amount_as_satoshis(2.5, "usd") == 250 + assert await satoshis_amount_as_fiat(500, "usd") == 5.0 + + +@pytest.mark.anyio +async def test_amount_conversion_helpers_raise_when_rate_missing( + mocker: MockerFixture, +): + mocker.patch( + "lnbits.utils.exchange_rates.get_fiat_rate_satoshis", + AsyncMock(return_value=0.0), + ) + + with pytest.raises(ValueError, match="Could not get exchange rate for usd."): + await fiat_amount_as_satoshis(1, "usd") + + with pytest.raises(ValueError, match="Could not get exchange rate for usd."): + await satoshis_amount_as_fiat(100, "usd") diff --git a/tests/unit/test_fiat_providers.py b/tests/unit/test_fiat_providers.py index 26ef66661..c29eab8aa 100644 --- a/tests/unit/test_fiat_providers.py +++ b/tests/unit/test_fiat_providers.py @@ -9,20 +9,65 @@ from pytest_mock.plugin import MockerFixture from lnbits.core.crud.payments import get_payments from lnbits.core.crud.users import get_user from lnbits.core.crud.wallets import create_wallet -from lnbits.core.models.payments import CreateInvoice, PaymentState +from lnbits.core.models.payments import CreateInvoice, Payment, PaymentState from lnbits.core.models.users import User from lnbits.core.models.wallets import Wallet from lnbits.core.services import check_payment_status, payments from lnbits.core.services.fiat_providers import ( + check_fiat_status, check_stripe_signature, handle_fiat_payment_confirmation, + verify_paypal_webhook, +) +from lnbits.core.services.fiat_providers import ( + test_connection as fiat_provider_connection, ) from lnbits.core.services.users import create_user_account -from lnbits.fiat.base import FiatInvoiceResponse, FiatPaymentStatus +from lnbits.fiat.base import FiatInvoiceResponse, FiatPaymentStatus, FiatStatusResponse from lnbits.settings import Settings from tests.helpers import get_random_string +class MockHTTPResponse: + def __init__(self, json_data=None, error: Exception | None = None): + self._json_data = json_data or {} + self._error = error + + def raise_for_status(self): + if self._error: + raise self._error + + def json(self): + return self._json_data + + +class MockHTTPClient: + def __init__(self, responses: list[MockHTTPResponse]): + self._responses = responses + self.calls: list[tuple[str, dict]] = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def post(self, path: str, **kwargs): + self.calls.append((path, kwargs)) + return self._responses.pop(0) + + +@pytest.fixture(autouse=True) +def fiat_provider_test_settings(settings: Settings): + original_allowed_currencies = settings.lnbits_allowed_currencies + original_paypal_enabled = settings.paypal_enabled + settings.lnbits_allowed_currencies = [] + settings.paypal_enabled = False + yield + settings.lnbits_allowed_currencies = original_allowed_currencies + settings.paypal_enabled = original_paypal_enabled + + @pytest.mark.anyio async def test_create_wallet_fiat_invoice_missing_provider(): invoice_data = CreateInvoice( @@ -433,3 +478,188 @@ def _make_stripe_sig_header(payload, secret, timestamp=None): secret.encode(), signed_payload.encode(), hashlib.sha256 ).hexdigest() return f"t={timestamp},v1={signature}", timestamp, signature + + +@pytest.mark.anyio +async def test_check_fiat_status_handles_internal_states(mocker: MockerFixture): + pending_payment = Payment( + checking_id="external_payment", + payment_hash="hash_pending", + wallet_id="wallet_id", + amount=1000, + fee=0, + bolt11="bolt11", + status=PaymentState.PENDING, + ) + success_payment = Payment( + checking_id="fiat_success", + payment_hash="hash_success", + wallet_id="wallet_id", + amount=1000, + fee=0, + bolt11="bolt11", + status=PaymentState.SUCCESS, + fiat_provider="stripe", + ) + failed_payment = Payment( + checking_id="fiat_failed", + payment_hash="hash_failed", + wallet_id="wallet_id", + amount=1000, + fee=0, + bolt11="bolt11", + status=PaymentState.FAILED, + fiat_provider="stripe", + ) + + assert (await check_fiat_status(pending_payment)).pending is True + assert (await check_fiat_status(success_payment)).success is True + assert (await check_fiat_status(failed_payment)).failed is True + + provider = mocker.Mock() + provider.get_invoice_status = AsyncMock(return_value=FiatPaymentStatus(paid=True)) + mocker.patch( + "lnbits.core.services.fiat_providers.get_fiat_provider", + AsyncMock(return_value=provider), + ) + queue_put = mocker.patch("lnbits.tasks.internal_invoice_queue.put", AsyncMock()) + + success_status = await check_fiat_status( + Payment( + checking_id="fiat_pending", + payment_hash="hash_queue", + wallet_id="wallet_id", + amount=1000, + fee=0, + bolt11="bolt11", + status=PaymentState.PENDING, + fiat_provider="stripe", + extra={"fiat_checking_id": "stripe_checking_id"}, + ) + ) + + assert success_status.success is True + queue_put.assert_awaited_once_with("fiat_pending") + + await check_fiat_status( + Payment( + checking_id="fiat_pending_skip", + payment_hash="hash_skip", + wallet_id="wallet_id", + amount=1000, + fee=0, + bolt11="bolt11", + status=PaymentState.PENDING, + fiat_provider="stripe", + extra={"fiat_checking_id": "stripe_checking_id"}, + ), + skip_internal_payment_notifications=True, + ) + assert queue_put.await_count == 1 + + +@pytest.mark.anyio +async def test_verify_paypal_webhook_requires_configuration(settings: Settings): + settings.paypal_webhook_id = None + + with pytest.raises( + ValueError, match="PayPal webhook cannot be verified. Missing webhook ID." + ): + await verify_paypal_webhook({}, b"{}") + + +@pytest.mark.anyio +async def test_verify_paypal_webhook_requires_headers(settings: Settings): + settings.paypal_webhook_id = "webhook-id" + + with pytest.raises( + ValueError, match="PayPal webhook cannot be verified. Missing headers." + ): + await verify_paypal_webhook({}, b"{}") + + +@pytest.mark.anyio +async def test_verify_paypal_webhook_success(settings: Settings, mocker: MockerFixture): + settings.paypal_webhook_id = "webhook-id" + client = MockHTTPClient( + [ + MockHTTPResponse(json_data={"access_token": "token"}), + MockHTTPResponse(json_data={"verification_status": "SUCCESS"}), + ] + ) + mocker.patch( + "lnbits.core.services.fiat_providers.httpx.AsyncClient", + return_value=client, + ) + + await verify_paypal_webhook( + { + "PAYPAL-TRANSMISSION-ID": "tx-id", + "PAYPAL-TRANSMISSION-TIME": "2024-01-01T00:00:00Z", + "PAYPAL-TRANSMISSION-SIG": "signature", + "PAYPAL-CERT-URL": "https://cert.example.com", + "PAYPAL-AUTH-ALGO": "SHA256withRSA", + }, + b'{"id":"event-1"}', + ) + + assert client.calls[0][0] == "/v1/oauth2/token" + assert client.calls[1][0] == "/v1/notifications/verify-webhook-signature" + assert client.calls[1][1]["headers"]["Authorization"] == "Bearer token" + + +@pytest.mark.anyio +async def test_verify_paypal_webhook_raises_on_failed_verification( + settings: Settings, mocker: MockerFixture +): + settings.paypal_webhook_id = "webhook-id" + client = MockHTTPClient( + [ + MockHTTPResponse(json_data={"access_token": "token"}), + MockHTTPResponse(json_data={"verification_status": "FAILURE"}), + ] + ) + mocker.patch( + "lnbits.core.services.fiat_providers.httpx.AsyncClient", + return_value=client, + ) + + with pytest.raises(ValueError, match="PayPal webhook cannot be verified."): + await verify_paypal_webhook( + { + "PAYPAL-TRANSMISSION-ID": "tx-id", + "PAYPAL-TRANSMISSION-TIME": "2024-01-01T00:00:00Z", + "PAYPAL-TRANSMISSION-SIG": "signature", + "PAYPAL-CERT-URL": "https://cert.example.com", + "PAYPAL-AUTH-ALGO": "SHA256withRSA", + }, + b'{"id":"event-1"}', + ) + + +@pytest.mark.anyio +async def test_test_connection_reports_provider_status(mocker: MockerFixture): + mocker.patch( + "lnbits.core.services.fiat_providers.get_fiat_provider", + AsyncMock(return_value=None), + ) + missing_status = await fiat_provider_connection("stripe") + assert missing_status.success is False + assert missing_status.message == "Fiat provider 'stripe' not found." + + provider = mocker.Mock() + provider.status = AsyncMock( + return_value=FiatStatusResponse(error_message="bad key") + ) + mocker.patch( + "lnbits.core.services.fiat_providers.get_fiat_provider", + AsyncMock(return_value=provider), + ) + error_status = await fiat_provider_connection("stripe") + assert error_status.success is False + assert error_status.message == "Cconnection test failed: bad key" + + provider.status = AsyncMock(return_value=FiatStatusResponse(balance=21.0)) + success_status = await fiat_provider_connection("stripe") + assert success_status.success is True + assert success_status.message == "Connection test successful. Balance: 21.0." diff --git a/tests/unit/test_helpers.py b/tests/unit/test_helpers.py index 924640f80..499a58d6f 100644 --- a/tests/unit/test_helpers.py +++ b/tests/unit/test_helpers.py @@ -1,6 +1,39 @@ -import pytest +import hashlib -from lnbits.helpers import check_callback_url +import jwt +import pytest +from fastapi import FastAPI + +from lnbits.helpers import ( + camel_to_snake, + camel_to_words, + check_callback_url, + create_access_token, + decrypt_internal_message, + download_url, + encrypt_internal_message, + file_hash, + filter_dict_keys, + get_api_routes, + get_db_vendor_name, + is_camel_case, + is_lnbits_version_ok, + is_snake_case, + is_valid_email_address, + is_valid_external_id, + is_valid_label, + is_valid_pubkey, + is_valid_username, + lowercase_first_letter, + normalize_endpoint, + normalize_path, + path_segments, + sha256s, + snake_to_camel, + static_url_for, + url_for, + version_parse, +) from lnbits.settings import Settings @@ -82,3 +115,167 @@ def test_check_callback_url_multiple_rules(settings: Settings): settings.lnbits_callback_url_rules.append("https://localhost:3000") check_callback_url("https://localhost:3000/callback") # should not raise + + +def test_get_db_vendor_name(settings: Settings): + original_database_url = settings.lnbits_database_url + try: + settings.lnbits_database_url = None + assert get_db_vendor_name() == "SQLite" + + settings.lnbits_database_url = "postgres://localhost/db" + assert get_db_vendor_name() == "PostgreSQL" + + settings.lnbits_database_url = "cockroachdb://localhost/db" + assert get_db_vendor_name() == "CockroachDB" + finally: + settings.lnbits_database_url = original_database_url + + +def test_url_helpers(settings: Settings): + assert url_for("/api/v1/wallet", external=False, usr="user") == ( + "/api/v1/wallet?usr=user&" + ) + assert url_for("/api/v1/wallet", external=True, usr="user") == ( + f"http://{settings.host}:{settings.port}/api/v1/wallet?usr=user&" + ) + assert static_url_for("static", "bundle.min.js") == ( + f"/static/bundle.min.js?v={settings.server_startup_time}" + ) + + +@pytest.mark.parametrize( + ("value", "validator"), + [ + ("alice@example.com", is_valid_email_address), + ("alice_1", is_valid_username), + ("Label 1", is_valid_label), + ("external-id-1", is_valid_external_id), + ("a" * 64, is_valid_pubkey), + ], +) +def test_validation_helpers_valid(value, validator): + assert validator(value) is True + + +@pytest.mark.parametrize( + ("value", "validator"), + [ + ("alice@example", is_valid_email_address), + ("_alice", is_valid_username), + ("bad/label", is_valid_label), + ("contains spaces", is_valid_external_id), + ("xyz", is_valid_pubkey), + ], +) +def test_validation_helpers_invalid(value, validator): + assert validator(value) is False + + +def test_is_valid_external_id_rejects_long_and_multiline_values(): + assert is_valid_external_id("x" * 257) is False + assert is_valid_external_id("evil\nnewline") is False + + +def test_access_token_and_internal_message_helpers(settings: Settings): + token = create_access_token( + {"sub": "alice", "usr": None, "email": "alice@example.com"}, + token_expire_minutes=1, + ) + payload = jwt.decode(token, settings.auth_secret_key, ["HS256"]) + assert payload["sub"] == "alice" + assert payload["email"] == "alice@example.com" + assert "usr" not in payload + assert "exp" in payload + + assert encrypt_internal_message(None) is None + assert decrypt_internal_message(None) is None + + encrypted = encrypt_internal_message("secret-message", urlsafe=True) + assert encrypted is not None + assert decrypt_internal_message(encrypted, urlsafe=True) == "secret-message" + + +def test_filter_dict_keys_returns_copy_when_no_filters(): + original = {"a": 1, "b": 2} + + clone = filter_dict_keys(original, None) + filtered = filter_dict_keys(original, ["b", "missing"]) + + assert clone == original + assert clone is not original + assert filtered == {"b": 2} + + +def test_version_helpers(settings: Settings): + original_version = settings.version + try: + settings.version = "1.2.3" + assert version_parse("1.2.3rc4") == version_parse("1.2.3") + assert version_parse("invalid-version") == version_parse("0.0.0") + assert is_lnbits_version_ok("1.2.0", "2.0.0") is True + assert is_lnbits_version_ok("2.0.0", None) is False + assert is_lnbits_version_ok(None, "1.2.3") is False + finally: + settings.version = original_version + + +def test_download_url_rejects_non_http_schemes(tmp_path): + with pytest.raises( + ValueError, match="Invalid URL: ftp://example.com. Must start with 'http'" + ): + download_url("ftp://example.com", tmp_path / "download.bin") + + +def test_file_hash(tmp_path): + filename = tmp_path / "payload.txt" + filename.write_text("hello world") + + assert file_hash(filename) == hashlib.sha256(b"hello world").hexdigest() + + +def test_get_api_routes_extracts_v1_paths(): + app = FastAPI() + + @app.get("/api/v1/payments") + async def payments(): + return {} + + @app.get("/myext/api/v1/settings") + async def extension_settings(): + return {} + + @app.get("/health") + async def health(): + return {} + + routes = get_api_routes([*app.routes, object()]) + + assert routes == { + "/api/v1/payments": "Payments", + "/myext/api/v1": "Myext", + } + + +def test_path_and_case_helpers(): + assert path_segments("/wallet/path") == ["wallet", "path"] + assert path_segments("/upgrades/ext/assets/app.js") == ["assets", "app.js"] + assert normalize_path(None) == "/" + assert normalize_path("/upgrades/ext/assets/app.js") == "/assets/app.js" + assert normalize_endpoint("example.com/") == "https://example.com" + assert normalize_endpoint("ws://socket.example.com") == "ws://socket.example.com" + assert ( + normalize_endpoint("http://example.com/", add_proto=False) + == "http://example.com" + ) + + assert camel_to_words("CamelCaseName") == "Camel Case Name" + assert camel_to_snake("CamelCaseName") == "camel_case_name" + assert snake_to_camel("snake_case_name") == "snakeCaseName" + assert snake_to_camel("snake_case_name", capitalize_first=True) == "SnakeCaseName" + assert is_camel_case("CamelCase1") is True + assert is_camel_case("camelCase") is False + assert is_snake_case("snake_case_1") is True + assert is_snake_case("SnakeCase") is False + assert lowercase_first_letter("Hello") == "hello" + assert sha256s("hello") == hashlib.sha256(b"hello").hexdigest() diff --git a/tests/unit/test_helpers_query.py b/tests/unit/test_helpers_query.py index 5e2469f4a..7ca7af334 100644 --- a/tests/unit/test_helpers_query.py +++ b/tests/unit/test_helpers_query.py @@ -4,6 +4,7 @@ import pytest from lnbits.db import ( dict_to_model, + dict_to_submodel, insert_query, model_to_dict, update_query, @@ -83,3 +84,22 @@ async def test_helpers_dict_to_model(): assert m.active is True assert type(m.child) is DbTestModel2 assert type(m.child.child) is DbTestModel + + +@pytest.mark.anyio +async def test_helpers_dict_to_submodel(): + model = dict_to_submodel( + DbTestModel, + '{"id": 9, "name": "submodel", "value": "value"}', + ) + + assert model == DbTestModel(id=9, name="submodel", value="value") + assert dict_to_submodel(DbTestModel, "") is None + assert dict_to_submodel(DbTestModel, "null") is None + + +@pytest.mark.anyio +async def test_helpers_dict_to_model_ignores_unknown_fields(): + model = dict_to_model({**test_dict, "ignored": "field"}, DbTestModel3) + + assert model == test_data diff --git a/tests/unit/test_services_assets.py b/tests/unit/test_services_assets.py new file mode 100644 index 000000000..20751ab6d --- /dev/null +++ b/tests/unit/test_services_assets.py @@ -0,0 +1,109 @@ +from io import BytesIO +from uuid import uuid4 + +import pytest +from PIL import Image +from pytest_mock.plugin import MockerFixture + +from lnbits.core.crud import create_account +from lnbits.core.crud.assets import get_user_asset, get_user_assets_count +from lnbits.core.models import Account +from lnbits.core.services.assets import create_user_asset, thumbnail_from_bytes +from lnbits.settings import Settings +from tests.helpers import make_upload_file + + +@pytest.mark.anyio +async def test_create_user_asset_validates_upload_constraints( + settings: Settings, mocker: MockerFixture +): + file_without_type = make_upload_file(b"hello", filename="a.txt", content_type=None) + with pytest.raises(ValueError, match="File must have a content type."): + await create_user_asset("user-1", file_without_type, is_public=False) + + bad_type = make_upload_file( + b"hello", + filename="bad.bin", + content_type="application/x-msdownload", + ) + with pytest.raises( + ValueError, match="File type 'application/x-msdownload' not allowed." + ): + await create_user_asset("user-1", bad_type, is_public=False) + + original_max_assets = settings.lnbits_max_assets_per_user + original_max_size = settings.lnbits_max_asset_size_mb + original_no_limit_users = list(settings.lnbits_assets_no_limit_users) + try: + settings.lnbits_max_assets_per_user = 1 + settings.lnbits_max_asset_size_mb = 1 + settings.lnbits_assets_no_limit_users = [] + limited_user = await _create_user() + allowed_type = make_upload_file( + b"hello", filename="ok.txt", content_type="text/plain" + ) + await create_user_asset(limited_user, allowed_type, is_public=False) + + blocked_by_count = make_upload_file( + b"again", + filename="again.txt", + content_type="text/plain", + ) + with pytest.raises(ValueError, match="Max upload count of 1 exceeded."): + await create_user_asset(limited_user, blocked_by_count, is_public=False) + + settings.lnbits_max_asset_size_mb = 0.000001 + oversized_user = await _create_user() + large_file = make_upload_file( + b"0123456789", + filename="ok.txt", + content_type="text/plain", + ) + with pytest.raises(ValueError, match="File limit of 1e-06MB exceeded."): + await create_user_asset(oversized_user, large_file, is_public=False) + finally: + settings.lnbits_max_assets_per_user = original_max_assets + settings.lnbits_max_asset_size_mb = original_max_size + settings.lnbits_assets_no_limit_users = original_no_limit_users + + +@pytest.mark.anyio +async def test_create_user_asset_success(mocker: MockerFixture): + user_id = await _create_user() + mocker.patch( + "lnbits.core.services.assets.thumbnail_from_bytes", + return_value=None, + ) + file = make_upload_file(b"hello", filename="hello.txt", content_type="text/plain") + + asset = await create_user_asset(user_id, file, is_public=True) + stored = await get_user_asset(user_id, asset.id) + + assert asset.id + assert asset.user_id == user_id + assert asset.name == "hello.txt" + assert asset.size_bytes == 5 + assert asset.data == b"hello" + assert asset.is_public is True + assert stored is not None + assert stored.id == asset.id + assert stored.data == b"hello" + assert await get_user_assets_count(user_id) == 1 + + +def test_thumbnail_from_bytes_success_and_failure(): + image = Image.new("RGB", (512, 512), color="red") + buffer = BytesIO() + image.save(buffer, format="PNG") + + thumbnail = thumbnail_from_bytes(buffer.getvalue()) + + assert thumbnail is not None + assert isinstance(thumbnail.getvalue(), bytes) + assert thumbnail_from_bytes(b"not-an-image") is None + + +async def _create_user() -> str: + user_id = uuid4().hex + await create_account(Account(id=user_id, username=f"user_{user_id[:8]}")) + return user_id diff --git a/tests/unit/test_services_extensions.py b/tests/unit/test_services_extensions.py new file mode 100644 index 000000000..df6cb2d6d --- /dev/null +++ b/tests/unit/test_services_extensions.py @@ -0,0 +1,283 @@ +from types import SimpleNamespace +from uuid import uuid4 + +import pytest +from pytest_mock.plugin import MockerFixture + +from lnbits.core.crud import ( + create_installed_extension, + delete_installed_extension, + get_installed_extension, +) +from lnbits.core.models.extensions import ( + Extension, + InstallableExtension, + ReleasePaymentInfo, +) +from lnbits.core.services.extensions import ( + activate_extension, + deactivate_extension, + get_valid_extension, + get_valid_extensions, + install_extension, + start_extension_background_work, + stop_extension_background_work, + uninstall_extension, +) +from lnbits.settings import Settings +from tests.helpers import make_installable_extension + + +@pytest.mark.anyio +async def test_install_extension_rejects_incompatible_release( + tmp_path, settings: Settings +): + ext_info = make_installable_extension(f"ext_{uuid4().hex[:8]}", compatible=False) + original_data_folder = settings.lnbits_data_folder + original_extensions_path = settings.lnbits_extensions_path + try: + settings.lnbits_data_folder = str(tmp_path / "data") + settings.lnbits_extensions_path = str(tmp_path / "code") + + with pytest.raises(ValueError, match="Incompatible extension version"): + await install_extension(ext_info) + finally: + settings.lnbits_data_folder = original_data_folder + settings.lnbits_extensions_path = original_extensions_path + + +@pytest.mark.anyio +async def test_install_extension_creates_new_extension_and_starts_background_work( + tmp_path, settings: Settings, mocker: MockerFixture +): + ext_id = f"ext_{uuid4().hex[:8]}" + ext_info = make_installable_extension(ext_id) + original_data_folder = settings.lnbits_data_folder + original_extensions_path = settings.lnbits_extensions_path + download_mock = mocker.patch.object( + InstallableExtension, "download_archive", mocker.AsyncMock() + ) + extract_mock = mocker.patch.object(InstallableExtension, "extract_archive") + start_mock = mocker.patch( + "lnbits.core.services.extensions.start_extension_background_work", + mocker.AsyncMock(return_value=True), + ) + mocker.patch( + "lnbits.core.services.extensions.get_db_version", + mocker.AsyncMock(return_value=0), + ) + mocker.patch( + "lnbits.core.services.extensions.migrate_extension_database", + mocker.AsyncMock(), + ) + + try: + settings.lnbits_data_folder = str(tmp_path / "data") + settings.lnbits_extensions_path = str(tmp_path / "code") + + extension = await install_extension(ext_info) + stored = await get_installed_extension(ext_id) + finally: + await delete_installed_extension(ext_id=ext_id) + settings.lnbits_data_folder = original_data_folder + settings.lnbits_extensions_path = original_extensions_path + + assert extension.code == ext_id + assert stored is not None + download_mock.assert_awaited_once() + extract_mock.assert_called_once() + start_mock.assert_awaited_once_with(ext_id) + + +@pytest.mark.anyio +async def test_install_extension_updates_existing_upgrade_and_preserves_payments( + tmp_path, settings: Settings, mocker: MockerFixture +): + ext_id = f"ext_{uuid4().hex[:8]}" + existing_payment = ReleasePaymentInfo( + pay_link="https://pay.example", + payment_hash="payment-hash", + ) + existing_ext = make_installable_extension(ext_id, payments=[existing_payment]) + updated_ext = make_installable_extension(ext_id, version="2.0.0") + original_data_folder = settings.lnbits_data_folder + original_extensions_path = settings.lnbits_extensions_path + extract_mock = mocker.patch.object(InstallableExtension, "extract_archive") + start_mock = mocker.patch( + "lnbits.core.services.extensions.start_extension_background_work", + mocker.AsyncMock(return_value=True), + ) + stop_mock = mocker.patch( + "lnbits.core.services.extensions.stop_extension_background_work", + mocker.AsyncMock(return_value=True), + ) + mocker.patch( + "lnbits.core.services.extensions.get_db_version", + mocker.AsyncMock(return_value=1), + ) + mocker.patch( + "lnbits.core.services.extensions.migrate_extension_database", + mocker.AsyncMock(), + ) + + try: + settings.lnbits_data_folder = str(tmp_path / "data") + settings.lnbits_extensions_path = str(tmp_path / "code") + await create_installed_extension(existing_ext) + updated_ext.ext_upgrade_dir.mkdir(parents=True, exist_ok=True) + + extension = await install_extension(updated_ext, skip_download=True) + stored = await get_installed_extension(ext_id) + finally: + await delete_installed_extension(ext_id=ext_id) + settings.lnbits_data_folder = original_data_folder + settings.lnbits_extensions_path = original_extensions_path + + assert extension.code == ext_id + assert extension.is_upgrade_extension is True + assert stored is not None + assert stored.meta is not None + assert stored.meta.payments == [existing_payment] + extract_mock.assert_called_once() + stop_mock.assert_awaited_once_with(ext_id) + start_mock.assert_awaited_once_with(ext_id) + + +@pytest.mark.anyio +async def test_uninstall_activate_and_deactivate_extensions( + tmp_path, settings: Settings, mocker: MockerFixture +): + ext_id = f"ext_{uuid4().hex[:8]}" + ext_info = make_installable_extension(ext_id) + original_data_folder = settings.lnbits_data_folder + original_extensions_path = settings.lnbits_extensions_path + original_deactivated = set(settings.lnbits_deactivated_extensions) + stop_mock = mocker.patch( + "lnbits.core.services.extensions.stop_extension_background_work", + mocker.AsyncMock(return_value=True), + ) + start_mock = mocker.patch( + "lnbits.core.services.extensions.start_extension_background_work", + mocker.AsyncMock(return_value=True), + ) + clean_mock = mocker.patch.object(InstallableExtension, "clean_extension_files") + register_routes_mock = mocker.patch( + "lnbits.core.services.extensions.core_app_extra.register_new_ext_routes" + ) + + try: + settings.lnbits_data_folder = str(tmp_path / "data") + settings.lnbits_extensions_path = str(tmp_path / "code") + await create_installed_extension(ext_info) + + await uninstall_extension(ext_id) + assert await get_installed_extension(ext_id) is None + assert ext_id in settings.lnbits_deactivated_extensions + + await create_installed_extension(ext_info) + await activate_extension(Extension(code=ext_id, is_valid=True)) + active_ext = await get_installed_extension(ext_id) + assert active_ext is not None + assert active_ext.active is True + + await deactivate_extension(ext_id) + inactive_ext = await get_installed_extension(ext_id) + assert inactive_ext is not None + assert inactive_ext.active is False + assert ext_id in settings.lnbits_deactivated_extensions + finally: + await delete_installed_extension(ext_id=ext_id) + settings.lnbits_data_folder = original_data_folder + settings.lnbits_extensions_path = original_extensions_path + settings.lnbits_deactivated_extensions = original_deactivated + + clean_mock.assert_called_once() + register_routes_mock.assert_called_once() + assert stop_mock.await_count == 2 + assert start_mock.await_count == 1 + + +@pytest.mark.anyio +async def test_stop_extension_background_work_handles_missing_and_async_stops( + mocker: MockerFixture, +): + import_module_mock = mocker.patch( + "lnbits.core.services.extensions.importlib.import_module", + return_value=object(), + ) + + assert await stop_extension_background_work("demoext") is False + + called = {"stop": False} + + async def demoext_stop(): + called["stop"] = True + + import_module_mock.return_value = SimpleNamespace(demoext_stop=demoext_stop) + + assert await stop_extension_background_work("demoext") is True + assert called["stop"] is True + + +@pytest.mark.anyio +async def test_start_extension_background_work_handles_missing_and_sync_starts( + mocker: MockerFixture, +): + import_module_mock = mocker.patch( + "lnbits.core.services.extensions.importlib.import_module", + return_value=object(), + ) + + assert await start_extension_background_work("demoext") is False + + called = {"start": False} + + def demoext_start(): + called["start"] = True + + import_module_mock.return_value = SimpleNamespace(demoext_start=demoext_start) + + assert await start_extension_background_work("demoext") is True + assert called["start"] is True + + +@pytest.mark.anyio +async def test_get_valid_extensions_and_single_extension_respect_settings( + tmp_path, settings: Settings +): + ext_id_one = f"ext_{uuid4().hex[:8]}" + ext_id_two = f"ext_{uuid4().hex[:8]}" + ext_one = make_installable_extension(ext_id_one) + ext_two = make_installable_extension(ext_id_two) + original_deactivated = set(settings.lnbits_deactivated_extensions) + original_deactivate_all = settings.lnbits_extensions_deactivate_all + original_data_folder = settings.lnbits_data_folder + original_extensions_path = settings.lnbits_extensions_path + + try: + settings.lnbits_data_folder = str(tmp_path / "data") + settings.lnbits_extensions_path = str(tmp_path / "code") + settings.lnbits_deactivated_extensions = {ext_id_two} + settings.lnbits_extensions_deactivate_all = False + await create_installed_extension(ext_one) + await create_installed_extension(ext_two) + + valid_extensions = await get_valid_extensions(include_deactivated=False) + valid_codes = {ext.code for ext in valid_extensions} + assert ext_id_one in valid_codes + assert ext_id_two not in valid_codes + + assert ( + await get_valid_extension(ext_id_one, include_deactivated=True) is not None + ) + + settings.lnbits_extensions_deactivate_all = True + assert await get_valid_extensions(include_deactivated=False) == [] + assert await get_valid_extension(ext_id_one, include_deactivated=False) is None + finally: + await delete_installed_extension(ext_id=ext_id_one) + await delete_installed_extension(ext_id=ext_id_two) + settings.lnbits_deactivated_extensions = original_deactivated + settings.lnbits_extensions_deactivate_all = original_deactivate_all + settings.lnbits_data_folder = original_data_folder + settings.lnbits_extensions_path = original_extensions_path diff --git a/tests/unit/test_services_extensions_builder.py b/tests/unit/test_services_extensions_builder.py new file mode 100644 index 000000000..d33770f96 --- /dev/null +++ b/tests/unit/test_services_extensions_builder.py @@ -0,0 +1,107 @@ +import hashlib +import zipfile +from pathlib import Path +from types import SimpleNamespace + +import pytest +from pytest_mock.plugin import MockerFixture + +from lnbits.core.models.extensions import ExtensionRelease +from lnbits.core.services.extensions_builder import ( + build_extension_from_data, + clean_extension_builder_data, + zip_directory, +) +from lnbits.settings import Settings +from tests.helpers import make_extension_data + + +@pytest.mark.anyio +async def test_build_extension_from_data_orchestrates_builder_steps( + tmp_path, mocker: MockerFixture +): + data = make_extension_data() + release = ExtensionRelease( + name="stub", + version="0.1.0", + archive="https://example.com/stub.zip", + source_repo="org/repo", + is_github_release=True, + ) + build_dir = tmp_path / "build" + fetch_mock = mocker.patch( + "lnbits.core.services.extensions_builder._fetch_extension_builder_stub", + mocker.AsyncMock(), + ) + transform_mock = mocker.patch( + "lnbits.core.services.extensions_builder._transform_extension_builder_stub" + ) + export_mock = mocker.patch( + "lnbits.core.services.extensions_builder._export_extension_data_json" + ) + mocker.patch( + "lnbits.core.services.extensions_builder._get_extension_stub_release", + mocker.AsyncMock(return_value=release), + ) + mocker.patch( + "lnbits.core.services.extensions_builder._copy_ext_stub_to_build_dir", + return_value=build_dir, + ) + mocker.patch( + "lnbits.core.services.extensions_builder.uuid4", + return_value=SimpleNamespace(hex="seed"), + ) + + built_release, output_dir = await build_extension_from_data(data, "stub-ext") + + assert output_dir == build_dir + assert built_release == release + assert built_release.hash == hashlib.sha256(b"seed").hexdigest() + assert built_release.icon == "/demoext/static/image/demoext.png" + assert built_release.is_github_release is False + fetch_mock.assert_awaited_once_with("stub-ext", release) + transform_mock.assert_called_once_with(data, build_dir) + export_mock.assert_called_once_with(data, build_dir) + + +def test_clean_extension_builder_data_recreates_working_directory( + settings: Settings, tmp_path +): + original_data_folder = settings.lnbits_data_folder + try: + settings.lnbits_data_folder = str(tmp_path) + working_dir = settings.extension_builder_working_dir_path + working_dir.mkdir(parents=True, exist_ok=True) + Path(working_dir, "stale.txt").write_text("stale") + + clean_extension_builder_data() + + assert working_dir.is_dir() + assert list(working_dir.iterdir()) == [] + finally: + settings.lnbits_data_folder = original_data_folder + + +def test_zip_directory_skips_excluded_directories(tmp_path): + source_dir = tmp_path / "source" + zip_path = tmp_path / "archive.zip" + (source_dir / "nested").mkdir(parents=True) + (source_dir / "node_modules").mkdir() + (source_dir / "__pycache__").mkdir() + (source_dir / "root.txt").write_text("root") + (source_dir / "nested" / "file.txt").write_text("nested") + (source_dir / "node_modules" / "ignored.txt").write_text("ignored") + (source_dir / "__pycache__" / "ignored.pyc").write_text("ignored") + + from_builder = "lnbits.core.services.extensions_builder._is_excluded_dir" + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr( + from_builder, + lambda path: "node_modules" in path or "__pycache__" in path, + ) + zip_directory(source_dir, zip_path) + + with zipfile.ZipFile(zip_path) as archive: + names = sorted(archive.namelist()) + + assert names == ["nested/file.txt", "root.txt"] diff --git a/tests/unit/test_services_funding_source.py b/tests/unit/test_services_funding_source.py new file mode 100644 index 000000000..9c2fa568d --- /dev/null +++ b/tests/unit/test_services_funding_source.py @@ -0,0 +1,171 @@ +from types import SimpleNamespace +from typing import Any, cast +from uuid import uuid4 + +import pytest +from pytest_mock.plugin import MockerFixture + +from lnbits.core.crud import create_account, create_wallet, get_total_balance +from lnbits.core.models import Account +from lnbits.core.models.misc import BalanceDelta +from lnbits.core.services.funding_source import ( + check_balance_delta_changed, + check_server_balance_against_node, + get_balance_delta, + switch_to_voidwallet, +) +from lnbits.core.services.payments import update_wallet_balance +from lnbits.settings import Settings + + +@pytest.mark.anyio +async def test_switch_to_voidwallet_returns_when_already_using_voidwallet( + settings: Settings, mocker: MockerFixture +): + original_backend_class = settings.lnbits_backend_wallet_class + try: + settings.lnbits_backend_wallet_class = "FakeWallet" + mocker.patch( + "lnbits.core.services.funding_source.get_funding_source", + return_value=type("VoidWallet", (), {})(), + ) + set_funding_source = mocker.patch( + "lnbits.core.services.funding_source.set_funding_source" + ) + + await switch_to_voidwallet() + + set_funding_source.assert_not_called() + assert settings.lnbits_backend_wallet_class == "FakeWallet" + finally: + settings.lnbits_backend_wallet_class = original_backend_class + + +@pytest.mark.anyio +async def test_switch_to_voidwallet_updates_backend_class( + settings: Settings, mocker: MockerFixture +): + original_backend_class = settings.lnbits_backend_wallet_class + try: + settings.lnbits_backend_wallet_class = "FakeWallet" + mocker.patch( + "lnbits.core.services.funding_source.get_funding_source", + return_value=type("FakeWallet", (), {})(), + ) + set_funding_source = mocker.patch( + "lnbits.core.services.funding_source.set_funding_source" + ) + + await switch_to_voidwallet() + + set_funding_source.assert_called_once_with("VoidWallet") + assert settings.lnbits_backend_wallet_class == "VoidWallet" + finally: + settings.lnbits_backend_wallet_class = original_backend_class + + +@pytest.mark.anyio +async def test_get_balance_delta(mocker: MockerFixture): + baseline_balance = await get_total_balance() + await _create_wallet_with_balance(11) + funding_source = SimpleNamespace( + status=mocker.AsyncMock( + return_value=SimpleNamespace(balance_msat=7_000, error_message=None) + ) + ) + mocker.patch( + "lnbits.core.services.funding_source.get_funding_source", + return_value=funding_source, + ) + + delta = await get_balance_delta() + expected_balance_sats = (baseline_balance + 11_000) // 1000 + + assert delta.lnbits_balance_sats == expected_balance_sats + assert delta.node_balance_sats == 7 + assert delta.delta_sats == expected_balance_sats - 7 + + +@pytest.mark.anyio +async def test_check_server_balance_against_node_notifies_and_switches( + settings: Settings, mocker: MockerFixture +): + original_switch = settings.lnbits_watchdog_switch_to_voidwallet + original_notification = settings.lnbits_notification_watchdog + original_delta = settings.lnbits_watchdog_delta + try: + settings.lnbits_watchdog_switch_to_voidwallet = True + settings.lnbits_notification_watchdog = True + settings.lnbits_watchdog_delta = 5 + mocker.patch( + "lnbits.core.services.funding_source.get_funding_source", + return_value=type("FakeWallet", (), {})(), + ) + mocker.patch( + "lnbits.core.services.funding_source.get_balance_delta", + mocker.AsyncMock( + return_value=BalanceDelta( + lnbits_balance_sats=12, + node_balance_sats=1, + ) + ), + ) + enqueue = mocker.patch( + "lnbits.core.services.funding_source.enqueue_admin_notification" + ) + switch = mocker.patch( + "lnbits.core.services.funding_source.switch_to_voidwallet", + mocker.AsyncMock(), + ) + + await check_server_balance_against_node() + + enqueue.assert_called_once() + switch.assert_awaited_once() + finally: + settings.lnbits_watchdog_switch_to_voidwallet = original_switch + settings.lnbits_notification_watchdog = original_notification + settings.lnbits_watchdog_delta = original_delta + + +@pytest.mark.anyio +async def test_check_balance_delta_changed_tracks_and_notifies( + settings: Settings, mocker: MockerFixture +): + settings_any = cast(Any, settings) + original_latest = settings.latest_balance_delta_sats + original_threshold = settings.notification_balance_delta_threshold_sats + try: + settings_any.latest_balance_delta_sats = None + settings.notification_balance_delta_threshold_sats = 3 + mocker.patch( + "lnbits.core.services.funding_source.get_balance_delta", + mocker.AsyncMock( + side_effect=[ + BalanceDelta(lnbits_balance_sats=12, node_balance_sats=10), + BalanceDelta(lnbits_balance_sats=20, node_balance_sats=10), + ] + ), + ) + enqueue = mocker.patch( + "lnbits.core.services.funding_source.enqueue_admin_notification" + ) + + await check_balance_delta_changed() + enqueue.assert_not_called() + assert settings.latest_balance_delta_sats == 2 + + await check_balance_delta_changed() + enqueue.assert_called_once() + assert settings.latest_balance_delta_sats == 10 + finally: + settings_any.latest_balance_delta_sats = original_latest + settings.notification_balance_delta_threshold_sats = original_threshold + + +async def _create_wallet_with_balance(amount: int): + user_id = uuid4().hex + await create_account(Account(id=user_id, username=f"user_{user_id[:8]}")) + wallet = await create_wallet(user_id=user_id, wallet_name="wallet") + await update_wallet_balance(wallet=wallet, amount=amount) + return wallet diff --git a/tests/unit/test_services_lnurl.py b/tests/unit/test_services_lnurl.py new file mode 100644 index 000000000..4145da67e --- /dev/null +++ b/tests/unit/test_services_lnurl.py @@ -0,0 +1,182 @@ +from uuid import uuid4 + +import pytest +from bolt11.types import MilliSatoshi +from lnurl import ( + LnAddress, + LnurlErrorResponse, + LnurlPayActionResponse, + LnurlResponseException, + LnurlSuccessResponse, + LnurlWithdrawResponse, +) +from lnurl.types import CallbackUrl, LightningInvoice +from pydantic import parse_obj_as +from pytest_mock.plugin import MockerFixture + +from lnbits.core.crud import create_account, create_wallet, get_wallet +from lnbits.core.models import Account +from lnbits.core.models.lnurl import CreateLnurlPayment +from lnbits.core.models.wallets import Wallet +from lnbits.core.services.lnurl import ( + fetch_lnurl_pay_request, + get_pr_from_lnurl, + perform_withdraw, + store_paylink, +) +from tests.helpers import make_lnurl_pay_response + +TEST_BOLT11 = ( + "lnbc1pnsu5z3pp57getmdaxhg5kc9yh2a2qsh7cjf4gnccgkw0qenm8vsqv50w7s" + "ygqdqj0fjhymeqv9kk7atwwscqzzsxqyz5vqsp5e2yyqcp0a3ujeesp24ya0glej" + "srh703md8mrx0g2lyvjxy5w27ss9qxpqysgqyjreasng8a086kpkczv48er5c6l5" + "73aym6ynrdl9nkzqnag49vt3sjjn8qdfq5cr6ha0vrdz5c5r3v4aghndly0hplmv" + "6hjxepwp93cq398l3s" +) + + +@pytest.mark.anyio +async def test_perform_withdraw_success_and_validation(mocker: MockerFixture): + withdraw_response = LnurlWithdrawResponse( + callback=parse_obj_as(CallbackUrl, "https://example.com/callback"), + k1="k1", + minWithdrawable=MilliSatoshi(1), + maxWithdrawable=MilliSatoshi(1000), + defaultDescription="test", + ) + execute_withdraw_mock = mocker.patch( + "lnbits.core.services.lnurl.execute_withdraw", + mocker.AsyncMock(return_value=LnurlSuccessResponse()), + ) + mocker.patch( + "lnbits.core.services.lnurl.handle", + mocker.AsyncMock(return_value=withdraw_response), + ) + + await perform_withdraw("lnurl", "bolt11") + + execute_withdraw_mock.assert_awaited_once() + + mocker.patch( + "lnbits.core.services.lnurl.check_callback_url", + side_effect=ValueError("blocked"), + ) + with pytest.raises(LnurlResponseException, match="Invalid callback URL"): + await perform_withdraw("lnurl", "bolt11") + + +@pytest.mark.anyio +async def test_perform_withdraw_rejects_error_response(mocker: MockerFixture): + mocker.patch( + "lnbits.core.services.lnurl.handle", + mocker.AsyncMock(return_value=LnurlErrorResponse(reason="boom")), + ) + + with pytest.raises(LnurlResponseException, match="boom"): + await perform_withdraw("lnurl", "bolt11") + + +@pytest.mark.anyio +async def test_get_pr_from_lnurl_success_and_error(mocker: MockerFixture): + pay_response = make_lnurl_pay_response(min_sendable_msat=1, text="Test") + mocker.patch( + "lnbits.core.services.lnurl.handle", + mocker.AsyncMock(return_value=pay_response), + ) + mocker.patch( + "lnbits.core.services.lnurl.execute_pay_request", + mocker.AsyncMock( + return_value=LnurlPayActionResponse(pr=LightningInvoice(TEST_BOLT11)) + ), + ) + + assert await get_pr_from_lnurl("lnurl", 1000, comment="hello") == TEST_BOLT11 + + mocker.patch( + "lnbits.core.services.lnurl.handle", + mocker.AsyncMock(return_value=LnurlErrorResponse(reason="nope")), + ) + with pytest.raises(LnurlResponseException, match="nope"): + await get_pr_from_lnurl("lnurl", 1000) + + +@pytest.mark.anyio +async def test_fetch_lnurl_pay_request_converts_currency_and_stores_paylink( + mocker: MockerFixture, +): + pay_response = make_lnurl_pay_response(min_sendable_msat=1, text="Test") + action_response = LnurlPayActionResponse( + pr=LightningInvoice(TEST_BOLT11), disposable=False + ) + mocker.patch( + "lnbits.core.services.lnurl.fiat_amount_as_satoshis", + mocker.AsyncMock(return_value=100), + ) + execute_mock = mocker.patch( + "lnbits.core.services.lnurl.execute_pay_request", + mocker.AsyncMock(return_value=action_response), + ) + store_paylink_mock = mocker.patch( + "lnbits.core.services.lnurl.store_paylink", + mocker.AsyncMock(), + ) + wallet = _make_wallet() + + data = CreateLnurlPayment(res=pay_response, amount=2500, unit="USD", comment="hi") + response, action = await fetch_lnurl_pay_request(data, wallet=wallet) + + assert response == pay_response + assert action == action_response + execute_mock.assert_awaited_once() + assert execute_mock.await_args is not None + assert execute_mock.await_args.kwargs["msat"] == 100_000 + store_paylink_mock.assert_awaited_once_with( + pay_response, action_response, wallet, None + ) + + with pytest.raises(LnurlResponseException, match="No LNURL pay request provided."): + await fetch_lnurl_pay_request(CreateLnurlPayment(amount=1)) + + +@pytest.mark.anyio +async def test_store_paylink_appends_and_updates_existing(): + wallet = await _create_wallet() + pay_response = make_lnurl_pay_response(min_sendable_msat=1, text="Test") + action_response = LnurlPayActionResponse( + pr=LightningInvoice(TEST_BOLT11), disposable=False + ) + + await store_paylink( + pay_response, action_response, wallet, LnAddress("alice@example.com") + ) + stored_wallet = await get_wallet(wallet.id) + + assert stored_wallet is not None + assert len(stored_wallet.stored_paylinks.links) == 1 + assert stored_wallet.stored_paylinks.links[0].lnurl == "alice@example.com" + + first_used = stored_wallet.stored_paylinks.links[0].last_used + await store_paylink( + pay_response, action_response, wallet, LnAddress("alice@example.com") + ) + stored_wallet = await get_wallet(wallet.id) + + assert stored_wallet is not None + assert len(stored_wallet.stored_paylinks.links) == 1 + assert stored_wallet.stored_paylinks.links[0].last_used >= first_used + + +def _make_wallet() -> Wallet: + return Wallet( + id="wallet-id", + user="user-id", + name="Wallet", + adminkey="admin-key", + inkey="invoice-key", + ) + + +async def _create_wallet() -> Wallet: + user_id = uuid4().hex + await create_account(Account(id=user_id, username=f"user_{user_id[:8]}")) + return await create_wallet(user_id=user_id, wallet_name="Wallet") diff --git a/tests/unit/test_services_nostr.py b/tests/unit/test_services_nostr.py new file mode 100644 index 000000000..86cded033 --- /dev/null +++ b/tests/unit/test_services_nostr.py @@ -0,0 +1,117 @@ +import pytest +from pytest_mock.plugin import MockerFixture + +from lnbits.core.services.nostr import fetch_nip5_details, send_nostr_dm + + +class FakeWebSocket: + def __init__(self): + self.sent: list[str] = [] + self.closed = False + + def send(self, message: str): + self.sent.append(message) + + def close(self): + self.closed = True + + +class MockHTTPClient: + def __init__(self, response): + self.response = response + self.calls: list[str] = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def get(self, url: str): + self.calls.append(url) + return self.response + + +class MockHTTPResponse: + def __init__(self, json_data: dict, error: Exception | None = None): + self._json_data = json_data + self._error = error + + def raise_for_status(self): + if self._error: + raise self._error + + def json(self): + return self._json_data + + +@pytest.mark.anyio +async def test_send_nostr_dm_sends_to_available_relays_and_closes_connections( + mocker: MockerFixture, +): + event = mocker.Mock() + event.to_message.return_value = "nostr-message" + event.to_dict.return_value = {"id": "event-id"} + dm = mocker.Mock() + dm.to_event.return_value = event + mocker.patch("lnbits.core.services.nostr.EncryptedDirectMessage", return_value=dm) + + ws_one = FakeWebSocket() + ws_two = FakeWebSocket() + mocker.patch( + "lnbits.core.services.nostr.create_connection", + side_effect=[ws_one, RuntimeError("boom"), ws_two], + ) + mocker.patch("lnbits.core.services.nostr.asyncio.sleep", mocker.AsyncMock()) + + result = await send_nostr_dm( + "privkey", + "pubkey", + "hello", + ["wss://relay-1", "wss://broken", "wss://relay-2"], + ) + + assert ws_one.sent == ["nostr-message"] + assert ws_two.sent == ["nostr-message"] + assert ws_one.closed is True + assert ws_two.closed is True + assert result == {"id": "event-id"} + + +@pytest.mark.anyio +async def test_fetch_nip5_details_returns_pubkey_and_relays(mocker: MockerFixture): + response = MockHTTPResponse( + { + "names": {"alice": "f" * 64}, + "relays": {"f" * 64: ["wss://relay.example.com"]}, + } + ) + client = MockHTTPClient(response) + mocker.patch("lnbits.core.services.nostr.is_valid_url", return_value=True) + validate_identifier = mocker.patch("lnbits.core.services.nostr.validate_identifier") + validate_pub_key = mocker.patch("lnbits.core.services.nostr.validate_pub_key") + mocker.patch("lnbits.core.services.nostr.httpx.AsyncClient", return_value=client) + + pubkey, relays = await fetch_nip5_details("alice@example.com") + + validate_identifier.assert_called_once_with("alice") + validate_pub_key.assert_called_once_with("f" * 64) + assert client.calls == ["https://example.com/.well-known/nostr.json?name=alice"] + assert pubkey == "f" * 64 + assert relays == ["wss://relay.example.com"] + + +@pytest.mark.anyio +async def test_fetch_nip5_details_rejects_invalid_values(mocker: MockerFixture): + with pytest.raises(ValueError, match="not enough values to unpack"): + await fetch_nip5_details("invalid") + + mocker.patch("lnbits.core.services.nostr.is_valid_url", return_value=False) + with pytest.raises(ValueError, match="Invalid NIP5 domain"): + await fetch_nip5_details("alice@example.com") + + mocker.patch("lnbits.core.services.nostr.is_valid_url", return_value=True) + client = MockHTTPClient(MockHTTPResponse({"names": {}})) + mocker.patch("lnbits.core.services.nostr.httpx.AsyncClient", return_value=client) + with pytest.raises(ValueError, match="NIP5 not name found"): + await fetch_nip5_details("alice@example.com") diff --git a/tests/unit/test_services_notifications.py b/tests/unit/test_services_notifications.py new file mode 100644 index 000000000..d379f2478 --- /dev/null +++ b/tests/unit/test_services_notifications.py @@ -0,0 +1,608 @@ +import asyncio +from http import HTTPStatus +from types import SimpleNamespace +from unittest.mock import MagicMock +from uuid import uuid4 + +import httpx +import pytest +from pytest_mock.plugin import MockerFixture +from pywebpush import WebPushException + +from lnbits.core.crud import ( + create_account, + create_payment, + create_wallet, + create_webpush_subscription, + get_payment, + get_webpush_subscription, + update_payment, + update_wallet, +) +from lnbits.core.models import Account, CreatePayment, Payment, PaymentState, Wallet +from lnbits.core.models.notifications import NotificationType +from lnbits.core.models.users import UserExtra, UserNotifications +from lnbits.core.models.wallets import ( + WalletPermission, + WalletSharePermission, + WalletShareStatus, +) +from lnbits.core.services.notifications import ( + dispatch_webhook, + enqueue_admin_notification, + enqueue_user_notification, + process_next_notification, + send_admin_notification, + send_chat_payment_notification, + send_email, + send_email_notification, + send_nostr_notification, + send_nostr_notifications, + send_notification, + send_payment_notification, + send_payment_push_notification, + send_push_notification, + send_telegram_message, + send_telegram_notification, + send_user_notification, + send_ws_payment_notification, +) +from lnbits.settings import Settings + + +class MockHTTPClient: + def __init__(self, post_response=None, post_exception=None): + self.post_response = post_response + self.post_exception = post_exception + self.posts: list[tuple[str, dict]] = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def post(self, url, **kwargs): + self.posts.append((url, kwargs)) + if self.post_exception: + raise self.post_exception + return self.post_response + + +@pytest.mark.anyio +async def test_enqueue_and_process_notifications( + settings: Settings, mocker: MockerFixture +): + queue: asyncio.Queue = asyncio.Queue() + admin_mock = mocker.patch( + "lnbits.core.services.notifications.send_admin_notification", + mocker.AsyncMock(), + ) + user_mock = mocker.patch( + "lnbits.core.services.notifications.send_user_notification", + mocker.AsyncMock(), + ) + mocker.patch("lnbits.core.services.notifications.notifications_queue", queue) + mocker.patch( + "lnbits.core.services.notifications._is_message_type_enabled", + return_value=True, + ) + + enqueue_admin_notification(NotificationType.settings_update, {"username": "alice"}) + await process_next_notification() + + assert admin_mock.await_count == 1 + assert admin_mock.await_args is not None + assert admin_mock.await_args.args[0].startswith(f"[{settings.lnbits_site_title}]") + assert "alice" in admin_mock.await_args.args[0] + assert admin_mock.await_args.args[1] == NotificationType.settings_update.value + + user_notifications = UserNotifications(email_address="alice@example.com") + enqueue_user_notification( + NotificationType.text_message, + {"message": "hello"}, + user_notifications, + ) + await process_next_notification() + + assert user_mock.await_count == 1 + assert user_mock.await_args is not None + assert user_mock.await_args.args[0] == user_notifications + assert "hello" in user_mock.await_args.args[1] + assert user_mock.await_args.args[2] == NotificationType.text_message.value + + +@pytest.mark.anyio +async def test_send_admin_and_user_notification_use_expected_targets( + settings: Settings, mocker: MockerFixture +): + send_mock = mocker.patch( + "lnbits.core.services.notifications.send_notification", + mocker.AsyncMock(), + ) + original_chat_id = settings.lnbits_telegram_notifications_chat_id + original_identifiers = list(settings.lnbits_nostr_notifications_identifiers) + original_emails = list(settings.lnbits_email_notifications_to_emails) + try: + settings.lnbits_telegram_notifications_chat_id = "chat-id" + settings.lnbits_nostr_notifications_identifiers = ["alice@example.com"] + settings.lnbits_email_notifications_to_emails = ["admin@example.com"] + + await send_admin_notification("hello", "settings_update") + await send_user_notification( + UserNotifications( + telegram_chat_id="user-chat", + nostr_identifier="bob@example.com", + email_address="bob@example.com", + ), + "hello user", + "text_message", + ) + finally: + settings.lnbits_telegram_notifications_chat_id = original_chat_id + settings.lnbits_nostr_notifications_identifiers = original_identifiers + settings.lnbits_email_notifications_to_emails = original_emails + + assert send_mock.await_args_list[0].args == ( + "chat-id", + ["alice@example.com"], + ["admin@example.com"], + "hello", + "settings_update", + ) + assert send_mock.await_args_list[1].args == ( + "user-chat", + ["bob@example.com"], + ["bob@example.com"], + "hello user", + "text_message", + ) + + +@pytest.mark.anyio +async def test_send_notification_uses_available_channels_and_swallows_exceptions( + settings: Settings, mocker: MockerFixture +): + original_email_enabled = settings.lnbits_email_notifications_enabled + try: + settings.lnbits_email_notifications_enabled = True + mocker.patch.object( + type(settings), + "is_telegram_notifications_configured", + return_value=True, + ) + mocker.patch.object( + type(settings), + "is_nostr_notifications_configured", + return_value=True, + ) + telegram_mock = mocker.patch( + "lnbits.core.services.notifications.send_telegram_notification", + mocker.AsyncMock(side_effect=Exception("telegram boom")), + ) + nostr_mock = mocker.patch( + "lnbits.core.services.notifications.send_nostr_notifications", + mocker.AsyncMock(return_value=["alice@example.com"]), + ) + email_mock = mocker.patch( + "lnbits.core.services.notifications.send_email_notification", + mocker.AsyncMock(side_effect=Exception("email boom")), + ) + + await send_notification( + "chat-id", + ["alice@example.com"], + ["alice@example.com"], + "hello", + "text_message", + ) + finally: + settings.lnbits_email_notifications_enabled = original_email_enabled + + telegram_mock.assert_awaited_once() + nostr_mock.assert_awaited_once() + email_mock.assert_awaited_once() + + +@pytest.mark.anyio +async def test_send_nostr_notifications_and_single_notification( + mocker: MockerFixture, +): + send_mock = mocker.patch( + "lnbits.core.services.notifications.send_nostr_notification", + mocker.AsyncMock(side_effect=[None, Exception("boom"), None]), + ) + + result = await send_nostr_notifications(["ok-1", "bad", "ok-2"], "hello") + + assert result == ["ok-1", "ok-2"] + assert send_mock.await_count == 3 + + fetch_mock = mocker.patch( + "lnbits.core.services.notifications.fetch_nip5_details", + mocker.AsyncMock(return_value=("pubkey", ["wss://relay"])), + ) + normalize_mock = mocker.patch( + "lnbits.core.services.notifications.normalize_private_key", + return_value="server-private-key", + ) + dm_mock = mocker.patch( + "lnbits.core.services.notifications.send_nostr_dm", + mocker.AsyncMock(), + ) + + await send_nostr_notification("alice@example.com", "hello") + + fetch_mock.assert_awaited_once_with("alice@example.com") + normalize_mock.assert_called_once() + dm_mock.assert_awaited_once_with( + "server-private-key", + "pubkey", + "hello", + ["wss://relay"], + ) + + +@pytest.mark.anyio +async def test_send_telegram_message_and_wrapper( + settings: Settings, mocker: MockerFixture +): + response = httpx.Response( + 200, + request=httpx.Request("POST", "https://api.telegram.org"), + json={"ok": True}, + ) + client = MockHTTPClient(post_response=response) + mocker.patch( + "lnbits.core.services.notifications.httpx.AsyncClient", + return_value=client, + ) + + result = await send_telegram_message("token", "chat-id", "hello") + + assert result == {"ok": True} + assert client.posts[0][0].endswith("/bottoken/sendMessage") + + original_token = settings.lnbits_telegram_notifications_access_token + try: + settings.lnbits_telegram_notifications_access_token = "wrapper-token" + wrapper_mock = mocker.patch( + "lnbits.core.services.notifications.send_telegram_message", + mocker.AsyncMock(return_value={"ok": True}), + ) + + await send_telegram_notification("chat-id", "hello") + finally: + settings.lnbits_telegram_notifications_access_token = original_token + + wrapper_mock.assert_awaited_once_with("wrapper-token", "chat-id", "hello") + + +@pytest.mark.anyio +async def test_send_email_notification_and_send_email( + settings: Settings, mocker: MockerFixture +): + original_email_enabled = settings.lnbits_email_notifications_enabled + try: + settings.lnbits_email_notifications_enabled = False + disabled = await send_email_notification(["alice@example.com"], "hello") + assert disabled["status"] == "error" + + settings.lnbits_email_notifications_enabled = True + send_email_mock = mocker.patch( + "lnbits.core.services.notifications.send_email", + mocker.AsyncMock(return_value=True), + ) + enabled = await send_email_notification(["alice@example.com"], "hello") + assert enabled == {"status": "ok"} + send_email_mock.assert_awaited_once() + finally: + settings.lnbits_email_notifications_enabled = original_email_enabled + + smtp_server = MagicMock() + smtp_context = MagicMock() + smtp_context.__enter__.return_value = smtp_server + smtp_context.__exit__.return_value = None + mocker.patch( + "lnbits.core.services.notifications.smtplib.SMTP", + return_value=smtp_context, + ) + + assert ( + await send_email( + "smtp.example.com", + 587, + "", + "password", + "from@example.com", + ["to@example.com"], + "Subject", + "Body", + ) + is True + ) + smtp_server.starttls.assert_called_once() + smtp_server.login.assert_called_once_with("from@example.com", "password") + smtp_server.sendmail.assert_called_once() + + with pytest.raises(ValueError, match="Invalid from email address"): + await send_email( + "smtp.example.com", + 587, + "user", + "password", + "bad-email", + ["to@example.com"], + "Subject", + "Body", + ) + + with pytest.raises(ValueError, match="No email addresses provided"): + await send_email( + "smtp.example.com", + 587, + "user", + "password", + "from@example.com", + [], + "Subject", + "Body", + ) + + +@pytest.mark.anyio +async def test_dispatch_webhook_marks_missing_invalid_and_failed_requests( + mocker: MockerFixture, +): + wallet = await _create_wallet() + + payment = await _create_payment(wallet, webhook=None) + await dispatch_webhook(payment) + assert (await get_payment(payment.checking_id)).webhook_status == "-1" + + invalid_payment = await _create_payment(wallet, webhook="https://invalid.example") + assert invalid_payment.webhook is not None + invalid_client = MockHTTPClient( + post_response=httpx.Response( + 200, + request=httpx.Request("POST", invalid_payment.webhook), + json={"ok": True}, + ) + ) + mocker.patch( + "lnbits.core.services.notifications.check_callback_url", + side_effect=ValueError("blocked"), + ) + mocker.patch( + "lnbits.core.services.notifications.httpx.AsyncClient", + return_value=invalid_client, + ) + + await dispatch_webhook(invalid_payment) + assert (await get_payment(invalid_payment.checking_id)).webhook_status in { + "-1", + "200", + } + + error_payment = await _create_payment(wallet, webhook="https://error.example") + assert error_payment.webhook is not None + mocker.patch( + "lnbits.core.services.notifications.check_callback_url", + return_value=None, + ) + mocker.patch( + "lnbits.core.services.notifications.httpx.AsyncClient", + return_value=MockHTTPClient( + post_response=httpx.Response( + 500, + request=httpx.Request("POST", error_payment.webhook), + ) + ), + ) + + await dispatch_webhook(error_payment) + assert (await get_payment(error_payment.checking_id)).webhook_status == "500" + + request_payment = await _create_payment(wallet, webhook="https://request.example") + assert request_payment.webhook is not None + mocker.patch( + "lnbits.core.services.notifications.httpx.AsyncClient", + return_value=MockHTTPClient( + post_exception=httpx.RequestError( + "boom", + request=httpx.Request("POST", request_payment.webhook), + ) + ), + ) + + await dispatch_webhook(request_payment) + assert (await get_payment(request_payment.checking_id)).webhook_status == "-1" + + +@pytest.mark.anyio +async def test_send_payment_notification_fans_out_to_shared_wallet_and_webhook( + mocker: MockerFixture, +): + wallet = await _create_wallet(name="Primary Wallet") + shared_wallet = await _create_wallet(name="Shared Wallet") + wallet.extra.shared_with = [ + WalletSharePermission( + request_id="share-1", + username="bob", + shared_with_wallet_id=shared_wallet.id, + permissions=[WalletPermission.VIEW_PAYMENTS], + status=WalletShareStatus.APPROVED, + ) + ] + await update_wallet(wallet) + payment = await _create_payment(wallet, webhook="https://webhook.example") + ws_mock = mocker.patch( + "lnbits.core.services.notifications.send_ws_payment_notification", + mocker.AsyncMock(), + ) + chat_mock = mocker.patch( + "lnbits.core.services.notifications.send_chat_payment_notification", + mocker.AsyncMock(), + ) + push_mock = mocker.patch( + "lnbits.core.services.notifications.send_payment_push_notification", + mocker.AsyncMock(), + ) + dispatch_mock = mocker.patch( + "lnbits.core.services.notifications.dispatch_webhook", + mocker.AsyncMock(), + ) + + await send_payment_notification(wallet, payment) + + assert [call.args[0].id for call in ws_mock.await_args_list] == [ + wallet.id, + shared_wallet.id, + ] + chat_mock.assert_awaited_once_with(wallet, payment) + push_mock.assert_awaited_once_with(wallet, payment) + dispatch_mock.assert_awaited_once_with(payment) + + +@pytest.mark.anyio +async def test_send_ws_payment_notification_and_chat_notifications( + settings: Settings, mocker: MockerFixture +): + user_notifications = UserNotifications( + telegram_chat_id="chat-id", + nostr_identifier="alice@example.com", + email_address="alice@example.com", + incoming_payments_sats=1, + outgoing_payments_sats=1, + ) + wallet = await _create_wallet(user_notifications) + payment = await _create_payment( + wallet, + amount_msat=-2_000, + extra={"wallet_fiat_currency": "USD", "wallet_fiat_amount": 5.25}, + ) + websocket_mock = mocker.patch( + "lnbits.core.services.notifications.websocket_manager.send", + mocker.AsyncMock(), + ) + + await send_ws_payment_notification(wallet, payment) + + assert [call.args[0] for call in websocket_mock.await_args_list] == [ + wallet.inkey, + wallet.adminkey, + payment.payment_hash, + ] + + original_outgoing = settings.lnbits_notification_outgoing_payment_amount_sats + original_incoming = settings.lnbits_notification_incoming_payment_amount_sats + try: + settings.lnbits_notification_outgoing_payment_amount_sats = 1 + settings.lnbits_notification_incoming_payment_amount_sats = 1 + admin_mock = mocker.patch( + "lnbits.core.services.notifications.enqueue_admin_notification" + ) + user_mock = mocker.patch( + "lnbits.core.services.notifications.enqueue_user_notification" + ) + + await send_chat_payment_notification(wallet, payment) + finally: + settings.lnbits_notification_outgoing_payment_amount_sats = original_outgoing + settings.lnbits_notification_incoming_payment_amount_sats = original_incoming + + assert admin_mock.call_args.args[0] == NotificationType.outgoing_payment + assert "`5.25`*USD* / " in admin_mock.call_args.args[1]["fiat_value_fmt"] + assert user_mock.call_args.args[0] == NotificationType.outgoing_payment + + +@pytest.mark.anyio +async def test_send_payment_push_notification_and_cleanup_gone_subscriptions( + settings: Settings, mocker: MockerFixture +): + wallet = await _create_wallet() + payment = await _create_payment(wallet, amount_msat=2_000, memo="Thanks") + endpoint = f"https://push.example/{uuid4().hex}" + subscription = await create_webpush_subscription( + endpoint, + wallet.user, + '{"endpoint":"https://push.example"}', + "push.example", + ) + send_push_mock = mocker.patch( + "lnbits.core.services.notifications.send_push_notification", + mocker.AsyncMock(), + ) + + await send_payment_push_notification(wallet, payment) + + assert send_push_mock.await_args is not None + assert send_push_mock.await_args.args[0].endpoint == subscription.endpoint + assert send_push_mock.await_args.args[1] == f"LNbits: {wallet.name}" + assert "received 2 sats" in send_push_mock.await_args.args[2] + assert send_push_mock.await_args.args[3] == ( + f"https://{subscription.host}/wallet?usr={wallet.user}&wal={wallet.id}" + ) + + original_privkey = settings.lnbits_webpush_privkey + try: + settings.lnbits_webpush_privkey = "" + exc = WebPushException("gone") + exc.response = SimpleNamespace(status_code=HTTPStatus.GONE, text="gone") + mocker.patch( + "lnbits.core.services.notifications.webpush", + side_effect=exc, + ) + + await send_push_notification(subscription, "Title", "Body") + finally: + settings.lnbits_webpush_privkey = original_privkey + + assert await get_webpush_subscription(subscription.endpoint, wallet.user) is None + + +async def _create_wallet( + notifications: UserNotifications | None = None, + *, + name: str | None = None, +) -> Wallet: + account = Account( + id=uuid4().hex, + username=f"user_{uuid4().hex[:8]}", + extra=UserExtra(notifications=notifications or UserNotifications()), + ) + await create_account(account) + return await create_wallet( + user_id=account.id, + wallet_name=name or f"wallet_{account.id[:8]}", + ) + + +async def _create_payment( + wallet: Wallet, + *, + amount_msat: int = 2_000, + status: PaymentState = PaymentState.SUCCESS, + webhook: str | None = None, + webhook_status: str | None = None, + memo: str | None = "memo", + extra: dict | None = None, +) -> Payment: + checking_id = f"checking_{uuid4().hex[:8]}" + payment = await create_payment( + checking_id=checking_id, + data=CreatePayment( + wallet_id=wallet.id, + payment_hash=uuid4().hex, + bolt11=f"bolt11-{checking_id}", + amount_msat=amount_msat, + memo=memo or "", + webhook=webhook, + extra=extra or {}, + ), + status=status, + ) + if webhook_status is not None: + payment.webhook_status = webhook_status + await update_payment(payment) + return await get_payment(checking_id) diff --git a/tests/unit/test_services_payments.py b/tests/unit/test_services_payments.py new file mode 100644 index 000000000..c847363eb --- /dev/null +++ b/tests/unit/test_services_payments.py @@ -0,0 +1,456 @@ +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from uuid import uuid4 + +import pytest +from pytest_mock.plugin import MockerFixture + +from lnbits.core.crud import ( + create_account, + create_payment, + create_wallet, + get_payment, + get_payments, + update_payment, +) +from lnbits.core.models import ( + Account, + CreateInvoice, + CreatePayment, + PaymentState, + Wallet, +) +from lnbits.core.services.payments import ( + calculate_fiat_amounts, + cancel_hold_invoice, + check_payment_status, + check_pending_payments, + check_time_limit_between_transactions, + check_transaction_status, + check_wallet_limits, + create_payment_request, + get_payments_daily_stats, + settle_hold_invoice, + update_pending_payment, + update_pending_payments, + update_wallet_balance, +) +from lnbits.db import Filters +from lnbits.exceptions import InvoiceError, PaymentError +from lnbits.settings import Settings +from lnbits.wallets.base import ( + InvoiceResponse, + PaymentFailedStatus, + PaymentPendingStatus, + PaymentSuccessStatus, +) + + +@pytest.mark.anyio +async def test_create_payment_request_routes_by_invoice_type(mocker: MockerFixture): + wallet_payment = SimpleNamespace(checking_id="wallet") + fiat_payment = SimpleNamespace(checking_id="fiat") + wallet_mock = mocker.patch( + "lnbits.core.services.payments.create_wallet_invoice", + mocker.AsyncMock(return_value=wallet_payment), + ) + fiat_mock = mocker.patch( + "lnbits.core.services.payments.create_fiat_invoice", + mocker.AsyncMock(return_value=fiat_payment), + ) + + assert ( + await create_payment_request("wallet-1", CreateInvoice(amount=1)) + == wallet_payment + ) + assert ( + await create_payment_request( + "wallet-1", + CreateInvoice(amount=1, fiat_provider="stripe"), + ) + == fiat_payment + ) + wallet_mock.assert_awaited_once() + fiat_mock.assert_awaited_once() + + +@pytest.mark.anyio +async def test_update_pending_payment_and_bulk_pending_updates(mocker: MockerFixture): + wallet = await _create_wallet() + failed_id = await _create_payment(wallet) + success_id = await _create_payment(wallet) + failed_payment = await get_payment(failed_id) + success_payment = await get_payment(success_id) + + mocker.patch( + "lnbits.core.services.payments.check_payment_status", + mocker.AsyncMock(side_effect=[PaymentFailedStatus(), PaymentSuccessStatus()]), + ) + + await update_pending_payment(failed_payment) + await update_pending_payment(success_payment) + + assert (await get_payment(failed_id)).status == PaymentState.FAILED + assert (await get_payment(success_id)).status == PaymentState.SUCCESS + + bulk_wallet = await _create_wallet() + bulk_failed_id = await _create_payment(bulk_wallet) + bulk_success_id = await _create_payment(bulk_wallet) + mocker.patch( + "lnbits.core.services.payments.check_payment_status", + mocker.AsyncMock(side_effect=[PaymentFailedStatus(), PaymentSuccessStatus()]), + ) + + await update_pending_payments(bulk_wallet.id) + + bulk_statuses = { + (await get_payment(bulk_failed_id)).status, + (await get_payment(bulk_success_id)).status, + } + assert bulk_statuses == {PaymentState.FAILED, PaymentState.SUCCESS} + + +@pytest.mark.anyio +async def test_check_pending_payments_skips_voidwallet_and_updates_recent_items( + mocker: MockerFixture, +): + class VoidWallet: + pass + + class FakeWalletSource: + pass + + mocker.patch( + "lnbits.core.services.payments.get_funding_source", + return_value=VoidWallet(), + ) + sleep_mock = mocker.patch( + "lnbits.core.services.payments.asyncio.sleep", mocker.AsyncMock() + ) + + await check_pending_payments() + sleep_mock.assert_not_awaited() + + existing_pending = await get_payments(pending=True, exclude_uncheckable=True) + wallet = await _create_wallet() + checking_id = await _create_payment(wallet) + mocker.patch( + "lnbits.core.services.payments.get_funding_source", + return_value=FakeWalletSource(), + ) + mocker.patch( + "lnbits.core.services.payments.check_payment_status", + mocker.AsyncMock(return_value=PaymentSuccessStatus()), + ) + + try: + await check_pending_payments() + finally: + for payment in existing_pending: + payment.status = PaymentState.PENDING + await update_payment(payment) + + assert (await get_payment(checking_id)).status == PaymentState.SUCCESS + assert sleep_mock.await_count >= 1 + + +@pytest.mark.anyio +async def test_update_wallet_balance_validates_credit_and_debit( + settings: Settings, mocker: MockerFixture +): + wallet = await _create_wallet() + wallet.balance_msat = 20_000 + + with pytest.raises(ValueError, match="Amount cannot be 0."): + await update_wallet_balance(wallet, 0) + + with pytest.raises(ValueError, match="can not go into negative balance"): + await update_wallet_balance(wallet, -30) + + payment_secret = (uuid4().hex * 2)[:64] + payment_hash = (uuid4().hex * 2)[:64] + mocker.patch( + "lnbits.core.services.payments.random_secret_and_hash", + return_value=(payment_secret, payment_hash), + ) + mocker.patch( + "lnbits.core.services.payments.fake_privkey", + return_value="privkey", + ) + mocker.patch( + "lnbits.core.services.payments.bolt11_encode", + return_value="encoded-bolt11", + ) + + await update_wallet_balance(wallet, -10) + + debit_payment = await get_payment("internal_" + payment_hash) + assert debit_payment is not None + assert debit_payment.amount == -10_000 + assert debit_payment.status == PaymentState.SUCCESS + + original_max_balance = settings.lnbits_wallet_limit_max_balance + try: + settings.lnbits_wallet_limit_max_balance = 21 + with pytest.raises(ValueError, match="amount exceeds maximum balance"): + await update_wallet_balance(wallet, 5) + + settings.lnbits_wallet_limit_max_balance = 0 + queue_mock = mocker.patch( + "lnbits.tasks.internal_invoice_queue_put", + mocker.AsyncMock(), + ) + + await update_wallet_balance(wallet, 5) + finally: + settings.lnbits_wallet_limit_max_balance = original_max_balance + + credit_payments = [ + payment + for payment in await get_payments(wallet_id=wallet.id, incoming=True) + if payment.memo == "Admin credit" + ] + assert credit_payments + assert credit_payments[0].status == PaymentState.SUCCESS + queue_mock.assert_awaited_once_with(credit_payments[0].checking_id) + + +@pytest.mark.anyio +async def test_check_wallet_limits_and_time_limit( + settings: Settings, mocker: MockerFixture +): + time_limit_mock = mocker.patch( + "lnbits.core.services.payments.check_time_limit_between_transactions", + mocker.AsyncMock(), + ) + daily_limit_mock = mocker.patch( + "lnbits.core.services.payments.check_wallet_daily_withdraw_limit", + mocker.AsyncMock(), + ) + + await check_wallet_limits("wallet-1", 1_000) + + time_limit_mock.assert_awaited_once_with("wallet-1", None) + daily_limit_mock.assert_awaited_once_with("wallet-1", 1_000, None) + + wallet = await _create_wallet() + await _create_payment(wallet, amount_msat=-2_000) + original_limit = settings.lnbits_wallet_limit_secs_between_trans + try: + settings.lnbits_wallet_limit_secs_between_trans = 30 + with pytest.raises(PaymentError) as exc_info: + await check_time_limit_between_transactions(wallet.id) + assert "30 seconds between payments" in exc_info.value.message + + other_wallet = await _create_wallet() + assert await check_time_limit_between_transactions(other_wallet.id) is None + finally: + settings.lnbits_wallet_limit_secs_between_trans = original_limit + + +@pytest.mark.anyio +async def test_calculate_fiat_amounts_handles_conversion_and_errors( + mocker: MockerFixture, +): + wallet = await _create_wallet() + wallet.currency = "EUR" + mocker.patch( + "lnbits.core.services.payments.fiat_amount_as_satoshis", + mocker.AsyncMock(return_value=200), + ) + sat_to_fiat_mock = mocker.patch( + "lnbits.core.services.payments.satoshis_amount_as_fiat", + mocker.AsyncMock(return_value=1.5), + ) + + amount_sat, fiat_amounts = await calculate_fiat_amounts(2.0, wallet, "USD") + + assert amount_sat == 200 + assert fiat_amounts["fiat_currency"] == "USD" + assert fiat_amounts["wallet_fiat_currency"] == "EUR" + assert fiat_amounts["wallet_fiat_amount"] == 1.5 + + sat_to_fiat_mock.side_effect = Exception("boom") + amount_sat, fiat_amounts = await calculate_fiat_amounts(10, wallet, "sat", extra={}) + + assert amount_sat == 10 + assert fiat_amounts == {} + + +@pytest.mark.anyio +async def test_check_transaction_status_and_payment_status(mocker: MockerFixture): + wallet = await _create_wallet() + missing_hash = uuid4().hex + assert (await check_transaction_status(wallet.id, missing_hash)).pending is True + + success_hash = uuid4().hex + success_id = await _create_payment( + wallet, + status=PaymentState.SUCCESS, + payment_hash=success_hash, + fee=-123, + ) + success_status = await check_transaction_status(wallet.id, success_hash) + assert success_status.success is True + assert success_status.fee_msat == -123 + + pending_hash = uuid4().hex + await _create_payment(wallet, payment_hash=pending_hash) + mocker.patch( + "lnbits.core.services.payments.check_payment_status", + mocker.AsyncMock(return_value=PaymentFailedStatus()), + ) + assert (await check_transaction_status(wallet.id, pending_hash)).failed is True + + internal_success = await get_payment(success_id) + internal_success.checking_id = "internal_" + internal_success.payment_hash + internal_success.status = PaymentState.SUCCESS.value + assert (await check_payment_status(internal_success)).success is True + + internal_failed = await get_payment(success_id) + internal_failed.checking_id = "internal_" + internal_failed.payment_hash + internal_failed.status = PaymentState.FAILED.value + assert (await check_payment_status(internal_failed)).failed is True + + internal_fiat = await get_payment(success_id) + internal_fiat.checking_id = "fiat_" + internal_fiat.payment_hash + internal_fiat.status = PaymentState.PENDING.value + internal_fiat.fiat_provider = "stripe" + mocker.patch( + "lnbits.core.services.payments.check_fiat_status", + mocker.AsyncMock(return_value=SimpleNamespace(paid=True)), + ) + assert (await check_payment_status(internal_fiat)).success is True + + outgoing = await get_payment(success_id) + outgoing.checking_id = "external-out" + outgoing.amount = -2_000 + incoming = await get_payment(success_id) + incoming.checking_id = "external-in" + incoming.amount = 2_000 + funding_source = SimpleNamespace( + get_payment_status=mocker.AsyncMock(return_value=PaymentSuccessStatus()), + get_invoice_status=mocker.AsyncMock(return_value=PaymentPendingStatus()), + ) + mocker.patch( + "lnbits.core.services.payments.get_funding_source", + return_value=funding_source, + ) + + assert (await check_payment_status(outgoing)).success is True + assert (await check_payment_status(incoming)).pending is True + + +@pytest.mark.anyio +async def test_get_payments_daily_stats_fills_missing_dates(): + wallet = await _create_wallet() + user_id = wallet.user + now = datetime.now(timezone.utc).replace(hour=12, minute=0, second=0, microsecond=0) + await _create_payment( + wallet, + amount_msat=2_000, + status=PaymentState.SUCCESS, + time=now - timedelta(days=2), + ) + await _create_payment( + wallet, + amount_msat=-500, + status=PaymentState.SUCCESS, + fee=100, + time=now, + ) + + stats = await get_payments_daily_stats(Filters(), user_id=user_id) + + assert [point.date.date() for point in stats[-3:]] == [ + (now - timedelta(days=2)).date(), + (now - timedelta(days=1)).date(), + now.date(), + ] + assert [point.balance for point in stats[-3:]] == [2, 2, 1] + assert stats[-1].fee == 0 + + +@pytest.mark.anyio +async def test_settle_and_cancel_hold_invoice_persist_status(mocker: MockerFixture): + wallet = await _create_wallet() + checking_id = await _create_payment(wallet, payment_hash="33" * 32) + payment = await get_payment(checking_id) + funding_source = SimpleNamespace( + settle_hold_invoice=mocker.AsyncMock( + return_value=InvoiceResponse(ok=True, checking_id="settled") + ), + cancel_hold_invoice=mocker.AsyncMock( + return_value=InvoiceResponse(ok=True, checking_id="cancelled") + ), + ) + mocker.patch( + "lnbits.core.services.payments.get_funding_source", + return_value=funding_source, + ) + mocker.patch( + "lnbits.core.services.payments.verify_preimage", + return_value=False, + ) + + with pytest.raises(InvoiceError, match="Invalid preimage."): + await settle_hold_invoice(payment, "00" * 32) + + mocker.patch( + "lnbits.core.services.payments.verify_preimage", + return_value=True, + ) + + assert (await settle_hold_invoice(payment, "11" * 32)).ok is True + assert (await cancel_hold_invoice(payment)).ok is True + + stored = await get_payment(checking_id) + assert stored.preimage == "11" * 32 + assert stored.extra["hold_invoice_settled"] is True + assert stored.extra["hold_invoice_cancelled"] is True + assert stored.status == PaymentState.FAILED + + +def _account() -> Account: + account_id = uuid4().hex + return Account(id=account_id, username=f"user_{account_id[:8]}") + + +async def _create_wallet() -> Wallet: + account = _account() + await create_account(account) + return await create_wallet( + user_id=account.id, wallet_name=f"wallet_{account.id[:8]}" + ) + + +async def _create_payment( + wallet: Wallet, + *, + amount_msat: int = 2_000, + status: PaymentState = PaymentState.PENDING, + checking_id: str | None = None, + payment_hash: str | None = None, + fee: int = 0, + time: datetime | None = None, +) -> str: + checking_id = checking_id or f"checking_{uuid4().hex[:8]}" + payment_hash = payment_hash or uuid4().hex + payment = await create_payment( + checking_id=checking_id, + data=CreatePayment( + wallet_id=wallet.id, + payment_hash=payment_hash, + bolt11=f"bolt11-{checking_id}", + amount_msat=amount_msat, + memo="memo", + fee=fee, + ), + status=status, + ) + if time: + payment.time = time + payment.created_at = time + payment.updated_at = time + await update_payment(payment) + return checking_id diff --git a/tests/unit/test_services_settings.py b/tests/unit/test_services_settings.py new file mode 100644 index 000000000..3defd06f7 --- /dev/null +++ b/tests/unit/test_services_settings.py @@ -0,0 +1,153 @@ +import pytest +from pydantic import ValidationError +from pytest_mock.plugin import MockerFixture + +from lnbits.core.crud import ( + create_admin_settings, + delete_admin_settings, + get_super_settings, +) +from lnbits.core.crud.settings import get_settings_field +from lnbits.core.services.settings import ( + check_webpush_settings, + dict_to_settings, + update_cached_settings, +) +from lnbits.settings import Settings + + +class FakePublicKey: + def public_bytes(self, *_args, **_kwargs): + return b"public-bytes" + + +class FakeVapid: + def __init__(self, has_public_key: bool = True): + self.public_key = FakePublicKey() if has_public_key else None + + def generate_keys(self): + return None + + def private_pem(self): + return b"private-key" + + +def test_dict_to_settings_parses_known_values(): + parsed = dict_to_settings( + { + "lnbits_site_title": "Test Title", + "lnbits_service_fee": 5, + "ignored_field": "ignored", + } + ) + + assert parsed.lnbits_site_title == "Test Title" + assert parsed.lnbits_service_fee == 5 + assert not hasattr(parsed, "ignored_field") + + +def test_dict_to_settings_validates_invalid_values(): + with pytest.raises(ValidationError): + dict_to_settings({"lnbits_service_fee": "not-a-number"}) + + +def test_update_cached_settings_updates_runtime_values(settings: Settings): + original_title = settings.lnbits_site_title + original_host = settings.host + original_super_user = settings.super_user + try: + update_cached_settings( + { + "lnbits_site_title": "Updated", + "host": "forbidden-host", + "super_user": "super-user-id", + "missing_field": "ignored", + } + ) + + assert settings.lnbits_site_title == "Updated" + assert settings.host == original_host + assert settings.super_user == "super-user-id" + finally: + settings.lnbits_site_title = original_title + settings.host = original_host + settings.super_user = original_super_user + + +@pytest.mark.anyio +async def test_check_webpush_settings_generates_and_persists_keys( + settings: Settings, mocker: MockerFixture +): + previous_settings = await get_super_settings() + previous_private = settings.lnbits_webpush_privkey + previous_public = settings.lnbits_webpush_pubkey + previous_admin_ui = settings.lnbits_admin_ui + await delete_admin_settings() + + settings.lnbits_webpush_privkey = "" + settings.lnbits_webpush_pubkey = None + settings.lnbits_admin_ui = True + mocker.patch("lnbits.core.services.settings.Vapid", return_value=FakeVapid()) + mocker.patch( + "lnbits.core.services.settings.b64urlencode", return_value="public-key" + ) + try: + await check_webpush_settings() + + stored_private = await get_settings_field("lnbits_webpush_privkey") + stored_public = await get_settings_field("lnbits_webpush_pubkey") + + assert settings.lnbits_webpush_privkey == "private-key" + assert settings.lnbits_webpush_pubkey == "public-key" + assert stored_private is not None + assert stored_private.value == "private-key" + assert stored_public is not None + assert stored_public.value == "public-key" + finally: + await delete_admin_settings() + if previous_settings: + await create_admin_settings( + previous_settings.super_user, + previous_settings.dict(exclude={"super_user"}), + ) + update_cached_settings(previous_settings.dict()) + settings.lnbits_webpush_privkey = previous_private + settings.lnbits_webpush_pubkey = previous_public + settings.lnbits_admin_ui = previous_admin_ui + + +@pytest.mark.anyio +async def test_check_webpush_settings_requires_public_key( + settings: Settings, mocker: MockerFixture +): + mocker.patch.object(settings, "lnbits_webpush_privkey", "") + mocker.patch.object(settings, "lnbits_admin_ui", False) + mocker.patch( + "lnbits.core.services.settings.Vapid", + return_value=FakeVapid(has_public_key=False), + ) + + with pytest.raises(ValueError, match="VAPID public key does not exist"): + await check_webpush_settings() + + +@pytest.mark.anyio +async def test_check_webpush_settings_skips_generation_when_keys_exist( + settings: Settings, mocker: MockerFixture +): + previous_private = settings.lnbits_webpush_privkey + previous_public = settings.lnbits_webpush_pubkey + previous_private_field = await get_settings_field("lnbits_webpush_privkey") + previous_public_field = await get_settings_field("lnbits_webpush_pubkey") + settings.lnbits_webpush_privkey = "existing-private-key" + settings.lnbits_webpush_pubkey = "existing-public-key" + vapid = mocker.patch("lnbits.core.services.settings.Vapid") + try: + await check_webpush_settings() + finally: + settings.lnbits_webpush_privkey = previous_private + settings.lnbits_webpush_pubkey = previous_public + + assert await get_settings_field("lnbits_webpush_privkey") == previous_private_field + assert await get_settings_field("lnbits_webpush_pubkey") == previous_public_field + vapid.assert_not_called() diff --git a/tests/unit/test_services_users.py b/tests/unit/test_services_users.py new file mode 100644 index 000000000..be16d3ce7 --- /dev/null +++ b/tests/unit/test_services_users.py @@ -0,0 +1,407 @@ +from uuid import uuid4 + +import pytest + +from lnbits.core.crud import ( + create_account, + create_admin_settings, + create_user_extension, + delete_admin_settings, + get_account, + get_super_settings, + get_user_extensions, + get_wallets, +) +from lnbits.core.crud.settings import get_settings_field, set_settings_field +from lnbits.core.models import Account +from lnbits.core.models.extensions import UserExtension +from lnbits.core.models.users import RegisterUser +from lnbits.core.services.settings import update_cached_settings +from lnbits.core.services.users import ( + check_admin_settings, + check_register_activation_settings, + create_user_account, + create_user_account_no_ckeck, + init_admin_settings, + update_user_account, + update_user_extensions, +) +from lnbits.settings import Settings + + +@pytest.mark.anyio +async def test_create_user_account_rejects_when_registration_disabled( + settings: Settings, +): + settings.lnbits_allow_new_accounts = False + + with pytest.raises(ValueError, match="Account creation is disabled."): + await create_user_account() + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("existing_data", "new_data", "message"), + [ + ( + {"username": f"user_{uuid4().hex[:8]}"}, + {"username": lambda existing: existing.username}, + "Username already exists.", + ), + ( + {"email": f"{uuid4().hex[:8]}@example.com"}, + {"email": lambda existing: existing.email}, + "Email already exists.", + ), + ( + {"pubkey": f"{1:064x}"}, + {"pubkey": lambda existing: existing.pubkey}, + "Pubkey already exists.", + ), + ], +) +async def test_create_user_account_no_check_rejects_duplicate_identity_fields( + existing_data: dict, new_data: dict, message: str +): + existing = _account(**existing_data) + await create_account(existing) + + resolved = { + key: (value(existing) if callable(value) else value) + for key, value in new_data.items() + } + account = _account(**resolved) + + with pytest.raises(ValueError, match=message): + await create_user_account_no_ckeck(account) + + +@pytest.mark.anyio +async def test_create_user_account_no_check_creates_wallet_and_extensions( + settings: Settings, +): + account = _account() + original_default_exts = list(settings.lnbits_user_default_extensions) + try: + settings.lnbits_user_default_extensions = ["default-ext"] + + user = await create_user_account_no_ckeck( + account, + wallet_name="Primary", + default_exts=["extra-ext"], + ) + finally: + settings.lnbits_user_default_extensions = original_default_exts + + wallets = await get_wallets(user.id) + user_extensions = await get_user_extensions(user.id) + + assert len(wallets) == 1 + assert wallets[0].name == "Primary" + assert {ext.extension for ext in user_extensions} == {"default-ext", "extra-ext"} + assert all(ext.active is True for ext in user_extensions) + + +# TDOO: revisit for postgres +# @pytest.mark.anyio +# async def test_create_user_account_no_check_duplicate_extension_insert_behavior( +# settings: Settings, +# ): +# account = _account() +# original_default_exts = list(settings.lnbits_user_default_extensions) +# try: +# settings.lnbits_user_default_extensions = ["dup-ext"] +# if DB_TYPE == POSTGRES: +# with pytest.raises(DBAPIError, match="current transaction is aborted"): +# await create_user_account_no_ckeck(account, default_exts=["dup-ext"]) +# else: +# user = await +# create_user_account_no_ckeck(account, default_exts=["dup-ext"]) +# finally: +# settings.lnbits_user_default_extensions = original_default_exts + +# if DB_TYPE == POSTGRES: +# assert await get_account(account.id) is not None +# else: +# user_extensions = await get_user_extensions(user.id) +# assert [ext.extension for ext in user_extensions] == ["dup-ext"] + + +@pytest.mark.anyio +async def test_update_user_account_requires_existing_user(): + account = _account() + + with pytest.raises(ValueError, match="User does not exist."): + await update_user_account(account) + + +@pytest.mark.anyio +async def test_update_user_account_rejects_conflicting_identity_fields(): + existing = _account(pubkey=_pubkey(2)) + conflict = _account(pubkey=_pubkey(3)) + await create_account(existing) + await create_account(conflict) + + with pytest.raises(ValueError, match="Username already exists."): + await update_user_account( + _account( + id_=existing.id, + username=conflict.username, + email=existing.email, + pubkey=existing.pubkey, + ) + ) + + with pytest.raises(ValueError, match="Email already exists."): + await update_user_account( + _account( + id_=existing.id, + username=existing.username, + email=conflict.email, + pubkey=existing.pubkey, + ) + ) + + with pytest.raises(ValueError, match="Pubkey already exists."): + await update_user_account( + _account( + id_=existing.id, + username=existing.username, + email=existing.email, + pubkey=conflict.pubkey, + ) + ) + + +@pytest.mark.anyio +async def test_update_user_account_updates_persisting_password(): + account = _account(pubkey=_pubkey(4)) + account.hash_password("secret1234") + await create_account(account) + + updated = _account( + id_=account.id, + username=f"updated_{account.id[:8]}", + email=f"{account.id[:8]}+updated@example.com", + pubkey=(uuid4().hex * 2)[:64], + ) + result = await update_user_account(updated) + stored = await get_account(account.id) + + assert result.id == account.id + assert stored is not None + assert stored.username == updated.username + assert stored.email == updated.email + assert stored.pubkey == updated.pubkey + assert stored.password_hash == account.password_hash + + +@pytest.mark.anyio +async def test_update_user_extensions_toggles_existing_and_creates_missing( + settings: Settings, +): + original_default_exts = list(settings.lnbits_user_default_extensions) + try: + settings.lnbits_user_default_extensions = [] + user = await create_user_account(_account()) + finally: + settings.lnbits_user_default_extensions = original_default_exts + + await create_user_extension( + UserExtension(user=user.id, extension="keep", active=True) + ) + await create_user_extension( + UserExtension(user=user.id, extension="enable", active=False) + ) + await create_user_extension( + UserExtension(user=user.id, extension="disable", active=True) + ) + + await update_user_extensions(user.id, ["keep", "enable", "new-ext"]) + + user_extensions = { + ext.extension: ext.active for ext in await get_user_extensions(user.id) + } + assert user_extensions == { + "keep": True, + "enable": True, + "disable": False, + "new-ext": True, + } + + +@pytest.mark.anyio +async def test_check_admin_settings_initializes_cache_and_marks_first_install( + settings: Settings, tmp_path +): + previous_settings = await get_super_settings() + previous_super_user = settings.super_user + previous_data_folder = settings.lnbits_data_folder + previous_admin_ui = settings.lnbits_admin_ui + previous_first_install = settings.first_install + super_user = uuid4().hex + + try: + await delete_admin_settings() + settings.super_user = super_user + settings.lnbits_data_folder = str(tmp_path) + settings.lnbits_admin_ui = True + settings.first_install = False + + await check_admin_settings() + + stored_settings = await get_super_settings() + stored_account = await get_account(super_user) + assert stored_settings is not None + assert stored_settings.super_user == super_user + assert stored_account is not None + assert stored_account.extra.provider == "env" + assert settings.first_install is True + assert (tmp_path / ".super_user").read_text() == super_user + finally: + await delete_admin_settings() + if previous_settings: + await create_admin_settings( + previous_settings.super_user, + previous_settings.dict(exclude={"super_user"}), + ) + update_cached_settings(previous_settings.dict()) + settings.super_user = previous_super_user + settings.lnbits_data_folder = previous_data_folder + settings.lnbits_admin_ui = previous_admin_ui + settings.first_install = previous_first_install + + +@pytest.mark.anyio +async def test_init_admin_settings_creates_account_and_wallet_when_missing(): + super_user = uuid4().hex + + result = await init_admin_settings(super_user) + + wallets = await get_wallets(super_user) + assert result.super_user == super_user + assert await get_account(super_user) is not None + assert len(wallets) == 1 + + +@pytest.mark.anyio +async def test_check_register_activation_settings_handles_invitation_codes( + settings: Settings, +): + reusable = "reusable-code" + one_time = "one-time-code" + original_require_activation = settings.lnbits_require_user_activation + original_by_invite = settings.lnbits_user_activation_by_invitation_code + original_reusable = settings.lnbits_register_reusable_activation_code + original_one_time = list(settings.lnbits_register_one_time_activation_codes) + previous_stored_codes = await get_settings_field( + "lnbits_register_one_time_activation_codes" + ) + + try: + settings.lnbits_require_user_activation = False + assert ( + await check_register_activation_settings( + RegisterUser( + username=f"user_{uuid4().hex[:8]}", + password="secret1234", + password_repeat="secret1234", + ) + ) + is None + ) + + settings.lnbits_require_user_activation = True + settings.lnbits_user_activation_by_invitation_code = True + settings.lnbits_register_reusable_activation_code = reusable + settings.lnbits_register_one_time_activation_codes = [one_time] + + with pytest.raises(ValueError, match="Invitation code cannot be empty."): + await check_register_activation_settings( + RegisterUser( + username=f"user_{uuid4().hex[:8]}", + password="secret1234", + password_repeat="secret1234", + invitation_code=" ", + ) + ) + + assert ( + await check_register_activation_settings( + RegisterUser( + username=f"user_{uuid4().hex[:8]}", + password="secret1234", + password_repeat="secret1234", + invitation_code=reusable, + ) + ) + is None + ) + + assert ( + await check_register_activation_settings( + RegisterUser( + username=f"user_{uuid4().hex[:8]}", + password="secret1234", + password_repeat="secret1234", + invitation_code=one_time, + ) + ) + is None + ) + assert one_time not in settings.lnbits_register_one_time_activation_codes + stored_codes = await get_settings_field( + "lnbits_register_one_time_activation_codes" + ) + assert stored_codes is not None + assert stored_codes.value == [] + + with pytest.raises(ValueError, match="Invalid invitation code."): + await check_register_activation_settings( + RegisterUser( + username=f"user_{uuid4().hex[:8]}", + password="secret1234", + password_repeat="secret1234", + invitation_code="bad-code", + ) + ) + + settings.lnbits_user_activation_by_invitation_code = False + with pytest.raises(ValueError, match="No activation method provided."): + await check_register_activation_settings( + RegisterUser( + username=f"user_{uuid4().hex[:8]}", + password="secret1234", + password_repeat="secret1234", + invitation_code=reusable, + ) + ) + finally: + settings.lnbits_require_user_activation = original_require_activation + settings.lnbits_user_activation_by_invitation_code = original_by_invite + settings.lnbits_register_reusable_activation_code = original_reusable + settings.lnbits_register_one_time_activation_codes = original_one_time + await set_settings_field( + "lnbits_register_one_time_activation_codes", + previous_stored_codes.value if previous_stored_codes else original_one_time, + ) + + +def _pubkey(value: int) -> str: + return f"{value:064x}" + + +def _account( + *, + id_: str | None = None, + username: str | None = None, + email: str | None = None, + pubkey: str | None = None, +) -> Account: + account_id = id_ or uuid4().hex + return Account( + id=account_id, + username=username or f"user_{account_id[:8]}", + email=email or f"{account_id[:8]}@example.com", + pubkey=pubkey, + ) diff --git a/tests/unit/test_services_websockets.py b/tests/unit/test_services_websockets.py new file mode 100644 index 000000000..a495fb677 --- /dev/null +++ b/tests/unit/test_services_websockets.py @@ -0,0 +1,76 @@ +from typing import cast + +import pytest +from fastapi import WebSocket, WebSocketDisconnect +from pytest_mock.plugin import MockerFixture + +from lnbits.core.services.websockets import ( + WebsocketConnectionManager, + websocket_updater, +) +from lnbits.settings import Settings + + +class FakeWebSocket: + def __init__(self, received=None): + self.received = list(received or []) + self.accepted = False + self.sent: list[str] = [] + + async def accept(self): + self.accepted = True + + async def receive_text(self): + if self.received: + value = self.received.pop(0) + if isinstance(value, Exception): + raise value + return value + raise WebSocketDisconnect() + + async def send_text(self, data: str): + self.sent.append(data) + + +@pytest.mark.anyio +async def test_websocket_connection_manager_connect_and_send(): + manager = WebsocketConnectionManager() + websocket = FakeWebSocket() + + conn = await manager.connect("item-1", cast(WebSocket, websocket)) + await manager.send("item-1", "payload") + + assert websocket.accepted is True + assert manager.has_connection("item-1") is True + assert manager.get_connections("item-1") == [conn] + assert websocket.sent == ["payload"] + + +@pytest.mark.anyio +async def test_websocket_connection_manager_listen_queues_messages_and_disconnects( + settings: Settings, +): + manager = WebsocketConnectionManager() + websocket = FakeWebSocket(["hello", WebSocketDisconnect()]) + conn = await manager.connect("item-2", cast(WebSocket, websocket)) + original_running = settings.lnbits_running + try: + settings.lnbits_running = True + await manager.listen(conn) + finally: + settings.lnbits_running = original_running + + assert conn.receive_queue.get_nowait() == "hello" + assert manager.has_connection("item-2") is False + + +@pytest.mark.anyio +async def test_websocket_updater_delegates_to_manager(mocker: MockerFixture): + send = mocker.patch( + "lnbits.core.services.websockets.websocket_manager.send", + mocker.AsyncMock(), + ) + + await websocket_updater("item-3", "data") + + send.assert_awaited_once_with("item-3", "data") diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index a300afb0f..6582ea45e 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -1,6 +1,18 @@ import pytest +from pytest_mock.plugin import MockerFixture -from lnbits.settings import RedirectPath +from lnbits.settings import ( + AssetSettings, + ExchangeRateProvider, + InstalledExtensionsSettings, + NotificationsSettings, + PublicSettings, + RedirectPath, + SecuritySettings, + Settings, + list_parse_fallback, + set_cli_settings, +) lnurlp_redirect_path = { "from_path": "/.well-known/lnurlp", @@ -166,3 +178,157 @@ def test_redirect_path_new_path_from(lnurlp: RedirectPath): lnurlp.new_path_from("/.well-known/lnurlp/path/more") == "/lnurlp/api/v1/well-known/path/more" ) + + +def test_list_parse_fallback(): + assert list_parse_fallback("a, b, c") == ["a", "b", "c"] + assert list_parse_fallback('["a", "b"]') == ["a", "b"] + assert list_parse_fallback("") == [] + + +def test_exchange_rate_provider_convert_ticker(): + provider = ExchangeRateProvider( + name="Provider", + api_url="https://example.com", + path="$.price", + ticker_conversion=["USD:USDT"], + ) + invalid_provider = ExchangeRateProvider( + name="Invalid", + api_url="https://example.com", + path="$.price", + ticker_conversion=["invalid"], + ) + + assert provider.convert_ticker("USD") == "USDT" + assert provider.convert_ticker("EUR") == "EUR" + assert invalid_provider.convert_ticker("USD") == "USD" + + +def test_installed_extensions_settings_activate_and_deactivate_paths(): + installed = InstalledExtensionsSettings() + redirects = [ + { + "from_path": "/.well-known/lnurlp", + "redirect_to_path": "/api/v1/well-known", + } + ] + + installed.activate_extension_paths( + "lnurlp", + upgrade_hash="hash123", + ext_redirects=redirects, + ) + + redirect = installed.find_extension_redirect("/.well-known/lnurlp", []) + assert redirect is not None + assert redirect.ext_id == "lnurlp" + assert installed.lnbits_upgraded_extensions["lnurlp"] == "hash123" + assert "lnurlp" in installed.lnbits_installed_extensions_ids + + installed.deactivate_extension_paths("lnurlp") + + assert "lnurlp" in installed.lnbits_deactivated_extensions + assert installed.find_extension_redirect("/.well-known/lnurlp", []) is None + + +def test_installed_extensions_settings_detects_conflicting_redirects(): + installed = InstalledExtensionsSettings( + lnbits_extensions_redirects=[ + RedirectPath( + ext_id="ext_a", + from_path="/.well-known/lnurlp", + redirect_to_path="/api/v1/well-known", + ) + ] + ) + + with pytest.raises(ValueError, match="Cannot redirect for extension 'ext_b'"): + installed.activate_extension_paths( + "ext_b", + ext_redirects=[ + { + "from_path": "/.well-known/lnurlp", + "redirect_to_path": "/api/v1/well-known", + } + ], + ) + + +def test_settings_helper_methods(settings: Settings, mocker: MockerFixture): + mocker.patch.object(settings, "super_user", "super-user") + mocker.patch.object(settings, "lnbits_admin_users", ["admin-user"]) + mocker.patch.object(settings, "lnbits_allowed_users", ["allowed-user"]) + mocker.patch.object(settings, "lnbits_installed_extensions_ids", {"installed"}) + mocker.patch.object(settings, "lnbits_all_extensions_ids", {"installed", "new"}) + + assert settings.is_user_allowed("allowed-user") is True + assert settings.is_user_allowed("admin-user") is True + assert settings.is_user_allowed("super-user") is True + assert settings.is_user_allowed("random-user") is False + assert settings.is_super_user("super-user") is True + assert settings.is_admin_user("admin-user") is True + assert settings.is_installed_extension_id("installed") is True + assert settings.is_ready_to_install_extension_id("new") is True + assert settings.is_ready_to_install_extension_id("installed") is False + + +def test_asset_security_and_notification_helpers( + settings: Settings, mocker: MockerFixture +): + mocker.patch.object(settings, "super_user", "super-user") + mocker.patch.object(settings, "lnbits_admin_users", ["admin-user"]) + + asset_settings = AssetSettings(lnbits_assets_no_limit_users=["vip-user"]) + security_settings = SecuritySettings(lnbits_wallet_limit_max_balance=100) + notification_settings = NotificationsSettings( + lnbits_nostr_notifications_enabled=True, + lnbits_nostr_notifications_private_key="nostr-key", + lnbits_telegram_notifications_enabled=True, + lnbits_telegram_notifications_access_token="telegram-token", + ) + + assert asset_settings.is_unlimited_assets_user("admin-user") is True + assert asset_settings.is_unlimited_assets_user("vip-user") is True + assert asset_settings.is_unlimited_assets_user("random-user") is False + assert security_settings.is_wallet_max_balance_exceeded(101) is True + assert security_settings.is_wallet_max_balance_exceeded(100) is False + assert notification_settings.is_nostr_notifications_configured() is True + assert notification_settings.is_telegram_notifications_configured() is True + + +def test_public_settings_from_settings(settings: Settings): + original_site_title = settings.lnbits_site_title + original_ad_space = settings.lnbits_ad_space + original_ad_space_enabled = settings.lnbits_ad_space_enabled + original_installed_extensions = settings.lnbits_installed_extensions_ids + original_first_install_token = settings.first_install_token + try: + settings.lnbits_site_title = "Test LNbits" + settings.lnbits_ad_space = "https://example.com;/banner.png;/thumb.png" + settings.lnbits_ad_space_enabled = True + settings.lnbits_installed_extensions_ids = {"ext_a"} + settings.first_install_token = "token" + + public = PublicSettings.from_settings(settings) + + assert public.site_title == "Test LNbits" + assert public.show_ad_space is True + assert public.ad_space == [["https://example.com", "/banner.png", "/thumb.png"]] + assert set(public.extensions) == {"ext_a"} + assert public.has_first_install_token is True + finally: + settings.lnbits_site_title = original_site_title + settings.lnbits_ad_space = original_ad_space + settings.lnbits_ad_space_enabled = original_ad_space_enabled + settings.lnbits_installed_extensions_ids = original_installed_extensions + settings.first_install_token = original_first_install_token + + +def test_set_cli_settings_updates_runtime_settings(settings: Settings): + original_host = settings.host + try: + set_cli_settings(host="0.0.0.0") # noqa S104 + assert settings.host == "0.0.0.0" # noqa S104 + finally: + settings.host = original_host