[test] Codex tests (#3911)

This commit is contained in:
Vlad Stan
2026-03-31 09:48:43 +03:00
committed by GitHub
parent 6b3fd80e46
commit 183e6e5661
40 changed files with 6327 additions and 15 deletions
+1
View File
@@ -66,6 +66,7 @@ test-regtest:
LNBITS_DATA_FOLDER="./tests/data" \ LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
DEBUG=true \ DEBUG=true \
rm -rf ./tests/data \
uv run pytest tests/regtest uv run pytest tests/regtest
test-migration: test-migration:
+4 -2
View File
@@ -1,4 +1,6 @@
from pydantic import BaseModel from datetime import datetime, timezone
from pydantic import BaseModel, Field
class TinyURL(BaseModel): class TinyURL(BaseModel):
@@ -6,4 +8,4 @@ class TinyURL(BaseModel):
url: str url: str
endless: bool endless: bool
wallet: str wallet: str
time: float time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+109
View File
@@ -1,6 +1,9 @@
from pathlib import Path
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from lnbits.server import server_restart
from lnbits.settings import Settings from lnbits.settings import Settings
@@ -49,3 +52,109 @@ async def test_admin_update_noneditable_settings(
headers={"Authorization": f"Bearer {superuser_token}"}, headers={"Authorization": f"Bearer {superuser_token}"},
) )
assert response.status_code == 400 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()
+2 -2
View File
@@ -507,9 +507,9 @@ async def test_api_payment_without_key(invoice: Payment):
# check api_payment() internal function call (NOT API): payment status # check api_payment() internal function call (NOT API): payment status
@pytest.mark.anyio @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 # 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 isinstance(response, dict)
assert response["paid"] is True assert response["paid"] is True
assert "details" in response assert "details" in response
+135
View File
@@ -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
+64
View File
@@ -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"
+161
View File
@@ -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
+175
View File
@@ -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"}},
}
)
+430
View File
@@ -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_")
+109
View File
@@ -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
+146
View File
@@ -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
+142
View File
@@ -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)
+366
View File
@@ -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
+187
View File
@@ -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
+71
View File
@@ -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"]
+102
View File
@@ -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"
+190
View File
@@ -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"}
+31
View File
@@ -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"}
+3 -1
View File
@@ -40,7 +40,9 @@ from tests.helpers import (
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
ADMIN_USER_ID = uuid4().hex 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( _PURE_SETTINGS_FIELDS = tuple(
sorted( sorted(
{ {
+24
View File
@@ -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
+144 -1
View File
@@ -1,8 +1,33 @@
import random import random
import string 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 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)}"} 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() set_funding_source()
funding_source = get_funding_source() funding_source = get_funding_source()
is_fake: bool = funding_source.__class__.__name__ == "FakeWallet" is_fake: bool = funding_source.__class__.__name__ == "FakeWallet"
is_regtest: bool = not is_fake is_regtest: bool = not is_fake
+56 -1
View File
@@ -1,8 +1,11 @@
import asyncio import asyncio
from time import time
import pytest 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" key = "foo"
value = "bar" value = "bar"
@@ -59,3 +62,55 @@ async def test_cache_coro(cache):
await cache.save_result(test, key="test") await cache.save_result(test, key="test")
result = await cache.save_result(test, key="test") result = await cache.save_result(test, key="test")
assert result == called == 1 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")
+91 -2
View File
@@ -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 @pytest.mark.anyio
@@ -18,3 +27,83 @@ async def test_aes_encrypt_decrypt(key):
encrypted_text = aes.encrypt(original_text.encode()) encrypted_text = aes.encrypt(original_text.encode())
decrypted_text = aes.decrypt(encrypted_text) decrypted_text = aes.decrypt(encrypted_text)
assert original_text == decrypted_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)
+90 -1
View File
@@ -11,7 +11,16 @@ from pydantic.types import UUID4
from lnbits.core.crud.users import delete_account from lnbits.core.crud.users import delete_account
from lnbits.core.models import User from lnbits.core.models import User
from lnbits.core.models.users import AccessTokenPayload 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 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)) await check_user_exists(request, access_token=None, usr=UUID4(user_alan.id))
assert exc_info.value.status_code == 401 assert exc_info.value.status_code == 401
assert exc_info.value.detail == "Missing user ID or access token." 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)
+207
View File
@@ -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 ( from lnbits.utils.exchange_rates import (
allowed_currencies,
apply_trimmed_mean_filter, 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: class TestApplyTrimmedMeanFilter:
"""Test the trimmed mean filtering function""" """Test the trimmed mean filtering function"""
@@ -123,3 +169,164 @@ class TestApplyTrimmedMeanFilter:
# Should keep the rate at exactly 1% deviation # Should keep the rate at exactly 1% deviation
assert len(result) == 2 assert len(result) == 2
assert result == rates 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")
+232 -2
View File
@@ -9,20 +9,65 @@ from pytest_mock.plugin import MockerFixture
from lnbits.core.crud.payments import get_payments from lnbits.core.crud.payments import get_payments
from lnbits.core.crud.users import get_user from lnbits.core.crud.users import get_user
from lnbits.core.crud.wallets import create_wallet 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.users import User
from lnbits.core.models.wallets import Wallet from lnbits.core.models.wallets import Wallet
from lnbits.core.services import check_payment_status, payments from lnbits.core.services import check_payment_status, payments
from lnbits.core.services.fiat_providers import ( from lnbits.core.services.fiat_providers import (
check_fiat_status,
check_stripe_signature, check_stripe_signature,
handle_fiat_payment_confirmation, 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.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 lnbits.settings import Settings
from tests.helpers import get_random_string 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 @pytest.mark.anyio
async def test_create_wallet_fiat_invoice_missing_provider(): async def test_create_wallet_fiat_invoice_missing_provider():
invoice_data = CreateInvoice( invoice_data = CreateInvoice(
@@ -433,3 +478,188 @@ def _make_stripe_sig_header(payload, secret, timestamp=None):
secret.encode(), signed_payload.encode(), hashlib.sha256 secret.encode(), signed_payload.encode(), hashlib.sha256
).hexdigest() ).hexdigest()
return f"t={timestamp},v1={signature}", timestamp, signature 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."
+199 -2
View File
@@ -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 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") settings.lnbits_callback_url_rules.append("https://localhost:3000")
check_callback_url("https://localhost:3000/callback") # should not raise 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()
+20
View File
@@ -4,6 +4,7 @@ import pytest
from lnbits.db import ( from lnbits.db import (
dict_to_model, dict_to_model,
dict_to_submodel,
insert_query, insert_query,
model_to_dict, model_to_dict,
update_query, update_query,
@@ -83,3 +84,22 @@ async def test_helpers_dict_to_model():
assert m.active is True assert m.active is True
assert type(m.child) is DbTestModel2 assert type(m.child) is DbTestModel2
assert type(m.child.child) is DbTestModel 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
+109
View File
@@ -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
+283
View File
@@ -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
@@ -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"]
+171
View File
@@ -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
+182
View File
@@ -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")
+117
View File
@@ -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")
+608
View File
@@ -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)
+456
View File
@@ -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
+153
View File
@@ -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()
+407
View File
@@ -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,
)
+76
View File
@@ -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")
+167 -1
View File
@@ -1,6 +1,18 @@
import pytest 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 = { lnurlp_redirect_path = {
"from_path": "/.well-known/lnurlp", "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.new_path_from("/.well-known/lnurlp/path/more")
== "/lnurlp/api/v1/well-known/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