test: some tests
This commit is contained in:
@@ -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,52 @@
|
|||||||
|
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 +167,162 @@ 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,63 @@ 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,
|
||||||
|
test_connection as fiat_provider_connection,
|
||||||
|
verify_paypal_webhook,
|
||||||
)
|
)
|
||||||
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 +476,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."
|
||||||
|
|||||||
+196
-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,164 @@ 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()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import json
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from lnbits.db import (
|
from lnbits.db import (
|
||||||
|
dict_to_submodel,
|
||||||
dict_to_model,
|
dict_to_model,
|
||||||
insert_query,
|
insert_query,
|
||||||
model_to_dict,
|
model_to_dict,
|
||||||
@@ -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,124 @@
|
|||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from pytest_mock.plugin import MockerFixture
|
||||||
|
|
||||||
|
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
|
||||||
|
):
|
||||||
|
mocker.patch.object(settings, "lnbits_webpush_privkey", "")
|
||||||
|
mocker.patch.object(settings, "lnbits_webpush_pubkey", None)
|
||||||
|
mocker.patch.object(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")
|
||||||
|
update_admin = mocker.patch(
|
||||||
|
"lnbits.core.services.settings.update_admin_settings",
|
||||||
|
AsyncMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
await check_webpush_settings()
|
||||||
|
|
||||||
|
assert settings.lnbits_webpush_privkey == "private-key"
|
||||||
|
assert settings.lnbits_webpush_pubkey == "public-key"
|
||||||
|
update_admin.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
):
|
||||||
|
mocker.patch.object(settings, "lnbits_webpush_privkey", "existing-private-key")
|
||||||
|
mocker.patch.object(settings, "lnbits_webpush_pubkey", "existing-public-key")
|
||||||
|
vapid = mocker.patch("lnbits.core.services.settings.Vapid")
|
||||||
|
update_admin = mocker.patch(
|
||||||
|
"lnbits.core.services.settings.update_admin_settings",
|
||||||
|
AsyncMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
await check_webpush_settings()
|
||||||
|
|
||||||
|
vapid.assert_not_called()
|
||||||
|
update_admin.assert_not_awaited()
|
||||||
+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")
|
||||||
|
assert settings.host == "0.0.0.0"
|
||||||
|
finally:
|
||||||
|
settings.host = original_host
|
||||||
|
|||||||
Reference in New Issue
Block a user