[test] Codex tests (#3911)
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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"}},
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -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_")
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
@@ -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"}
|
||||||
@@ -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
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user