Compare commits

..
Author SHA1 Message Date
dni ⚡ bc7e3d4dc8 fifi 2026-03-25 11:29:03 +01:00
dni ⚡ 034ba44241 manual testing works 2026-03-25 11:14:53 +01:00
dni ⚡ b0b8ea95f2 2nd stage draft pydantic 2026-03-25 10:47:13 +01:00
dni ⚡ 261350243f stage2 update fastapi 2026-03-25 10:23:35 +01:00
dni ⚡ ed02802ad6 stage one moving to last fastapi version supporting pydantic.v1 2026-03-25 09:39:22 +01:00
84 changed files with 966 additions and 13344 deletions
+5 -1
View File
@@ -1,5 +1,9 @@
name: LNbits CI
on:
push:
branches:
- main
- dev
pull_request:
@@ -65,7 +69,7 @@ jobs:
make: openapi
regtest:
needs: [ lint, test-api, test-wallets, test-unit, migration, openapi ]
needs: [ lint ]
uses: ./.github/workflows/regtest.yml
strategy:
matrix:
+1 -1
View File
@@ -43,4 +43,4 @@ ENV LNBITS_HOST="0.0.0.0"
EXPOSE 5000
CMD ["sh", "-c", "uv --offline run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
CMD ["sh", "-c", "uv run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
-1
View File
@@ -66,7 +66,6 @@ test-regtest:
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
rm -rf ./tests/data \
uv run pytest tests/regtest
test-migration:
+3 -1
View File
@@ -711,7 +711,9 @@ async def _call_install_extension(
user_id = user_id or get_super_user()
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{url}/api/v1/extension?usr={user_id}", json=data.dict(), timeout=40
f"{url}/api/v1/extension?usr={user_id}",
json=data.model_dump(),
timeout=40,
)
resp.raise_for_status()
else:
+2 -3
View File
@@ -44,7 +44,7 @@ async def update_admin_settings(
data: EditableSettings, tag: str | None = "core"
) -> None:
editable_settings = await get_settings_by_tag("core") or {}
editable_settings.update(data.dict(exclude_unset=True))
editable_settings.update(data.model_dump(exclude_unset=True))
for key, value in editable_settings.items():
try:
await set_settings_field(key, value, tag)
@@ -105,8 +105,7 @@ async def get_settings_field(
)
if not row:
return None
value = json.loads(row["value"]) if row["value"] else None
return SettingsField(id=row["id"], value=value, tag=row["tag"])
return SettingsField(id=row["id"], value=json.loads(row["value"]), tag=row["tag"])
async def set_settings_field(id_: str, value: Any | None, tag: str | None = "core"):
+2 -2
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from pydantic import BaseModel, Field
@@ -21,8 +22,7 @@ class AuditEntry(BaseModel):
delete_at: datetime | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
def __init__(self, **data):
super().__init__(**data)
def model_post_init(self, __context: Any) -> None:
retention_days = max(0, settings.lnbits_audit_retention_days) or 365
self.delete_at = self.created_at + timedelta(days=retention_days)
+6 -6
View File
@@ -96,7 +96,7 @@ class ExtensionConfig(BaseModel):
)
error_msg = "Cannot fetch GitHub extension config"
config = await github_api_get(config_url, error_msg)
return ExtensionConfig.parse_obj(config)
return ExtensionConfig.model_validate(config)
class ReleasePaymentInfo(BaseModel):
@@ -304,7 +304,7 @@ class ExtensionRelease(BaseModel):
releases_url = f"https://api.github.com/repos/{org}/{repo}/releases"
error_msg = "Cannot fetch extension releases"
releases = await github_api_get(releases_url, error_msg)
return [GitHubRepoRelease.parse_obj(r) for r in releases]
return [GitHubRepoRelease.model_validate(r) for r in releases]
@classmethod
async def fetch_release_details(cls, details_link: str) -> dict | None:
@@ -757,7 +757,7 @@ class InstallableExtension(BaseModel):
repo_url = f"https://api.github.com/repos/{org}/{repository}"
error_msg = "Cannot fetch extension repo"
repo = await github_api_get(repo_url, error_msg)
github_repo = GitHubRepo.parse_obj(repo)
github_repo = GitHubRepo.model_validate(repo)
lates_release_url = (
f"https://api.github.com/repos/{org}/{repository}/releases/latest"
@@ -771,15 +771,15 @@ class InstallableExtension(BaseModel):
return (
github_repo,
GitHubRepoRelease.parse_obj(latest_release),
ExtensionConfig.parse_obj(config),
GitHubRepoRelease.model_validate(latest_release),
ExtensionConfig.model_validate(config),
)
@classmethod
async def fetch_manifest(cls, url) -> Manifest:
error_msg = "Cannot fetch extensions manifest"
manifest = await github_api_get(url, error_msg)
return Manifest.parse_obj(manifest)
return Manifest.model_validate(manifest)
class CreateExtension(BaseModel):
+31 -21
View File
@@ -6,7 +6,7 @@ import uuid
from datetime import datetime, timedelta, timezone
from typing import Any, Literal
from pydantic import BaseModel, validator
from pydantic import BaseModel, field_validator
from lnbits.helpers import (
camel_to_snake,
@@ -141,7 +141,8 @@ class DataField(BaseModel):
else:
return f"{self.name} {index}"
@validator("name")
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Field name is required.")
@@ -149,7 +150,8 @@ class DataField(BaseModel):
raise ValueError(f"Field Name must be snake_case. Found: {v}")
return v
@validator("type")
@field_validator("type")
@classmethod
def validate_type(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Owner Data type is required")
@@ -171,7 +173,8 @@ class DataField(BaseModel):
)
return v
@validator("label")
@field_validator("label")
@classmethod
def validate_label(cls, v: str | None) -> str | None:
if v and '"' in v:
raise ValueError(
@@ -179,7 +182,8 @@ class DataField(BaseModel):
)
return v
@validator("hint")
@field_validator("hint")
@classmethod
def validate_hint(cls, v: str | None) -> str | None:
if v and '"' in v:
raise ValueError(f'Field hint cannot contain double quotes ("). Value: {v}')
@@ -191,8 +195,7 @@ class DataFields(BaseModel):
editable: bool = True
fields: list[DataField] = []
def __init__(self, **data):
super().__init__(**data)
def model_post_init(self, __context: Any) -> None:
self.normalize()
def normalize(self) -> None:
@@ -210,7 +213,8 @@ class DataFields(BaseModel):
return field
return None
@validator("name")
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Data fields name is required")
@@ -223,12 +227,13 @@ class SettingsFields(DataFields):
enabled: bool = False
type: str = "user"
@validator("type")
@field_validator("type")
@classmethod
def validate_type(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Settings type is required")
if v not in ["user", "admin"]:
raise ValueError("Field Type must be one of: user, admin." f" Found: {v}")
raise ValueError(f"Field Type must be one of: user, admin. Found: {v}")
return v
@@ -265,8 +270,7 @@ class PreviewAction(BaseModel):
is_client_data_preview: bool = False
is_public_page_preview: bool = False
def __init__(self, **data):
super().__init__(**data)
def model_post_init(self, __context: Any) -> None:
if not self.is_preview_mode:
self.is_settings_preview = False
self.is_owner_data_preview = False
@@ -286,8 +290,7 @@ class ExtensionData(BaseModel):
public_page: PublicPageFields
preview_action: PreviewAction = PreviewAction()
def __init__(self, **data):
super().__init__(**data)
def model_post_init(self, __context: Any) -> None:
self.validate_data()
self.normalize()
@@ -434,7 +437,8 @@ class ExtensionData(BaseModel):
f" Received: {paid_flag_field.type}."
)
@validator("id")
@field_validator("id")
@classmethod
def validate_id(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Extension ID is required")
@@ -442,13 +446,15 @@ class ExtensionData(BaseModel):
raise ValueError(f"Extension Id must be snake_case. Found: {v}")
return v
@validator("name")
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Extension name is required")
return v
@validator("stub_version")
@field_validator("stub_version")
@classmethod
def validate_stub_version(cls, v: str | None) -> str | None:
if v and '"' in v:
raise ValueError(
@@ -456,7 +462,8 @@ class ExtensionData(BaseModel):
)
return v
@validator("short_description")
@field_validator("short_description")
@classmethod
def validate_short_description(cls, v: str | None) -> str | None:
if v and '"' in v:
raise ValueError(
@@ -464,7 +471,8 @@ class ExtensionData(BaseModel):
)
return v
@validator("description")
@field_validator("description")
@classmethod
def validate_description(cls, v: str | None) -> str | None:
if v and '"' in v:
raise ValueError(
@@ -472,13 +480,15 @@ class ExtensionData(BaseModel):
)
return v
@validator("owner_data")
@field_validator("owner_data")
@classmethod
def validate_owner_data(cls, v: DataFields) -> DataFields:
if len(v.fields) == 0:
raise ValueError("At least one owner data field is required")
return v
@validator("client_data")
@field_validator("client_data")
@classmethod
def validate_client_data(cls, v: DataFields) -> DataFields:
if len(v.fields) == 0:
raise ValueError("At least one client data field is required")
+2 -2
View File
@@ -6,7 +6,7 @@ from pydantic import BaseModel, Field
class CreateLnurlPayment(BaseModel):
res: LnurlPayResponse | None = None
lnurl: Lnurl | LnAddress | None = None
lnurl: Lnurl | None = None
amount: int
comment: str | None = None
unit: str | None = None
@@ -14,7 +14,7 @@ class CreateLnurlPayment(BaseModel):
class CreateLnurlWithdraw(BaseModel):
lnurl_w: Lnurl
lnurl_w: Lnurl | LnAddress
class LnurlScan(BaseModel):
+14 -9
View File
@@ -2,12 +2,12 @@ from __future__ import annotations
from datetime import datetime, timezone
from enum import Enum
from typing import Literal
from typing import Any, Literal
from fastapi import Query
from lnurl import LnurlWithdrawResponse
from loguru import logger
from pydantic import BaseModel, Field, validator
from pydantic import BaseModel, Field, field_validator
from lnbits.db import FilterModel
from lnbits.fiat.base import (
@@ -62,7 +62,10 @@ class Payment(BaseModel):
amount: int
fee: int
bolt11: str
payment_request: str | None = Field(default=None, no_database=True)
payment_request: str | None = Field(
default=None,
json_schema_extra={"no_database": True},
)
fiat_provider: str | None = None
status: str = PaymentState.PENDING
memo: str | None = None
@@ -78,8 +81,7 @@ class Payment(BaseModel):
labels: list[str] = []
extra: dict = {}
def __init__(self, **data):
super().__init__(**data)
def model_post_init(self, __context: Any) -> None:
if "fiat_payment_request" in self.extra:
self.payment_request = self.extra["fiat_payment_request"]
else:
@@ -250,13 +252,14 @@ class CreateInvoice(BaseModel):
fiat_provider: str | None = None
labels: list[str] = []
@validator("payment_hash")
@field_validator("payment_hash")
@classmethod
def check_hex(cls, v):
if v:
_ = bytes.fromhex(v)
return v
@validator("unit")
@field_validator("unit")
@classmethod
def unit_is_from_allowed_currencies(cls, v):
if v != "sat" and v not in allowed_currencies():
@@ -279,7 +282,8 @@ class SettleInvoice(BaseModel):
max_length=64,
)
@validator("preimage")
@field_validator("preimage")
@classmethod
def check_hex(cls, v):
_ = bytes.fromhex(v)
return v
@@ -293,7 +297,8 @@ class CancelInvoice(BaseModel):
max_length=64,
)
@validator("payment_hash")
@field_validator("payment_hash")
@classmethod
def check_hex(cls, v):
_ = bytes.fromhex(v)
return v
+2 -4
View File
@@ -1,6 +1,4 @@
from datetime import datetime, timezone
from pydantic import BaseModel, Field
from pydantic import BaseModel
class TinyURL(BaseModel):
@@ -8,4 +6,4 @@ class TinyURL(BaseModel):
url: str
endless: bool
wallet: str
time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
time: float
+16 -9
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from uuid import UUID
from bcrypt import checkpw, gensalt, hashpw
@@ -38,11 +39,9 @@ class WalletInviteRequest(BaseModel):
class UserLabel(BaseModel):
name: str = Field(regex=r"([A-Za-z0-9 ._-]{1,100}$)")
name: str = Field(pattern=r"([A-Za-z0-9 ._-]{1,100}$)")
description: str | None = Field(default=None, max_length=250)
color: str | None = Field(
default=None, regex=r"^#[0-9A-Fa-f]{6}$"
) # e.g., "#RRGGBB"
color: str | None = Field(default=None, pattern=r"^#[0-9A-Fa-f]{6}$")
class UserExtra(BaseModel):
@@ -193,12 +192,20 @@ class Account(AccountId):
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
is_super_user: bool = Field(default=False, no_database=True)
is_admin: bool = Field(default=False, no_database=True)
fiat_providers: list[str] = Field(default=[], no_database=True)
is_super_user: bool = Field(
default=False,
json_schema_extra={"no_database": True},
)
is_admin: bool = Field(
default=False,
json_schema_extra={"no_database": True},
)
fiat_providers: list[str] = Field(
default=[],
json_schema_extra={"no_database": True},
)
def __init__(self, **data):
super().__init__(**data)
def model_post_init(self, __context: Any) -> None:
self.is_super_user = settings.is_super_user(self.id)
self.is_admin = settings.is_admin_user(self.id)
self.fiat_providers = settings.get_fiat_providers_for_user(self.id)
+8 -5
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from typing import Any
from pydantic import BaseModel, Field
@@ -126,14 +127,16 @@ class Wallet(BaseWallet):
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
currency: str | None = None
balance_msat: int = Field(default=0, no_database=True)
balance_msat: int = Field(default=0, json_schema_extra={"no_database": True})
extra: WalletExtra = WalletExtra()
stored_paylinks: StoredPayLinks = StoredPayLinks()
stored_paylinks: StoredPayLinks = Field(default_factory=StoredPayLinks)
# What permission this wallet has when it's a shared wallet
share_permissions: list[WalletPermission] = Field(default=[], no_database=True)
share_permissions: list[WalletPermission] = Field(
default=[],
json_schema_extra={"no_database": True},
)
def __init__(self, **data):
super().__init__(**data)
def model_post_init(self, __context: Any) -> None:
self._validate_data()
def mirror_shared_wallet(
+3 -3
View File
@@ -97,7 +97,7 @@ def _transform_extension_builder_stub(data: ExtensionData, extension_dir: Path)
def _export_extension_data_json(data: ExtensionData, build_dir: Path):
json.dump(
data.dict(),
data.model_dump(),
open(Path(build_dir, "builder.json"), "w", encoding="utf-8"),
indent=4,
)
@@ -133,7 +133,7 @@ async def _get_extension_stub_release(
logger.debug(f"Save release cache {stub_ext_id} ({stub_version}).")
with open(release_cache_file, "w", encoding="utf-8") as f:
f.write(json.dumps(release.dict(), indent=4))
f.write(json.dumps(release.model_dump(), indent=4))
return release
@@ -290,7 +290,7 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
{
"extension_builder_stub_public_client_inputs": public_client_inputs,
"preview": data.preview_action,
**data.public_page.action_fields.dict(),
**data.public_page.action_fields.model_dump(),
"cancel_comment": remove_line_marker,
},
)
+1 -32
View File
@@ -13,13 +13,13 @@ from lnbits.core.crud.payments import get_daily_stats
from lnbits.core.db import db
from lnbits.core.models import PaymentDailyStats, PaymentFilters
from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services.fiat_providers import handle_fiat_payment_confirmation
from lnbits.db import Connection, Filters
from lnbits.decorators import check_user_extension_access
from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
from lnbits.fiat import get_fiat_provider
from lnbits.helpers import check_callback_url
from lnbits.settings import settings
from lnbits.tasks import create_task, internal_invoice_queue_put
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_preimage
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import fake_wallet, get_funding_source
@@ -509,8 +509,6 @@ async def update_wallet_balance(
)
payment.status = PaymentState.SUCCESS
await update_payment(payment, conn=conn)
from lnbits.tasks import internal_invoice_queue_put
await internal_invoice_queue_put(payment.checking_id)
@@ -821,8 +819,6 @@ async def _pay_external_invoice(
fee_reserve_msat = fee_reserve(amount_msat, internal=False)
from lnbits.tasks import create_task
task = create_task(
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
)
@@ -1072,30 +1068,3 @@ async def _send_payment_notification_in_background(
if not wallet:
raise PaymentError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
send_payment_notification_in_background(wallet, payment)
async def update_invoice_callback(checking_id: str) -> Payment | None:
"""
Takes a checking_id of an incoming payment, from either paid_invoices_stream()
or internal_invoice_queue. Checks its status, updates and returns it.
returns None if no payment was found or it not and incoming payment.
"""
payment = await get_standalone_payment(checking_id, incoming=True)
if not payment:
logger.warning(f"No payment found for '{checking_id}'.")
return None
if not payment.is_in:
logger.warning(f"Payment '{checking_id}' is not incoming, skipping.")
return None
status = await check_payment_status(
payment, skip_internal_payment_notifications=True
)
payment.fee = status.fee_msat or payment.fee
# only overwrite preimage if status.preimage provides it
payment.preimage = status.preimage or payment.preimage
payment.status = PaymentState.SUCCESS
await update_payment(payment)
if payment.fiat_provider:
await handle_fiat_payment_confirmation(payment)
return payment
+2 -1
View File
@@ -45,10 +45,11 @@ def dict_to_settings(sets_dict: dict) -> UpdateSettings:
def update_cached_settings(sets_dict: dict):
editable_settings = dict_to_settings(sets_dict)
settings_keys = settings.model_dump().keys()
for key in sets_dict.keys():
if key in readonly_variables:
continue
if key not in settings.dict().keys():
if key not in settings_keys:
continue
try:
value = getattr(editable_settings, key)
+3 -9
View File
@@ -163,7 +163,7 @@ async def check_admin_settings():
# .env super_user overwrites DB super_user
settings_db = await update_super_user(settings.super_user)
update_cached_settings(settings_db.dict())
update_cached_settings(settings_db.model_dump())
# saving superuser to {data_dir}/.super_user file
with open(Path(settings.lnbits_data_folder) / ".super_user", "w") as file:
@@ -173,12 +173,6 @@ async def check_admin_settings():
if account and account.extra and account.extra.provider == "env":
settings.first_install = True
if settings.has_first_install_token_changed():
logger.warning("First install token is changed. Resetting admin settings.")
new_settings = await init_admin_settings()
settings.super_user = new_settings.super_user
settings.first_install = True
logger.success(
"✔️ Admin UI is enabled. run `uv run lnbits-cli superuser` "
"to get the superuser."
@@ -198,8 +192,8 @@ async def init_admin_settings(super_user: str | None = None) -> SuperSettings:
await create_account(account)
await create_wallet(user_id=account.id)
editable_settings = EditableSettings.from_dict(settings.dict())
return await create_admin_settings(account.id, editable_settings.dict())
editable_settings = EditableSettings.from_dict(settings.model_dump())
return await create_admin_settings(account.id, editable_settings.model_dump())
async def check_register_activation_settings(data: RegisterUser):
+2 -2
View File
@@ -87,7 +87,7 @@ async def api_update_settings(
admin_settings = await get_admin_settings(account.is_super_user)
if not admin_settings:
raise ValueError("Updated admin settings not found.")
update_cached_settings(admin_settings.dict())
update_cached_settings(admin_settings.model_dump())
core_app_extra.register_new_ratelimiter()
return {"status": "Success"}
@@ -99,7 +99,7 @@ async def api_update_settings(
async def api_update_settings_partial(
data: dict, account: Account = Depends(check_admin)
):
updatable_settings = dict_to_settings({**settings.dict(), **data})
updatable_settings = dict_to_settings({**settings.model_dump(), **data})
return await api_update_settings(updatable_settings, account)
+3 -11
View File
@@ -12,7 +12,6 @@ from fastapi.responses import JSONResponse, RedirectResponse
from fastapi_sso.sso.base import OpenID, SSOBase
from loguru import logger
from lnbits.core.crud.settings import set_settings_field
from lnbits.core.crud.users import (
get_user_access_control_lists,
update_user_access_control_list,
@@ -549,13 +548,6 @@ async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
account.hash_password(data.password)
await update_account(account)
settings.first_install = False
# only confrm it after the super user has been successfully updated
if settings.first_install_token:
settings.first_install_token_confirmed = data.first_install_token
await set_settings_field(
"first_install_token_confirmed", data.first_install_token
)
return _auth_success_response(account.username, account.id, account.email)
@@ -595,7 +587,7 @@ def _auth_success_response(
payload = AccessTokenPayload(
sub=username or "", usr=user_id, email=email, auth_time=int(time())
)
access_token = create_access_token(data=payload.dict())
access_token = create_access_token(data=payload.model_dump())
max_age = settings.auth_token_expire_minutes * 60
response = JSONResponse({"access_token": access_token, "token_type": "bearer"})
response.set_cookie(
@@ -625,7 +617,7 @@ def _auth_api_token_response(
sub=username, api_token_id=api_token_id, auth_time=int(time())
)
return create_access_token(
data=payload.dict(), token_expire_minutes=token_expire_minutes
data=payload.model_dump(), token_expire_minutes=token_expire_minutes
)
@@ -633,7 +625,7 @@ def _auth_redirect_response(path: str, user_id: str, email: str) -> RedirectResp
payload = AccessTokenPayload(
usr=user_id, sub="", email=email, auth_time=int(time())
)
access_token = create_access_token(data=payload.dict())
access_token = create_access_token(data=payload.model_dump())
max_age = settings.auth_token_expire_minutes * 60
response = RedirectResponse(path)
response.set_cookie(
+2 -1
View File
@@ -637,7 +637,8 @@ async def create_extension_review(
) -> ExtensionReviewPaymentRequest:
async with httpx.AsyncClient() as client:
resp = await client.post(
settings.lnbits_extensions_reviews_url + "/reviews", json=data.dict()
settings.lnbits_extensions_reviews_url + "/reviews",
json=data.model_dump(),
)
resp.raise_for_status()
payment_request = resp.json()
+125 -37
View File
@@ -8,10 +8,20 @@ import time
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Generic, Literal, TypeVar, get_origin
from types import UnionType
from typing import (
Any,
ClassVar,
Generic,
Literal,
TypeVar,
Union,
get_args,
get_origin,
)
from loguru import logger
from pydantic import BaseModel, ValidationError, root_validator
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, model_validator
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
from sqlalchemy.sql import text
@@ -20,6 +30,7 @@ from lnbits.settings import settings
POSTGRES = "POSTGRES"
COCKROACH = "COCKROACH"
SQLITE = "SQLITE"
PYDANTIC_MODEL_TYPES = (BaseModel,)
DateTrunc = Literal["hour", "day", "month"]
sqlite_formats = {
@@ -28,6 +39,72 @@ sqlite_formats = {
"month": "%Y-%m-01 00:00:00",
}
def _is_pydantic_model_class(model: Any) -> bool:
return isinstance(model, type) and any(
issubclass(model, model_type) for model_type in PYDANTIC_MODEL_TYPES
)
def _issubclass(candidate: Any, parent: Any) -> bool:
return isinstance(candidate, type) and issubclass(candidate, parent)
def _strip_optional(annotation: Any) -> Any:
origin = get_origin(annotation)
if origin not in {Union, UnionType}:
return annotation
args = [arg for arg in get_args(annotation) if arg is not type(None)]
if len(args) == 1:
return _strip_optional(args[0])
return annotation
def _model_fields(model: type[Any]) -> dict[str, Any]:
fields = getattr(model, "model_fields", None)
if fields is not None:
return fields
return getattr(model, "__fields__", {})
def _field_annotation(field: Any) -> Any:
annotation = getattr(field, "annotation", None)
if annotation is None:
annotation = getattr(field, "outer_type_", Any)
return _strip_optional(annotation)
def _field_inner_type(field: Any) -> Any:
type_ = getattr(field, "type_", None)
if type_ is not None:
return _strip_optional(type_)
annotation = _field_annotation(field)
origin = get_origin(annotation)
if origin in {list, set, tuple, dict}:
args = get_args(annotation)
return _strip_optional(args[0]) if args else Any
return annotation
def _field_extra(field: Any) -> dict[str, Any]:
field_info = getattr(field, "field_info", None)
if field_info is not None:
return getattr(field_info, "extra", {})
return getattr(field, "json_schema_extra", None) or {}
def _model_dump(model: BaseModel) -> dict[str, Any]:
return model.model_dump()
def _validate_model(model: type[Any], values: dict[str, Any]) -> Any:
return model.model_validate(values)
if settings.lnbits_database_url:
database_uri = settings.lnbits_database_url
if database_uri.startswith("cockroachdb://"):
@@ -35,7 +112,7 @@ if settings.lnbits_database_url:
else:
if not database_uri.startswith("postgres://"):
raise ValueError(
"Please use the 'postgres://...' " "format for the database URL."
"Please use the 'postgres://...' format for the database URL."
)
DB_TYPE = POSTGRES
@@ -447,8 +524,8 @@ class Operator(Enum):
class FilterModel(BaseModel):
__search_fields__: list[str] = []
__sort_fields__: list[str] | None = None
__search_fields__: ClassVar[list[str]] = []
__sort_fields__: ClassVar[list[str] | None] = None
T = TypeVar("T")
@@ -462,10 +539,12 @@ class Page(BaseModel, Generic[T]):
class Filter(BaseModel, Generic[TFilterModel]):
model_config = ConfigDict(arbitrary_types_allowed=True)
table_name: str | None = None
field: str
op: Operator = Operator.EQ
model: type[TFilterModel] | None
model: type[TFilterModel] | None = Field(default=None, exclude=True)
values: dict | None = None
@classmethod
@@ -487,16 +566,17 @@ class Filter(BaseModel, Generic[TFilterModel]):
field = key
op = Operator("eq")
if field in model.__fields__:
compare_field = model.__fields__[field]
model_fields = _model_fields(model)
if field in model_fields:
compare_field = model_fields[field]
values: dict = {}
if op in {Operator.EVERY, Operator.ANY, Operator.INCLUDE, Operator.EXCLUDE}:
raw_values = [v for rv in raw_values for v in rv.split(",")]
for index, raw_value in enumerate(raw_values):
validated, errors = compare_field.validate(raw_value, {}, loc="none")
if errors:
raise ValidationError(errors=[errors], model=model)
validated = TypeAdapter(
_field_annotation(compare_field)
).validate_python(raw_value)
values[f"{field}__{index}"] = validated
else:
raise ValueError("Unknown filter field")
@@ -508,7 +588,10 @@ class Filter(BaseModel, Generic[TFilterModel]):
prefix = f"{self.table_name}." if self.table_name else ""
stmt = []
for key in self.values.keys() if self.values else []:
if self.model and self.model.__fields__[self.field].type_ == datetime:
if (
self.model
and _field_inner_type(_model_fields(self.model)[self.field]) == datetime
):
placeholder = compat_timestamp_placeholder(key)
stmt.append(f"{prefix}{self.field} {self.op.as_sql} {placeholder}")
if self.op in {Operator.INCLUDE, Operator.EXCLUDE}:
@@ -534,7 +617,9 @@ class Filters(BaseModel, Generic[TFilterModel]):
the values can be validated. Otherwise, make sure to validate the inputs manually.
"""
filters: list[Filter[TFilterModel]] = []
model_config = ConfigDict(arbitrary_types_allowed=True)
filters: list[Filter[TFilterModel]] = Field(default_factory=list)
search: str | None = None
offset: int | None = None
@@ -542,18 +627,20 @@ class Filters(BaseModel, Generic[TFilterModel]):
sortby: str | None = None
direction: Literal["asc", "desc"] | None = None
model: type[TFilterModel] | None = None
model: type[TFilterModel] | None = Field(default=None, exclude=True)
table_name: str | None = None
table_name: str | None = Field(default=None, exclude=True)
@root_validator(pre=True)
def validate_sortby(cls, values):
@model_validator(mode="before")
@classmethod
def validate_sortby(cls, values: Any):
if not isinstance(values, dict):
return values
sortby = values.get("sortby")
model = values.get("model")
if sortby and model:
model = values["model"]
# if no sort fields are specified explicitly all fields are allowed
allowed = model.__sort_fields__ or model.__fields__
allowed = model.__sort_fields__ or _model_fields(model).keys()
if sortby not in allowed:
raise ValueError("Invalid sort field")
return values
@@ -667,10 +754,12 @@ def model_to_dict(model: BaseModel) -> dict:
:param model: Pydantic model
"""
_dict: dict = {}
for key, value in model.dict().items():
type_ = model.__fields__[key].type_
outertype_ = model.__fields__[key].outer_type_
if model.__fields__[key].field_info.extra.get("no_database", False):
model_fields = _model_fields(type(model))
for key, value in _model_dump(model).items():
field = model_fields[key]
type_ = _field_inner_type(field)
outertype_ = _field_annotation(field)
if _field_extra(field).get("no_database", False):
continue
if isinstance(value, datetime):
if DB_TYPE == SQLITE:
@@ -681,7 +770,7 @@ def model_to_dict(model: BaseModel) -> dict:
_dict[key] = value.replace(tzinfo=None)
continue
if (
type(type_) is type(BaseModel)
_is_pydantic_model_class(type_)
or type_ is dict
or get_origin(outertype_) is list
):
@@ -705,51 +794,50 @@ def dict_to_submodel(model: type[TModel], value: dict | str) -> TModel | None:
return dict_to_model(_subdict, model)
def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901
def dict_to_model(_row: dict, model: type[TModel]) -> TModel:
"""
Convert a dictionary with JSON-encoded nested models to a Pydantic model
:param _dict: Dictionary from database
:param model: Pydantic model
"""
_dict: dict = {}
model_fields = _model_fields(model)
for key, value in _row.items():
if value is None:
continue
if key not in model.__fields__:
if key not in model_fields:
# Somethimes an SQL JOIN will create additional column
continue
type_ = model.__fields__[key].type_
outertype_ = model.__fields__[key].outer_type_
field = model_fields[key]
type_ = _field_inner_type(field)
outertype_ = _field_annotation(field)
if get_origin(outertype_) is list:
_items = _safe_load_json(value) if isinstance(value, str) else value
_dict[key] = [
dict_to_submodel(type_, v) if issubclass(type_, BaseModel) else v
dict_to_submodel(type_, v) if _is_pydantic_model_class(type_) else v
for v in _items
]
continue
if issubclass(type_, bool):
if _issubclass(type_, bool):
_dict[key] = bool(value)
continue
if issubclass(type_, datetime):
if _issubclass(type_, datetime):
if DB_TYPE == SQLITE:
_dict[key] = datetime.fromtimestamp(value, timezone.utc)
else:
_dict[key] = value.replace(tzinfo=timezone.utc)
continue
if issubclass(type_, BaseModel):
if _is_pydantic_model_class(type_):
_dict[key] = dict_to_submodel(type_, value)
continue
# TODO: remove this when all sub models are migrated to Pydantic
# NOTE: this is for type dict on BaseModel, (used in Payment class)
if type_ is dict and value:
if (type_ is dict or get_origin(outertype_) is dict) and value:
_dict[key] = _safe_load_json(value)
continue
_dict[key] = value
continue
_model = model.construct(**_dict)
if isinstance(_model, BaseModel):
_model.__init__(**_dict) # type: ignore
return _model
return _validate_model(model, _dict)
def _safe_load_json(value: str) -> dict:
+5 -8
View File
@@ -6,7 +6,7 @@ from typing import Any, Literal
import httpx
from loguru import logger
from pydantic import BaseModel, Field, ValidationError
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from lnbits.helpers import normalize_endpoint, urlsafe_short_hash
from lnbits.settings import settings
@@ -28,8 +28,7 @@ FiatMethod = Literal["checkout", "subscription"]
class PayPalCheckoutOptions(BaseModel):
class Config:
extra = "ignore"
model_config = ConfigDict(extra="ignore")
success_url: str | None = None
cancel_url: str | None = None
@@ -37,16 +36,14 @@ class PayPalCheckoutOptions(BaseModel):
class PayPalSubscriptionOptions(BaseModel):
class Config:
extra = "ignore"
model_config = ConfigDict(extra="ignore")
checking_id: str | None = None
payment_request: str | None = None
class PayPalCreateInvoiceOptions(BaseModel):
class Config:
extra = "ignore"
model_config = ConfigDict(extra="ignore")
fiat_method: FiatMethod = "checkout"
checkout: PayPalCheckoutOptions | None = None
@@ -339,7 +336,7 @@ class PayPalWallet(FiatProvider):
self, raw_opts: dict[str, Any]
) -> PayPalCreateInvoiceOptions | None:
try:
return PayPalCreateInvoiceOptions.parse_obj(raw_opts)
return PayPalCreateInvoiceOptions.model_validate(raw_opts)
except ValidationError as e:
logger.warning(f"Invalid PayPal options: {e}")
return None
+10 -11
View File
@@ -8,7 +8,7 @@ from urllib.parse import urlencode
import httpx
from loguru import logger
from pydantic import BaseModel, Field, ValidationError
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from lnbits.helpers import normalize_endpoint, urlsafe_short_hash
from lnbits.settings import settings
@@ -30,8 +30,7 @@ FiatMethod = Literal["checkout", "terminal", "subscription"]
class StripeTerminalOptions(BaseModel):
class Config:
extra = "ignore"
model_config = ConfigDict(extra="ignore")
capture_method: Literal["automatic", "manual"] = "automatic"
metadata: dict[str, str] = Field(default_factory=dict)
@@ -39,8 +38,7 @@ class StripeTerminalOptions(BaseModel):
class StripeCheckoutOptions(BaseModel):
class Config:
extra = "ignore"
model_config = ConfigDict(extra="ignore")
success_url: str | None = None
metadata: dict[str, str] = Field(default_factory=dict)
@@ -48,16 +46,14 @@ class StripeCheckoutOptions(BaseModel):
class StripeSubscriptionOptions(BaseModel):
class Config:
extra = "ignore"
model_config = ConfigDict(extra="ignore")
checking_id: str | None = None
payment_request: str | None = None
class StripeCreateInvoiceOptions(BaseModel):
class Config:
extra = "ignore"
model_config = ConfigDict(extra="ignore")
fiat_method: FiatMethod = "checkout"
terminal: StripeTerminalOptions | None = None
@@ -171,7 +167,10 @@ class StripeWallet(FiatProvider):
("line_items[0][price]", subscription_id),
("line_items[0][quantity]", f"{quantity}"),
]
subscription_data = {**payment_options.dict(), "alan_action": "subscription"}
subscription_data = {
**payment_options.model_dump(),
"alan_action": "subscription",
}
subscription_data["extra"] = json.dumps(subscription_data.get("extra") or {})
form_data += self._encode_metadata(
@@ -494,7 +493,7 @@ class StripeWallet(FiatProvider):
self, raw_opts: dict[str, Any]
) -> StripeCreateInvoiceOptions | None:
try:
return StripeCreateInvoiceOptions.parse_obj(raw_opts)
return StripeCreateInvoiceOptions.model_validate(raw_opts)
except ValidationError as e:
logger.warning(f"Invalid Stripe options: {e}")
return None
+11 -9
View File
@@ -12,7 +12,6 @@ import shortuuid
from fastapi.routing import APIRoute
from loguru import logger
from packaging import version
from pydantic.schema import field_schema
from starlette.templating import Jinja2Templates
from lnbits.settings import settings
@@ -71,7 +70,7 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
# used in base.html
t.env.globals["SITE_TITLE"] = settings.lnbits_site_title
t.env.globals["LNBITS_APPLE_TOUCH_ICON"] = settings.lnbits_apple_touch_icon
t.env.globals["SETTINGS"] = settings.to_public().dict(by_alias=True)
t.env.globals["SETTINGS"] = settings.to_public().model_dump(by_alias=True)
t.env.globals["CURRENCIES"] = list(currencies.keys())
if settings.bundle_assets:
@@ -127,23 +126,26 @@ def generate_filter_params_openapi(model: type[FilterModel], keep_optional=False
:param keep_optional: If false, all parameters will be optional,
otherwise inferred from model
"""
fields = list(model.__fields__.values())
schema = model.model_json_schema()
properties = schema.get("properties", {})
required = set(schema.get("required", []))
params = []
for field in fields:
schema, _, _ = field_schema(field, model_name_map={})
for field_name, field in model.model_fields.items():
field_key = field.alias or field_name
field_schema = properties.get(field_key, {})
description = "Supports Filtering"
if (
hasattr(model, "__search_fields__")
and field.name in model.__search_fields__
and field_name in model.__search_fields__
):
description += ". Supports Search"
parameter = {
"name": field.alias,
"name": field_key,
"in": "query",
"required": field.required if keep_optional else False,
"schema": schema,
"required": field_key in required if keep_optional else False,
"schema": field_schema,
"description": description,
}
params.append(parameter)
+1 -1
View File
@@ -364,7 +364,7 @@ class LndRestNode(Node):
fee_report = await self.get("/v1/fees")
balance = await self.get("/v1/balance/channels")
return NodeInfoResponse(
**public.dict(),
**public.model_dump(),
onchain_balance_sat=onchain["total_balance"],
onchain_confirmed_sat=onchain["confirmed_balance"],
balance_msat=balance["local_balance"]["msat"],
+116 -38
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import importlib
import importlib.metadata
import inspect
import json
import os
import re
@@ -11,11 +10,12 @@ from enum import Enum
from os import path
from pathlib import Path
from time import gmtime, strftime, time
from typing import Any
from typing import Any, get_args, get_origin
from uuid import uuid4
from dotenv import dotenv_values
from loguru import logger
from pydantic import BaseModel, BaseSettings, Extra, Field, validator
from pydantic import BaseModel, ConfigDict, Field, field_validator
def list_parse_fallback(v: str):
@@ -29,6 +29,89 @@ def list_parse_fallback(v: str):
return []
def _remove_env_names(schema: dict[str, Any]) -> None:
for prop in schema.get("properties", {}).values():
prop.pop("env_names", None)
def _iter_validation_aliases(validation_alias: Any) -> list[str]:
if isinstance(validation_alias, str):
return [validation_alias]
choices = getattr(validation_alias, "choices", None)
if not choices:
return []
return [choice for choice in choices if isinstance(choice, str)]
def _annotation_has_origin(annotation: Any, origins: tuple[type, ...]) -> bool:
origin = get_origin(annotation)
if origin in origins:
return True
return any(_annotation_has_origin(arg, origins) for arg in get_args(annotation))
def _parse_settings_value(value: Any, annotation: Any) -> Any:
if not isinstance(value, str):
return value
stripped_value = value.strip()
if stripped_value.startswith("[") or stripped_value.startswith("{"):
return json.loads(stripped_value)
if _annotation_has_origin(annotation, (list, set, tuple)):
return list_parse_fallback(value)
return value
class LNbitsBaseSettings(BaseModel):
def __init__(self, **data):
super().__init__(**{**self._settings_data(), **data})
@classmethod
def _settings_data(cls) -> dict[str, Any]:
env_file = cls.model_config.get("env_file")
env_file_encoding = cls.model_config.get("env_file_encoding")
case_sensitive = bool(cls.model_config.get("case_sensitive", False))
raw_values: dict[str, Any] = {}
if env_file:
dotenv_items = dotenv_values(env_file, encoding=env_file_encoding)
raw_values.update(
{key: value for key, value in dotenv_items.items() if value is not None}
)
raw_values.update(os.environ)
lookup_values = (
raw_values
if case_sensitive
else {str(key).lower(): value for key, value in raw_values.items()}
)
settings_values: dict[str, Any] = {}
for field_name, field in cls.model_fields.items():
lookup_keys = _iter_validation_aliases(field.validation_alias)
if field.alias and field.alias != field_name:
lookup_keys.append(field.alias)
lookup_keys.append(field_name)
for key in lookup_keys:
lookup_key = key if case_sensitive else key.lower()
if lookup_key not in lookup_values:
continue
settings_values[field_name] = _parse_settings_value(
lookup_values[lookup_key], field.annotation
)
break
return settings_values
class LNbitsSettings(BaseModel):
@classmethod
def validate_list(cls, val):
@@ -324,7 +407,7 @@ class AssetSettings(LNbitsSettings):
"heif",
"heics",
"text/plain",
"text/json" "text/xml",
"text/jsontext/xml",
"application/json",
"application/pdf",
]
@@ -447,7 +530,6 @@ class SecuritySettings(LNbitsSettings):
lnbits_max_outgoing_payment_amount_sats: int = Field(default=10_000_000, ge=0)
lnbits_max_incoming_payment_amount_sats: int = Field(default=10_000_000, ge=0)
first_install_token_confirmed: str | None = Field(default=None)
def is_wallet_max_balance_exceeded(self, amount):
return (
@@ -653,9 +735,9 @@ class BoltzFundingSource(LNbitsSettings):
class StrikeFundingSource(LNbitsSettings):
strike_api_endpoint: str | None = Field(
default="https://api.strike.me/v1", env="STRIKE_API_ENDPOINT"
default="https://api.strike.me/v1", validation_alias="STRIKE_API_ENDPOINT"
)
strike_api_key: str | None = Field(default=None, env="STRIKE_API_KEY")
strike_api_key: str | None = Field(default=None, validation_alias="STRIKE_API_KEY")
class FiatProviderLimits(BaseModel):
@@ -972,12 +1054,12 @@ class EditableSettings(
KeycloakAuthSettings,
OidcAuthSettings,
):
@validator(
@field_validator(
"lnbits_admin_users",
"lnbits_allowed_users",
"lnbits_theme_options",
"lnbits_admin_extensions",
pre=True,
mode="before",
)
@classmethod
def validate_editable_settings(cls, val):
@@ -985,21 +1067,21 @@ class EditableSettings(
@classmethod
def from_dict(cls, d: dict):
return cls(
**{k: v for k, v in d.items() if k in inspect.signature(cls).parameters}
)
return cls(**{k: v for k, v in d.items() if k in cls.model_fields})
# fixes openapi.json validation, remove field env_names
class Config:
@staticmethod
def schema_extra(schema: dict[str, Any]) -> None:
for prop in schema.get("properties", {}).values():
prop.pop("env_names", None)
# Fixes openapi.json validation by removing the v1-only env_names metadata.
model_config = ConfigDict(
populate_by_name=True,
json_schema_extra=_remove_env_names,
)
class UpdateSettings(EditableSettings):
class Config:
extra = Extra.forbid
model_config = ConfigDict(
populate_by_name=True,
extra="forbid",
json_schema_extra=_remove_env_names,
)
class EnvSettings(LNbitsSettings):
@@ -1031,13 +1113,6 @@ class EnvSettings(LNbitsSettings):
def has_default_extension_path(self) -> bool:
return self.lnbits_extensions_path == "lnbits"
def has_first_install_token_changed(self) -> bool:
if not self.first_install_token:
return False
if not settings.first_install_token_confirmed:
return False
return self.first_install_token != settings.first_install_token_confirmed
def check_auth_secret_key(self):
if self.auth_secret_key:
return
@@ -1118,7 +1193,7 @@ class TransientSettings(InstalledExtensionsSettings, ExchangeHistorySettings):
@classmethod
def readonly_fields(cls):
return [f for f in inspect.signature(cls).parameters if not f.startswith("_")]
return [f for f in cls.model_fields if not f.startswith("_")]
class ReadOnlySettings(
@@ -1133,9 +1208,9 @@ class ReadOnlySettings(
def lnbits_extensions_upgrade_path(self) -> str:
return str(Path(self.lnbits_data_folder, "upgrades"))
@validator(
@field_validator(
"lnbits_allowed_funding_sources",
pre=True,
mode="before",
)
@classmethod
def validate_readonly_settings(cls, val):
@@ -1143,15 +1218,18 @@ class ReadOnlySettings(
@classmethod
def readonly_fields(cls):
return [f for f in inspect.signature(cls).parameters if not f.startswith("_")]
return [f for f in cls.model_fields if not f.startswith("_")]
class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings):
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
case_sensitive = False
json_loads = list_parse_fallback
class Settings(
EditableSettings, ReadOnlySettings, TransientSettings, LNbitsBaseSettings
):
model_config = ConfigDict(
populate_by_name=True,
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
def is_user_allowed(self, user_id: str) -> bool:
return (
@@ -1356,7 +1434,7 @@ if not settings.user_agent:
# printing environment variable for debugging
if not settings.lnbits_admin_ui:
logger.debug("Environment Settings:")
for key, value in settings.dict(exclude_none=True).items():
for key, value in settings.model_dump(exclude_none=True).items():
logger.debug(f"{key}: {value}")
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -59,7 +59,7 @@ window.LNbits = {
newWallet.canSendPayments = perms.includes('send-payments')
}
newWallet.url = `/wallet?&wal=${data.id}`
newWallet.storedPaylinks = data.stored_paylinks.links
newWallet.storedPaylinks = data.stored_paylinks?.links
return newWallet
}
}
@@ -165,26 +165,5 @@ window.app.component('lnbits-qrcode', {
this.$refs.qrCode.$el.style.maxWidth = this.maxWidth + 'px'
this.$refs.qrCode.$el.setAttribute('width', '100%')
this.$refs.qrCode.$el.removeAttribute('height')
},
computed: {
optimizedValue() {
const separatorIndex = this.value.indexOf(':')
const type =
separatorIndex === -1 ? '' : this.value.substring(0, separatorIndex)
const value =
separatorIndex === -1
? this.value
: this.value.substring(separatorIndex + 1)
if (this.utils.isValidBech32(value)) {
const normalizedValue = value.toUpperCase()
if (type) {
return `${type.toUpperCase()}:${normalizedValue}`
}
return normalizedValue
}
return this.value
}
}
})
-40
View File
@@ -160,46 +160,6 @@ window._lnbitsUtils = {
return null
}
},
isValidBech32(value) {
if (typeof value !== 'string') {
return false
}
const candidate = value.trim()
if (
!candidate ||
(candidate !== candidate.toLowerCase() &&
candidate !== candidate.toUpperCase())
) {
return false
}
const normalized = candidate.toLowerCase()
const splitPosition = normalized.lastIndexOf('1')
if (splitPosition <= 0) {
return false
}
const humanReadablePart = normalized.substring(0, splitPosition)
const data = normalized.substring(splitPosition + 1)
if (data.length < 6) {
return false
}
if (
typeof bech32ToFiveBitArray !== 'function' ||
typeof verify_checksum !== 'function'
) {
return false
}
const words = bech32ToFiveBitArray(data)
if (words.some(word => word < 0)) {
return false
}
return verify_checksum(humanReadablePart, words)
},
async notifyApiError(error) {
if (!error.response) {
return console.error(error)
+35 -11
View File
@@ -5,8 +5,12 @@ from collections.abc import Callable, Coroutine
from loguru import logger
from lnbits.core.models import Payment
from lnbits.core.services.payments import update_invoice_callback
from lnbits.core.crud import (
get_standalone_payment,
update_payment,
)
from lnbits.core.models import Payment, PaymentState
from lnbits.core.services.fiat_providers import handle_fiat_payment_confirmation
from lnbits.settings import settings
from lnbits.wallets import get_funding_source
@@ -112,10 +116,7 @@ async def internal_invoice_listener() -> None:
while settings.lnbits_running:
checking_id = await internal_invoice_queue.get()
logger.info(f"got an internal payment notification {checking_id}")
payment = await update_invoice_callback(checking_id)
if payment:
logger.success(f"internal invoice {checking_id} settled")
await invoice_callback_dispatcher(payment)
await invoice_callback_dispatcher(checking_id, is_internal=True)
async def invoice_listener() -> None:
@@ -128,10 +129,7 @@ async def invoice_listener() -> None:
funding_source = get_funding_source()
async for checking_id in funding_source.paid_invoices_stream():
logger.info(f"got a payment notification {checking_id}")
payment = await update_invoice_callback(checking_id)
if payment:
logger.success(f"fundingsource invoice {checking_id} settled")
await invoice_callback_dispatcher(payment)
await invoice_callback_dispatcher(checking_id)
def wait_for_paid_invoices(
@@ -167,7 +165,33 @@ def run_interval(
return wrapper
async def invoice_callback_dispatcher(payment: Payment):
async def invoice_callback_dispatcher(checking_id: str, is_internal: bool = False):
"""
Takes an incoming payment, checks its status, and dispatches it to
invoice_listeners from core and extensions.
"""
payment = await get_standalone_payment(checking_id, incoming=True)
if not payment:
logger.warning(f"No payment found for '{checking_id}'.")
return
if not payment.is_in:
logger.warning(f"Payment '{checking_id}' is not incoming, skipping.")
return
from lnbits.core.services.payments import check_payment_status
status = await check_payment_status(
payment, skip_internal_payment_notifications=True
)
payment.fee = status.fee_msat or payment.fee
# only overwrite preimage if status.preimage provides it
payment.preimage = status.preimage or payment.preimage
payment.status = PaymentState.SUCCESS
await update_payment(payment)
if payment.fiat_provider:
await handle_fiat_payment_confirmation(payment)
internal = "internal" if is_internal else ""
logger.success(f"{internal} invoice {checking_id} settled")
for name, send_chan in invoice_listeners.items():
logger.trace(f"invoice listeners: sending to `{name}`")
await send_chan.put(payment)
@@ -12,7 +12,7 @@
>
<qrcode-vue
ref="qrCode"
:value="optimizedValue"
:value="value"
:margin="margin"
:size="size"
level="Q"
+1 -5
View File
@@ -15,11 +15,7 @@ from lnbits.settings import settings
def log_server_info():
logger.info("LNbits Info")
if settings.first_install:
if settings.has_first_install_token_changed():
logger.success("This is a first install token reset.")
else:
logger.success("This is a fresh install of LNbits.")
logger.success("This is a fresh install of LNbits.")
if settings.first_install_token:
logger.success(
f"FIRST_INSTALL_TOKEN: `{settings.first_install_token}`. "
+3 -3
View File
@@ -1498,9 +1498,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
Generated
+178 -49
View File
@@ -199,6 +199,18 @@ files = [
{file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"},
]
[[package]]
name = "annotated-types"
version = "0.7.0"
description = "Reusable constraint types to use with typing.Annotated"
optional = false
python-versions = ">=3.8"
groups = ["main", "dev"]
files = [
{file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
{file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
]
[[package]]
name = "anyio"
version = "4.12.1"
@@ -2259,14 +2271,14 @@ valkey = ["valkey (>=6)"]
[[package]]
name = "lnurl"
version = "0.10.0"
version = "2.0.0"
description = "LNURL implementation for Python."
optional = false
python-versions = "<3.13,>=3.10"
groups = ["main"]
files = [
{file = "lnurl-0.10.0-py3-none-any.whl", hash = "sha256:0d43561b4370cecec19c8f1044fc840f0199fc2698bc83cd8782e8264bfde8f4"},
{file = "lnurl-0.10.0.tar.gz", hash = "sha256:55ba0a7e810bcdfc7410c682f91c76df4446ff24e83d6bf931a7c759e2e0d5cc"},
{file = "lnurl-2.0.0-py3-none-any.whl", hash = "sha256:e355f7433aa5ba31557ffbfd042978e8e6f9df90aaae907ebeb613f06ff4f9f4"},
{file = "lnurl-2.0.0.tar.gz", hash = "sha256:4ac17e289eb3818f217b84b41d455f27d0161b300560f6bddd345b923b5ba82d"},
]
[package.dependencies]
@@ -2276,7 +2288,7 @@ bolt11 = "*"
coincurve = ">=20.0.0"
httpx = "*"
pycryptodomex = ">=3.21.0"
pydantic = ">=1.10.0,<2.0.0"
pydantic = ">=2.10.0,<3.0.0"
[[package]]
name = "loguru"
@@ -3320,58 +3332,160 @@ files = [
[[package]]
name = "pydantic"
version = "1.10.26"
description = "Data validation and settings management using python type hints"
version = "2.12.5"
description = "Data validation using Python type hints"
optional = false
python-versions = ">=3.7"
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "pydantic-1.10.26-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f7ae36fa0ecef8d39884120f212e16c06bb096a38f523421278e2f39c1784546"},
{file = "pydantic-1.10.26-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d95a76cf503f0f72ed7812a91de948440b2bf564269975738a4751e4fadeb572"},
{file = "pydantic-1.10.26-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a943ce8e00ad708ed06a1d9df5b4fd28f5635a003b82a4908ece6f24c0b18464"},
{file = "pydantic-1.10.26-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:465ad8edb29b15c10b779b16431fe8e77c380098badf6db367b7a1d3e572cf53"},
{file = "pydantic-1.10.26-cp310-cp310-win_amd64.whl", hash = "sha256:80e6be6272839c8a7641d26ad569ab77772809dd78f91d0068dc0fc97f071945"},
{file = "pydantic-1.10.26-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:116233e53889bcc536f617e38c1b8337d7fa9c280f0fd7a4045947515a785637"},
{file = "pydantic-1.10.26-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c3cfdd361addb6eb64ccd26ac356ad6514cee06a61ab26b27e16b5ed53108f77"},
{file = "pydantic-1.10.26-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e4451951a9a93bf9a90576f3e25240b47ee49ab5236adccb8eff6ac943adf0f"},
{file = "pydantic-1.10.26-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9858ed44c6bea5f29ffe95308db9e62060791c877766c67dd5f55d072c8612b5"},
{file = "pydantic-1.10.26-cp311-cp311-win_amd64.whl", hash = "sha256:ac1089f723e2106ebde434377d31239e00870a7563245072968e5af5cc4d33df"},
{file = "pydantic-1.10.26-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:468d5b9cacfcaadc76ed0a4645354ab6f263ec01a63fb6d05630ea1df6ae453f"},
{file = "pydantic-1.10.26-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2c1b0b914be31671000ca25cf7ea17fcaaa68cfeadf6924529c5c5aa24b7ab1f"},
{file = "pydantic-1.10.26-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15b13b9f8ba8867095769e1156e0d7fbafa1f65b898dd40fd1c02e34430973cb"},
{file = "pydantic-1.10.26-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad7025ca324ae263d4313998e25078dcaec5f9ed0392c06dedb57e053cc8086b"},
{file = "pydantic-1.10.26-cp312-cp312-win_amd64.whl", hash = "sha256:4482b299874dabb88a6c3759e3d85c6557c407c3b586891f7d808d8a38b66b9c"},
{file = "pydantic-1.10.26-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1ae7913bb40a96c87e3d3f6fe4e918ef53bf181583de4e71824360a9b11aef1c"},
{file = "pydantic-1.10.26-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8154c13f58d4de5d3a856bb6c909c7370f41fb876a5952a503af6b975265f4ba"},
{file = "pydantic-1.10.26-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f8af0507bf6118b054a9765fb2e402f18a8b70c964f420d95b525eb711122d62"},
{file = "pydantic-1.10.26-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dcb5a7318fb43189fde6af6f21ac7149c4bcbcfffc54bc87b5becddc46084847"},
{file = "pydantic-1.10.26-cp313-cp313-win_amd64.whl", hash = "sha256:71cde228bc0600cf8619f0ee62db050d1880dcc477eba0e90b23011b4ee0f314"},
{file = "pydantic-1.10.26-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6b40730cc81d53d515dc0b8bb5c9b43fadb9bed46de4a3c03bd95e8571616dba"},
{file = "pydantic-1.10.26-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c3bbb9c0eecdf599e4db9b372fa9cc55be12e80a0d9c6d307950a39050cb0e37"},
{file = "pydantic-1.10.26-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc2e3fe7bc4993626ef6b6fa855defafa1d6f8996aa1caef2deb83c5ac4d043a"},
{file = "pydantic-1.10.26-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:36d9e46b588aaeb1dcd2409fa4c467fe0b331f3cc9f227b03a7a00643704e962"},
{file = "pydantic-1.10.26-cp314-cp314-win_amd64.whl", hash = "sha256:81ce3c8616d12a7be31b4aadfd3434f78f6b44b75adbfaec2fe1ad4f7f999b8c"},
{file = "pydantic-1.10.26-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bc5c91a3b3106caf07ac6735ec6efad8ba37b860b9eb569923386debe65039ad"},
{file = "pydantic-1.10.26-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:dde599e0388e04778480d57f49355c9cc7916de818bf674de5d5429f2feebfb6"},
{file = "pydantic-1.10.26-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8be08b5cfe88e58198722861c7aab737c978423c3a27300911767931e5311d0d"},
{file = "pydantic-1.10.26-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0141f4bafe5eda539d98c9755128a9ea933654c6ca4306b5059fc87a01a38573"},
{file = "pydantic-1.10.26-cp38-cp38-win_amd64.whl", hash = "sha256:eb664305ffca8a9766a8629303bb596607d77eae35bb5f32ff9245984881b638"},
{file = "pydantic-1.10.26-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:502b9d30d18a2dfaf81b7302f6ba0e5853474b1c96212449eb4db912cb604b7d"},
{file = "pydantic-1.10.26-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0d8f6087bf697dec3bf7ffcd7fe8362674f16519f3151789f33cbe8f1d19fc15"},
{file = "pydantic-1.10.26-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dd40a99c358419910c85e6f5d22f9c56684c25b5e7abc40879b3b4a52f34ae90"},
{file = "pydantic-1.10.26-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ce3293b86ca9f4125df02ff0a70be91bc7946522467cbd98e7f1493f340616ba"},
{file = "pydantic-1.10.26-cp39-cp39-win_amd64.whl", hash = "sha256:1a4e3062b71ab1d5df339ba12c48f9ed5817c5de6cb92a961dd5c64bb32e7b96"},
{file = "pydantic-1.10.26-py3-none-any.whl", hash = "sha256:c43ad70dc3ce7787543d563792426a16fd7895e14be4b194b5665e36459dd917"},
{file = "pydantic-1.10.26.tar.gz", hash = "sha256:8c6aa39b494c5af092e690127c283d84f363ac36017106a9e66cb33a22ac412e"},
{file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"},
{file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"},
]
[package.dependencies]
email-validator = {version = ">=1.0.3", optional = true, markers = "extra == \"email\""}
typing-extensions = ">=4.2.0"
annotated-types = ">=0.6.0"
email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"email\""}
pydantic-core = "2.41.5"
typing-extensions = ">=4.14.1"
typing-inspection = ">=0.4.2"
[package.extras]
dotenv = ["python-dotenv (>=0.10.4)"]
email = ["email-validator (>=1.0.3)"]
email = ["email-validator (>=2.0.0)"]
timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""]
[[package]]
name = "pydantic-core"
version = "2.41.5"
description = "Core functionality for Pydantic validation and serialization"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"},
{file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"},
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"},
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"},
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"},
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"},
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"},
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"},
{file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"},
{file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"},
{file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"},
{file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"},
{file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"},
{file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"},
{file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"},
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"},
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"},
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"},
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"},
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"},
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"},
{file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"},
{file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"},
{file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"},
{file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"},
{file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"},
{file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"},
{file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"},
{file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"},
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"},
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"},
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"},
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"},
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"},
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"},
{file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"},
{file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"},
{file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"},
{file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"},
{file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"},
{file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"},
{file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"},
{file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"},
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"},
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"},
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"},
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"},
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"},
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"},
{file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"},
{file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"},
{file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"},
{file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"},
{file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"},
{file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"},
{file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"},
{file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"},
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"},
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"},
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"},
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"},
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"},
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"},
{file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"},
{file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"},
{file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"},
{file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"},
{file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"},
{file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"},
{file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"},
{file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"},
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"},
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"},
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"},
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"},
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"},
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"},
{file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"},
{file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"},
{file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"},
{file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"},
{file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"},
{file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"},
{file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"},
{file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"},
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"},
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"},
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"},
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"},
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"},
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"},
{file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"},
{file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"},
{file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"},
{file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"},
{file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"},
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"},
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"},
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"},
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"},
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"},
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"},
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"},
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"},
{file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"},
]
[package.dependencies]
typing-extensions = ">=4.14.1"
[[package]]
name = "pygments"
@@ -4476,6 +4590,21 @@ files = [
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
description = "Runtime typing introspection tools"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"},
{file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"},
]
[package.dependencies]
typing-extensions = ">=4.12.0"
[[package]]
name = "urllib3"
version = "2.6.3"
@@ -5028,4 +5157,4 @@ migration = ["psycopg2-binary"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<3.13"
content-hash = "42751c05e1fa8250ff90981def5e0ce8d1b284eeb2acc2c7ac9ae5847cf99e17"
content-hash = "42433ecd056377e5a537ebf0d9aeb70fc2bc275276ba9721310e8efc7a7d933b"
+8 -7
View File
@@ -1,6 +1,6 @@
[project]
name = "lnbits"
version = "1.5.3"
version = "1.5.2"
requires-python = ">=3.10,<3.13"
description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
@@ -9,15 +9,15 @@ readme = "README.md"
dependencies = [
"bech32~=1.2.0",
"click~=8.3.1",
"fastapi~=0.116.1",
"starlette~=0.47.1",
"fastapi~=0.135.0",
"starlette~=1.0.0",
"httpx~=0.27.2",
"jinja2~=3.1.6",
"lnurl~=0.10.0",
"pydantic~=1.10.26",
"lnurl~=2.0.0",
"pydantic~=2.12.0",
"pyqrcode~=1.2.1",
"shortuuid~=1.0.13",
"sse-starlette~=2.3.6",
"sse-starlette~=3.3.3",
"typing-extensions~=4.15.0",
"uvicorn~=0.40.0",
"sqlalchemy~=1.4.54",
@@ -37,7 +37,7 @@ dependencies = [
"bolt11~=2.1.1",
"pyjwt~=2.12.0",
"itsdangerous~=2.2.0",
"fastapi-sso~=0.19.0",
"fastapi-sso~=0.21.0",
# needed for boltz, lnurldevice, watchonly extensions
"embit~=0.8.0",
# needed for scheduler extension
@@ -51,6 +51,7 @@ dependencies = [
"pillow~=12.1.0",
"python-dotenv~=1.2.1",
"greenlet~=3.3.0",
"pydantic-settings>=2.13.1",
]
[project.scripts]
-109
View File
@@ -1,9 +1,6 @@
from pathlib import Path
import pytest
from httpx import AsyncClient
from lnbits.server import server_restart
from lnbits.settings import Settings
@@ -52,109 +49,3 @@ async def test_admin_update_noneditable_settings(
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 400
@pytest.mark.anyio
async def test_admin_audit_monitor_and_test_email(
client: AsyncClient, superuser_token: str, mocker
):
mocker.patch(
"lnbits.core.views.admin_api.get_balance_delta",
mocker.AsyncMock(
return_value={"lnbits_balance_sats": 21, "node_balance_sats": 13}
),
)
mocker.patch(
"lnbits.core.views.admin_api.send_email_notification",
mocker.AsyncMock(return_value={"status": "queued"}),
)
audit = await client.get(
"/admin/api/v1/audit",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert audit.status_code == 200
assert audit.json()["lnbits_balance_sats"] == 21
monitor = await client.get(
"/admin/api/v1/monitor",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert monitor.status_code == 200
assert "invoice_listeners" in monitor.json()
test_email = await client.get(
"/admin/api/v1/testemail",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert test_email.status_code == 200
assert test_email.json()["status"] == "queued"
@pytest.mark.anyio
async def test_admin_partial_reset_restart_and_backup(
client: AsyncClient,
superuser_token: str,
settings: Settings,
tmp_path,
):
response = await client.patch(
"/admin/api/v1/settings",
json={"lnbits_site_title": "PATCHED TITLE"},
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 200
assert response.json()["status"] == "Success"
default_value = await client.get(
"/admin/api/v1/settings/default",
params={"field_name": "lnbits_site_title"},
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert default_value.status_code == 200
assert "default_value" in default_value.json()
backup_path = Path("lnbits-backup.zip")
original_data_folder = settings.lnbits_data_folder
try:
data_folder = tmp_path / "backup_data"
data_folder.mkdir(parents=True, exist_ok=True)
(data_folder / "sample.txt").write_text("backup me")
settings.lnbits_data_folder = str(data_folder)
backup = await client.get(
"/admin/api/v1/backup",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert backup.status_code == 200
assert backup.headers["content-type"] == "application/zip"
assert backup.content.startswith(b"PK")
assert backup_path.is_file()
finally:
settings.lnbits_data_folder = original_data_folder
backup_path.unlink(missing_ok=True)
server_restart.clear()
restart = await client.get(
"/admin/api/v1/restart",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert restart.status_code == 200
assert restart.json()["status"] == "Success"
assert server_restart.is_set() is True
server_restart.clear()
@pytest.mark.anyio
async def test_admin_delete_settings_requires_superuser(
client: AsyncClient, superuser_token: str
):
server_restart.clear()
response = await client.delete(
"/admin/api/v1/settings",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 200
assert server_restart.is_set() is True
server_restart.clear()
+2 -2
View File
@@ -507,9 +507,9 @@ async def test_api_payment_without_key(invoice: Payment):
# check api_payment() internal function call (NOT API): payment status
@pytest.mark.anyio
async def test_api_payment_with_key(invoice: Payment, inkey_headers_to):
async def test_api_payment_with_key(invoice: Payment, inkey_headers_from):
# check the payment status
response = await api_payment(invoice.payment_hash, inkey_headers_to["X-Api-Key"])
response = await api_payment(invoice.payment_hash, inkey_headers_from["X-Api-Key"])
assert isinstance(response, dict)
assert response["paid"] is True
assert "details" in response
-135
View File
@@ -1,135 +0,0 @@
from uuid import uuid4
import pytest
from httpx import AsyncClient
from lnbits.core.crud.assets import get_user_asset
from lnbits.core.services.assets import create_user_asset
from tests.helpers import get_png_bytes, get_user_token_headers, make_upload_file
@pytest.mark.anyio
async def test_asset_api_upload_list_update_and_delete(
client: AsyncClient,
user_headers_from: dict[str, str],
):
upload = await client.post(
"/api/v1/assets?public_asset=false",
headers={"Authorization": user_headers_from["Authorization"]},
files={"file": ("note.txt", b"hello world", "text/plain")},
)
assert upload.status_code == 200
asset = upload.json()
assert asset["name"] == "note.txt"
assert asset["is_public"] is False
page = await client.get("/api/v1/assets/paginated", headers=user_headers_from)
assert page.status_code == 200
assert any(item["id"] == asset["id"] for item in page.json()["data"])
info = await client.get(f"/api/v1/assets/{asset['id']}", headers=user_headers_from)
assert info.status_code == 200
assert info.json()["name"] == "note.txt"
data = await client.get(
f"/api/v1/assets/{asset['id']}/data", headers=user_headers_from
)
assert data.status_code == 200
assert data.content == b"hello world"
assert data.headers["content-disposition"] == 'inline; filename="note.txt"'
updated = await client.put(
f"/api/v1/assets/{asset['id']}",
headers=user_headers_from,
json={"name": "renamed.txt", "is_public": True},
)
assert updated.status_code == 200
assert updated.json()["name"] == "renamed.txt"
assert updated.json()["is_public"] is True
public_data = await client.get(f"/api/v1/assets/{asset['id']}/data")
assert public_data.status_code == 200
assert public_data.content == b"hello world"
deleted = await client.delete(
f"/api/v1/assets/{asset['id']}", headers=user_headers_from
)
assert deleted.status_code == 200
assert deleted.json()["success"] is True
missing = await client.get(
f"/api/v1/assets/{asset['id']}", headers=user_headers_from
)
assert missing.status_code == 404
@pytest.mark.anyio
async def test_asset_api_enforces_visibility_and_supports_admin_updates(
client: AsyncClient,
from_user,
to_user,
superuser_token: str,
):
private_asset = await create_user_asset(
from_user.id,
make_upload_file(
get_png_bytes(),
filename=f"private_{uuid4().hex[:8]}.png",
content_type="image/png",
),
is_public=False,
)
other_user_headers = await get_user_token_headers(client, to_user.id)
anonymous = await client.get(f"/api/v1/assets/{private_asset.id}/data")
assert anonymous.status_code == 404
wrong_user = await client.get(
f"/api/v1/assets/{private_asset.id}/data", headers=other_user_headers
)
assert wrong_user.status_code == 404
admin_updated = await client.put(
f"/api/v1/assets/{private_asset.id}",
headers={"Authorization": f"Bearer {superuser_token}"},
json={"is_public": True, "name": "admin-visible.png"},
)
assert admin_updated.status_code == 200
assert admin_updated.json()["is_public"] is True
assert admin_updated.json()["name"] == "admin-visible.png"
thumbnail = await client.get(f"/api/v1/assets/{private_asset.id}/thumbnail")
assert thumbnail.status_code == 200
assert thumbnail.content
assert thumbnail.headers["content-type"] == "image/png"
@pytest.mark.anyio
async def test_asset_api_validates_uploads_and_missing_assets(
client: AsyncClient,
user_headers_from: dict[str, str],
):
invalid = await client.post(
"/api/v1/assets",
headers={"Authorization": user_headers_from["Authorization"]},
files={"file": ("payload.exe", b"boom", "application/x-msdownload")},
)
assert invalid.status_code == 400
assert "not allowed" in invalid.json()["detail"]
missing = await client.delete(
f"/api/v1/assets/{uuid4().hex}",
headers=user_headers_from,
)
assert missing.status_code == 404
missing_thumb = await client.get(f"/api/v1/assets/{uuid4().hex}/thumbnail")
assert missing_thumb.status_code == 404
stored = await create_user_asset(
"missing-user-check",
make_upload_file(b"content", filename="content.txt", content_type="text/plain"),
is_public=True,
)
fetched = await get_user_asset("missing-user-check", stored.id)
assert fetched is not None
-64
View File
@@ -1,64 +0,0 @@
from datetime import datetime, timezone
from uuid import uuid4
import pytest
from httpx import AsyncClient
from lnbits.core.crud.audit import create_audit_entry
from lnbits.core.models import AuditEntry
@pytest.mark.anyio
async def test_audit_api_requires_admin(client: AsyncClient, user_headers_from):
response = await client.get("/audit/api/v1", headers=user_headers_from)
assert response.status_code == 403
@pytest.mark.anyio
async def test_audit_api_returns_entries_and_stats(
client: AsyncClient,
superuser_token: str,
):
component = f"audit_component_{uuid4().hex[:8]}"
await create_audit_entry(
AuditEntry(
component=component,
ip_address="127.0.0.1",
user_id=uuid4().hex,
path="/api/v1/test",
request_method="GET",
response_code="200",
duration=0.12,
created_at=datetime.now(timezone.utc),
)
)
await create_audit_entry(
AuditEntry(
component=component,
ip_address="127.0.0.2",
user_id=uuid4().hex,
path="/api/v1/test",
request_method="POST",
response_code="400",
duration=2.5,
created_at=datetime.now(timezone.utc),
)
)
headers = {"Authorization": f"Bearer {superuser_token}"}
page = await client.get(f"/audit/api/v1?component={component}", headers=headers)
assert page.status_code == 200
page_data = page.json()
assert page_data["total"] == 2
assert {item["request_method"] for item in page_data["data"]} == {"GET", "POST"}
stats = await client.get(
f"/audit/api/v1/stats?component={component}",
headers=headers,
)
assert stats.status_code == 200
payload = stats.json()
assert {item["field"] for item in payload["request_method"]} == {"GET", "POST"}
assert {item["field"] for item in payload["response_code"]} == {"200", "400"}
assert payload["component"][0]["field"] == component
assert payload["long_duration"][0]["field"] == "/api/v1/test"
-161
View File
@@ -1,161 +0,0 @@
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi.responses import RedirectResponse
from httpx import AsyncClient
from lnbits.core.crud.users import get_account, update_account
from lnbits.core.models.users import Account
from lnbits.core.services.users import create_user_account
from lnbits.core.views.auth_api import get_account_by_email
from lnbits.settings import Settings
class _FakeSSO:
def __init__(self, userinfo: object | None = None, state: str = ""):
self.userinfo = userinfo
self.state = state
self.redirect_uri: str | None = None
def __enter__(self):
return self
def __exit__(self, *_args):
return False
async def get_login_redirect(self, state: str):
self.state = state
return RedirectResponse("https://example.com/sso/login")
async def verify_and_process(self, _request):
return self.userinfo
@pytest.mark.anyio
async def test_auth_api_logout_and_update_ui_customization(
http_client: AsyncClient,
):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
response = await http_client.patch(
f"/api/v1/auth/ui?usr={user.id}",
json={"theme": "amber", "walletLayout": "grid"},
)
assert response.status_code == 200
assert response.json()["ui_customization"]["theme"] == "amber"
assert response.json()["ui_customization"]["walletLayout"] == "grid"
logout = await http_client.post("/api/v1/auth/logout")
assert logout.status_code == 200
assert logout.json()["status"] == "success"
assert "cookie_access_token=" in logout.headers["set-cookie"]
@pytest.mark.anyio
async def test_auth_api_sso_login_and_callback(http_client: AsyncClient, mocker):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
provider = "github"
login_sso = _FakeSSO()
mocker.patch("lnbits.core.views.auth_api._new_sso", return_value=login_sso)
response = await http_client.get(
f"/api/v1/auth/{provider}", params={"user_id": user.id}
)
assert response.status_code == 307
assert response.headers["location"] == "https://example.com/sso/login"
assert login_sso.redirect_uri == f"{http_client.base_url}/api/v1/auth/github/token"
assert login_sso.state
email = f"sso_{uuid4().hex[:8]}@lnbits.com"
callback_sso = _FakeSSO(userinfo=SimpleNamespace(email=email), state="")
mocker.patch("lnbits.core.views.auth_api._new_sso", return_value=callback_sso)
callback = await http_client.get(f"/api/v1/auth/{provider}/token")
assert callback.status_code == 307
assert callback.headers["location"] == "/wallet"
account = await get_account_by_email(email, active_only=False)
assert account is not None
assert account.email == email
assert account.extra.email_verified is True
@pytest.mark.anyio
async def test_auth_api_first_install_success_and_validation(
http_client: AsyncClient, settings: Settings
):
superuser = await get_account(settings.super_user, active_only=False)
assert superuser is not None
original_username = superuser.username
original_password_hash = superuser.password_hash
original_first_install = settings.first_install
original_first_install_token = settings.first_install_token
first_install_token = f"install_{uuid4().hex[:8]}"
new_username = f"reinstall_{uuid4().hex[:8]}"
try:
settings.first_install = True
settings.first_install_token = first_install_token
missing_token = await http_client.put(
"/api/v1/auth/first_install",
json={
"username": new_username,
"password": "secret1234",
"password_repeat": "secret1234",
},
)
assert missing_token.status_code == 401
assert missing_token.json()["detail"] == "Missing first_install_token."
success = await http_client.put(
"/api/v1/auth/first_install",
json={
"username": new_username,
"password": "secret1234",
"password_repeat": "secret1234",
"first_install_token": first_install_token,
},
)
assert success.status_code == 200
assert success.json()["access_token"]
updated_superuser = await get_account(settings.super_user, active_only=False)
assert updated_superuser is not None
assert updated_superuser.username == new_username
assert settings.first_install is False
forbidden = await http_client.put(
"/api/v1/auth/first_install",
json={
"username": f"blocked_{uuid4().hex[:8]}",
"password": "secret1234",
"password_repeat": "secret1234",
},
)
assert forbidden.status_code == 403
assert forbidden.json()["detail"] == "This is not your first install"
finally:
restored_superuser = await get_account(settings.super_user, active_only=False)
assert restored_superuser is not None
restored_superuser.username = original_username
restored_superuser.password_hash = original_password_hash
await update_account(restored_superuser)
settings.first_install = original_first_install
settings.first_install_token = original_first_install_token
-175
View File
@@ -1,175 +0,0 @@
import json
from uuid import uuid4
import pytest
from httpx import AsyncClient
from lnbits.core.models import Account, CreateInvoice
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.services.users import create_user_account
from lnbits.core.views.callback_api import (
handle_paypal_event,
handle_stripe_event,
)
@pytest.mark.anyio
async def test_callback_api_generic_webhook_handler_routes_providers(
http_client: AsyncClient, mocker
):
stripe_mock = mocker.patch(
"lnbits.core.views.callback_api.handle_stripe_event", mocker.AsyncMock()
)
paypal_mock = mocker.patch(
"lnbits.core.views.callback_api.handle_paypal_event", mocker.AsyncMock()
)
mocker.patch("lnbits.core.views.callback_api.check_stripe_signature")
mocker.patch(
"lnbits.core.views.callback_api.verify_paypal_webhook", mocker.AsyncMock()
)
stripe = await http_client.post(
"/api/v1/callback/stripe",
headers={"Stripe-Signature": "sig"},
json={"id": "evt_1", "type": "payment_intent.succeeded"},
)
assert stripe.status_code == 200
assert stripe.json()["success"] is True
stripe_mock.assert_awaited_once()
paypal = await http_client.post(
"/api/v1/callback/paypal",
json={"id": "evt_2", "event_type": "CHECKOUT.ORDER.APPROVED"},
)
assert paypal.status_code == 200
assert paypal.json()["success"] is True
paypal_mock.assert_awaited_once()
unknown = await http_client.post("/api/v1/callback/unknown", json={"id": "evt_3"})
assert unknown.status_code == 200
assert unknown.json()["success"] is False
@pytest.mark.anyio
async def test_callback_api_handles_paid_events_with_real_payments(mocker):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
payment = await create_wallet_invoice(
wallet.id, CreateInvoice(out=False, amount=11, memo="fiat callback")
)
fiat_status_mock = mocker.patch(
"lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock()
)
await handle_stripe_event(
{
"id": "evt_stripe",
"type": "payment_intent.succeeded",
"data": {
"object": {
"object": "payment_intent",
"metadata": {"payment_hash": payment.payment_hash},
}
},
}
)
await handle_paypal_event(
{
"id": "evt_paypal",
"event_type": "CHECKOUT.ORDER.APPROVED",
"resource": {
"purchase_units": [{"invoice_id": payment.payment_hash}],
},
}
)
await handle_stripe_event({"id": "evt_unhandled", "type": "customer.created"})
assert fiat_status_mock.await_count == 2
@pytest.mark.anyio
async def test_callback_api_handles_subscription_flows_and_validation(mocker):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
payment = await create_wallet_invoice(
wallet.id, CreateInvoice(out=False, amount=15, memo="subscription")
)
create_fiat_invoice_mock = mocker.patch(
"lnbits.core.views.callback_api.create_fiat_invoice",
mocker.AsyncMock(return_value=payment),
)
fiat_status_mock = mocker.patch(
"lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock()
)
await handle_stripe_event(
{
"id": "evt_invoice_paid",
"type": "invoice.paid",
"data": {
"object": {
"id": "invoice_1",
"currency": "usd",
"amount_paid": 500,
"hosted_invoice_url": "https://stripe.example/invoice",
"customer_email": "alice@example.com",
"lines": {"data": [{"description": "Gold Plan"}]},
"parent": {
"type": "subscription_details",
"subscription_details": {
"metadata": {
"alan_action": "subscription",
"wallet_id": wallet.id,
"tag": "gold",
"memo": "Monthly Gold",
"extra": json.dumps({"plan": "gold"}),
}
},
},
}
},
}
)
create_fiat_invoice_mock.assert_awaited()
fiat_status_mock.assert_awaited()
await handle_paypal_event(
{
"id": "evt_sale_completed",
"event_type": "PAYMENT.SALE.COMPLETED",
"resource": {
"id": "sale_1",
"billing_agreement_id": "agreement_1",
"amount": {"currency": "USD", "total": "7.50"},
"custom_id": json.dumps(
[wallet.id, "vip", "subscription_1", "link-1", "VIP Plan"]
),
},
}
)
assert create_fiat_invoice_mock.await_count == 2
with pytest.raises(
ValueError, match="PayPal subscription event missing custom metadata."
):
await handle_paypal_event(
{
"id": "evt_bad_sale",
"event_type": "PAYMENT.SALE.COMPLETED",
"resource": {"amount": {"currency": "USD", "total": "5.00"}},
}
)
-430
View File
@@ -1,430 +0,0 @@
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi import HTTPException
from starlette.requests import Request
from lnbits.core.crud.db_versions import get_db_version, update_migration_version
from lnbits.core.crud.extensions import (
create_installed_extension,
get_installed_extension,
get_user_extension,
)
from lnbits.core.crud.users import get_account
from lnbits.core.crud.wallets import create_wallet
from lnbits.core.models import Account, CreateInvoice
from lnbits.core.models.extensions import (
CreateExtension,
CreateExtensionReview,
Extension,
ExtensionConfig,
ExtensionRelease,
InstallableExtension,
PayToEnableInfo,
ReleasePaymentInfo,
UserExtensionInfo,
)
from lnbits.core.models.users import AccountId
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.services.users import create_user_account
from lnbits.core.views.extension_api import (
api_activate_extension,
api_deactivate_extension,
api_disable_extension,
api_enable_extension,
api_extension_details,
api_get_user_extensions,
api_install_extension,
api_uninstall_extension,
api_update_pay_to_enable,
create_extension_review,
delete_extension_db,
extensions,
get_extension_release,
get_extension_releases,
get_extension_reviews,
get_extension_reviews_tags,
get_pay_to_enable_invoice,
get_pay_to_install_invoice,
)
from tests.helpers import make_extension_release, make_installable_extension
class _MockHTTPResponse:
def __init__(
self,
*,
json_data=None,
text: str = "",
status_code: int = 200,
is_error: bool = False,
):
self._json_data = json_data
self.text = text
self.status_code = status_code
self.is_error = is_error
def json(self):
return self._json_data
def raise_for_status(self):
if self.status_code >= 400:
raise ValueError(self.text or "request failed")
class _MockHTTPClient:
def __init__(self, responses: dict[str, _MockHTTPResponse]):
self.responses = responses
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return False
async def get(self, url: str):
return self.responses[url]
async def post(self, url: str, json=None):
return self.responses[url]
@pytest.mark.anyio
async def test_extension_api_install_details_and_release_endpoints(mocker):
ext_id = f"ext_{uuid4().hex[:8]}"
release = make_extension_release(ext_id)
create_data = CreateExtension(
ext_id=ext_id,
archive=release.archive,
source_repo=release.source_repo,
version=release.version,
)
mocker.patch.object(
InstallableExtension,
"get_extension_release",
mocker.AsyncMock(return_value=release),
)
mocker.patch(
"lnbits.core.views.extension_api.install_extension",
mocker.AsyncMock(return_value=Extension(code=ext_id, is_valid=True)),
)
activate_mock = mocker.patch(
"lnbits.core.views.extension_api.activate_extension", mocker.AsyncMock()
)
installed = await api_install_extension(create_data)
assert installed.code == ext_id
activate_mock.assert_awaited_once()
mocker.patch.object(
InstallableExtension,
"get_extension_releases",
mocker.AsyncMock(return_value=[release]),
)
mocker.patch.object(
ExtensionRelease,
"fetch_release_details",
mocker.AsyncMock(return_value={"description": "Extension details"}),
)
details = await api_extension_details(ext_id, release.details_link or "")
assert details["description"] == "Extension details"
assert details["icon"] == release.icon
assert details["repo"] == release.repo
installed_ext = make_installable_extension(
ext_id,
payments=[
ReleasePaymentInfo(
amount=55,
pay_link=release.pay_link,
payment_hash=f"payment_{uuid4().hex[:8]}",
)
],
)
await create_installed_extension(installed_ext)
releases = await get_extension_releases(ext_id)
assert releases[0].paid_sats == 55
config = ExtensionConfig(
name=ext_id,
short_description="Config",
min_lnbits_version="0.1.0",
max_lnbits_version=None,
)
mocker.patch.object(
ExtensionConfig,
"fetch_github_release_config",
mocker.AsyncMock(return_value=config),
)
release_info = await get_extension_release("org", ext_id, "v1.0.0")
assert release_info["is_version_compatible"] is True
@pytest.mark.anyio
async def test_extension_api_pay_to_enable_and_catalog_views(mocker, admin_user):
regular_user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
admin_account = await get_account(admin_user.id)
assert admin_account is not None
admin_wallet = await create_wallet(
user_id=admin_account.id, wallet_name="extension sales"
)
ext_id = f"paid_{uuid4().hex[:8]}"
await create_installed_extension(
make_installable_extension(
ext_id,
pay_to_enable=PayToEnableInfo(
required=True, amount=10, wallet=admin_wallet.id
),
)
)
updated = await api_update_pay_to_enable(
ext_id,
PayToEnableInfo(required=True, amount=21, wallet=admin_wallet.id),
account=admin_account,
)
assert updated.success is True
stored_extension = await get_installed_extension(ext_id)
assert stored_extension is not None
assert stored_extension.meta is not None
assert stored_extension.meta.pay_to_enable is not None
assert stored_extension.meta.pay_to_enable.amount == 21
enable_invoice = await create_wallet_invoice(
admin_wallet.id, CreateInvoice(out=False, amount=21, memo="enable extension")
)
mocker.patch(
"lnbits.core.views.extension_api.create_invoice",
mocker.AsyncMock(return_value=enable_invoice),
)
invoice_response = await get_pay_to_enable_invoice(
ext_id,
PayToEnableInfo(amount=21),
account_id=AccountId(id=regular_user.id),
)
assert invoice_response["payment_hash"] == enable_invoice.payment_hash
user_ext = await get_user_extension(regular_user.id, ext_id)
assert user_ext is not None
assert user_ext.extra is not None
assert user_ext.extra.payment_hash_to_enable == enable_invoice.payment_hash
mocker.patch(
"lnbits.core.views.extension_api.get_valid_extensions",
mocker.AsyncMock(return_value=[Extension(code=ext_id, is_valid=True)]),
)
mocker.patch(
"lnbits.core.views.extension_api.check_transaction_status",
mocker.AsyncMock(return_value=SimpleNamespace(paid=True)),
)
enabled = await api_enable_extension(ext_id, AccountId(id=regular_user.id))
assert enabled.success is True
user_ext = await get_user_extension(regular_user.id, ext_id)
assert user_ext is not None
assert user_ext.active is True
assert user_ext.extra == UserExtensionInfo(
payment_hash_to_enable=enable_invoice.payment_hash,
paid_to_enable=True,
)
disabled = await api_disable_extension(ext_id, AccountId(id=regular_user.id))
assert disabled.success is True
disabled_again = await api_disable_extension(ext_id, AccountId(id=regular_user.id))
assert disabled_again.success is True
assert "already disabled" in disabled_again.message
mocker.patch(
"lnbits.core.views.extension_api.get_valid_extensions",
mocker.AsyncMock(
return_value=[
Extension(code=ext_id, is_valid=True, name="Paid Extension"),
Extension(code="other", is_valid=True),
]
),
)
visible_extensions = await api_get_user_extensions(AccountId(id=regular_user.id))
assert [ext.code for ext in visible_extensions] == [ext_id]
catalog_entry = make_installable_extension(
ext_id,
pay_to_enable=PayToEnableInfo(required=True, amount=21, wallet=admin_wallet.id),
)
mocker.patch.object(
InstallableExtension,
"get_installable_extensions",
mocker.AsyncMock(return_value=[catalog_entry]),
)
catalog = await extensions(AccountId(id=regular_user.id))
catalog_item = next(item for item in catalog if item["id"] == ext_id)
assert catalog_item["payToEnable"]["wallet"] is None
@pytest.mark.anyio
async def test_extension_api_activate_uninstall_install_invoice_and_cleanup(mocker):
base_ext = f"base_{uuid4().hex[:8]}"
dependent_ext = f"dependent_{uuid4().hex[:8]}"
uninstall_ext = f"uninstall_{uuid4().hex[:8]}"
db_ext = f"db_{uuid4().hex[:8]}"
await create_installed_extension(make_installable_extension(base_ext))
await create_installed_extension(
make_installable_extension(dependent_ext, dependencies=[base_ext])
)
await create_installed_extension(make_installable_extension(uninstall_ext))
mocker.patch(
"lnbits.core.views.extension_api.get_valid_extensions",
mocker.AsyncMock(
return_value=[
Extension(code=base_ext, is_valid=True, name="Base"),
Extension(code=dependent_ext, is_valid=True, name="Dependent"),
Extension(code=uninstall_ext, is_valid=True, name="Remove"),
]
),
)
with pytest.raises(HTTPException, match="depends on this one"):
await api_uninstall_extension(base_ext)
uninstall_mock = mocker.patch(
"lnbits.core.views.extension_api.uninstall_extension", mocker.AsyncMock()
)
uninstalled = await api_uninstall_extension(uninstall_ext)
assert uninstalled.success is True
uninstall_mock.assert_awaited_once_with(uninstall_ext)
mocker.patch(
"lnbits.core.views.extension_api.get_valid_extension",
mocker.AsyncMock(return_value=Extension(code=base_ext, is_valid=True)),
)
activate_mock = mocker.patch(
"lnbits.core.views.extension_api.activate_extension", mocker.AsyncMock()
)
deactivate_mock = mocker.patch(
"lnbits.core.views.extension_api.deactivate_extension", mocker.AsyncMock()
)
activated = await api_activate_extension(base_ext)
assert activated.success is True
deactivated = await api_deactivate_extension(base_ext)
assert deactivated.success is True
activate_mock.assert_awaited_once()
deactivate_mock.assert_awaited_once()
owner = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = owner.wallets[0]
install_invoice = await create_wallet_invoice(
wallet.id, CreateInvoice(out=False, amount=33, memo="install extension")
)
release = make_extension_release(base_ext, version="2.0.0")
payment_info = ReleasePaymentInfo(
amount=33,
pay_link=release.pay_link,
payment_hash=install_invoice.payment_hash,
payment_request=install_invoice.bolt11,
)
mocker.patch.object(
InstallableExtension,
"get_extension_release",
mocker.AsyncMock(return_value=release),
)
mocker.patch.object(
ExtensionRelease,
"fetch_release_payment_info",
mocker.AsyncMock(return_value=payment_info),
)
invoice = await get_pay_to_install_invoice(
base_ext,
CreateExtension(
ext_id=base_ext,
archive=release.archive,
source_repo=release.source_repo,
version=release.version,
cost_sats=33,
),
)
assert invoice.payment_hash == install_invoice.payment_hash
await update_migration_version(None, db_ext, 1)
drop_mock = mocker.patch(
"lnbits.core.views.extension_api.drop_extension_db", mocker.AsyncMock()
)
deleted = await delete_extension_db(db_ext)
assert deleted.success is True
drop_mock.assert_awaited_once_with(ext_id=db_ext)
assert await get_db_version(db_ext) is None
@pytest.mark.anyio
async def test_extension_api_review_endpoints(mocker):
ext_id = f"review_{uuid4().hex[:8]}"
reviews_base = "https://demo.lnbits.com/paidreviews/api/v1/AdFzLjzuKFLsdk4Bcnff6r"
tags_url = f"{reviews_base}/tags"
reviews_url = f"{reviews_base}/reviews/{ext_id}?offset=0&limit=5"
create_review_url = f"{reviews_base}/reviews"
request = Request(
{
"type": "http",
"method": "GET",
"path": f"/api/v1/extension/reviews/{ext_id}",
"query_string": b"offset=0&limit=5",
"headers": [],
}
)
mock_client = _MockHTTPClient(
{
tags_url: _MockHTTPResponse(
json_data=[{"tag": "good", "avg_rating": 900, "review_count": 3}]
),
reviews_url: _MockHTTPResponse(
json_data={
"data": [
{
"id": "1",
"name": "Alice",
"tag": "good",
"rating": 950,
"comment": "solid",
}
],
"total": 1,
}
),
create_review_url: _MockHTTPResponse(
json_data={
"payment_hash": f"hash_{uuid4().hex[:8]}",
"payment_request": "lnbc1review",
}
),
}
)
mocker.patch(
"lnbits.core.views.extension_api.httpx.AsyncClient", return_value=mock_client
)
tags = await get_extension_reviews_tags()
assert tags[0].tag == "good"
reviews = await get_extension_reviews(ext_id, request)
assert reviews.total == 1
assert reviews.data[0].comment == "solid"
payment_request = await create_extension_review(
CreateExtensionReview(tag=ext_id, name="Alice", rating=900, comment="Great")
)
assert payment_request.payment_hash.startswith("hash_")
-109
View File
@@ -1,109 +0,0 @@
from pathlib import Path
from uuid import uuid4
import pytest
from lnbits.core.crud.extensions import create_user_extension, get_user_extension
from lnbits.core.crud.users import get_account
from lnbits.core.models.extensions import (
Extension,
UserExtension,
)
from lnbits.core.models.users import AccountId
from lnbits.core.views.extensions_builder_api import (
api_build_extension,
api_delete_extension_builder_data,
api_deploy_extension,
api_preview_extension,
)
from lnbits.settings import Settings
from tests.helpers import make_extension_data, make_extension_release
@pytest.mark.anyio
async def test_extensions_builder_api_build_preview_and_cleanup(
tmp_path, settings: Settings, mocker, from_user
):
ext_id = f"builder_{uuid4().hex[:8]}"
data = make_extension_data(ext_id)
build_dir = tmp_path / "build"
build_dir.mkdir(parents=True, exist_ok=True)
(build_dir / "index.txt").write_text("hello")
original_data_folder = settings.lnbits_data_folder
build_mock = mocker.patch(
"lnbits.core.views.extensions_builder_api.build_extension_from_data",
mocker.AsyncMock(
return_value=(make_extension_release(ext_id, "0.1.0"), build_dir)
),
)
clean_mock = mocker.patch(
"lnbits.core.views.extensions_builder_api.clean_extension_builder_data"
)
try:
settings.lnbits_data_folder = str(tmp_path)
build_response = await api_build_extension(data)
assert Path(build_response.path).is_file()
assert build_response.filename == f"{ext_id}.zip"
preview = await api_preview_extension(data, AccountId(id=from_user.id))
assert preview.success is True
assert ext_id in preview.message
cleaned = await api_delete_extension_builder_data()
assert cleaned.success is True
clean_mock.assert_called_once()
assert build_mock.await_count == 2
finally:
settings.lnbits_data_folder = original_data_folder
@pytest.mark.anyio
async def test_extensions_builder_api_deploy_updates_user_extension(
tmp_path, settings: Settings, mocker, admin_user
):
ext_id = f"deploy_{uuid4().hex[:8]}"
data = make_extension_data(ext_id)
account = await get_account(admin_user.id)
assert account is not None
build_root = tmp_path / "deploy-root" / ext_id
build_root.mkdir(parents=True, exist_ok=True)
(build_root / "manifest.json").write_text("{}")
original_data_folder = settings.lnbits_data_folder
await create_user_extension(
UserExtension(user=account.id, extension=ext_id, active=False)
)
mocker.patch(
"lnbits.core.views.extensions_builder_api.build_extension_from_data",
mocker.AsyncMock(
return_value=(make_extension_release(ext_id, "0.1.0"), build_root)
),
)
install_mock = mocker.patch(
"lnbits.core.views.extensions_builder_api.install_extension",
mocker.AsyncMock(return_value=Extension(code=ext_id, is_valid=True)),
)
activate_mock = mocker.patch(
"lnbits.core.views.extensions_builder_api.activate_extension",
mocker.AsyncMock(),
)
try:
settings.lnbits_data_folder = str(tmp_path)
deployed = await api_deploy_extension(data, account=account)
finally:
settings.lnbits_data_folder = original_data_folder
assert deployed.success is True
assert ext_id in deployed.message
install_mock.assert_awaited_once()
activate_mock.assert_awaited_once()
user_ext = await get_user_extension(account.id, ext_id)
assert user_ext is not None
assert user_ext.active is True
-146
View File
@@ -1,146 +0,0 @@
import pytest
from httpx import AsyncClient
from pytest_mock.plugin import MockerFixture
from lnbits.core.models.misc import SimpleStatus
from lnbits.fiat.base import FiatSubscriptionResponse
class _UnsetSecret:
pass
UNSET_SECRET = _UnsetSecret()
class FakeStripeWallet:
def __init__(self, secret: str | None | _UnsetSecret = UNSET_SECRET):
self._secret = (
"connection-token" if isinstance(secret, _UnsetSecret) else secret
)
async def create_terminal_connection_token(self) -> dict[str, str]:
if self._secret is None:
return {}
return {"secret": self._secret}
@pytest.mark.anyio
async def test_fiat_api_test_provider_and_subscription_lifecycle(
client: AsyncClient,
superuser_token: str,
adminkey_headers_from: dict[str, str],
from_wallet,
mocker: MockerFixture,
):
test_connection = mocker.patch(
"lnbits.core.views.fiat_api.test_connection",
mocker.AsyncMock(return_value=SimpleStatus(success=True, message="ok")),
)
response = await client.put(
"/api/v1/fiat/check/stripe",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 200
assert response.json()["success"] is True
test_connection.assert_awaited_once_with("stripe")
provider = mocker.Mock()
provider.create_subscription = mocker.AsyncMock(
return_value=FiatSubscriptionResponse(
ok=True,
subscription_request_id="sub-1",
checkout_session_url="https://stripe.example/checkout",
)
)
provider.cancel_subscription = mocker.AsyncMock(
return_value=FiatSubscriptionResponse(ok=True, subscription_request_id="sub-1")
)
get_provider = mocker.patch(
"lnbits.core.views.fiat_api.get_fiat_provider",
mocker.AsyncMock(return_value=provider),
)
mismatch = await client.post(
"/api/v1/fiat/stripe/subscription",
headers=adminkey_headers_from,
json={
"subscription_id": "sub-1",
"quantity": 2,
"payment_options": {"wallet_id": "wrong-wallet"},
},
)
assert mismatch.status_code == 403
created = await client.post(
"/api/v1/fiat/stripe/subscription",
headers=adminkey_headers_from,
json={
"subscription_id": "sub-1",
"quantity": 2,
"payment_options": {"memo": "hello", "wallet_id": from_wallet.id},
},
)
assert created.status_code == 200
assert created.json()["checkout_session_url"] == "https://stripe.example/checkout"
provider.create_subscription.assert_awaited_once()
assert provider.create_subscription.await_args is not None
assert provider.create_subscription.await_args.args[2].wallet_id == from_wallet.id
cancelled = await client.delete(
"/api/v1/fiat/stripe/subscription/sub-1",
headers=adminkey_headers_from,
)
assert cancelled.status_code == 200
provider.cancel_subscription.assert_awaited_once_with("sub-1", from_wallet.id)
assert get_provider.await_count == 3
@pytest.mark.anyio
async def test_fiat_api_connection_token_validates_provider_configuration(
client: AsyncClient,
superuser_token: str,
mocker: MockerFixture,
):
headers = {"Authorization": f"Bearer {superuser_token}"}
not_found = mocker.patch(
"lnbits.core.views.fiat_api.get_fiat_provider",
mocker.AsyncMock(return_value=None),
)
missing = await client.post("/api/v1/fiat/stripe/connection_token", headers=headers)
assert missing.status_code == 404
assert not_found.await_count == 1
unsupported_provider = mocker.patch(
"lnbits.core.views.fiat_api.get_fiat_provider",
mocker.AsyncMock(return_value=object()),
)
unsupported = await client.post(
"/api/v1/fiat/paypal/connection_token", headers=headers
)
assert unsupported.status_code == 400
assert unsupported_provider.await_count == 1
mocker.patch("lnbits.core.views.fiat_api.StripeWallet", FakeStripeWallet)
bad_wallet = FakeStripeWallet(secret=None)
bad_provider = mocker.patch(
"lnbits.core.views.fiat_api.get_fiat_provider",
mocker.AsyncMock(return_value=bad_wallet),
)
no_secret = await client.post(
"/api/v1/fiat/stripe/connection_token", headers=headers
)
assert no_secret.status_code == 500
assert no_secret.json()["detail"] == "Failed to create connection token"
assert bad_provider.await_count == 1
good_wallet = FakeStripeWallet(secret="tok_live")
good_provider = mocker.patch(
"lnbits.core.views.fiat_api.get_fiat_provider",
mocker.AsyncMock(return_value=good_wallet),
)
ok = await client.post("/api/v1/fiat/stripe/connection_token", headers=headers)
assert ok.status_code == 200
assert ok.json() == {"secret": "tok_live"}
assert good_provider.await_count == 1
-142
View File
@@ -1,142 +0,0 @@
from uuid import uuid4
import pytest
from fastapi import HTTPException
from lnurl import (
LnAddress,
LnurlAuthResponse,
LnurlErrorResponse,
LnurlException,
LnurlPayActionResponse,
LnurlPayResponse,
LnurlResponseException,
)
from lnurl.models import MessageAction
from lnurl.types import CallbackUrl, LightningInvoice
from pydantic import parse_obj_as
from lnbits.core.models import Account, CreateInvoice
from lnbits.core.models.lnurl import CreateLnurlPayment, LnurlScan
from lnbits.core.models.wallets import KeyType, WalletTypeInfo
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.services.users import create_user_account
from lnbits.core.views.lnurl_api import (
api_lnurlscan,
api_lnurlscan_post,
api_payments_pay_lnurl,
api_perform_lnurlauth,
)
from tests.helpers import make_lnurl_pay_response
TEST_BOLT11 = (
"lnbc1pnsu5z3pp57getmdaxhg5kc9yh2a2qsh7cjf4gnccgkw0qenm8vsqv50w7s"
"ygqdqj0fjhymeqv9kk7atwwscqzzsxqyz5vqsp5e2yyqcp0a3ujeesp24ya0glej"
"srh703md8mrx0g2lyvjxy5w27ss9qxpqysgqyjreasng8a086kpkczv48er5c6l5"
"73aym6ynrdl9nkzqnag49vt3sjjn8qdfq5cr6ha0vrdz5c5r3v4aghndly0hplmv"
"6hjxepwp93cq398l3s"
)
@pytest.mark.anyio
async def test_lnurl_api_scan_routes_validate_and_forward(mocker):
pay_response = make_lnurl_pay_response()
mocker.patch(
"lnbits.core.views.lnurl_api.lnurl_handle",
mocker.AsyncMock(return_value=pay_response),
)
scanned = await api_lnurlscan("lnurl1example")
assert isinstance(scanned, LnurlPayResponse)
assert scanned.callback == pay_response.callback
scanned_post = await api_lnurlscan_post(
scan=LnurlScan(lnurl=LnAddress("alice@example.com"))
)
assert isinstance(scanned_post, LnurlPayResponse)
assert scanned_post.callback == pay_response.callback
mocker.patch(
"lnbits.core.views.lnurl_api.lnurl_handle",
mocker.AsyncMock(return_value=LnurlErrorResponse(reason="blocked callback")),
)
with pytest.raises(HTTPException, match="blocked callback"):
await api_lnurlscan("lnurl1blocked")
mocker.patch(
"lnbits.core.views.lnurl_api.lnurl_handle",
mocker.AsyncMock(side_effect=LnurlException("invalid lnurl")),
)
with pytest.raises(HTTPException, match="invalid lnurl"):
await api_lnurlscan("lnurl1invalid")
@pytest.mark.anyio
async def test_lnurl_api_auth_and_pay_flow(mocker):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
wallet_info = WalletTypeInfo(key_type=KeyType.admin, wallet=wallet)
pay_response = make_lnurl_pay_response()
payment = await create_wallet_invoice(
wallet.id, CreateInvoice(out=False, amount=21, memo="lnurl")
)
auth_response = LnurlAuthResponse(
callback=parse_obj_as(CallbackUrl, "https://example.com/auth"),
k1="k1-value",
)
mocker.patch(
"lnbits.core.views.lnurl_api.lnurlauth",
mocker.AsyncMock(return_value=auth_response),
)
authenticated = await api_perform_lnurlauth(auth_response, wallet_info)
assert isinstance(authenticated, LnurlAuthResponse)
assert authenticated.k1 == "k1-value"
mocker.patch(
"lnbits.core.views.lnurl_api.lnurlauth",
mocker.AsyncMock(side_effect=LnurlResponseException("denied")),
)
with pytest.raises(HTTPException, match="denied"):
await api_perform_lnurlauth(auth_response, wallet_info)
action_response = LnurlPayActionResponse(
pr=LightningInvoice(TEST_BOLT11),
disposable=False,
successAction=parse_obj_as(MessageAction, {"message": "paid"}),
)
fetch_mock = mocker.patch(
"lnbits.core.views.lnurl_api.fetch_lnurl_pay_request",
mocker.AsyncMock(return_value=(pay_response, action_response)),
)
pay_mock = mocker.patch(
"lnbits.core.views.lnurl_api.pay_invoice",
mocker.AsyncMock(return_value=payment),
)
paid = await api_payments_pay_lnurl(
CreateLnurlPayment(
res=pay_response, amount=2_000, unit="USD", comment="thanks"
),
wallet_info,
)
assert paid.payment_hash == payment.payment_hash
fetch_mock.assert_awaited_once()
pay_mock.assert_awaited_once()
assert pay_mock.await_args is not None
assert action_response.successAction is not None
assert pay_mock.await_args.kwargs["extra"] == {
"stored": True,
"success_action": action_response.successAction.json(),
"comment": "thanks",
"fiat_currency": "USD",
"fiat_amount": 2.0,
}
with pytest.raises(HTTPException, match="Missing LNURL or LnurlPayResponse data."):
await api_payments_pay_lnurl(CreateLnurlPayment(amount=1), wallet_info)
-366
View File
@@ -1,366 +0,0 @@
from collections.abc import Callable
from typing import Any, cast
from uuid import uuid4
import httpx
import pytest
from fastapi import HTTPException
from pytest_mock.plugin import MockerFixture
from lnbits.core.views import node_api
from lnbits.db import Filters, Page
from lnbits.nodes.base import (
ChannelBalance,
ChannelPoint,
ChannelState,
ChannelStats,
Node,
NodeChannel,
NodeFees,
NodeInfoResponse,
NodeInvoice,
NodePayment,
NodePeerInfo,
PublicNodeInfo,
)
from lnbits.settings import Settings
from lnbits.wallets.base import Feature
class FakeNode:
def __init__(self):
self.channel = NodeChannel(
id="chan-1",
short_id="123x1x0",
peer_id="peer-1",
name="Peer One",
color="#ffffff",
state=ChannelState.ACTIVE,
balance=ChannelBalance(local_msat=1000, remote_msat=2000, total_msat=3000),
point=ChannelPoint(funding_txid="ab" * 32, output_index=1),
fee_ppm=10,
fee_base_msat=1000,
)
self.peer = NodePeerInfo(id="peer-1", alias="Peer One", addresses=["127.0.0.1"])
self.info = NodeInfoResponse(
id="node-id",
backend_name="FakeNode",
alias="Fake Alias",
color="#ffffff",
num_peers=1,
blockheight=1,
channel_stats=ChannelStats(
counts={ChannelState.ACTIVE: 1},
avg_size=3000,
biggest_size=3000,
smallest_size=3000,
total_capacity=3000,
),
addresses=["127.0.0.1:9735"],
onchain_balance_sat=1,
onchain_confirmed_sat=1,
fees=NodeFees(total_msat=0),
balance_msat=3000,
)
self.fees_updated: tuple[str, int | None, int | None] | None = None
async def get_public_info(self) -> PublicNodeInfo:
return PublicNodeInfo(**self.info.dict())
async def get_info(self) -> NodeInfoResponse:
return self.info
async def get_channels(self) -> list[NodeChannel]:
return [self.channel]
async def get_channel(self, channel_id: str) -> NodeChannel | None:
return self.channel if channel_id == self.channel.id else None
async def open_channel(
self,
peer_id: str,
funding_amount: int,
push_amount: int | None = None,
fee_rate: int | None = None,
) -> ChannelPoint:
assert peer_id == "peer-1"
assert funding_amount == 10_000
assert push_amount == 100
assert fee_rate == 5
return ChannelPoint(funding_txid="cd" * 32, output_index=0)
async def close_channel(
self,
short_id: str | None = None,
point: ChannelPoint | None = None,
force: bool = False,
) -> list[NodeChannel]:
assert short_id == self.channel.short_id
assert point is None
assert force is True
return [self.channel]
async def set_channel_fee(
self, channel_id: str, fee_base_msat: int | None, fee_ppm: int | None
) -> None:
self.fees_updated = (channel_id, fee_base_msat, fee_ppm)
async def get_payments(self, filters: Filters[Any]) -> Page[NodePayment]:
return Page(
data=[
NodePayment(
pending=False,
amount=1,
fee=0,
memo="payment",
time=1,
preimage="11" * 32,
payment_hash="22" * 32,
)
],
total=1,
)
async def get_invoices(self, filters: Filters[Any]) -> Page[NodeInvoice]:
return Page(
data=[
NodeInvoice(
pending=False,
amount=1,
memo="invoice",
bolt11="lnbc1dummy",
preimage="11" * 32,
payment_hash="33" * 32,
)
],
total=1,
)
async def get_peers(self) -> list[NodePeerInfo]:
return [self.peer]
async def connect_peer(self, uri: str) -> dict[str, str]:
return {"uri": uri}
async def disconnect_peer(self, peer_id: str) -> dict[str, str]:
return {"peer_id": peer_id}
async def get_id(self) -> str:
return "fake-node-id"
class FakeFundingSource:
def __init__(
self,
features: list[Feature],
node_factory: Callable[[Any], Any] | None,
):
self.features = features
self.__node_cls__ = node_factory
class MockHTTPResponse:
def __init__(
self, json_data: dict[str, Any], status_error: Exception | None = None
):
self._json_data = json_data
self._status_error = status_error
def raise_for_status(self) -> None:
if self._status_error:
raise self._status_error
def json(self) -> dict[str, Any]:
return self._json_data
class MockHTTPClient:
def __init__(self, response: MockHTTPResponse):
self.response = response
self.calls: list[str] = []
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def get(self, url: str, timeout: int):
self.calls.append(url)
return self.response
@pytest.mark.anyio
async def test_node_api_dependency_guards(settings: Settings, mocker: MockerFixture):
original_node_ui = settings.lnbits_node_ui
original_public = settings.lnbits_public_node_ui
try:
settings.lnbits_node_ui = True
funding_source = FakeFundingSource([], None)
mocker.patch(
"lnbits.core.views.node_api.get_funding_source",
return_value=funding_source,
)
with pytest.raises(HTTPException) as excinfo:
node_api.require_node()
assert excinfo.value.status_code == 501
node_enabled_source = FakeFundingSource(
[Feature.nodemanager], lambda wallet: "fake-node"
)
mocker.patch(
"lnbits.core.views.node_api.get_funding_source",
return_value=node_enabled_source,
)
settings.lnbits_node_ui = False
with pytest.raises(HTTPException) as disabled:
node_api.require_node()
assert disabled.value.status_code == 503
settings.lnbits_node_ui = True
assert node_api.require_node() == "fake-node"
settings.lnbits_public_node_ui = False
with pytest.raises(HTTPException) as public_disabled:
node_api.check_public()
assert public_disabled.value.status_code == 503
finally:
settings.lnbits_node_ui = original_node_ui
settings.lnbits_public_node_ui = original_public
@pytest.mark.anyio
async def test_node_api_route_functions_with_fake_node(
settings: Settings,
mocker: MockerFixture,
):
fake_node = FakeNode()
node = cast(Node, fake_node)
original_transactions = settings.lnbits_node_ui_transactions
settings.lnbits_node_ui_transactions = True
rank_response = MockHTTPResponse(
{
"noderank": {
"capacity": 1,
"channelcount": 2,
"age": 3,
"growth": 4,
"availability": 5,
}
}
)
mocker.patch(
"lnbits.core.views.node_api.httpx.AsyncClient",
return_value=MockHTTPClient(rank_response),
)
try:
assert await node_api.api_get_ok() is None
public_info = await node_api.api_get_public_info(node=node)
assert public_info.backend_name == "FakeNode"
info = await node_api.api_get_info(node=node)
assert info is not None
assert info.id == "node-id"
channels = await node_api.api_get_channels(node=node)
assert channels is not None
assert channels[0].id == "chan-1"
channel = await node_api.api_get_channel("chan-1", node=node)
assert channel is not None
assert channel.peer_id == "peer-1"
created = await node_api.api_create_channel(
node=node,
peer_id="peer-1",
funding_amount=10_000,
push_amount=100,
fee_rate=5,
)
assert created.output_index == 0
deleted = await node_api.api_delete_channel(
short_id="123x1x0",
funding_txid=None,
output_index=None,
force=True,
node=node,
)
assert deleted is not None
assert deleted[0].id == "chan-1"
await node_api.api_set_channel_fees(
"chan-1",
node=node,
fee_ppm=42,
fee_base_msat=7,
)
assert fake_node.fees_updated == ("chan-1", 7, 42)
payments = await node_api.api_get_payments(node=node, filters=Filters())
assert payments is not None
assert payments.total == 1
invoices = await node_api.api_get_invoices(node=node, filters=Filters())
assert invoices is not None
assert invoices.total == 1
peers = await node_api.api_get_peers(node=node)
assert peers[0].id == "peer-1"
connect = await node_api.api_connect_peer(
uri="peer-1@127.0.0.1:9735", node=node
)
assert connect["uri"] == "peer-1@127.0.0.1:9735"
disconnect = await node_api.api_disconnect_peer("peer-1", node=node)
assert disconnect["peer_id"] == "peer-1"
rank = await node_api.api_get_1ml_stats(node=node)
assert rank is not None
rank_data = node_api.NodeRank.parse_obj(rank)
assert rank_data.channelcount == 2
finally:
settings.lnbits_node_ui_transactions = original_transactions
@pytest.mark.anyio
async def test_node_api_transactions_and_rank_errors(
settings: Settings,
mocker: MockerFixture,
):
fake_node = FakeNode()
node = cast(Node, fake_node)
original_transactions = settings.lnbits_node_ui_transactions
settings.lnbits_node_ui_transactions = False
request = httpx.Request("GET", f"https://1ml.com/node/{uuid4().hex}/json")
mocker.patch(
"lnbits.core.views.node_api.httpx.AsyncClient",
return_value=MockHTTPClient(
MockHTTPResponse(
{},
status_error=httpx.HTTPStatusError(
"not found", request=request, response=httpx.Response(404)
),
)
),
)
try:
with pytest.raises(HTTPException) as payments:
await node_api.api_get_payments(node=node, filters=Filters())
assert payments.value.status_code == 503
with pytest.raises(HTTPException) as invoices:
await node_api.api_get_invoices(node=node, filters=Filters())
assert invoices.value.status_code == 503
with pytest.raises(HTTPException) as rank:
await node_api.api_get_1ml_stats(node=node)
assert rank.value.status_code == 404
assert rank.value.detail == "Node not found on 1ml.com"
finally:
settings.lnbits_node_ui_transactions = original_transactions
-187
View File
@@ -1,187 +0,0 @@
import json
from hashlib import sha256
from uuid import uuid4
import pytest
from fastapi import HTTPException
from lnbits.core.crud.payments import create_payment
from lnbits.core.models import Account, CreateInvoice, PaymentState
from lnbits.core.models.payments import CancelInvoice, CreatePayment, SettleInvoice
from lnbits.core.models.users import AccountId
from lnbits.core.models.wallets import KeyType, WalletTypeInfo
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.services.users import create_user_account
from lnbits.core.views.payment_api import (
api_all_payments_paginated,
api_payments_cancel,
api_payments_counting_stats,
api_payments_daily_stats,
api_payments_fee_reserve,
api_payments_settle,
api_payments_wallets_stats,
)
from lnbits.db import Filters
from lnbits.wallets.base import InvoiceResponse
ZERO_AMOUNT_INVOICE = (
"lnbc1pnsu5z3pp57getmdaxhg5kc9yh2a2qsh7cjf4gnccgkw0qenm8vsqv50w7s"
"ygqdqj0fjhymeqv9kk7atwwscqzzsxqyz5vqsp5e2yyqcp0a3ujeesp24ya0glej"
"srh703md8mrx0g2lyvjxy5w27ss9qxpqysgqyjreasng8a086kpkczv48er5c6l5"
"73aym6ynrdl9nkzqnag49vt3sjjn8qdfq5cr6ha0vrdz5c5r3v4aghndly0hplmv"
"6hjxepwp93cq398l3s"
)
@pytest.mark.anyio
async def test_payment_api_stats_and_all_paginated(admin_user):
first_user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
second_user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
first_wallet = first_user.wallets[0]
second_wallet = second_user.wallets[0]
await _create_payment(first_wallet.id, amount_msat=2_000, tag="coffee")
await _create_payment(first_wallet.id, amount_msat=-1_000, tag="coffee")
await _create_payment(second_wallet.id, amount_msat=5_000, tag="books")
count_stats = await api_payments_counting_stats(
count_by="tag",
filters=Filters(limit=20),
account_id=AccountId(id=first_user.id),
)
assert any(item.field == "coffee" for item in count_stats)
assert all(item.field != "books" for item in count_stats)
wallet_stats = await api_payments_wallets_stats(
filters=Filters(limit=20), account_id=AccountId(id=first_user.id)
)
assert any(item.wallet_id == first_wallet.id for item in wallet_stats)
assert all(item.wallet_id != second_wallet.id for item in wallet_stats)
daily_stats = await api_payments_daily_stats(
account_id=AccountId(id=first_user.id),
filters=Filters(limit=20),
)
assert daily_stats
assert daily_stats[0].payments_count >= 1
regular_page = await api_all_payments_paginated(
filters=Filters(limit=20), account_id=AccountId(id=first_user.id)
)
assert regular_page.total >= 2
assert all(payment.wallet_id == first_wallet.id for payment in regular_page.data)
admin_page = await api_all_payments_paginated(
filters=Filters(limit=50), account_id=AccountId(id=admin_user.id)
)
wallet_ids = {payment.wallet_id for payment in admin_page.data}
assert first_wallet.id in wallet_ids
assert second_wallet.id in wallet_ids
@pytest.mark.anyio
async def test_payment_api_fee_reserve_and_hold_invoice_actions(mocker):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
invoice = await create_wallet_invoice(
wallet.id, CreateInvoice(out=False, amount=42, memo="reserve")
)
reserve = await api_payments_fee_reserve(invoice.bolt11)
assert json.loads(reserve.body)["fee_reserve"] >= 0
with pytest.raises(HTTPException, match="Invoice has no amount."):
await api_payments_fee_reserve(ZERO_AMOUNT_INVOICE)
preimage = "11" * 32
payment_hash = sha256(bytes.fromhex(preimage)).hexdigest()
await _create_payment(
wallet.id,
amount_msat=1_000,
payment_hash=payment_hash,
status=PaymentState.PENDING,
)
settle_mock = mocker.patch(
"lnbits.core.views.payment_api.settle_hold_invoice",
mocker.AsyncMock(
return_value=InvoiceResponse(
ok=True,
checking_id="settled",
preimage=preimage,
)
),
)
settled = await api_payments_settle(
SettleInvoice(preimage=preimage),
WalletTypeInfo(key_type=KeyType.admin, wallet=wallet),
)
assert settled.success is True
settle_mock.assert_awaited_once()
cancel_hash = (uuid4().hex * 2)[:64]
await _create_payment(
wallet.id,
amount_msat=2_000,
payment_hash=cancel_hash,
status=PaymentState.PENDING,
)
cancel_mock = mocker.patch(
"lnbits.core.views.payment_api.cancel_hold_invoice",
mocker.AsyncMock(
return_value=InvoiceResponse(
ok=False,
checking_id="cancelled",
error_message="cancelled",
)
),
)
cancelled = await api_payments_cancel(
CancelInvoice(payment_hash=cancel_hash),
WalletTypeInfo(key_type=KeyType.admin, wallet=wallet),
)
assert cancelled.failed is True
cancel_mock.assert_awaited_once()
async def _create_payment(
wallet_id: str,
*,
amount_msat: int,
status: PaymentState = PaymentState.SUCCESS,
payment_hash: str | None = None,
tag: str | None = None,
) -> str:
checking_id = f"checking_{uuid4().hex[:8]}"
await create_payment(
checking_id=checking_id,
data=CreatePayment(
wallet_id=wallet_id,
payment_hash=payment_hash or uuid4().hex,
bolt11=f"bolt11_{checking_id}",
amount_msat=amount_msat,
memo=f"payment_{checking_id}",
extra={"tag": tag} if tag else {},
),
status=status,
)
return checking_id
-71
View File
@@ -1,71 +0,0 @@
from http import HTTPStatus
import pytest
from httpx import AsyncClient
@pytest.mark.anyio
async def test_tinyurl_api_create_get_redirect_and_delete(
client: AsyncClient,
adminkey_headers_from: dict[str, str],
inkey_headers_from: dict[str, str],
inkey_headers_to: dict[str, str],
):
created = await client.post(
"/api/v1/tinyurl",
params={"url": "https://example.com/landing", "endless": "true"},
headers=adminkey_headers_from,
)
assert created.status_code == HTTPStatus.OK
tinyurl = created.json()
assert tinyurl["url"] == "https://example.com/landing"
assert tinyurl["endless"] is True
fetched = await client.get(
f"/api/v1/tinyurl/{tinyurl['id']}",
headers=inkey_headers_from,
)
assert fetched.status_code == HTTPStatus.OK
assert fetched.json()["id"] == tinyurl["id"]
wrong_wallet = await client.get(
f"/api/v1/tinyurl/{tinyurl['id']}",
headers=inkey_headers_to,
)
assert wrong_wallet.status_code == HTTPStatus.NOT_FOUND
assert wrong_wallet.json()["detail"] == "Unable to fetch tinyurl"
redirect = await client.get(f"/t/{tinyurl['id']}")
assert redirect.status_code == HTTPStatus.TEMPORARY_REDIRECT
assert redirect.headers["location"] == "https://example.com/landing"
deleted = await client.delete(
f"/api/v1/tinyurl/{tinyurl['id']}",
headers=adminkey_headers_from,
)
assert deleted.status_code == HTTPStatus.OK
assert deleted.json()["deleted"] is True
missing_redirect = await client.get(f"/t/{tinyurl['id']}")
assert missing_redirect.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.anyio
async def test_tinyurl_api_reuses_existing_entries_for_same_wallet(
client: AsyncClient,
adminkey_headers_from: dict[str, str],
):
first = await client.post(
"/api/v1/tinyurl",
params={"url": "https://example.com/reused"},
headers=adminkey_headers_from,
)
second = await client.post(
"/api/v1/tinyurl",
params={"url": "https://example.com/reused"},
headers=adminkey_headers_from,
)
assert first.status_code == HTTPStatus.OK
assert second.status_code == HTTPStatus.OK
assert first.json()["id"] == second.json()["id"]
-102
View File
@@ -1,102 +0,0 @@
from uuid import uuid4
import pytest
from httpx import AsyncClient
from lnbits.core.crud.wallets import create_wallet, get_wallet, get_wallets
from lnbits.core.models import UpdateBalance
from lnbits.core.models.users import Account
from lnbits.core.services.users import create_user_account
from lnbits.core.views.user_api import api_users_create_user_wallet
from lnbits.settings import settings
@pytest.mark.anyio
async def test_user_api_toggle_admin_and_update_balance(
http_client: AsyncClient, superuser_token: str
):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
promote = await http_client.put(
f"/users/api/v1/user/{user.id}/admin",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert promote.status_code == 200
assert settings.is_admin_user(user.id) is True
demote = await http_client.put(
f"/users/api/v1/user/{user.id}/admin",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert demote.status_code == 200
assert settings.is_admin_user(user.id) is False
balance = await http_client.put(
"/users/api/v1/balance",
headers={"Authorization": f"Bearer {superuser_token}"},
json=UpdateBalance(id=wallet.id, amount=7).dict(),
)
assert balance.status_code == 200
assert balance.json()["success"] is True
updated_wallet = await get_wallet(wallet.id)
assert updated_wallet is not None
assert updated_wallet.balance == 7
@pytest.mark.anyio
async def test_user_api_get_wallets_and_delete_all_wallets(
http_client: AsyncClient, superuser_token: str
):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
extra_wallet = await create_wallet(user_id=user.id, wallet_name="spare")
wallets = await http_client.get(
f"/users/api/v1/user/{user.id}/wallet",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert wallets.status_code == 200
wallet_ids = {wallet["id"] for wallet in wallets.json()}
assert extra_wallet.id in wallet_ids
deleted = await http_client.delete(
f"/users/api/v1/user/{user.id}/wallets",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert deleted.status_code == 200
assert deleted.json()["success"] is True
active_wallets = await get_wallets(user.id, deleted=False)
assert active_wallets == []
@pytest.mark.anyio
async def test_user_api_create_wallet_validates_currency():
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
with pytest.raises(ValueError, match="Currency 'INVALID' not allowed."):
await api_users_create_user_wallet(user.id, name="invalid", currency="INVALID")
wallet = await api_users_create_user_wallet(
user.id, name="eur wallet", currency="EUR"
)
assert wallet.currency == "EUR"
-190
View File
@@ -1,190 +0,0 @@
from uuid import uuid4
import pytest
from httpx import AsyncClient
from lnbits.core.crud.wallets import create_wallet, get_wallet
from lnbits.core.models.users import Account
from lnbits.core.services.users import create_user_account
@pytest.mark.anyio
async def test_wallet_api_share_invite_reject_accept_and_delete(
http_client: AsyncClient,
):
owner = await create_user_account(
Account(
id=uuid4().hex,
username=f"owner_{uuid4().hex[:8]}",
email=f"owner_{uuid4().hex[:8]}@lnbits.com",
)
)
invited = await create_user_account(
Account(
id=uuid4().hex,
username=f"invited_{uuid4().hex[:8]}",
email=f"invited_{uuid4().hex[:8]}@lnbits.com",
)
)
source_wallet = owner.wallets[0]
owner_headers = _admin_headers(source_wallet.adminkey)
invite = await http_client.put(
"/api/v1/wallet/share/invite",
headers=owner_headers,
json={
"username": invited.username,
"permissions": ["view-payments"],
"status": "invite_sent",
},
)
assert invite.status_code == 200
share_request = invite.json()
assert share_request["request_id"]
reject = await http_client.delete(
f"/api/v1/wallet/share/invite/{share_request['request_id']}?usr={invited.id}"
)
assert reject.status_code == 200
assert reject.json()["success"] is True
removed_share = await http_client.delete(
f"/api/v1/wallet/share/{share_request['request_id']}",
headers=owner_headers,
)
assert removed_share.status_code == 200
assert removed_share.json()["success"] is True
invite = await http_client.put(
"/api/v1/wallet/share/invite",
headers=owner_headers,
json={
"username": invited.username,
"permissions": ["view-payments", "receive-payments"],
"status": "invite_sent",
},
)
assert invite.status_code == 200
share_request = invite.json()
create_shared = await http_client.post(
f"/api/v1/wallet?usr={invited.id}",
json={
"name": "shared",
"wallet_type": "lightning-shared",
"shared_wallet_id": source_wallet.id,
},
)
assert create_shared.status_code == 200
mirror_wallet = create_shared.json()
assert mirror_wallet["shared_wallet_id"] == source_wallet.id
approve = await http_client.put(
"/api/v1/wallet/share",
headers=owner_headers,
json={
"username": invited.username,
"shared_with_wallet_id": mirror_wallet["id"],
"permissions": ["view-payments", "receive-payments"],
"status": "approved",
},
)
assert approve.status_code == 200
assert approve.json()["status"] == "approved"
delete_share = await http_client.delete(
f"/api/v1/wallet/share/{share_request['request_id']}",
headers=owner_headers,
)
assert delete_share.status_code == 200
assert delete_share.json()["success"] is True
assert await get_wallet(mirror_wallet["id"]) is None
@pytest.mark.anyio
async def test_wallet_api_paginated_update_reset_and_store_paylinks(
http_client: AsyncClient,
):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
extra_wallet = await create_wallet(user_id=user.id, wallet_name="second")
first_wallet = user.wallets[0]
page = await http_client.get(f"/api/v1/wallet/paginated?usr={user.id}&limit=10")
assert page.status_code == 200
assert page.json()["total"] >= 2
renamed = await http_client.put(
"/api/v1/wallet/renamed-wallet",
headers=_admin_headers(first_wallet.adminkey),
)
assert renamed.status_code == 200
assert renamed.json()["name"] == "renamed-wallet"
original_admin_key = extra_wallet.adminkey
reset = await http_client.put(
f"/api/v1/wallet/reset/{extra_wallet.id}?usr={user.id}"
)
assert reset.status_code == 200
assert reset.json()["adminkey"] != original_admin_key
stored = await http_client.put(
f"/api/v1/wallet/stored_paylinks/{extra_wallet.id}",
headers=_admin_headers(reset.json()["adminkey"]),
json={
"links": [
{
"lnurl": "alice@example.com",
"label": "Alice",
}
]
},
)
assert stored.status_code == 200
assert stored.json()[0]["lnurl"] == "alice@example.com"
forbidden = await http_client.put(
f"/api/v1/wallet/stored_paylinks/{extra_wallet.id}",
headers=_admin_headers(first_wallet.adminkey),
json={"links": []},
)
assert forbidden.status_code == 403
updated = await http_client.patch(
"/api/v1/wallet",
headers=_admin_headers(first_wallet.adminkey),
json={"icon": "bolt", "color": "amber", "pinned": True},
)
assert updated.status_code == 200
assert updated.json()["extra"]["icon"] == "bolt"
assert updated.json()["extra"]["color"] == "amber"
assert updated.json()["extra"]["pinned"] is True
@pytest.mark.anyio
async def test_wallet_api_shared_wallet_requires_source_id(http_client: AsyncClient):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
response = await http_client.post(
f"/api/v1/wallet?usr={user.id}",
json={"wallet_type": "lightning-shared"},
)
assert response.status_code == 400
assert (
response.json()["detail"] == "Shared wallet ID is required for shared wallets."
)
def _admin_headers(adminkey: str) -> dict[str, str]:
return {"X-Api-Key": adminkey, "Content-type": "application/json"}
-31
View File
@@ -1,31 +0,0 @@
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"}
+1 -38
View File
@@ -1,6 +1,4 @@
import asyncio
import copy
import inspect
from datetime import datetime, timezone
from uuid import uuid4
@@ -25,12 +23,7 @@ from lnbits.core.services import create_user_account, update_wallet_balance
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.views.auth_api import first_install
from lnbits.db import DB_TYPE, SQLITE, Database
from lnbits.settings import (
AuthMethods,
EditableSettings,
FiatProviderLimits,
Settings,
)
from lnbits.settings import AuthMethods, FiatProviderLimits, Settings
from lnbits.settings import settings as lnbits_settings
from lnbits.wallets.fake import FakeWallet
from tests.helpers import (
@@ -40,21 +33,6 @@ from tests.helpers import (
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
ADMIN_USER_ID = uuid4().hex
_PURE_SETTINGS = Settings()
_PURE_SETTINGS_FIELDS = tuple(
sorted(
{
field_name
for field_name in Settings.readonly_fields()
if field_name != "super_user"
}
| {
name
for name in inspect.signature(EditableSettings).parameters
if not name.startswith("_")
}
)
)
@pytest.fixture(scope="session")
@@ -94,7 +72,6 @@ async def app(settings: Settings):
username="superadmin",
password="secret1234",
password_repeat="secret1234",
first_install_token=settings.first_install_token,
)
)
@@ -350,21 +327,7 @@ async def new_user(username: str | None = None) -> User:
return user
def _restore_pure_settings(settings: Settings):
for field_name in _PURE_SETTINGS_FIELDS:
setattr(
settings, field_name, copy.deepcopy(getattr(_PURE_SETTINGS, field_name))
)
def _settings_cleanup(settings: Settings):
_restore_pure_settings(settings)
settings.auth_https_only = False
settings.lnbits_data_folder = "./tests/data"
settings.bundle_assets = True
settings.lnbits_admin_ui = True
settings.lnbits_extensions_default_install = []
settings.lnbits_extensions_deactivate_all = True
settings.lnbits_allow_new_accounts = True
settings.lnbits_allowed_users = []
settings.auth_allowed_methods = AuthMethods.all()
-24
View File
@@ -1,24 +0,0 @@
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
+3 -144
View File
@@ -1,34 +1,9 @@
import random
import string
from io import BytesIO
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 pydantic.v1 import BaseModel
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
from lnbits.wallets import get_funding_source, set_funding_source
class DbTestModel(BaseModel):
@@ -65,123 +40,7 @@ async def get_random_invoice_data():
return {"out": False, "amount": 10, "memo": f"test_memo_{get_random_string(10)}"}
def get_png_bytes(*, color: str = "blue", size: tuple[int, int] = (32, 32)) -> bytes:
image = Image.new("RGB", size, color=color)
buffer = BytesIO()
image.save(buffer, format="PNG")
return buffer.getvalue()
def make_upload_file(
contents: bytes,
*,
filename: str,
content_type: str | None,
) -> UploadFile:
headers = (
Headers({"content-type": content_type}) if content_type is not None else None
)
return UploadFile(BytesIO(contents), filename=filename, headers=headers)
async def get_user_token_headers(client: AsyncClient, user_id: str) -> dict[str, str]:
response = await client.post("/api/v1/auth/usr", json={"usr": user_id})
client.cookies.clear()
return {
"Authorization": f"Bearer {response.json()['access_token']}",
"Content-type": "application/json",
}
def make_extension_data(ext_id: str = "demoext") -> ExtensionData:
return ExtensionData(
id=ext_id,
name="Demo Extension",
stub_version="0.1.0",
short_description="Generated extension",
owner_data=DataFields(
name="OwnerData",
fields=[DataField(name="wallet_id", type="wallet")],
),
client_data=DataFields(
name="ClientData",
fields=[DataField(name="amount", type="int")],
),
settings_data=SettingsFields(name="SettingsData", fields=[]),
public_page=PublicPageFields(
owner_data_fields=OwnerDataFields(),
client_data_fields=ClientDataFields(),
action_fields=ActionFields(),
),
)
def make_extension_release(ext_id: str, version: str = "1.0.0") -> ExtensionRelease:
return ExtensionRelease(
name=ext_id,
version=version,
archive=f"https://example.com/{ext_id}.zip",
source_repo="org/repo",
hash=f"hash-{ext_id}",
details_link=f"https://example.com/{ext_id}/details.json",
repo=f"https://github.com/org/{ext_id}",
icon=f"/{ext_id}/static/icon.png",
pay_link=f"https://pay.example/{ext_id}",
is_github_release=False,
is_version_compatible=True,
)
def make_installable_extension(
ext_id: str,
*,
version: str = "1.0.0",
compatible: bool = True,
active: bool = True,
pay_to_enable: PayToEnableInfo | None = None,
dependencies: list[str] | None = None,
payments: list[ReleasePaymentInfo] | None = None,
) -> InstallableExtension:
release = make_extension_release(ext_id, version)
release.is_version_compatible = compatible
return InstallableExtension(
id=ext_id,
name=f"Extension {ext_id}",
version=version,
active=active,
short_description="Demo extension",
icon=release.icon,
meta=ExtensionMeta(
installed_release=release,
pay_to_enable=pay_to_enable,
dependencies=dependencies or [],
payments=payments or [],
),
)
def make_lnurl_pay_response(
*,
min_sendable_msat: int = 1_000,
max_sendable_msat: int = 10_000,
text: str = "Test payment",
identifier: str = "alice@example.com",
callback: str = "https://example.com/callback",
) -> LnurlPayResponse:
return LnurlPayResponse(
callback=parse_obj_as(CallbackUrl, callback),
minSendable=MilliSatoshi(min_sendable_msat),
maxSendable=MilliSatoshi(max_sendable_msat),
metadata=LnurlPayMetadata(
f"[["
f'"text/plain","{text}"'
f"],["
f'"text/identifier","{identifier}"'
f"]]"
),
)
set_funding_source()
funding_source = get_funding_source()
is_fake: bool = funding_source.__class__.__name__ == "FakeWallet"
is_regtest: bool = not is_fake
+1 -9
View File
@@ -9,15 +9,7 @@ from loguru import logger
from lnbits.wallets import get_funding_source
funding_source = get_funding_source()
def is_boltz_wallet():
print(
"### funding_source.__class__.__name__ 2",
get_funding_source().__class__.__name__,
)
return get_funding_source().__class__.__name__ == "BoltzWallet"
is_boltz_wallet = funding_source.__class__.__name__ == "BoltzWallet"
docker_lightning_cli = [
"docker",
@@ -21,8 +21,7 @@ async def test_create_invoice(from_wallet):
)
# we cannot know the preimage of the swap yet
funding_source = get_funding_source()
if not is_boltz_wallet():
if not is_boltz_wallet:
assert payment.preimage
invoice = decode(payment.bolt11)
@@ -43,8 +42,7 @@ async def test_create_internal_invoice(from_wallet):
)
# we cannot know the preimage of the swap yet
funding_source = get_funding_source()
if not is_boltz_wallet():
if not is_boltz_wallet:
assert payment.preimage
invoice = decode(payment.bolt11)
+1 -1
View File
@@ -20,7 +20,7 @@ async def test_services_pay_invoice(to_wallet, real_invoice):
)
assert payment
assert payment.memo == description
if not is_boltz_wallet():
if not is_boltz_wallet:
assert payment.status == PaymentState.SUCCESS
assert payment.preimage
else:
+6 -6
View File
@@ -4,7 +4,7 @@ from http import HTTPStatus
import pytest
from httpx import AsyncClient, Headers
from pydantic import parse_obj_as
from pydantic import TypeAdapter
from lnbits import bolt11
from lnbits.nodes.base import ChannelPoint, ChannelState, NodeChannel
@@ -111,7 +111,7 @@ async def test_get_channel(node_client):
await asyncio.sleep(3)
response = await node_client.get("/node/api/v1/channels")
assert response.status_code == 200
channels = parse_obj_as(list[NodeChannel], response.json())
channels = TypeAdapter(list[NodeChannel]).validate_python(response.json())
ch = random.choice(
[channel for channel in channels if channel.state == ChannelState.ACTIVE]
)
@@ -121,7 +121,7 @@ async def test_get_channel(node_client):
response = await node_client.get(f"/node/api/v1/channels/{ch.id}")
assert response.status_code == 200
channel = parse_obj_as(NodeChannel, response.json())
channel = TypeAdapter(NodeChannel).validate_python(response.json())
assert channel.id == ch.id
@@ -131,7 +131,7 @@ async def test_set_channel_fees(node_client):
await asyncio.sleep(3)
response = await node_client.get("/node/api/v1/channels")
assert response.status_code == 200
channels = parse_obj_as(list[NodeChannel], response.json())
channels = TypeAdapter(list[NodeChannel]).validate_python(response.json())
ch = random.choice(
[channel for channel in channels if channel.state == ChannelState.ACTIVE]
@@ -146,7 +146,7 @@ async def test_set_channel_fees(node_client):
response = await node_client.get(f"/node/api/v1/channels/{ch.id}")
assert response.status_code == 200
channel = parse_obj_as(NodeChannel, response.json())
channel = TypeAdapter(NodeChannel).validate_python(response.json())
assert channel.fee_ppm == 69
assert channel.fee_base_msat == 42
@@ -162,7 +162,7 @@ async def test_channel_management(node_client):
await asyncio.sleep(3)
response = await node_client.get("/node/api/v1/channels")
assert response.status_code == 200
return parse_obj_as(list[NodeChannel], response.json())
return TypeAdapter(list[NodeChannel]).validate_python(response.json())
data = await get_channels()
close = random.choice(
+1 -56
View File
@@ -1,11 +1,8 @@
import asyncio
from time import time
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.settings import Settings
from lnbits.utils.cache import Cache, Cached
from lnbits.utils.cache import Cache
key = "foo"
value = "bar"
@@ -62,55 +59,3 @@ async def test_cache_coro(cache):
await cache.save_result(test, key="test")
result = await cache.save_result(test, key="test")
assert result == called == 1
def test_cached_older_than():
cached = Cached(value="value", expiry=time() - 5)
assert cached.older_than(1) is True
assert cached.older_than(10) is False
@pytest.mark.anyio
async def test_cache_value_returns_cached_metadata(cache):
cache.set(key, value, expiry=1)
cached = cache.value(key)
assert cached is not None
assert cached.value == value
assert cached.expiry > time()
@pytest.mark.anyio
async def test_cache_pop_expired_returns_default(cache):
cache.set(key, value, expiry=0.01)
await asyncio.sleep(0.02)
assert cache.pop(key, default="fallback") == "fallback"
@pytest.mark.anyio
async def test_invalidate_forever_logs_and_recovers_from_errors(
settings: Settings, mocker: MockerFixture
):
test_cache = Cache(interval=0)
logger_error = mocker.patch("lnbits.utils.cache.logger.error")
original_running = settings.lnbits_running
calls = 0
async def fake_sleep(_interval):
nonlocal calls
calls += 1
if calls == 1:
raise RuntimeError("boom")
settings.lnbits_running = False
try:
settings.lnbits_running = True
mocker.patch("lnbits.utils.cache.asyncio.sleep", side_effect=fake_sleep)
await test_cache.invalidate_forever()
finally:
settings.lnbits_running = original_running
logger_error.assert_called_once_with("Error invalidating cache")
+111
View File
@@ -0,0 +1,111 @@
import pytest
from pydantic import TypeAdapter, ValidationError
from lnbits.core.models.extensions import InstallableExtension
from lnbits.core.models.lnurl import StoredPayLink
from lnbits.core.models.payments import PaymentFilters
from lnbits.core.models.users import UserLabel
from lnbits.core.models.wallets import (
Wallet,
WalletPermission,
WalletSharePermission,
WalletShareStatus,
)
from lnbits.db import Filter, Page, dict_to_model
from lnbits.nodes.base import NodePayment
def test_user_label_uses_pydantic_v2_pattern_validation():
label = UserLabel(name="label-1", color="#FF00AA")
assert label.name == "label-1"
assert label.color == "#FF00AA"
with pytest.raises(ValidationError):
UserLabel(name="label-1", color="bad-color")
def test_wallet_has_stored_paylinks_field_and_mirrors_it():
source_wallet = Wallet(
id="source-wallet-id",
user="source-user-id",
name="source",
adminkey="admin-key",
inkey="invoice-key",
)
source_wallet.stored_paylinks.links.append(
StoredPayLink(lnurl="lnurl1example", label="saved paylink")
)
shared_wallet = Wallet(
id="shared-wallet-id",
user="shared-user-id",
name="shared",
adminkey="shared-admin-key",
inkey="shared-invoice-key",
)
source_wallet.extra.shared_with.append(
WalletSharePermission(
request_id="share-request-id",
username="shared-user",
shared_with_wallet_id=shared_wallet.id,
permissions=[WalletPermission.VIEW_PAYMENTS],
status=WalletShareStatus.APPROVED,
)
)
shared_wallet.mirror_shared_wallet(source_wallet)
assert shared_wallet.stored_paylinks.links[0].lnurl == "lnurl1example"
assert shared_wallet.stored_paylinks.links[0].label == "saved paylink"
def test_db_dict_to_model_parses_optional_nested_pydantic_v2_models():
ext = dict_to_model(
{
"id": "ext-id",
"name": "Extension",
"version": "1.0.0",
"active": 1,
"meta": (
'{"installed_release": {"name": "Release", "version": "1.0.0", '
'"archive": "https://example.com/release.zip", '
'"source_repo": "lnbits/example"}, "payments": [], '
'"dependencies": [], "featured": false, '
'"has_paid_release": false, "has_free_release": false}'
),
},
InstallableExtension,
)
assert ext.meta is not None
assert ext.meta.installed_release is not None
assert ext.meta.installed_release.source_repo == "lnbits/example"
def test_page_generic_validates_through_pydantic_v2_type_adapter():
page = Page[NodePayment](
data=[
NodePayment(
pending=False,
amount=1,
time=1,
preimage="preimage",
payment_hash="payment-hash",
)
],
total=1,
)
validated = TypeAdapter(Page[NodePayment]).validate_python(
page, from_attributes=True
)
assert validated.total == 1
assert validated.data[0].payment_hash == "payment-hash"
def test_filter_parse_query_uses_pydantic_v2_field_validation():
parsed = Filter.parse_query("amount[eq]", ["42"], PaymentFilters)
assert parsed.field == "amount"
assert parsed.values == {"amount__0": 42}
+1 -90
View File
@@ -1,15 +1,6 @@
from base64 import b64encode
from hashlib import sha256
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.utils.crypto import (
AESCipher,
fake_privkey,
random_secret_and_hash,
verify_preimage,
)
from lnbits.utils.crypto import AESCipher
@pytest.mark.anyio
@@ -27,83 +18,3 @@ async def test_aes_encrypt_decrypt(key):
encrypted_text = aes.encrypt(original_text.encode())
decrypted_text = aes.decrypt(encrypted_text)
assert original_text == decrypted_text
def test_random_secret_and_hash():
secret, payment_hash = random_secret_and_hash(16)
assert len(secret) == 32
assert payment_hash == sha256(bytes.fromhex(secret)).hexdigest()
def test_fake_privkey_is_deterministic():
assert fake_privkey("secret") == fake_privkey("secret")
assert fake_privkey("secret") != fake_privkey("other-secret")
def test_verify_preimage_success_and_failure():
preimage = "00" * 32
payment_hash = sha256(bytes.fromhex(preimage)).hexdigest()
assert verify_preimage(preimage, payment_hash) is True
assert verify_preimage(preimage, "0" * 64) is False
@pytest.mark.anyio
async def test_aes_urlsafe_encrypt_decrypt():
aes = AESCipher("normal_string")
encrypted_text = aes.encrypt(b"url-safe", urlsafe=True)
assert aes.decrypt(encrypted_text, urlsafe=True) == "url-safe"
def test_aes_derive_iv_and_key_requires_eight_byte_salt():
aes = AESCipher("normal_string")
with pytest.raises(ValueError, match="Salt must be 8 bytes"):
aes.derive_iv_and_key(b"short")
def test_aes_decrypt_rejects_invalid_salt_prefix():
aes = AESCipher("normal_string")
encrypted_text = b64encode(b"NotSalted__12345678ciphertext").decode()
with pytest.raises(ValueError, match="Invalid salt."):
aes.decrypt(encrypted_text)
def test_aes_decrypt_raises_for_cipher_errors(mocker: MockerFixture):
aes = AESCipher("normal_string")
fake_cipher = mocker.Mock()
fake_cipher.decrypt.side_effect = RuntimeError("boom")
mocker.patch.object(
aes,
"derive_iv_and_key",
return_value=(b"0" * aes.block_size, b"1" * 32),
)
mocker.patch("lnbits.utils.crypto.AES.new", return_value=fake_cipher)
encrypted_text = b64encode(b"Salted__12345678ciphertext").decode()
with pytest.raises(ValueError, match="Could not decrypt payload"):
aes.decrypt(encrypted_text)
def test_aes_decrypt_raises_for_invalid_utf8_output(mocker: MockerFixture):
aes = AESCipher("normal_string")
fake_cipher = mocker.Mock()
fake_cipher.decrypt.return_value = b"\xff\x01"
mocker.patch.object(
aes,
"derive_iv_and_key",
return_value=(b"0" * aes.block_size, b"1" * 32),
)
mocker.patch("lnbits.utils.crypto.AES.new", return_value=fake_cipher)
encrypted_text = b64encode(b"Salted__12345678ciphertext").decode()
with pytest.raises(ValueError, match="invalid UTF-8 data"):
aes.decrypt(encrypted_text)
+1 -90
View File
@@ -11,16 +11,7 @@ from pydantic.types import UUID4
from lnbits.core.crud.users import delete_account
from lnbits.core.models import User
from lnbits.core.models.users import AccessTokenPayload
from lnbits.decorators import (
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.decorators import check_user_exists
from lnbits.settings import AuthMethods, Settings, settings
@@ -145,83 +136,3 @@ async def test_check_user_exists_with_user_id_only_not_allowed(user_alan: User):
await check_user_exists(request, access_token=None, usr=UUID4(user_alan.id))
assert exc_info.value.status_code == 401
assert exc_info.value.detail == "Missing user ID or access token."
@pytest.mark.anyio
async def test_check_access_token_prefers_available_source():
assert await check_access_token("header", "cookie", "bearer") == "header"
assert await check_access_token(None, "cookie", "bearer") == "cookie"
assert await check_access_token(None, None, "bearer") == "bearer"
@pytest.mark.anyio
async def test_access_token_payload_success_and_missing(settings: Settings):
token = create_access_token({"sub": "alice", "usr": "user-id"})
payload = await access_token_payload(token)
assert isinstance(payload, AccessTokenPayload)
assert payload.sub == "alice"
assert payload.usr == "user-id"
with pytest.raises(HTTPException, match="Missing access token."):
await access_token_payload(None)
@pytest.mark.anyio
async def test_optional_user_id_uses_user_id_or_access_token(
user_alan: User, settings: Settings
):
settings.auth_allowed_methods = [AuthMethods.user_id_only.value]
request = Request({"type": "http", "path": "/wallet", "method": "GET"})
assert (
await optional_user_id(request, access_token=None, usr=UUID4(user_alan.id))
== user_alan.id
)
settings.auth_allowed_methods = []
token = create_access_token({"sub": user_alan.username, "usr": user_alan.id})
assert await optional_user_id(request, access_token=token, usr=None) == user_alan.id
assert await optional_user_id(request, access_token=None, usr=None) is None
@pytest.mark.anyio
async def test_check_admin_ui_and_first_install(settings: Settings):
original_admin_ui = settings.lnbits_admin_ui
original_first_install = settings.first_install
try:
settings.lnbits_admin_ui = False
with pytest.raises(HTTPException, match="Admin UI is disabled."):
await check_admin_ui()
settings.lnbits_admin_ui = True
await check_admin_ui()
settings.first_install = False
with pytest.raises(
HTTPException, match="Super user account has already been configured."
):
await check_first_install()
settings.first_install = True
await check_first_install()
finally:
settings.lnbits_admin_ui = original_admin_ui
settings.first_install = original_first_install
@pytest.mark.anyio
async def test_check_extension_builder_requires_admin_when_disabled_for_users(
settings: Settings, user_alan: User
):
settings.lnbits_extensions_builder_activate_non_admins = False
with pytest.raises(
HTTPException, match="Extension Builder is disabled for non admin users."
):
await check_extension_builder(user_alan)
admin_user = user_alan.copy(deep=True)
admin_user.admin = True
await check_extension_builder(admin_user)
-207
View File
@@ -1,54 +1,8 @@
from unittest.mock import AsyncMock
import httpx
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.settings import ExchangeRateProvider, Settings
from lnbits.utils.exchange_rates import (
allowed_currencies,
apply_trimmed_mean_filter,
btc_price,
btc_rates,
fiat_amount_as_satoshis,
get_fiat_rate_and_price_satoshis,
get_fiat_rate_satoshis,
satoshis_amount_as_fiat,
)
class MockResponse:
def __init__(
self, *, text: str = "", json_data=None, error: Exception | None = None
):
self.text = text
self._json_data = json_data or {}
self._error = error
def raise_for_status(self):
if self._error:
raise self._error
def json(self):
return self._json_data
class MockAsyncClient:
def __init__(self, response: MockResponse):
self.response = response
self.calls: list[tuple[str, int]] = []
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def get(self, url: str, timeout: int = 3):
self.calls.append((url, timeout))
return self.response
class TestApplyTrimmedMeanFilter:
"""Test the trimmed mean filtering function"""
@@ -169,164 +123,3 @@ class TestApplyTrimmedMeanFilter:
# Should keep the rate at exactly 1% deviation
assert len(result) == 2
assert result == rates
def test_allowed_currencies_returns_full_list_by_default(settings: Settings):
original_allowed_currencies = settings.lnbits_allowed_currencies
try:
settings.lnbits_allowed_currencies = []
currencies = allowed_currencies()
assert "USD" in currencies
assert "EUR" in currencies
finally:
settings.lnbits_allowed_currencies = original_allowed_currencies
def test_allowed_currencies_respects_allow_list(settings: Settings):
original_allowed_currencies = settings.lnbits_allowed_currencies
try:
settings.lnbits_allowed_currencies = ["USD", "EUR"]
assert allowed_currencies() == ["EUR", "USD"]
finally:
settings.lnbits_allowed_currencies = original_allowed_currencies
@pytest.mark.anyio
async def test_btc_rates_rejects_disallowed_currency(settings: Settings):
original_allowed_currencies = settings.lnbits_allowed_currencies
try:
settings.lnbits_allowed_currencies = ["EUR"]
with pytest.raises(ValueError, match="Currency 'usd' not allowed."):
await btc_rates("usd")
finally:
settings.lnbits_allowed_currencies = original_allowed_currencies
@pytest.mark.anyio
async def test_btc_rates_parses_plain_text_response(
settings: Settings, mocker: MockerFixture
):
provider = ExchangeRateProvider(
name="PlainText",
api_url="https://plain.test/{TO}",
path="",
)
client = MockAsyncClient(MockResponse(text="12,345.67"))
mocker.patch.object(settings, "lnbits_allowed_currencies", [])
mocker.patch.object(settings, "lnbits_exchange_rate_providers", [provider])
mocker.patch("lnbits.utils.exchange_rates.httpx.AsyncClient", return_value=client)
rates = await btc_rates("usd")
assert rates == [("PlainText", 12345.67)]
assert client.calls == [("https://plain.test/USD", 3)]
@pytest.mark.anyio
async def test_btc_rates_parses_json_path_response(
settings: Settings, mocker: MockerFixture
):
provider = ExchangeRateProvider(
name="JsonProvider",
api_url="https://json.test/{TO}",
path="$.data.rates.{TO}",
)
client = MockAsyncClient(
MockResponse(json_data={"data": {"rates": {"USD": "54321.0"}}})
)
mocker.patch.object(settings, "lnbits_allowed_currencies", [])
mocker.patch.object(settings, "lnbits_exchange_rate_providers", [provider])
mocker.patch("lnbits.utils.exchange_rates.httpx.AsyncClient", return_value=client)
rates = await btc_rates("usd")
assert rates == [("JsonProvider", 54321.0)]
assert client.calls == [("https://json.test/USD", 3)]
@pytest.mark.anyio
async def test_btc_rates_skips_unsupported_and_failing_providers(
settings: Settings, mocker: MockerFixture
):
unsupported = ExchangeRateProvider(
name="Unsupported",
api_url="https://unsupported.test/{TO}",
path="$.price",
exclude_to=["usd"],
)
failing = ExchangeRateProvider(
name="Failing",
api_url="https://failing.test/{TO}",
path="$.price",
)
client = MockAsyncClient(MockResponse(error=httpx.HTTPError("boom")))
mocker.patch.object(settings, "lnbits_allowed_currencies", [])
mocker.patch.object(
settings, "lnbits_exchange_rate_providers", [unsupported, failing]
)
mocker.patch("lnbits.utils.exchange_rates.httpx.AsyncClient", return_value=client)
assert await btc_rates("usd") == []
@pytest.mark.anyio
async def test_btc_price_handles_empty_single_and_multiple_rates(mocker: MockerFixture):
mocker.patch("lnbits.utils.exchange_rates.btc_rates", AsyncMock(return_value=[]))
assert await btc_price("usd") == 0.0
mocker.patch(
"lnbits.utils.exchange_rates.btc_rates",
AsyncMock(return_value=[("Only", 50000.0)]),
)
assert await btc_price("usd") == 50000.0
mocker.patch(
"lnbits.utils.exchange_rates.btc_rates",
AsyncMock(return_value=[("A", 40000.0), ("B", 50000.0)]),
)
assert await btc_price("usd") == 45000.0
@pytest.mark.anyio
async def test_rate_and_amount_conversion_helpers(mocker: MockerFixture):
cache_result = AsyncMock(return_value=50000.0)
mocker.patch("lnbits.utils.exchange_rates.cache.save_result", cache_result)
rate, price = await get_fiat_rate_and_price_satoshis("usd")
assert price == 50000.0
assert rate == 2000.0
cache_result.assert_awaited_once()
mocker.patch(
"lnbits.utils.exchange_rates.get_fiat_rate_and_price_satoshis",
AsyncMock(return_value=(1250.0, 80000.0)),
)
assert await get_fiat_rate_satoshis("usd") == 1250.0
mocker.patch(
"lnbits.utils.exchange_rates.get_fiat_rate_satoshis",
AsyncMock(return_value=100.0),
)
assert await fiat_amount_as_satoshis(2.5, "usd") == 250
assert await satoshis_amount_as_fiat(500, "usd") == 5.0
@pytest.mark.anyio
async def test_amount_conversion_helpers_raise_when_rate_missing(
mocker: MockerFixture,
):
mocker.patch(
"lnbits.utils.exchange_rates.get_fiat_rate_satoshis",
AsyncMock(return_value=0.0),
)
with pytest.raises(ValueError, match="Could not get exchange rate for usd."):
await fiat_amount_as_satoshis(1, "usd")
with pytest.raises(ValueError, match="Could not get exchange rate for usd."):
await satoshis_amount_as_fiat(100, "usd")
+2 -232
View File
@@ -9,65 +9,20 @@ from pytest_mock.plugin import MockerFixture
from lnbits.core.crud.payments import get_payments
from lnbits.core.crud.users import get_user
from lnbits.core.crud.wallets import create_wallet
from lnbits.core.models.payments import CreateInvoice, Payment, PaymentState
from lnbits.core.models.payments import CreateInvoice, PaymentState
from lnbits.core.models.users import User
from lnbits.core.models.wallets import Wallet
from lnbits.core.services import check_payment_status, payments
from lnbits.core.services.fiat_providers import (
check_fiat_status,
check_stripe_signature,
handle_fiat_payment_confirmation,
verify_paypal_webhook,
)
from lnbits.core.services.fiat_providers import (
test_connection as fiat_provider_connection,
)
from lnbits.core.services.users import create_user_account
from lnbits.fiat.base import FiatInvoiceResponse, FiatPaymentStatus, FiatStatusResponse
from lnbits.fiat.base import FiatInvoiceResponse, FiatPaymentStatus
from lnbits.settings import Settings
from tests.helpers import get_random_string
class MockHTTPResponse:
def __init__(self, json_data=None, error: Exception | None = None):
self._json_data = json_data or {}
self._error = error
def raise_for_status(self):
if self._error:
raise self._error
def json(self):
return self._json_data
class MockHTTPClient:
def __init__(self, responses: list[MockHTTPResponse]):
self._responses = responses
self.calls: list[tuple[str, dict]] = []
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def post(self, path: str, **kwargs):
self.calls.append((path, kwargs))
return self._responses.pop(0)
@pytest.fixture(autouse=True)
def fiat_provider_test_settings(settings: Settings):
original_allowed_currencies = settings.lnbits_allowed_currencies
original_paypal_enabled = settings.paypal_enabled
settings.lnbits_allowed_currencies = []
settings.paypal_enabled = False
yield
settings.lnbits_allowed_currencies = original_allowed_currencies
settings.paypal_enabled = original_paypal_enabled
@pytest.mark.anyio
async def test_create_wallet_fiat_invoice_missing_provider():
invoice_data = CreateInvoice(
@@ -478,188 +433,3 @@ def _make_stripe_sig_header(payload, secret, timestamp=None):
secret.encode(), signed_payload.encode(), hashlib.sha256
).hexdigest()
return f"t={timestamp},v1={signature}", timestamp, signature
@pytest.mark.anyio
async def test_check_fiat_status_handles_internal_states(mocker: MockerFixture):
pending_payment = Payment(
checking_id="external_payment",
payment_hash="hash_pending",
wallet_id="wallet_id",
amount=1000,
fee=0,
bolt11="bolt11",
status=PaymentState.PENDING,
)
success_payment = Payment(
checking_id="fiat_success",
payment_hash="hash_success",
wallet_id="wallet_id",
amount=1000,
fee=0,
bolt11="bolt11",
status=PaymentState.SUCCESS,
fiat_provider="stripe",
)
failed_payment = Payment(
checking_id="fiat_failed",
payment_hash="hash_failed",
wallet_id="wallet_id",
amount=1000,
fee=0,
bolt11="bolt11",
status=PaymentState.FAILED,
fiat_provider="stripe",
)
assert (await check_fiat_status(pending_payment)).pending is True
assert (await check_fiat_status(success_payment)).success is True
assert (await check_fiat_status(failed_payment)).failed is True
provider = mocker.Mock()
provider.get_invoice_status = AsyncMock(return_value=FiatPaymentStatus(paid=True))
mocker.patch(
"lnbits.core.services.fiat_providers.get_fiat_provider",
AsyncMock(return_value=provider),
)
queue_put = mocker.patch("lnbits.tasks.internal_invoice_queue.put", AsyncMock())
success_status = await check_fiat_status(
Payment(
checking_id="fiat_pending",
payment_hash="hash_queue",
wallet_id="wallet_id",
amount=1000,
fee=0,
bolt11="bolt11",
status=PaymentState.PENDING,
fiat_provider="stripe",
extra={"fiat_checking_id": "stripe_checking_id"},
)
)
assert success_status.success is True
queue_put.assert_awaited_once_with("fiat_pending")
await check_fiat_status(
Payment(
checking_id="fiat_pending_skip",
payment_hash="hash_skip",
wallet_id="wallet_id",
amount=1000,
fee=0,
bolt11="bolt11",
status=PaymentState.PENDING,
fiat_provider="stripe",
extra={"fiat_checking_id": "stripe_checking_id"},
),
skip_internal_payment_notifications=True,
)
assert queue_put.await_count == 1
@pytest.mark.anyio
async def test_verify_paypal_webhook_requires_configuration(settings: Settings):
settings.paypal_webhook_id = None
with pytest.raises(
ValueError, match="PayPal webhook cannot be verified. Missing webhook ID."
):
await verify_paypal_webhook({}, b"{}")
@pytest.mark.anyio
async def test_verify_paypal_webhook_requires_headers(settings: Settings):
settings.paypal_webhook_id = "webhook-id"
with pytest.raises(
ValueError, match="PayPal webhook cannot be verified. Missing headers."
):
await verify_paypal_webhook({}, b"{}")
@pytest.mark.anyio
async def test_verify_paypal_webhook_success(settings: Settings, mocker: MockerFixture):
settings.paypal_webhook_id = "webhook-id"
client = MockHTTPClient(
[
MockHTTPResponse(json_data={"access_token": "token"}),
MockHTTPResponse(json_data={"verification_status": "SUCCESS"}),
]
)
mocker.patch(
"lnbits.core.services.fiat_providers.httpx.AsyncClient",
return_value=client,
)
await verify_paypal_webhook(
{
"PAYPAL-TRANSMISSION-ID": "tx-id",
"PAYPAL-TRANSMISSION-TIME": "2024-01-01T00:00:00Z",
"PAYPAL-TRANSMISSION-SIG": "signature",
"PAYPAL-CERT-URL": "https://cert.example.com",
"PAYPAL-AUTH-ALGO": "SHA256withRSA",
},
b'{"id":"event-1"}',
)
assert client.calls[0][0] == "/v1/oauth2/token"
assert client.calls[1][0] == "/v1/notifications/verify-webhook-signature"
assert client.calls[1][1]["headers"]["Authorization"] == "Bearer token"
@pytest.mark.anyio
async def test_verify_paypal_webhook_raises_on_failed_verification(
settings: Settings, mocker: MockerFixture
):
settings.paypal_webhook_id = "webhook-id"
client = MockHTTPClient(
[
MockHTTPResponse(json_data={"access_token": "token"}),
MockHTTPResponse(json_data={"verification_status": "FAILURE"}),
]
)
mocker.patch(
"lnbits.core.services.fiat_providers.httpx.AsyncClient",
return_value=client,
)
with pytest.raises(ValueError, match="PayPal webhook cannot be verified."):
await verify_paypal_webhook(
{
"PAYPAL-TRANSMISSION-ID": "tx-id",
"PAYPAL-TRANSMISSION-TIME": "2024-01-01T00:00:00Z",
"PAYPAL-TRANSMISSION-SIG": "signature",
"PAYPAL-CERT-URL": "https://cert.example.com",
"PAYPAL-AUTH-ALGO": "SHA256withRSA",
},
b'{"id":"event-1"}',
)
@pytest.mark.anyio
async def test_test_connection_reports_provider_status(mocker: MockerFixture):
mocker.patch(
"lnbits.core.services.fiat_providers.get_fiat_provider",
AsyncMock(return_value=None),
)
missing_status = await fiat_provider_connection("stripe")
assert missing_status.success is False
assert missing_status.message == "Fiat provider 'stripe' not found."
provider = mocker.Mock()
provider.status = AsyncMock(
return_value=FiatStatusResponse(error_message="bad key")
)
mocker.patch(
"lnbits.core.services.fiat_providers.get_fiat_provider",
AsyncMock(return_value=provider),
)
error_status = await fiat_provider_connection("stripe")
assert error_status.success is False
assert error_status.message == "Cconnection test failed: bad key"
provider.status = AsyncMock(return_value=FiatStatusResponse(balance=21.0))
success_status = await fiat_provider_connection("stripe")
assert success_status.success is True
assert success_status.message == "Connection test successful. Balance: 21.0."
-140
View File
@@ -1,140 +0,0 @@
from pathlib import Path
from uuid import uuid4
import pytest
from lnbits.core.crud import create_account, delete_account, get_account
from lnbits.core.crud.settings import get_settings_field, set_settings_field
from lnbits.core.db import db
from lnbits.core.models import Account, UpdateSuperuserPassword, UserExtra
from lnbits.core.services.users import check_admin_settings
from lnbits.core.views.auth_api import first_install
from lnbits.settings import settings
async def _restore_setting_field(field_name: str, original_row) -> None:
if original_row is None:
await db.execute(
"DELETE FROM system_settings WHERE id = :id AND tag = :tag",
{"id": field_name, "tag": "core"},
)
return
await set_settings_field(field_name, original_row.value, original_row.tag)
def test_has_first_install_token_changed_requires_a_confirmed_mismatch():
original_token = settings.first_install_token
original_confirmed = settings.first_install_token_confirmed
try:
settings.first_install_token = "new-token"
settings.first_install_token_confirmed = "old-token"
assert settings.has_first_install_token_changed() is True
settings.first_install_token_confirmed = "new-token"
assert settings.has_first_install_token_changed() is False
settings.first_install_token_confirmed = None
assert settings.has_first_install_token_changed() is False
settings.first_install_token = None
assert settings.has_first_install_token_changed() is False
finally:
settings.first_install_token = original_token
settings.first_install_token_confirmed = original_confirmed
@pytest.mark.anyio
async def test_first_install_confirms_first_install_token(app):
temp_super_user = uuid4().hex
username = f"super_{temp_super_user[:8]}"
original_super_user = settings.super_user
original_first_install = settings.first_install
original_first_install_token = settings.first_install_token
original_first_install_token_confirmed = settings.first_install_token_confirmed
original_confirmed_row = await get_settings_field("first_install_token_confirmed")
await create_account(Account(id=temp_super_user, extra=UserExtra(provider="env")))
try:
settings.super_user = temp_super_user
settings.first_install = True
settings.first_install_token = "expected-token"
settings.first_install_token_confirmed = None
response = await first_install(
UpdateSuperuserPassword(
username=username,
password="secret1234",
password_repeat="secret1234",
first_install_token="expected-token",
)
)
assert response.status_code == 200
assert settings.first_install is False
assert settings.first_install_token_confirmed == "expected-token"
confirmed_row = await get_settings_field("first_install_token_confirmed")
assert confirmed_row is not None
assert confirmed_row.value == "expected-token"
account = await get_account(temp_super_user)
assert account is not None
assert account.username == username
assert account.extra.provider == "lnbits"
assert account.verify_password("secret1234")
finally:
await _restore_setting_field(
"first_install_token_confirmed", original_confirmed_row
)
settings.super_user = original_super_user
settings.first_install = original_first_install
settings.first_install_token = original_first_install_token
settings.first_install_token_confirmed = original_first_install_token_confirmed
await delete_account(temp_super_user)
@pytest.mark.anyio
async def test_check_admin_settings_clears_persisted_super_user_when_token_changes(app):
temp_super_user = uuid4().hex
original_super_user = settings.super_user
original_first_install = settings.first_install
original_first_install_token = settings.first_install_token
original_first_install_token_confirmed = settings.first_install_token_confirmed
original_super_user_row = await get_settings_field("super_user")
original_confirmed_row = await get_settings_field("first_install_token_confirmed")
super_user_file = Path(settings.lnbits_data_folder) / ".super_user"
await create_account(
Account(id=temp_super_user, extra=UserExtra(provider="lnbits"))
)
try:
await set_settings_field("super_user", temp_super_user)
await set_settings_field("first_install_token_confirmed", "old-token")
settings.lnbits_admin_ui = True
settings.super_user = temp_super_user
settings.first_install = False
settings.first_install_token = "new-token"
settings.first_install_token_confirmed = "old-token"
await check_admin_settings()
super_user_row = await get_settings_field("super_user")
assert super_user_row is not None
assert super_user_row.value
assert settings.first_install is True
finally:
await _restore_setting_field("super_user", original_super_user_row)
await _restore_setting_field(
"first_install_token_confirmed", original_confirmed_row
)
settings.super_user = original_super_user
settings.first_install = original_first_install
settings.first_install_token = original_first_install_token
settings.first_install_token_confirmed = original_first_install_token_confirmed
super_user_file.write_text(original_super_user)
await delete_account(temp_super_user)
+1 -198
View File
@@ -1,39 +1,6 @@
import hashlib
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.helpers import check_callback_url
from lnbits.settings import Settings
@@ -115,167 +82,3 @@ def test_check_callback_url_multiple_rules(settings: Settings):
settings.lnbits_callback_url_rules.append("https://localhost:3000")
check_callback_url("https://localhost:3000/callback") # should not raise
def test_get_db_vendor_name(settings: Settings):
original_database_url = settings.lnbits_database_url
try:
settings.lnbits_database_url = None
assert get_db_vendor_name() == "SQLite"
settings.lnbits_database_url = "postgres://localhost/db"
assert get_db_vendor_name() == "PostgreSQL"
settings.lnbits_database_url = "cockroachdb://localhost/db"
assert get_db_vendor_name() == "CockroachDB"
finally:
settings.lnbits_database_url = original_database_url
def test_url_helpers(settings: Settings):
assert url_for("/api/v1/wallet", external=False, usr="user") == (
"/api/v1/wallet?usr=user&"
)
assert url_for("/api/v1/wallet", external=True, usr="user") == (
f"http://{settings.host}:{settings.port}/api/v1/wallet?usr=user&"
)
assert static_url_for("static", "bundle.min.js") == (
f"/static/bundle.min.js?v={settings.server_startup_time}"
)
@pytest.mark.parametrize(
("value", "validator"),
[
("alice@example.com", is_valid_email_address),
("alice_1", is_valid_username),
("Label 1", is_valid_label),
("external-id-1", is_valid_external_id),
("a" * 64, is_valid_pubkey),
],
)
def test_validation_helpers_valid(value, validator):
assert validator(value) is True
@pytest.mark.parametrize(
("value", "validator"),
[
("alice@example", is_valid_email_address),
("_alice", is_valid_username),
("bad/label", is_valid_label),
("contains spaces", is_valid_external_id),
("xyz", is_valid_pubkey),
],
)
def test_validation_helpers_invalid(value, validator):
assert validator(value) is False
def test_is_valid_external_id_rejects_long_and_multiline_values():
assert is_valid_external_id("x" * 257) is False
assert is_valid_external_id("evil\nnewline") is False
def test_access_token_and_internal_message_helpers(settings: Settings):
token = create_access_token(
{"sub": "alice", "usr": None, "email": "alice@example.com"},
token_expire_minutes=1,
)
payload = jwt.decode(token, settings.auth_secret_key, ["HS256"])
assert payload["sub"] == "alice"
assert payload["email"] == "alice@example.com"
assert "usr" not in payload
assert "exp" in payload
assert encrypt_internal_message(None) is None
assert decrypt_internal_message(None) is None
encrypted = encrypt_internal_message("secret-message", urlsafe=True)
assert encrypted is not None
assert decrypt_internal_message(encrypted, urlsafe=True) == "secret-message"
def test_filter_dict_keys_returns_copy_when_no_filters():
original = {"a": 1, "b": 2}
clone = filter_dict_keys(original, None)
filtered = filter_dict_keys(original, ["b", "missing"])
assert clone == original
assert clone is not original
assert filtered == {"b": 2}
def test_version_helpers(settings: Settings):
original_version = settings.version
try:
settings.version = "1.2.3"
assert version_parse("1.2.3rc4") == version_parse("1.2.3")
assert version_parse("invalid-version") == version_parse("0.0.0")
assert is_lnbits_version_ok("1.2.0", "2.0.0") is True
assert is_lnbits_version_ok("2.0.0", None) is False
assert is_lnbits_version_ok(None, "1.2.3") is False
finally:
settings.version = original_version
def test_download_url_rejects_non_http_schemes(tmp_path):
with pytest.raises(
ValueError, match="Invalid URL: ftp://example.com. Must start with 'http'"
):
download_url("ftp://example.com", tmp_path / "download.bin")
def test_file_hash(tmp_path):
filename = tmp_path / "payload.txt"
filename.write_text("hello world")
assert file_hash(filename) == hashlib.sha256(b"hello world").hexdigest()
def test_get_api_routes_extracts_v1_paths():
app = FastAPI()
@app.get("/api/v1/payments")
async def payments():
return {}
@app.get("/myext/api/v1/settings")
async def extension_settings():
return {}
@app.get("/health")
async def health():
return {}
routes = get_api_routes([*app.routes, object()])
assert routes == {
"/api/v1/payments": "Payments",
"/myext/api/v1": "Myext",
}
def test_path_and_case_helpers():
assert path_segments("/wallet/path") == ["wallet", "path"]
assert path_segments("/upgrades/ext/assets/app.js") == ["assets", "app.js"]
assert normalize_path(None) == "/"
assert normalize_path("/upgrades/ext/assets/app.js") == "/assets/app.js"
assert normalize_endpoint("example.com/") == "https://example.com"
assert normalize_endpoint("ws://socket.example.com") == "ws://socket.example.com"
assert (
normalize_endpoint("http://example.com/", add_proto=False)
== "http://example.com"
)
assert camel_to_words("CamelCaseName") == "Camel Case Name"
assert camel_to_snake("CamelCaseName") == "camel_case_name"
assert snake_to_camel("snake_case_name") == "snakeCaseName"
assert snake_to_camel("snake_case_name", capitalize_first=True) == "SnakeCaseName"
assert is_camel_case("CamelCase1") is True
assert is_camel_case("camelCase") is False
assert is_snake_case("snake_case_1") is True
assert is_snake_case("SnakeCase") is False
assert lowercase_first_letter("Hello") == "hello"
assert sha256s("hello") == hashlib.sha256(b"hello").hexdigest()
-20
View File
@@ -4,7 +4,6 @@ import pytest
from lnbits.db import (
dict_to_model,
dict_to_submodel,
insert_query,
model_to_dict,
update_query,
@@ -84,22 +83,3 @@ async def test_helpers_dict_to_model():
assert m.active is True
assert type(m.child) is DbTestModel2
assert type(m.child.child) is DbTestModel
@pytest.mark.anyio
async def test_helpers_dict_to_submodel():
model = dict_to_submodel(
DbTestModel,
'{"id": 9, "name": "submodel", "value": "value"}',
)
assert model == DbTestModel(id=9, name="submodel", value="value")
assert dict_to_submodel(DbTestModel, "") is None
assert dict_to_submodel(DbTestModel, "null") is None
@pytest.mark.anyio
async def test_helpers_dict_to_model_ignores_unknown_fields():
model = dict_to_model({**test_dict, "ignored": "field"}, DbTestModel3)
assert model == test_data
-109
View File
@@ -1,109 +0,0 @@
from io import BytesIO
from uuid import uuid4
import pytest
from PIL import Image
from pytest_mock.plugin import MockerFixture
from lnbits.core.crud import create_account
from lnbits.core.crud.assets import get_user_asset, get_user_assets_count
from lnbits.core.models import Account
from lnbits.core.services.assets import create_user_asset, thumbnail_from_bytes
from lnbits.settings import Settings
from tests.helpers import make_upload_file
@pytest.mark.anyio
async def test_create_user_asset_validates_upload_constraints(
settings: Settings, mocker: MockerFixture
):
file_without_type = make_upload_file(b"hello", filename="a.txt", content_type=None)
with pytest.raises(ValueError, match="File must have a content type."):
await create_user_asset("user-1", file_without_type, is_public=False)
bad_type = make_upload_file(
b"hello",
filename="bad.bin",
content_type="application/x-msdownload",
)
with pytest.raises(
ValueError, match="File type 'application/x-msdownload' not allowed."
):
await create_user_asset("user-1", bad_type, is_public=False)
original_max_assets = settings.lnbits_max_assets_per_user
original_max_size = settings.lnbits_max_asset_size_mb
original_no_limit_users = list(settings.lnbits_assets_no_limit_users)
try:
settings.lnbits_max_assets_per_user = 1
settings.lnbits_max_asset_size_mb = 1
settings.lnbits_assets_no_limit_users = []
limited_user = await _create_user()
allowed_type = make_upload_file(
b"hello", filename="ok.txt", content_type="text/plain"
)
await create_user_asset(limited_user, allowed_type, is_public=False)
blocked_by_count = make_upload_file(
b"again",
filename="again.txt",
content_type="text/plain",
)
with pytest.raises(ValueError, match="Max upload count of 1 exceeded."):
await create_user_asset(limited_user, blocked_by_count, is_public=False)
settings.lnbits_max_asset_size_mb = 0.000001
oversized_user = await _create_user()
large_file = make_upload_file(
b"0123456789",
filename="ok.txt",
content_type="text/plain",
)
with pytest.raises(ValueError, match="File limit of 1e-06MB exceeded."):
await create_user_asset(oversized_user, large_file, is_public=False)
finally:
settings.lnbits_max_assets_per_user = original_max_assets
settings.lnbits_max_asset_size_mb = original_max_size
settings.lnbits_assets_no_limit_users = original_no_limit_users
@pytest.mark.anyio
async def test_create_user_asset_success(mocker: MockerFixture):
user_id = await _create_user()
mocker.patch(
"lnbits.core.services.assets.thumbnail_from_bytes",
return_value=None,
)
file = make_upload_file(b"hello", filename="hello.txt", content_type="text/plain")
asset = await create_user_asset(user_id, file, is_public=True)
stored = await get_user_asset(user_id, asset.id)
assert asset.id
assert asset.user_id == user_id
assert asset.name == "hello.txt"
assert asset.size_bytes == 5
assert asset.data == b"hello"
assert asset.is_public is True
assert stored is not None
assert stored.id == asset.id
assert stored.data == b"hello"
assert await get_user_assets_count(user_id) == 1
def test_thumbnail_from_bytes_success_and_failure():
image = Image.new("RGB", (512, 512), color="red")
buffer = BytesIO()
image.save(buffer, format="PNG")
thumbnail = thumbnail_from_bytes(buffer.getvalue())
assert thumbnail is not None
assert isinstance(thumbnail.getvalue(), bytes)
assert thumbnail_from_bytes(b"not-an-image") is None
async def _create_user() -> str:
user_id = uuid4().hex
await create_account(Account(id=user_id, username=f"user_{user_id[:8]}"))
return user_id
-283
View File
@@ -1,283 +0,0 @@
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
@@ -1,107 +0,0 @@
import hashlib
import zipfile
from pathlib import Path
from types import SimpleNamespace
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.core.models.extensions import ExtensionRelease
from lnbits.core.services.extensions_builder import (
build_extension_from_data,
clean_extension_builder_data,
zip_directory,
)
from lnbits.settings import Settings
from tests.helpers import make_extension_data
@pytest.mark.anyio
async def test_build_extension_from_data_orchestrates_builder_steps(
tmp_path, mocker: MockerFixture
):
data = make_extension_data()
release = ExtensionRelease(
name="stub",
version="0.1.0",
archive="https://example.com/stub.zip",
source_repo="org/repo",
is_github_release=True,
)
build_dir = tmp_path / "build"
fetch_mock = mocker.patch(
"lnbits.core.services.extensions_builder._fetch_extension_builder_stub",
mocker.AsyncMock(),
)
transform_mock = mocker.patch(
"lnbits.core.services.extensions_builder._transform_extension_builder_stub"
)
export_mock = mocker.patch(
"lnbits.core.services.extensions_builder._export_extension_data_json"
)
mocker.patch(
"lnbits.core.services.extensions_builder._get_extension_stub_release",
mocker.AsyncMock(return_value=release),
)
mocker.patch(
"lnbits.core.services.extensions_builder._copy_ext_stub_to_build_dir",
return_value=build_dir,
)
mocker.patch(
"lnbits.core.services.extensions_builder.uuid4",
return_value=SimpleNamespace(hex="seed"),
)
built_release, output_dir = await build_extension_from_data(data, "stub-ext")
assert output_dir == build_dir
assert built_release == release
assert built_release.hash == hashlib.sha256(b"seed").hexdigest()
assert built_release.icon == "/demoext/static/image/demoext.png"
assert built_release.is_github_release is False
fetch_mock.assert_awaited_once_with("stub-ext", release)
transform_mock.assert_called_once_with(data, build_dir)
export_mock.assert_called_once_with(data, build_dir)
def test_clean_extension_builder_data_recreates_working_directory(
settings: Settings, tmp_path
):
original_data_folder = settings.lnbits_data_folder
try:
settings.lnbits_data_folder = str(tmp_path)
working_dir = settings.extension_builder_working_dir_path
working_dir.mkdir(parents=True, exist_ok=True)
Path(working_dir, "stale.txt").write_text("stale")
clean_extension_builder_data()
assert working_dir.is_dir()
assert list(working_dir.iterdir()) == []
finally:
settings.lnbits_data_folder = original_data_folder
def test_zip_directory_skips_excluded_directories(tmp_path):
source_dir = tmp_path / "source"
zip_path = tmp_path / "archive.zip"
(source_dir / "nested").mkdir(parents=True)
(source_dir / "node_modules").mkdir()
(source_dir / "__pycache__").mkdir()
(source_dir / "root.txt").write_text("root")
(source_dir / "nested" / "file.txt").write_text("nested")
(source_dir / "node_modules" / "ignored.txt").write_text("ignored")
(source_dir / "__pycache__" / "ignored.pyc").write_text("ignored")
from_builder = "lnbits.core.services.extensions_builder._is_excluded_dir"
with pytest.MonkeyPatch.context() as monkeypatch:
monkeypatch.setattr(
from_builder,
lambda path: "node_modules" in path or "__pycache__" in path,
)
zip_directory(source_dir, zip_path)
with zipfile.ZipFile(zip_path) as archive:
names = sorted(archive.namelist())
assert names == ["nested/file.txt", "root.txt"]
-171
View File
@@ -1,171 +0,0 @@
from types import SimpleNamespace
from typing import Any, cast
from uuid import uuid4
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.core.crud import create_account, create_wallet, get_total_balance
from lnbits.core.models import Account
from lnbits.core.models.misc import BalanceDelta
from lnbits.core.services.funding_source import (
check_balance_delta_changed,
check_server_balance_against_node,
get_balance_delta,
switch_to_voidwallet,
)
from lnbits.core.services.payments import update_wallet_balance
from lnbits.settings import Settings
@pytest.mark.anyio
async def test_switch_to_voidwallet_returns_when_already_using_voidwallet(
settings: Settings, mocker: MockerFixture
):
original_backend_class = settings.lnbits_backend_wallet_class
try:
settings.lnbits_backend_wallet_class = "FakeWallet"
mocker.patch(
"lnbits.core.services.funding_source.get_funding_source",
return_value=type("VoidWallet", (), {})(),
)
set_funding_source = mocker.patch(
"lnbits.core.services.funding_source.set_funding_source"
)
await switch_to_voidwallet()
set_funding_source.assert_not_called()
assert settings.lnbits_backend_wallet_class == "FakeWallet"
finally:
settings.lnbits_backend_wallet_class = original_backend_class
@pytest.mark.anyio
async def test_switch_to_voidwallet_updates_backend_class(
settings: Settings, mocker: MockerFixture
):
original_backend_class = settings.lnbits_backend_wallet_class
try:
settings.lnbits_backend_wallet_class = "FakeWallet"
mocker.patch(
"lnbits.core.services.funding_source.get_funding_source",
return_value=type("FakeWallet", (), {})(),
)
set_funding_source = mocker.patch(
"lnbits.core.services.funding_source.set_funding_source"
)
await switch_to_voidwallet()
set_funding_source.assert_called_once_with("VoidWallet")
assert settings.lnbits_backend_wallet_class == "VoidWallet"
finally:
settings.lnbits_backend_wallet_class = original_backend_class
@pytest.mark.anyio
async def test_get_balance_delta(mocker: MockerFixture):
baseline_balance = await get_total_balance()
await _create_wallet_with_balance(11)
funding_source = SimpleNamespace(
status=mocker.AsyncMock(
return_value=SimpleNamespace(balance_msat=7_000, error_message=None)
)
)
mocker.patch(
"lnbits.core.services.funding_source.get_funding_source",
return_value=funding_source,
)
delta = await get_balance_delta()
expected_balance_sats = (baseline_balance + 11_000) // 1000
assert delta.lnbits_balance_sats == expected_balance_sats
assert delta.node_balance_sats == 7
assert delta.delta_sats == expected_balance_sats - 7
@pytest.mark.anyio
async def test_check_server_balance_against_node_notifies_and_switches(
settings: Settings, mocker: MockerFixture
):
original_switch = settings.lnbits_watchdog_switch_to_voidwallet
original_notification = settings.lnbits_notification_watchdog
original_delta = settings.lnbits_watchdog_delta
try:
settings.lnbits_watchdog_switch_to_voidwallet = True
settings.lnbits_notification_watchdog = True
settings.lnbits_watchdog_delta = 5
mocker.patch(
"lnbits.core.services.funding_source.get_funding_source",
return_value=type("FakeWallet", (), {})(),
)
mocker.patch(
"lnbits.core.services.funding_source.get_balance_delta",
mocker.AsyncMock(
return_value=BalanceDelta(
lnbits_balance_sats=12,
node_balance_sats=1,
)
),
)
enqueue = mocker.patch(
"lnbits.core.services.funding_source.enqueue_admin_notification"
)
switch = mocker.patch(
"lnbits.core.services.funding_source.switch_to_voidwallet",
mocker.AsyncMock(),
)
await check_server_balance_against_node()
enqueue.assert_called_once()
switch.assert_awaited_once()
finally:
settings.lnbits_watchdog_switch_to_voidwallet = original_switch
settings.lnbits_notification_watchdog = original_notification
settings.lnbits_watchdog_delta = original_delta
@pytest.mark.anyio
async def test_check_balance_delta_changed_tracks_and_notifies(
settings: Settings, mocker: MockerFixture
):
settings_any = cast(Any, settings)
original_latest = settings.latest_balance_delta_sats
original_threshold = settings.notification_balance_delta_threshold_sats
try:
settings_any.latest_balance_delta_sats = None
settings.notification_balance_delta_threshold_sats = 3
mocker.patch(
"lnbits.core.services.funding_source.get_balance_delta",
mocker.AsyncMock(
side_effect=[
BalanceDelta(lnbits_balance_sats=12, node_balance_sats=10),
BalanceDelta(lnbits_balance_sats=20, node_balance_sats=10),
]
),
)
enqueue = mocker.patch(
"lnbits.core.services.funding_source.enqueue_admin_notification"
)
await check_balance_delta_changed()
enqueue.assert_not_called()
assert settings.latest_balance_delta_sats == 2
await check_balance_delta_changed()
enqueue.assert_called_once()
assert settings.latest_balance_delta_sats == 10
finally:
settings_any.latest_balance_delta_sats = original_latest
settings.notification_balance_delta_threshold_sats = original_threshold
async def _create_wallet_with_balance(amount: int):
user_id = uuid4().hex
await create_account(Account(id=user_id, username=f"user_{user_id[:8]}"))
wallet = await create_wallet(user_id=user_id, wallet_name="wallet")
await update_wallet_balance(wallet=wallet, amount=amount)
return wallet
-182
View File
@@ -1,182 +0,0 @@
from uuid import uuid4
import pytest
from bolt11.types import MilliSatoshi
from lnurl import (
LnAddress,
LnurlErrorResponse,
LnurlPayActionResponse,
LnurlResponseException,
LnurlSuccessResponse,
LnurlWithdrawResponse,
)
from lnurl.types import CallbackUrl, LightningInvoice
from pydantic import parse_obj_as
from pytest_mock.plugin import MockerFixture
from lnbits.core.crud import create_account, create_wallet, get_wallet
from lnbits.core.models import Account
from lnbits.core.models.lnurl import CreateLnurlPayment
from lnbits.core.models.wallets import Wallet
from lnbits.core.services.lnurl import (
fetch_lnurl_pay_request,
get_pr_from_lnurl,
perform_withdraw,
store_paylink,
)
from tests.helpers import make_lnurl_pay_response
TEST_BOLT11 = (
"lnbc1pnsu5z3pp57getmdaxhg5kc9yh2a2qsh7cjf4gnccgkw0qenm8vsqv50w7s"
"ygqdqj0fjhymeqv9kk7atwwscqzzsxqyz5vqsp5e2yyqcp0a3ujeesp24ya0glej"
"srh703md8mrx0g2lyvjxy5w27ss9qxpqysgqyjreasng8a086kpkczv48er5c6l5"
"73aym6ynrdl9nkzqnag49vt3sjjn8qdfq5cr6ha0vrdz5c5r3v4aghndly0hplmv"
"6hjxepwp93cq398l3s"
)
@pytest.mark.anyio
async def test_perform_withdraw_success_and_validation(mocker: MockerFixture):
withdraw_response = LnurlWithdrawResponse(
callback=parse_obj_as(CallbackUrl, "https://example.com/callback"),
k1="k1",
minWithdrawable=MilliSatoshi(1),
maxWithdrawable=MilliSatoshi(1000),
defaultDescription="test",
)
execute_withdraw_mock = mocker.patch(
"lnbits.core.services.lnurl.execute_withdraw",
mocker.AsyncMock(return_value=LnurlSuccessResponse()),
)
mocker.patch(
"lnbits.core.services.lnurl.handle",
mocker.AsyncMock(return_value=withdraw_response),
)
await perform_withdraw("lnurl", "bolt11")
execute_withdraw_mock.assert_awaited_once()
mocker.patch(
"lnbits.core.services.lnurl.check_callback_url",
side_effect=ValueError("blocked"),
)
with pytest.raises(LnurlResponseException, match="Invalid callback URL"):
await perform_withdraw("lnurl", "bolt11")
@pytest.mark.anyio
async def test_perform_withdraw_rejects_error_response(mocker: MockerFixture):
mocker.patch(
"lnbits.core.services.lnurl.handle",
mocker.AsyncMock(return_value=LnurlErrorResponse(reason="boom")),
)
with pytest.raises(LnurlResponseException, match="boom"):
await perform_withdraw("lnurl", "bolt11")
@pytest.mark.anyio
async def test_get_pr_from_lnurl_success_and_error(mocker: MockerFixture):
pay_response = make_lnurl_pay_response(min_sendable_msat=1, text="Test")
mocker.patch(
"lnbits.core.services.lnurl.handle",
mocker.AsyncMock(return_value=pay_response),
)
mocker.patch(
"lnbits.core.services.lnurl.execute_pay_request",
mocker.AsyncMock(
return_value=LnurlPayActionResponse(pr=LightningInvoice(TEST_BOLT11))
),
)
assert await get_pr_from_lnurl("lnurl", 1000, comment="hello") == TEST_BOLT11
mocker.patch(
"lnbits.core.services.lnurl.handle",
mocker.AsyncMock(return_value=LnurlErrorResponse(reason="nope")),
)
with pytest.raises(LnurlResponseException, match="nope"):
await get_pr_from_lnurl("lnurl", 1000)
@pytest.mark.anyio
async def test_fetch_lnurl_pay_request_converts_currency_and_stores_paylink(
mocker: MockerFixture,
):
pay_response = make_lnurl_pay_response(min_sendable_msat=1, text="Test")
action_response = LnurlPayActionResponse(
pr=LightningInvoice(TEST_BOLT11), disposable=False
)
mocker.patch(
"lnbits.core.services.lnurl.fiat_amount_as_satoshis",
mocker.AsyncMock(return_value=100),
)
execute_mock = mocker.patch(
"lnbits.core.services.lnurl.execute_pay_request",
mocker.AsyncMock(return_value=action_response),
)
store_paylink_mock = mocker.patch(
"lnbits.core.services.lnurl.store_paylink",
mocker.AsyncMock(),
)
wallet = _make_wallet()
data = CreateLnurlPayment(res=pay_response, amount=2500, unit="USD", comment="hi")
response, action = await fetch_lnurl_pay_request(data, wallet=wallet)
assert response == pay_response
assert action == action_response
execute_mock.assert_awaited_once()
assert execute_mock.await_args is not None
assert execute_mock.await_args.kwargs["msat"] == 100_000
store_paylink_mock.assert_awaited_once_with(
pay_response, action_response, wallet, None
)
with pytest.raises(LnurlResponseException, match="No LNURL pay request provided."):
await fetch_lnurl_pay_request(CreateLnurlPayment(amount=1))
@pytest.mark.anyio
async def test_store_paylink_appends_and_updates_existing():
wallet = await _create_wallet()
pay_response = make_lnurl_pay_response(min_sendable_msat=1, text="Test")
action_response = LnurlPayActionResponse(
pr=LightningInvoice(TEST_BOLT11), disposable=False
)
await store_paylink(
pay_response, action_response, wallet, LnAddress("alice@example.com")
)
stored_wallet = await get_wallet(wallet.id)
assert stored_wallet is not None
assert len(stored_wallet.stored_paylinks.links) == 1
assert stored_wallet.stored_paylinks.links[0].lnurl == "alice@example.com"
first_used = stored_wallet.stored_paylinks.links[0].last_used
await store_paylink(
pay_response, action_response, wallet, LnAddress("alice@example.com")
)
stored_wallet = await get_wallet(wallet.id)
assert stored_wallet is not None
assert len(stored_wallet.stored_paylinks.links) == 1
assert stored_wallet.stored_paylinks.links[0].last_used >= first_used
def _make_wallet() -> Wallet:
return Wallet(
id="wallet-id",
user="user-id",
name="Wallet",
adminkey="admin-key",
inkey="invoice-key",
)
async def _create_wallet() -> Wallet:
user_id = uuid4().hex
await create_account(Account(id=user_id, username=f"user_{user_id[:8]}"))
return await create_wallet(user_id=user_id, wallet_name="Wallet")
-117
View File
@@ -1,117 +0,0 @@
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.core.services.nostr import fetch_nip5_details, send_nostr_dm
class FakeWebSocket:
def __init__(self):
self.sent: list[str] = []
self.closed = False
def send(self, message: str):
self.sent.append(message)
def close(self):
self.closed = True
class MockHTTPClient:
def __init__(self, response):
self.response = response
self.calls: list[str] = []
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def get(self, url: str):
self.calls.append(url)
return self.response
class MockHTTPResponse:
def __init__(self, json_data: dict, error: Exception | None = None):
self._json_data = json_data
self._error = error
def raise_for_status(self):
if self._error:
raise self._error
def json(self):
return self._json_data
@pytest.mark.anyio
async def test_send_nostr_dm_sends_to_available_relays_and_closes_connections(
mocker: MockerFixture,
):
event = mocker.Mock()
event.to_message.return_value = "nostr-message"
event.to_dict.return_value = {"id": "event-id"}
dm = mocker.Mock()
dm.to_event.return_value = event
mocker.patch("lnbits.core.services.nostr.EncryptedDirectMessage", return_value=dm)
ws_one = FakeWebSocket()
ws_two = FakeWebSocket()
mocker.patch(
"lnbits.core.services.nostr.create_connection",
side_effect=[ws_one, RuntimeError("boom"), ws_two],
)
mocker.patch("lnbits.core.services.nostr.asyncio.sleep", mocker.AsyncMock())
result = await send_nostr_dm(
"privkey",
"pubkey",
"hello",
["wss://relay-1", "wss://broken", "wss://relay-2"],
)
assert ws_one.sent == ["nostr-message"]
assert ws_two.sent == ["nostr-message"]
assert ws_one.closed is True
assert ws_two.closed is True
assert result == {"id": "event-id"}
@pytest.mark.anyio
async def test_fetch_nip5_details_returns_pubkey_and_relays(mocker: MockerFixture):
response = MockHTTPResponse(
{
"names": {"alice": "f" * 64},
"relays": {"f" * 64: ["wss://relay.example.com"]},
}
)
client = MockHTTPClient(response)
mocker.patch("lnbits.core.services.nostr.is_valid_url", return_value=True)
validate_identifier = mocker.patch("lnbits.core.services.nostr.validate_identifier")
validate_pub_key = mocker.patch("lnbits.core.services.nostr.validate_pub_key")
mocker.patch("lnbits.core.services.nostr.httpx.AsyncClient", return_value=client)
pubkey, relays = await fetch_nip5_details("alice@example.com")
validate_identifier.assert_called_once_with("alice")
validate_pub_key.assert_called_once_with("f" * 64)
assert client.calls == ["https://example.com/.well-known/nostr.json?name=alice"]
assert pubkey == "f" * 64
assert relays == ["wss://relay.example.com"]
@pytest.mark.anyio
async def test_fetch_nip5_details_rejects_invalid_values(mocker: MockerFixture):
with pytest.raises(ValueError, match="not enough values to unpack"):
await fetch_nip5_details("invalid")
mocker.patch("lnbits.core.services.nostr.is_valid_url", return_value=False)
with pytest.raises(ValueError, match="Invalid NIP5 domain"):
await fetch_nip5_details("alice@example.com")
mocker.patch("lnbits.core.services.nostr.is_valid_url", return_value=True)
client = MockHTTPClient(MockHTTPResponse({"names": {}}))
mocker.patch("lnbits.core.services.nostr.httpx.AsyncClient", return_value=client)
with pytest.raises(ValueError, match="NIP5 not name found"):
await fetch_nip5_details("alice@example.com")
-608
View File
@@ -1,608 +0,0 @@
import asyncio
from http import HTTPStatus
from types import SimpleNamespace
from unittest.mock import MagicMock
from uuid import uuid4
import httpx
import pytest
from pytest_mock.plugin import MockerFixture
from pywebpush import WebPushException
from lnbits.core.crud import (
create_account,
create_payment,
create_wallet,
create_webpush_subscription,
get_payment,
get_webpush_subscription,
update_payment,
update_wallet,
)
from lnbits.core.models import Account, CreatePayment, Payment, PaymentState, Wallet
from lnbits.core.models.notifications import NotificationType
from lnbits.core.models.users import UserExtra, UserNotifications
from lnbits.core.models.wallets import (
WalletPermission,
WalletSharePermission,
WalletShareStatus,
)
from lnbits.core.services.notifications import (
dispatch_webhook,
enqueue_admin_notification,
enqueue_user_notification,
process_next_notification,
send_admin_notification,
send_chat_payment_notification,
send_email,
send_email_notification,
send_nostr_notification,
send_nostr_notifications,
send_notification,
send_payment_notification,
send_payment_push_notification,
send_push_notification,
send_telegram_message,
send_telegram_notification,
send_user_notification,
send_ws_payment_notification,
)
from lnbits.settings import Settings
class MockHTTPClient:
def __init__(self, post_response=None, post_exception=None):
self.post_response = post_response
self.post_exception = post_exception
self.posts: list[tuple[str, dict]] = []
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return None
async def post(self, url, **kwargs):
self.posts.append((url, kwargs))
if self.post_exception:
raise self.post_exception
return self.post_response
@pytest.mark.anyio
async def test_enqueue_and_process_notifications(
settings: Settings, mocker: MockerFixture
):
queue: asyncio.Queue = asyncio.Queue()
admin_mock = mocker.patch(
"lnbits.core.services.notifications.send_admin_notification",
mocker.AsyncMock(),
)
user_mock = mocker.patch(
"lnbits.core.services.notifications.send_user_notification",
mocker.AsyncMock(),
)
mocker.patch("lnbits.core.services.notifications.notifications_queue", queue)
mocker.patch(
"lnbits.core.services.notifications._is_message_type_enabled",
return_value=True,
)
enqueue_admin_notification(NotificationType.settings_update, {"username": "alice"})
await process_next_notification()
assert admin_mock.await_count == 1
assert admin_mock.await_args is not None
assert admin_mock.await_args.args[0].startswith(f"[{settings.lnbits_site_title}]")
assert "alice" in admin_mock.await_args.args[0]
assert admin_mock.await_args.args[1] == NotificationType.settings_update.value
user_notifications = UserNotifications(email_address="alice@example.com")
enqueue_user_notification(
NotificationType.text_message,
{"message": "hello"},
user_notifications,
)
await process_next_notification()
assert user_mock.await_count == 1
assert user_mock.await_args is not None
assert user_mock.await_args.args[0] == user_notifications
assert "hello" in user_mock.await_args.args[1]
assert user_mock.await_args.args[2] == NotificationType.text_message.value
@pytest.mark.anyio
async def test_send_admin_and_user_notification_use_expected_targets(
settings: Settings, mocker: MockerFixture
):
send_mock = mocker.patch(
"lnbits.core.services.notifications.send_notification",
mocker.AsyncMock(),
)
original_chat_id = settings.lnbits_telegram_notifications_chat_id
original_identifiers = list(settings.lnbits_nostr_notifications_identifiers)
original_emails = list(settings.lnbits_email_notifications_to_emails)
try:
settings.lnbits_telegram_notifications_chat_id = "chat-id"
settings.lnbits_nostr_notifications_identifiers = ["alice@example.com"]
settings.lnbits_email_notifications_to_emails = ["admin@example.com"]
await send_admin_notification("hello", "settings_update")
await send_user_notification(
UserNotifications(
telegram_chat_id="user-chat",
nostr_identifier="bob@example.com",
email_address="bob@example.com",
),
"hello user",
"text_message",
)
finally:
settings.lnbits_telegram_notifications_chat_id = original_chat_id
settings.lnbits_nostr_notifications_identifiers = original_identifiers
settings.lnbits_email_notifications_to_emails = original_emails
assert send_mock.await_args_list[0].args == (
"chat-id",
["alice@example.com"],
["admin@example.com"],
"hello",
"settings_update",
)
assert send_mock.await_args_list[1].args == (
"user-chat",
["bob@example.com"],
["bob@example.com"],
"hello user",
"text_message",
)
@pytest.mark.anyio
async def test_send_notification_uses_available_channels_and_swallows_exceptions(
settings: Settings, mocker: MockerFixture
):
original_email_enabled = settings.lnbits_email_notifications_enabled
try:
settings.lnbits_email_notifications_enabled = True
mocker.patch.object(
type(settings),
"is_telegram_notifications_configured",
return_value=True,
)
mocker.patch.object(
type(settings),
"is_nostr_notifications_configured",
return_value=True,
)
telegram_mock = mocker.patch(
"lnbits.core.services.notifications.send_telegram_notification",
mocker.AsyncMock(side_effect=Exception("telegram boom")),
)
nostr_mock = mocker.patch(
"lnbits.core.services.notifications.send_nostr_notifications",
mocker.AsyncMock(return_value=["alice@example.com"]),
)
email_mock = mocker.patch(
"lnbits.core.services.notifications.send_email_notification",
mocker.AsyncMock(side_effect=Exception("email boom")),
)
await send_notification(
"chat-id",
["alice@example.com"],
["alice@example.com"],
"hello",
"text_message",
)
finally:
settings.lnbits_email_notifications_enabled = original_email_enabled
telegram_mock.assert_awaited_once()
nostr_mock.assert_awaited_once()
email_mock.assert_awaited_once()
@pytest.mark.anyio
async def test_send_nostr_notifications_and_single_notification(
mocker: MockerFixture,
):
send_mock = mocker.patch(
"lnbits.core.services.notifications.send_nostr_notification",
mocker.AsyncMock(side_effect=[None, Exception("boom"), None]),
)
result = await send_nostr_notifications(["ok-1", "bad", "ok-2"], "hello")
assert result == ["ok-1", "ok-2"]
assert send_mock.await_count == 3
fetch_mock = mocker.patch(
"lnbits.core.services.notifications.fetch_nip5_details",
mocker.AsyncMock(return_value=("pubkey", ["wss://relay"])),
)
normalize_mock = mocker.patch(
"lnbits.core.services.notifications.normalize_private_key",
return_value="server-private-key",
)
dm_mock = mocker.patch(
"lnbits.core.services.notifications.send_nostr_dm",
mocker.AsyncMock(),
)
await send_nostr_notification("alice@example.com", "hello")
fetch_mock.assert_awaited_once_with("alice@example.com")
normalize_mock.assert_called_once()
dm_mock.assert_awaited_once_with(
"server-private-key",
"pubkey",
"hello",
["wss://relay"],
)
@pytest.mark.anyio
async def test_send_telegram_message_and_wrapper(
settings: Settings, mocker: MockerFixture
):
response = httpx.Response(
200,
request=httpx.Request("POST", "https://api.telegram.org"),
json={"ok": True},
)
client = MockHTTPClient(post_response=response)
mocker.patch(
"lnbits.core.services.notifications.httpx.AsyncClient",
return_value=client,
)
result = await send_telegram_message("token", "chat-id", "hello")
assert result == {"ok": True}
assert client.posts[0][0].endswith("/bottoken/sendMessage")
original_token = settings.lnbits_telegram_notifications_access_token
try:
settings.lnbits_telegram_notifications_access_token = "wrapper-token"
wrapper_mock = mocker.patch(
"lnbits.core.services.notifications.send_telegram_message",
mocker.AsyncMock(return_value={"ok": True}),
)
await send_telegram_notification("chat-id", "hello")
finally:
settings.lnbits_telegram_notifications_access_token = original_token
wrapper_mock.assert_awaited_once_with("wrapper-token", "chat-id", "hello")
@pytest.mark.anyio
async def test_send_email_notification_and_send_email(
settings: Settings, mocker: MockerFixture
):
original_email_enabled = settings.lnbits_email_notifications_enabled
try:
settings.lnbits_email_notifications_enabled = False
disabled = await send_email_notification(["alice@example.com"], "hello")
assert disabled["status"] == "error"
settings.lnbits_email_notifications_enabled = True
send_email_mock = mocker.patch(
"lnbits.core.services.notifications.send_email",
mocker.AsyncMock(return_value=True),
)
enabled = await send_email_notification(["alice@example.com"], "hello")
assert enabled == {"status": "ok"}
send_email_mock.assert_awaited_once()
finally:
settings.lnbits_email_notifications_enabled = original_email_enabled
smtp_server = MagicMock()
smtp_context = MagicMock()
smtp_context.__enter__.return_value = smtp_server
smtp_context.__exit__.return_value = None
mocker.patch(
"lnbits.core.services.notifications.smtplib.SMTP",
return_value=smtp_context,
)
assert (
await send_email(
"smtp.example.com",
587,
"",
"password",
"from@example.com",
["to@example.com"],
"Subject",
"Body",
)
is True
)
smtp_server.starttls.assert_called_once()
smtp_server.login.assert_called_once_with("from@example.com", "password")
smtp_server.sendmail.assert_called_once()
with pytest.raises(ValueError, match="Invalid from email address"):
await send_email(
"smtp.example.com",
587,
"user",
"password",
"bad-email",
["to@example.com"],
"Subject",
"Body",
)
with pytest.raises(ValueError, match="No email addresses provided"):
await send_email(
"smtp.example.com",
587,
"user",
"password",
"from@example.com",
[],
"Subject",
"Body",
)
@pytest.mark.anyio
async def test_dispatch_webhook_marks_missing_invalid_and_failed_requests(
mocker: MockerFixture,
):
wallet = await _create_wallet()
payment = await _create_payment(wallet, webhook=None)
await dispatch_webhook(payment)
assert (await get_payment(payment.checking_id)).webhook_status == "-1"
invalid_payment = await _create_payment(wallet, webhook="https://invalid.example")
assert invalid_payment.webhook is not None
invalid_client = MockHTTPClient(
post_response=httpx.Response(
200,
request=httpx.Request("POST", invalid_payment.webhook),
json={"ok": True},
)
)
mocker.patch(
"lnbits.core.services.notifications.check_callback_url",
side_effect=ValueError("blocked"),
)
mocker.patch(
"lnbits.core.services.notifications.httpx.AsyncClient",
return_value=invalid_client,
)
await dispatch_webhook(invalid_payment)
assert (await get_payment(invalid_payment.checking_id)).webhook_status in {
"-1",
"200",
}
error_payment = await _create_payment(wallet, webhook="https://error.example")
assert error_payment.webhook is not None
mocker.patch(
"lnbits.core.services.notifications.check_callback_url",
return_value=None,
)
mocker.patch(
"lnbits.core.services.notifications.httpx.AsyncClient",
return_value=MockHTTPClient(
post_response=httpx.Response(
500,
request=httpx.Request("POST", error_payment.webhook),
)
),
)
await dispatch_webhook(error_payment)
assert (await get_payment(error_payment.checking_id)).webhook_status == "500"
request_payment = await _create_payment(wallet, webhook="https://request.example")
assert request_payment.webhook is not None
mocker.patch(
"lnbits.core.services.notifications.httpx.AsyncClient",
return_value=MockHTTPClient(
post_exception=httpx.RequestError(
"boom",
request=httpx.Request("POST", request_payment.webhook),
)
),
)
await dispatch_webhook(request_payment)
assert (await get_payment(request_payment.checking_id)).webhook_status == "-1"
@pytest.mark.anyio
async def test_send_payment_notification_fans_out_to_shared_wallet_and_webhook(
mocker: MockerFixture,
):
wallet = await _create_wallet(name="Primary Wallet")
shared_wallet = await _create_wallet(name="Shared Wallet")
wallet.extra.shared_with = [
WalletSharePermission(
request_id="share-1",
username="bob",
shared_with_wallet_id=shared_wallet.id,
permissions=[WalletPermission.VIEW_PAYMENTS],
status=WalletShareStatus.APPROVED,
)
]
await update_wallet(wallet)
payment = await _create_payment(wallet, webhook="https://webhook.example")
ws_mock = mocker.patch(
"lnbits.core.services.notifications.send_ws_payment_notification",
mocker.AsyncMock(),
)
chat_mock = mocker.patch(
"lnbits.core.services.notifications.send_chat_payment_notification",
mocker.AsyncMock(),
)
push_mock = mocker.patch(
"lnbits.core.services.notifications.send_payment_push_notification",
mocker.AsyncMock(),
)
dispatch_mock = mocker.patch(
"lnbits.core.services.notifications.dispatch_webhook",
mocker.AsyncMock(),
)
await send_payment_notification(wallet, payment)
assert [call.args[0].id for call in ws_mock.await_args_list] == [
wallet.id,
shared_wallet.id,
]
chat_mock.assert_awaited_once_with(wallet, payment)
push_mock.assert_awaited_once_with(wallet, payment)
dispatch_mock.assert_awaited_once_with(payment)
@pytest.mark.anyio
async def test_send_ws_payment_notification_and_chat_notifications(
settings: Settings, mocker: MockerFixture
):
user_notifications = UserNotifications(
telegram_chat_id="chat-id",
nostr_identifier="alice@example.com",
email_address="alice@example.com",
incoming_payments_sats=1,
outgoing_payments_sats=1,
)
wallet = await _create_wallet(user_notifications)
payment = await _create_payment(
wallet,
amount_msat=-2_000,
extra={"wallet_fiat_currency": "USD", "wallet_fiat_amount": 5.25},
)
websocket_mock = mocker.patch(
"lnbits.core.services.notifications.websocket_manager.send",
mocker.AsyncMock(),
)
await send_ws_payment_notification(wallet, payment)
assert [call.args[0] for call in websocket_mock.await_args_list] == [
wallet.inkey,
wallet.adminkey,
payment.payment_hash,
]
original_outgoing = settings.lnbits_notification_outgoing_payment_amount_sats
original_incoming = settings.lnbits_notification_incoming_payment_amount_sats
try:
settings.lnbits_notification_outgoing_payment_amount_sats = 1
settings.lnbits_notification_incoming_payment_amount_sats = 1
admin_mock = mocker.patch(
"lnbits.core.services.notifications.enqueue_admin_notification"
)
user_mock = mocker.patch(
"lnbits.core.services.notifications.enqueue_user_notification"
)
await send_chat_payment_notification(wallet, payment)
finally:
settings.lnbits_notification_outgoing_payment_amount_sats = original_outgoing
settings.lnbits_notification_incoming_payment_amount_sats = original_incoming
assert admin_mock.call_args.args[0] == NotificationType.outgoing_payment
assert "`5.25`*USD* / " in admin_mock.call_args.args[1]["fiat_value_fmt"]
assert user_mock.call_args.args[0] == NotificationType.outgoing_payment
@pytest.mark.anyio
async def test_send_payment_push_notification_and_cleanup_gone_subscriptions(
settings: Settings, mocker: MockerFixture
):
wallet = await _create_wallet()
payment = await _create_payment(wallet, amount_msat=2_000, memo="Thanks")
endpoint = f"https://push.example/{uuid4().hex}"
subscription = await create_webpush_subscription(
endpoint,
wallet.user,
'{"endpoint":"https://push.example"}',
"push.example",
)
send_push_mock = mocker.patch(
"lnbits.core.services.notifications.send_push_notification",
mocker.AsyncMock(),
)
await send_payment_push_notification(wallet, payment)
assert send_push_mock.await_args is not None
assert send_push_mock.await_args.args[0].endpoint == subscription.endpoint
assert send_push_mock.await_args.args[1] == f"LNbits: {wallet.name}"
assert "received 2 sats" in send_push_mock.await_args.args[2]
assert send_push_mock.await_args.args[3] == (
f"https://{subscription.host}/wallet?usr={wallet.user}&wal={wallet.id}"
)
original_privkey = settings.lnbits_webpush_privkey
try:
settings.lnbits_webpush_privkey = ""
exc = WebPushException("gone")
exc.response = SimpleNamespace(status_code=HTTPStatus.GONE, text="gone")
mocker.patch(
"lnbits.core.services.notifications.webpush",
side_effect=exc,
)
await send_push_notification(subscription, "Title", "Body")
finally:
settings.lnbits_webpush_privkey = original_privkey
assert await get_webpush_subscription(subscription.endpoint, wallet.user) is None
async def _create_wallet(
notifications: UserNotifications | None = None,
*,
name: str | None = None,
) -> Wallet:
account = Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
extra=UserExtra(notifications=notifications or UserNotifications()),
)
await create_account(account)
return await create_wallet(
user_id=account.id,
wallet_name=name or f"wallet_{account.id[:8]}",
)
async def _create_payment(
wallet: Wallet,
*,
amount_msat: int = 2_000,
status: PaymentState = PaymentState.SUCCESS,
webhook: str | None = None,
webhook_status: str | None = None,
memo: str | None = "memo",
extra: dict | None = None,
) -> Payment:
checking_id = f"checking_{uuid4().hex[:8]}"
payment = await create_payment(
checking_id=checking_id,
data=CreatePayment(
wallet_id=wallet.id,
payment_hash=uuid4().hex,
bolt11=f"bolt11-{checking_id}",
amount_msat=amount_msat,
memo=memo or "",
webhook=webhook,
extra=extra or {},
),
status=status,
)
if webhook_status is not None:
payment.webhook_status = webhook_status
await update_payment(payment)
return await get_payment(checking_id)
-456
View File
@@ -1,456 +0,0 @@
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.core.services.payments.internal_invoice_queue_put",
mocker.AsyncMock(),
)
await update_wallet_balance(wallet, 5)
finally:
settings.lnbits_wallet_limit_max_balance = original_max_balance
credit_payments = [
payment
for payment in await get_payments(wallet_id=wallet.id, incoming=True)
if payment.memo == "Admin credit"
]
assert credit_payments
assert credit_payments[0].status == PaymentState.SUCCESS
queue_mock.assert_awaited_once_with(credit_payments[0].checking_id)
@pytest.mark.anyio
async def test_check_wallet_limits_and_time_limit(
settings: Settings, mocker: MockerFixture
):
time_limit_mock = mocker.patch(
"lnbits.core.services.payments.check_time_limit_between_transactions",
mocker.AsyncMock(),
)
daily_limit_mock = mocker.patch(
"lnbits.core.services.payments.check_wallet_daily_withdraw_limit",
mocker.AsyncMock(),
)
await check_wallet_limits("wallet-1", 1_000)
time_limit_mock.assert_awaited_once_with("wallet-1", None)
daily_limit_mock.assert_awaited_once_with("wallet-1", 1_000, None)
wallet = await _create_wallet()
await _create_payment(wallet, amount_msat=-2_000)
original_limit = settings.lnbits_wallet_limit_secs_between_trans
try:
settings.lnbits_wallet_limit_secs_between_trans = 30
with pytest.raises(PaymentError) as exc_info:
await check_time_limit_between_transactions(wallet.id)
assert "30 seconds between payments" in exc_info.value.message
other_wallet = await _create_wallet()
assert await check_time_limit_between_transactions(other_wallet.id) is None
finally:
settings.lnbits_wallet_limit_secs_between_trans = original_limit
@pytest.mark.anyio
async def test_calculate_fiat_amounts_handles_conversion_and_errors(
mocker: MockerFixture,
):
wallet = await _create_wallet()
wallet.currency = "EUR"
mocker.patch(
"lnbits.core.services.payments.fiat_amount_as_satoshis",
mocker.AsyncMock(return_value=200),
)
sat_to_fiat_mock = mocker.patch(
"lnbits.core.services.payments.satoshis_amount_as_fiat",
mocker.AsyncMock(return_value=1.5),
)
amount_sat, fiat_amounts = await calculate_fiat_amounts(2.0, wallet, "USD")
assert amount_sat == 200
assert fiat_amounts["fiat_currency"] == "USD"
assert fiat_amounts["wallet_fiat_currency"] == "EUR"
assert fiat_amounts["wallet_fiat_amount"] == 1.5
sat_to_fiat_mock.side_effect = Exception("boom")
amount_sat, fiat_amounts = await calculate_fiat_amounts(10, wallet, "sat", extra={})
assert amount_sat == 10
assert fiat_amounts == {}
@pytest.mark.anyio
async def test_check_transaction_status_and_payment_status(mocker: MockerFixture):
wallet = await _create_wallet()
missing_hash = uuid4().hex
assert (await check_transaction_status(wallet.id, missing_hash)).pending is True
success_hash = uuid4().hex
success_id = await _create_payment(
wallet,
status=PaymentState.SUCCESS,
payment_hash=success_hash,
fee=-123,
)
success_status = await check_transaction_status(wallet.id, success_hash)
assert success_status.success is True
assert success_status.fee_msat == -123
pending_hash = uuid4().hex
await _create_payment(wallet, payment_hash=pending_hash)
mocker.patch(
"lnbits.core.services.payments.check_payment_status",
mocker.AsyncMock(return_value=PaymentFailedStatus()),
)
assert (await check_transaction_status(wallet.id, pending_hash)).failed is True
internal_success = await get_payment(success_id)
internal_success.checking_id = "internal_" + internal_success.payment_hash
internal_success.status = PaymentState.SUCCESS.value
assert (await check_payment_status(internal_success)).success is True
internal_failed = await get_payment(success_id)
internal_failed.checking_id = "internal_" + internal_failed.payment_hash
internal_failed.status = PaymentState.FAILED.value
assert (await check_payment_status(internal_failed)).failed is True
internal_fiat = await get_payment(success_id)
internal_fiat.checking_id = "fiat_" + internal_fiat.payment_hash
internal_fiat.status = PaymentState.PENDING.value
internal_fiat.fiat_provider = "stripe"
mocker.patch(
"lnbits.core.services.payments.check_fiat_status",
mocker.AsyncMock(return_value=SimpleNamespace(paid=True)),
)
assert (await check_payment_status(internal_fiat)).success is True
outgoing = await get_payment(success_id)
outgoing.checking_id = "external-out"
outgoing.amount = -2_000
incoming = await get_payment(success_id)
incoming.checking_id = "external-in"
incoming.amount = 2_000
funding_source = SimpleNamespace(
get_payment_status=mocker.AsyncMock(return_value=PaymentSuccessStatus()),
get_invoice_status=mocker.AsyncMock(return_value=PaymentPendingStatus()),
)
mocker.patch(
"lnbits.core.services.payments.get_funding_source",
return_value=funding_source,
)
assert (await check_payment_status(outgoing)).success is True
assert (await check_payment_status(incoming)).pending is True
@pytest.mark.anyio
async def test_get_payments_daily_stats_fills_missing_dates():
wallet = await _create_wallet()
user_id = wallet.user
now = datetime.now(timezone.utc).replace(hour=12, minute=0, second=0, microsecond=0)
await _create_payment(
wallet,
amount_msat=2_000,
status=PaymentState.SUCCESS,
time=now - timedelta(days=2),
)
await _create_payment(
wallet,
amount_msat=-500,
status=PaymentState.SUCCESS,
fee=100,
time=now,
)
stats = await get_payments_daily_stats(Filters(), user_id=user_id)
assert [point.date.date() for point in stats[-3:]] == [
(now - timedelta(days=2)).date(),
(now - timedelta(days=1)).date(),
now.date(),
]
assert [point.balance for point in stats[-3:]] == [2, 2, 1]
assert stats[-1].fee == 0
@pytest.mark.anyio
async def test_settle_and_cancel_hold_invoice_persist_status(mocker: MockerFixture):
wallet = await _create_wallet()
checking_id = await _create_payment(wallet, payment_hash="33" * 32)
payment = await get_payment(checking_id)
funding_source = SimpleNamespace(
settle_hold_invoice=mocker.AsyncMock(
return_value=InvoiceResponse(ok=True, checking_id="settled")
),
cancel_hold_invoice=mocker.AsyncMock(
return_value=InvoiceResponse(ok=True, checking_id="cancelled")
),
)
mocker.patch(
"lnbits.core.services.payments.get_funding_source",
return_value=funding_source,
)
mocker.patch(
"lnbits.core.services.payments.verify_preimage",
return_value=False,
)
with pytest.raises(InvoiceError, match="Invalid preimage."):
await settle_hold_invoice(payment, "00" * 32)
mocker.patch(
"lnbits.core.services.payments.verify_preimage",
return_value=True,
)
assert (await settle_hold_invoice(payment, "11" * 32)).ok is True
assert (await cancel_hold_invoice(payment)).ok is True
stored = await get_payment(checking_id)
assert stored.preimage == "11" * 32
assert stored.extra["hold_invoice_settled"] is True
assert stored.extra["hold_invoice_cancelled"] is True
assert stored.status == PaymentState.FAILED
def _account() -> Account:
account_id = uuid4().hex
return Account(id=account_id, username=f"user_{account_id[:8]}")
async def _create_wallet() -> Wallet:
account = _account()
await create_account(account)
return await create_wallet(
user_id=account.id, wallet_name=f"wallet_{account.id[:8]}"
)
async def _create_payment(
wallet: Wallet,
*,
amount_msat: int = 2_000,
status: PaymentState = PaymentState.PENDING,
checking_id: str | None = None,
payment_hash: str | None = None,
fee: int = 0,
time: datetime | None = None,
) -> str:
checking_id = checking_id or f"checking_{uuid4().hex[:8]}"
payment_hash = payment_hash or uuid4().hex
payment = await create_payment(
checking_id=checking_id,
data=CreatePayment(
wallet_id=wallet.id,
payment_hash=payment_hash,
bolt11=f"bolt11-{checking_id}",
amount_msat=amount_msat,
memo="memo",
fee=fee,
),
status=status,
)
if time:
payment.time = time
payment.created_at = time
payment.updated_at = time
await update_payment(payment)
return checking_id
-153
View File
@@ -1,153 +0,0 @@
import pytest
from pydantic import ValidationError
from pytest_mock.plugin import MockerFixture
from lnbits.core.crud import (
create_admin_settings,
delete_admin_settings,
get_super_settings,
)
from lnbits.core.crud.settings import get_settings_field
from lnbits.core.services.settings import (
check_webpush_settings,
dict_to_settings,
update_cached_settings,
)
from lnbits.settings import Settings
class FakePublicKey:
def public_bytes(self, *_args, **_kwargs):
return b"public-bytes"
class FakeVapid:
def __init__(self, has_public_key: bool = True):
self.public_key = FakePublicKey() if has_public_key else None
def generate_keys(self):
return None
def private_pem(self):
return b"private-key"
def test_dict_to_settings_parses_known_values():
parsed = dict_to_settings(
{
"lnbits_site_title": "Test Title",
"lnbits_service_fee": 5,
"ignored_field": "ignored",
}
)
assert parsed.lnbits_site_title == "Test Title"
assert parsed.lnbits_service_fee == 5
assert not hasattr(parsed, "ignored_field")
def test_dict_to_settings_validates_invalid_values():
with pytest.raises(ValidationError):
dict_to_settings({"lnbits_service_fee": "not-a-number"})
def test_update_cached_settings_updates_runtime_values(settings: Settings):
original_title = settings.lnbits_site_title
original_host = settings.host
original_super_user = settings.super_user
try:
update_cached_settings(
{
"lnbits_site_title": "Updated",
"host": "forbidden-host",
"super_user": "super-user-id",
"missing_field": "ignored",
}
)
assert settings.lnbits_site_title == "Updated"
assert settings.host == original_host
assert settings.super_user == "super-user-id"
finally:
settings.lnbits_site_title = original_title
settings.host = original_host
settings.super_user = original_super_user
@pytest.mark.anyio
async def test_check_webpush_settings_generates_and_persists_keys(
settings: Settings, mocker: MockerFixture
):
previous_settings = await get_super_settings()
previous_private = settings.lnbits_webpush_privkey
previous_public = settings.lnbits_webpush_pubkey
previous_admin_ui = settings.lnbits_admin_ui
await delete_admin_settings()
settings.lnbits_webpush_privkey = ""
settings.lnbits_webpush_pubkey = None
settings.lnbits_admin_ui = True
mocker.patch("lnbits.core.services.settings.Vapid", return_value=FakeVapid())
mocker.patch(
"lnbits.core.services.settings.b64urlencode", return_value="public-key"
)
try:
await check_webpush_settings()
stored_private = await get_settings_field("lnbits_webpush_privkey")
stored_public = await get_settings_field("lnbits_webpush_pubkey")
assert settings.lnbits_webpush_privkey == "private-key"
assert settings.lnbits_webpush_pubkey == "public-key"
assert stored_private is not None
assert stored_private.value == "private-key"
assert stored_public is not None
assert stored_public.value == "public-key"
finally:
await delete_admin_settings()
if previous_settings:
await create_admin_settings(
previous_settings.super_user,
previous_settings.dict(exclude={"super_user"}),
)
update_cached_settings(previous_settings.dict())
settings.lnbits_webpush_privkey = previous_private
settings.lnbits_webpush_pubkey = previous_public
settings.lnbits_admin_ui = previous_admin_ui
@pytest.mark.anyio
async def test_check_webpush_settings_requires_public_key(
settings: Settings, mocker: MockerFixture
):
mocker.patch.object(settings, "lnbits_webpush_privkey", "")
mocker.patch.object(settings, "lnbits_admin_ui", False)
mocker.patch(
"lnbits.core.services.settings.Vapid",
return_value=FakeVapid(has_public_key=False),
)
with pytest.raises(ValueError, match="VAPID public key does not exist"):
await check_webpush_settings()
@pytest.mark.anyio
async def test_check_webpush_settings_skips_generation_when_keys_exist(
settings: Settings, mocker: MockerFixture
):
previous_private = settings.lnbits_webpush_privkey
previous_public = settings.lnbits_webpush_pubkey
previous_private_field = await get_settings_field("lnbits_webpush_privkey")
previous_public_field = await get_settings_field("lnbits_webpush_pubkey")
settings.lnbits_webpush_privkey = "existing-private-key"
settings.lnbits_webpush_pubkey = "existing-public-key"
vapid = mocker.patch("lnbits.core.services.settings.Vapid")
try:
await check_webpush_settings()
finally:
settings.lnbits_webpush_privkey = previous_private
settings.lnbits_webpush_pubkey = previous_public
assert await get_settings_field("lnbits_webpush_privkey") == previous_private_field
assert await get_settings_field("lnbits_webpush_pubkey") == previous_public_field
vapid.assert_not_called()
-407
View File
@@ -1,407 +0,0 @@
from uuid import uuid4
import pytest
from lnbits.core.crud import (
create_account,
create_admin_settings,
create_user_extension,
delete_admin_settings,
get_account,
get_super_settings,
get_user_extensions,
get_wallets,
)
from lnbits.core.crud.settings import get_settings_field, set_settings_field
from lnbits.core.models import Account
from lnbits.core.models.extensions import UserExtension
from lnbits.core.models.users import RegisterUser
from lnbits.core.services.settings import update_cached_settings
from lnbits.core.services.users import (
check_admin_settings,
check_register_activation_settings,
create_user_account,
create_user_account_no_ckeck,
init_admin_settings,
update_user_account,
update_user_extensions,
)
from lnbits.settings import Settings
@pytest.mark.anyio
async def test_create_user_account_rejects_when_registration_disabled(
settings: Settings,
):
settings.lnbits_allow_new_accounts = False
with pytest.raises(ValueError, match="Account creation is disabled."):
await create_user_account()
@pytest.mark.anyio
@pytest.mark.parametrize(
("existing_data", "new_data", "message"),
[
(
{"username": f"user_{uuid4().hex[:8]}"},
{"username": lambda existing: existing.username},
"Username already exists.",
),
(
{"email": f"{uuid4().hex[:8]}@example.com"},
{"email": lambda existing: existing.email},
"Email already exists.",
),
(
{"pubkey": f"{1:064x}"},
{"pubkey": lambda existing: existing.pubkey},
"Pubkey already exists.",
),
],
)
async def test_create_user_account_no_check_rejects_duplicate_identity_fields(
existing_data: dict, new_data: dict, message: str
):
existing = _account(**existing_data)
await create_account(existing)
resolved = {
key: (value(existing) if callable(value) else value)
for key, value in new_data.items()
}
account = _account(**resolved)
with pytest.raises(ValueError, match=message):
await create_user_account_no_ckeck(account)
@pytest.mark.anyio
async def test_create_user_account_no_check_creates_wallet_and_extensions(
settings: Settings,
):
account = _account()
original_default_exts = list(settings.lnbits_user_default_extensions)
try:
settings.lnbits_user_default_extensions = ["default-ext"]
user = await create_user_account_no_ckeck(
account,
wallet_name="Primary",
default_exts=["extra-ext"],
)
finally:
settings.lnbits_user_default_extensions = original_default_exts
wallets = await get_wallets(user.id)
user_extensions = await get_user_extensions(user.id)
assert len(wallets) == 1
assert wallets[0].name == "Primary"
assert {ext.extension for ext in user_extensions} == {"default-ext", "extra-ext"}
assert all(ext.active is True for ext in user_extensions)
# TDOO: revisit for postgres
# @pytest.mark.anyio
# async def test_create_user_account_no_check_duplicate_extension_insert_behavior(
# settings: Settings,
# ):
# account = _account()
# original_default_exts = list(settings.lnbits_user_default_extensions)
# try:
# settings.lnbits_user_default_extensions = ["dup-ext"]
# if DB_TYPE == POSTGRES:
# with pytest.raises(DBAPIError, match="current transaction is aborted"):
# await create_user_account_no_ckeck(account, default_exts=["dup-ext"])
# else:
# user = await
# create_user_account_no_ckeck(account, default_exts=["dup-ext"])
# finally:
# settings.lnbits_user_default_extensions = original_default_exts
# if DB_TYPE == POSTGRES:
# assert await get_account(account.id) is not None
# else:
# user_extensions = await get_user_extensions(user.id)
# assert [ext.extension for ext in user_extensions] == ["dup-ext"]
@pytest.mark.anyio
async def test_update_user_account_requires_existing_user():
account = _account()
with pytest.raises(ValueError, match="User does not exist."):
await update_user_account(account)
@pytest.mark.anyio
async def test_update_user_account_rejects_conflicting_identity_fields():
existing = _account(pubkey=_pubkey(2))
conflict = _account(pubkey=_pubkey(3))
await create_account(existing)
await create_account(conflict)
with pytest.raises(ValueError, match="Username already exists."):
await update_user_account(
_account(
id_=existing.id,
username=conflict.username,
email=existing.email,
pubkey=existing.pubkey,
)
)
with pytest.raises(ValueError, match="Email already exists."):
await update_user_account(
_account(
id_=existing.id,
username=existing.username,
email=conflict.email,
pubkey=existing.pubkey,
)
)
with pytest.raises(ValueError, match="Pubkey already exists."):
await update_user_account(
_account(
id_=existing.id,
username=existing.username,
email=existing.email,
pubkey=conflict.pubkey,
)
)
@pytest.mark.anyio
async def test_update_user_account_updates_persisting_password():
account = _account(pubkey=_pubkey(4))
account.hash_password("secret1234")
await create_account(account)
updated = _account(
id_=account.id,
username=f"updated_{account.id[:8]}",
email=f"{account.id[:8]}+updated@example.com",
pubkey=(uuid4().hex * 2)[:64],
)
result = await update_user_account(updated)
stored = await get_account(account.id)
assert result.id == account.id
assert stored is not None
assert stored.username == updated.username
assert stored.email == updated.email
assert stored.pubkey == updated.pubkey
assert stored.password_hash == account.password_hash
@pytest.mark.anyio
async def test_update_user_extensions_toggles_existing_and_creates_missing(
settings: Settings,
):
original_default_exts = list(settings.lnbits_user_default_extensions)
try:
settings.lnbits_user_default_extensions = []
user = await create_user_account(_account())
finally:
settings.lnbits_user_default_extensions = original_default_exts
await create_user_extension(
UserExtension(user=user.id, extension="keep", active=True)
)
await create_user_extension(
UserExtension(user=user.id, extension="enable", active=False)
)
await create_user_extension(
UserExtension(user=user.id, extension="disable", active=True)
)
await update_user_extensions(user.id, ["keep", "enable", "new-ext"])
user_extensions = {
ext.extension: ext.active for ext in await get_user_extensions(user.id)
}
assert user_extensions == {
"keep": True,
"enable": True,
"disable": False,
"new-ext": True,
}
@pytest.mark.anyio
async def test_check_admin_settings_initializes_cache_and_marks_first_install(
settings: Settings, tmp_path
):
previous_settings = await get_super_settings()
previous_super_user = settings.super_user
previous_data_folder = settings.lnbits_data_folder
previous_admin_ui = settings.lnbits_admin_ui
previous_first_install = settings.first_install
super_user = uuid4().hex
try:
await delete_admin_settings()
settings.super_user = super_user
settings.lnbits_data_folder = str(tmp_path)
settings.lnbits_admin_ui = True
settings.first_install = False
await check_admin_settings()
stored_settings = await get_super_settings()
stored_account = await get_account(super_user)
assert stored_settings is not None
assert stored_settings.super_user == super_user
assert stored_account is not None
assert stored_account.extra.provider == "env"
assert settings.first_install is True
assert (tmp_path / ".super_user").read_text() == super_user
finally:
await delete_admin_settings()
if previous_settings:
await create_admin_settings(
previous_settings.super_user,
previous_settings.dict(exclude={"super_user"}),
)
update_cached_settings(previous_settings.dict())
settings.super_user = previous_super_user
settings.lnbits_data_folder = previous_data_folder
settings.lnbits_admin_ui = previous_admin_ui
settings.first_install = previous_first_install
@pytest.mark.anyio
async def test_init_admin_settings_creates_account_and_wallet_when_missing():
super_user = uuid4().hex
result = await init_admin_settings(super_user)
wallets = await get_wallets(super_user)
assert result.super_user == super_user
assert await get_account(super_user) is not None
assert len(wallets) == 1
@pytest.mark.anyio
async def test_check_register_activation_settings_handles_invitation_codes(
settings: Settings,
):
reusable = "reusable-code"
one_time = "one-time-code"
original_require_activation = settings.lnbits_require_user_activation
original_by_invite = settings.lnbits_user_activation_by_invitation_code
original_reusable = settings.lnbits_register_reusable_activation_code
original_one_time = list(settings.lnbits_register_one_time_activation_codes)
previous_stored_codes = await get_settings_field(
"lnbits_register_one_time_activation_codes"
)
try:
settings.lnbits_require_user_activation = False
assert (
await check_register_activation_settings(
RegisterUser(
username=f"user_{uuid4().hex[:8]}",
password="secret1234",
password_repeat="secret1234",
)
)
is None
)
settings.lnbits_require_user_activation = True
settings.lnbits_user_activation_by_invitation_code = True
settings.lnbits_register_reusable_activation_code = reusable
settings.lnbits_register_one_time_activation_codes = [one_time]
with pytest.raises(ValueError, match="Invitation code cannot be empty."):
await check_register_activation_settings(
RegisterUser(
username=f"user_{uuid4().hex[:8]}",
password="secret1234",
password_repeat="secret1234",
invitation_code=" ",
)
)
assert (
await check_register_activation_settings(
RegisterUser(
username=f"user_{uuid4().hex[:8]}",
password="secret1234",
password_repeat="secret1234",
invitation_code=reusable,
)
)
is None
)
assert (
await check_register_activation_settings(
RegisterUser(
username=f"user_{uuid4().hex[:8]}",
password="secret1234",
password_repeat="secret1234",
invitation_code=one_time,
)
)
is None
)
assert one_time not in settings.lnbits_register_one_time_activation_codes
stored_codes = await get_settings_field(
"lnbits_register_one_time_activation_codes"
)
assert stored_codes is not None
assert stored_codes.value == []
with pytest.raises(ValueError, match="Invalid invitation code."):
await check_register_activation_settings(
RegisterUser(
username=f"user_{uuid4().hex[:8]}",
password="secret1234",
password_repeat="secret1234",
invitation_code="bad-code",
)
)
settings.lnbits_user_activation_by_invitation_code = False
with pytest.raises(ValueError, match="No activation method provided."):
await check_register_activation_settings(
RegisterUser(
username=f"user_{uuid4().hex[:8]}",
password="secret1234",
password_repeat="secret1234",
invitation_code=reusable,
)
)
finally:
settings.lnbits_require_user_activation = original_require_activation
settings.lnbits_user_activation_by_invitation_code = original_by_invite
settings.lnbits_register_reusable_activation_code = original_reusable
settings.lnbits_register_one_time_activation_codes = original_one_time
await set_settings_field(
"lnbits_register_one_time_activation_codes",
previous_stored_codes.value if previous_stored_codes else original_one_time,
)
def _pubkey(value: int) -> str:
return f"{value:064x}"
def _account(
*,
id_: str | None = None,
username: str | None = None,
email: str | None = None,
pubkey: str | None = None,
) -> Account:
account_id = id_ or uuid4().hex
return Account(
id=account_id,
username=username or f"user_{account_id[:8]}",
email=email or f"{account_id[:8]}@example.com",
pubkey=pubkey,
)
-76
View File
@@ -1,76 +0,0 @@
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")
+44 -152
View File
@@ -1,18 +1,7 @@
import pytest
from pytest_mock.plugin import MockerFixture
from pydantic import ValidationError
from lnbits.settings import (
AssetSettings,
ExchangeRateProvider,
InstalledExtensionsSettings,
NotificationsSettings,
PublicSettings,
RedirectPath,
SecuritySettings,
Settings,
list_parse_fallback,
set_cli_settings,
)
from lnbits.settings import EditableSettings, RedirectPath, Settings, UpdateSettings
lnurlp_redirect_path = {
"from_path": "/.well-known/lnurlp",
@@ -180,155 +169,58 @@ def test_redirect_path_new_path_from(lnurlp: RedirectPath):
)
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"],
def test_settings_loads_env_and_dotenv_values(tmp_path, monkeypatch):
env_file = tmp_path / ".env"
env_file.write_text(
"\n".join(
[
"lnbits_admin_users=alice,bob",
"STRIKE_API_ENDPOINT=https://dotenv.strike.example",
]
),
encoding="utf-8",
)
invalid_provider = ExchangeRateProvider(
name="Invalid",
api_url="https://example.com",
path="$.price",
ticker_conversion=["invalid"],
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("LNBITS_ALLOWED_USERS", ' ["carol", "dave"] ')
monkeypatch.setenv("STRIKE_API_KEY", "secret-key")
monkeypatch.setenv("STRIKE_API_ENDPOINT", "https://env.strike.example")
settings = Settings()
assert settings.lnbits_admin_users == ["alice", "bob"]
assert settings.lnbits_allowed_users == ["carol", "dave"]
assert settings.strike_api_endpoint == "https://env.strike.example"
assert settings.strike_api_key == "secret-key"
def test_editable_settings_from_dict_ignores_unknown_fields():
editable_settings = EditableSettings.from_dict(
{"lnbits_admin_users": ["alice"], "unknown_setting": "ignored"}
)
assert provider.convert_ticker("USD") == "USDT"
assert provider.convert_ticker("EUR") == "EUR"
assert invalid_provider.convert_ticker("USD") == "USD"
assert editable_settings.lnbits_admin_users == ["alice"]
assert "unknown_setting" not in editable_settings.model_dump()
def test_installed_extensions_settings_activate_and_deactivate_paths():
installed = InstalledExtensionsSettings()
redirects = [
def test_update_settings_splits_string_lists_and_forbids_extra_fields():
updated = UpdateSettings.model_validate(
{
"from_path": "/.well-known/lnurlp",
"redirect_to_path": "/api/v1/well-known",
"lnbits_admin_users": "alice,bob",
"strike_api_endpoint": "https://api.strike.me/v1",
"strike_api_key": "secret-key",
}
]
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
assert updated.lnbits_admin_users == ["alice", "bob"]
assert updated.strike_api_endpoint == "https://api.strike.me/v1"
assert updated.strike_api_key == "secret-key"
installed.deactivate_extension_paths("lnurlp")
assert "lnurlp" in installed.lnbits_deactivated_extensions
assert installed.find_extension_redirect("/.well-known/lnurlp", []) is None
with pytest.raises(ValidationError):
UpdateSettings.model_validate({"unknown_setting": True})
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",
)
]
)
def test_update_settings_schema_does_not_include_env_names():
schema = UpdateSettings.model_json_schema()
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
assert "properties" in schema
assert all("env_names" not in prop for prop in schema["properties"].values())
+1 -1
View File
@@ -1,4 +1,4 @@
from pydantic import BaseModel
from pydantic.v1 import BaseModel
class FundingSourceConfig(BaseModel):
File diff suppressed because it is too large Load Diff
Generated
+183 -79
View File
@@ -111,6 +111,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.12.1"
@@ -671,48 +680,48 @@ toml = [
[[package]]
name = "cryptography"
version = "46.0.6"
version = "46.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" }
sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" },
{ url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" },
{ url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" },
{ url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" },
{ url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" },
{ url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" },
{ url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" },
{ url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" },
{ url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" },
{ url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" },
{ url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" },
{ url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" },
{ url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" },
{ url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" },
{ url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" },
{ url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" },
{ url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" },
{ url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" },
{ url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" },
{ url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" },
{ url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" },
{ url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" },
{ url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" },
{ url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" },
{ url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" },
{ url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" },
{ url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" },
{ url = "https://files.pythonhosted.org/packages/2e/84/7ccff00ced5bac74b775ce0beb7d1be4e8637536b522b5df9b73ada42da2/cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead", size = 3475444, upload-time = "2026-03-25T23:34:38.944Z" },
{ url = "https://files.pythonhosted.org/packages/bc/1f/4c926f50df7749f000f20eede0c896769509895e2648db5da0ed55db711d/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8", size = 4218227, upload-time = "2026-03-25T23:34:40.871Z" },
{ url = "https://files.pythonhosted.org/packages/c6/65/707be3ffbd5f786028665c3223e86e11c4cda86023adbc56bd72b1b6bab5/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0", size = 4381399, upload-time = "2026-03-25T23:34:42.609Z" },
{ url = "https://files.pythonhosted.org/packages/f3/6d/73557ed0ef7d73d04d9aba745d2c8e95218213687ee5e76b7d236a5030fc/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b", size = 4217595, upload-time = "2026-03-25T23:34:44.205Z" },
{ url = "https://files.pythonhosted.org/packages/9e/c5/e1594c4eec66a567c3ac4400008108a415808be2ce13dcb9a9045c92f1a0/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a", size = 4380912, upload-time = "2026-03-25T23:34:46.328Z" },
{ url = "https://files.pythonhosted.org/packages/1a/89/843b53614b47f97fe1abc13f9a86efa5ec9e275292c457af1d4a60dc80e0/cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e", size = 3409955, upload-time = "2026-03-25T23:34:48.465Z" },
{ url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
{ url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
{ url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
{ url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
{ url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
{ url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
{ url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
{ url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
{ url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
{ url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
{ url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
{ url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
{ url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
{ url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
{ url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
{ url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
{ url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
{ url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
{ url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
{ url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
{ url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
{ url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
{ url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
{ url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
{ url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
{ url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
{ url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" },
{ url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" },
{ url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" },
{ url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" },
{ url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" },
{ url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" },
]
[[package]]
@@ -787,21 +796,23 @@ wheels = [
[[package]]
name = "fastapi"
version = "0.116.2"
version = "0.135.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/01/64/1296f46d6b9e3b23fb22e5d01af3f104ef411425531376212f1eefa2794d/fastapi-0.116.2.tar.gz", hash = "sha256:231a6af2fe21cfa2c32730170ad8514985fc250bec16c9b242d3b94c835ef529", size = 298595, upload-time = "2025-09-16T18:29:23.058Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/e4/c543271a8018874b7f682bf6156863c416e1334b8ed3e51a69495c5d4360/fastapi-0.116.2-py3-none-any.whl", hash = "sha256:c3a7a8fb830b05f7e087d920e0d786ca1fc9892eb4e9a84b227be4c1bc7569db", size = 95670, upload-time = "2025-09-16T18:29:21.329Z" },
{ url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" },
]
[[package]]
name = "fastapi-sso"
version = "0.19.0"
version = "0.21.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fastapi" },
@@ -810,9 +821,9 @@ dependencies = [
{ name = "pydantic", extra = ["email"] },
{ name = "pyjwt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/fc/644bc8f82fc887fffcf9a3eab8eb3dea06ee9ea160ef20441455ed7a0001/fastapi_sso-0.19.0.tar.gz", hash = "sha256:629f00581f72ea7e57f7b8775f8d2c425629c428c194359a2b4ebaa6bcb8e12b", size = 17278, upload-time = "2025-12-17T15:18:06.721Z" }
sdist = { url = "https://files.pythonhosted.org/packages/96/0a/81e67a78dd6562732cde9e68c9a483516d9de5bd2d5454ac465b66b4e97c/fastapi_sso-0.21.0.tar.gz", hash = "sha256:5fa596e28abb3c9d14567df8a2757f13a0fe4be8a2701fbf6853cf0cd297470b", size = 18029, upload-time = "2026-02-24T18:46:47.041Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/61/d2ac0800cb1f390ec2aad464ea4cc5944de285fabc8f987327da4633f17d/fastapi_sso-0.19.0-py3-none-any.whl", hash = "sha256:d958c46cd9996234c7b162e192168b4c0807a248224a55b0f877d3a82a16a930", size = 26419, upload-time = "2025-12-17T15:18:05.439Z" },
{ url = "https://files.pythonhosted.org/packages/56/4f/22d8cfe4ac207b4718dfdd7a1fea0acdcdb61be2d546bde905ca6ce065a4/fastapi_sso-0.21.0-py3-none-any.whl", hash = "sha256:30e631277050c16e0eb03eb228867b586631e2f5155cc5574e27e28df73356ff", size = 29219, upload-time = "2026-02-24T18:46:47.821Z" },
]
[[package]]
@@ -1263,7 +1274,7 @@ wheels = [
[[package]]
name = "lnbits"
version = "1.5.3"
version = "1.5.2"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },
@@ -1290,6 +1301,7 @@ dependencies = [
{ name = "protobuf" },
{ name = "pycryptodomex" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyjwt" },
{ name = "pyln-client" },
{ name = "pynostr" },
@@ -1356,8 +1368,8 @@ requires-dist = [
{ name = "breez-sdk-liquid", marker = "extra == 'breez'", specifier = "~=0.11.11" },
{ name = "click", specifier = "~=8.3.1" },
{ name = "embit", specifier = "~=0.8.0" },
{ name = "fastapi", specifier = "~=0.116.1" },
{ name = "fastapi-sso", specifier = "~=0.19.0" },
{ name = "fastapi", specifier = "~=0.135.0" },
{ name = "fastapi-sso", specifier = "~=0.21.0" },
{ name = "filetype", specifier = "~=1.2.0" },
{ name = "greenlet", specifier = "~=3.3.0" },
{ name = "grpcio", specifier = "~=1.76.0" },
@@ -1365,7 +1377,7 @@ requires-dist = [
{ name = "itsdangerous", specifier = "~=2.2.0" },
{ name = "jinja2", specifier = "~=3.1.6" },
{ name = "jsonpath-ng", specifier = "~=1.7.0" },
{ name = "lnurl", specifier = "~=0.10.0" },
{ name = "lnurl", specifier = "~=2.0.0" },
{ name = "loguru", specifier = "~=0.7.3" },
{ name = "nostr-sdk", specifier = "~=0.44.0" },
{ name = "packaging", specifier = "~=25.0.0" },
@@ -1373,7 +1385,8 @@ requires-dist = [
{ name = "protobuf", specifier = "~=6.33.5" },
{ name = "psycopg2-binary", marker = "extra == 'migration'", specifier = "~=2.9.11" },
{ name = "pycryptodomex", specifier = "~=3.23.0" },
{ name = "pydantic", specifier = "~=1.10.26" },
{ name = "pydantic", specifier = "~=2.12.0" },
{ name = "pydantic-settings", specifier = ">=2.13.1" },
{ name = "pyjwt", specifier = "~=2.12.0" },
{ name = "pyln-client", specifier = "~=25.12.0" },
{ name = "pynostr", specifier = "~=0.7.0" },
@@ -1385,8 +1398,8 @@ requires-dist = [
{ name = "shortuuid", specifier = "~=1.0.13" },
{ name = "slowapi", specifier = "~=0.1.9" },
{ name = "sqlalchemy", specifier = "~=1.4.54" },
{ name = "sse-starlette", specifier = "~=2.3.6" },
{ name = "starlette", specifier = "~=0.47.1" },
{ name = "sse-starlette", specifier = "~=3.3.3" },
{ name = "starlette", specifier = "~=1.0.0" },
{ name = "typing-extensions", specifier = "~=4.15.0" },
{ name = "uvicorn", specifier = "~=0.40.0" },
{ name = "uvloop", specifier = "~=0.22.1" },
@@ -1421,7 +1434,7 @@ dev = [
[[package]]
name = "lnurl"
version = "0.10.0"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bech32" },
@@ -1432,9 +1445,9 @@ dependencies = [
{ name = "pycryptodomex" },
{ name = "pydantic" },
]
sdist = { url = "https://files.pythonhosted.org/packages/25/cc/e4849752e88c6d346de52eb14f4bd2860e8276dcbc657536211d38e0a2e0/lnurl-0.10.0.tar.gz", hash = "sha256:55ba0a7e810bcdfc7410c682f91c76df4446ff24e83d6bf931a7c759e2e0d5cc", size = 67646, upload-time = "2026-03-20T12:42:10.879Z" }
sdist = { url = "https://files.pythonhosted.org/packages/75/1f/ba20c0754ae29bc6f53439649a5295640e09ca189741911e215338b7aae3/lnurl-2.0.0.tar.gz", hash = "sha256:4ac17e289eb3818f217b84b41d455f27d0161b300560f6bddd345b923b5ba82d", size = 75603, upload-time = "2026-03-25T07:18:31.473Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/88/b8abeed9844a71ad91c10b3046d79afc8de1da1b8e74002090b54c1e1d7d/lnurl-0.10.0-py3-none-any.whl", hash = "sha256:0d43561b4370cecec19c8f1044fc840f0199fc2698bc83cd8782e8264bfde8f4", size = 17375, upload-time = "2026-03-20T12:42:09.872Z" },
{ url = "https://files.pythonhosted.org/packages/23/86/fd25c848d71b0a18ab95b16e4c718364ac1f1b691fc7ba2334da4ff547c0/lnurl-2.0.0-py3-none-any.whl", hash = "sha256:e355f7433aa5ba31557ffbfd042978e8e6f9df90aaae907ebeb613f06ff4f9f4", size = 17979, upload-time = "2026-03-25T07:18:32.35Z" },
]
[[package]]
@@ -1990,29 +2003,17 @@ wheels = [
[[package]]
name = "pydantic"
version = "1.10.26"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7b/da/fd89f987a376c807cd81ea0eff4589aade783bbb702637b4734ef2c743a2/pydantic-1.10.26.tar.gz", hash = "sha256:8c6aa39b494c5af092e690127c283d84f363ac36017106a9e66cb33a22ac412e", size = 357906, upload-time = "2025-12-18T15:47:46.557Z" }
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/08/2587a6d4314e7539eec84acd062cb7b037638edb57a0335d20e4c5b8878c/pydantic-1.10.26-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f7ae36fa0ecef8d39884120f212e16c06bb096a38f523421278e2f39c1784546", size = 2444588, upload-time = "2025-12-18T15:46:28.882Z" },
{ url = "https://files.pythonhosted.org/packages/47/e6/10df5f08c105bcbb4adbee7d1108ff4b347702b110fed058f6a03f1c6b73/pydantic-1.10.26-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d95a76cf503f0f72ed7812a91de948440b2bf564269975738a4751e4fadeb572", size = 2255972, upload-time = "2025-12-18T15:46:31.72Z" },
{ url = "https://files.pythonhosted.org/packages/ba/7d/fdb961e7adc2c31f394feba6f560ef2c74c446f0285e2c2eb87d2b7206c7/pydantic-1.10.26-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a943ce8e00ad708ed06a1d9df5b4fd28f5635a003b82a4908ece6f24c0b18464", size = 2857175, upload-time = "2025-12-18T15:46:34Z" },
{ url = "https://files.pythonhosted.org/packages/8f/6c/f21e27dda475d4c562bd01b5874284dd3180f336c1e669413b743ca8b278/pydantic-1.10.26-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:465ad8edb29b15c10b779b16431fe8e77c380098badf6db367b7a1d3e572cf53", size = 2947001, upload-time = "2025-12-18T15:46:35.922Z" },
{ url = "https://files.pythonhosted.org/packages/6d/f6/27ea206232cbb6ec24dc4e4e8888a9a734f96a1eaf13504be4b30ef26aa7/pydantic-1.10.26-cp310-cp310-win_amd64.whl", hash = "sha256:80e6be6272839c8a7641d26ad569ab77772809dd78f91d0068dc0fc97f071945", size = 2066217, upload-time = "2025-12-18T15:46:37.614Z" },
{ url = "https://files.pythonhosted.org/packages/1d/c1/d521e64c8130e1ad9d22c270bed3fabcc0940c9539b076b639c88fd32a8d/pydantic-1.10.26-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:116233e53889bcc536f617e38c1b8337d7fa9c280f0fd7a4045947515a785637", size = 2428347, upload-time = "2025-12-18T15:46:39.41Z" },
{ url = "https://files.pythonhosted.org/packages/2c/08/f4b804a00c16e3ea994cb640a7c25c579b4f1fa674cde6a19fa0dfb0ae4f/pydantic-1.10.26-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c3cfdd361addb6eb64ccd26ac356ad6514cee06a61ab26b27e16b5ed53108f77", size = 2212605, upload-time = "2025-12-18T15:46:41.006Z" },
{ url = "https://files.pythonhosted.org/packages/5d/78/0df4b9efef29bbc5e39f247fcba99060d15946b4463d82a5589cf7923d71/pydantic-1.10.26-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e4451951a9a93bf9a90576f3e25240b47ee49ab5236adccb8eff6ac943adf0f", size = 2753560, upload-time = "2025-12-18T15:46:43.215Z" },
{ url = "https://files.pythonhosted.org/packages/68/66/6ab6c1d3a116d05d2508fce64f96e35242938fac07544d611e11d0d363a0/pydantic-1.10.26-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9858ed44c6bea5f29ffe95308db9e62060791c877766c67dd5f55d072c8612b5", size = 2859235, upload-time = "2025-12-18T15:46:45.112Z" },
{ url = "https://files.pythonhosted.org/packages/61/4e/f1676bb0fcdf6ed2ce4670d7d1fc1d6c3a06d84497644acfbe02649503f1/pydantic-1.10.26-cp311-cp311-win_amd64.whl", hash = "sha256:ac1089f723e2106ebde434377d31239e00870a7563245072968e5af5cc4d33df", size = 2066646, upload-time = "2025-12-18T15:46:46.816Z" },
{ url = "https://files.pythonhosted.org/packages/02/6c/cd97a5a776c4515e6ee2ae81c2f2c5be51376dda6c31f965d7746ce0019f/pydantic-1.10.26-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:468d5b9cacfcaadc76ed0a4645354ab6f263ec01a63fb6d05630ea1df6ae453f", size = 2433795, upload-time = "2025-12-18T15:46:49.321Z" },
{ url = "https://files.pythonhosted.org/packages/47/12/de20affa30dcef728fcf9cc98e13ff4438c7a630de8d2f90eb38eba0891c/pydantic-1.10.26-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2c1b0b914be31671000ca25cf7ea17fcaaa68cfeadf6924529c5c5aa24b7ab1f", size = 2227387, upload-time = "2025-12-18T15:46:50.877Z" },
{ url = "https://files.pythonhosted.org/packages/7b/1d/9d65dcc5b8c17ba590f1f9f486e9306346831902318b7ee93f63516f4003/pydantic-1.10.26-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15b13b9f8ba8867095769e1156e0d7fbafa1f65b898dd40fd1c02e34430973cb", size = 2629594, upload-time = "2025-12-18T15:46:53.42Z" },
{ url = "https://files.pythonhosted.org/packages/3f/76/acb41409356789e23e1a7ef58f93821410c96409183ce314ddb58d97f23e/pydantic-1.10.26-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad7025ca324ae263d4313998e25078dcaec5f9ed0392c06dedb57e053cc8086b", size = 2745305, upload-time = "2025-12-18T15:46:55.987Z" },
{ url = "https://files.pythonhosted.org/packages/22/72/a98c0c5e527a66057d969fedd61675223c7975ade61acebbca9f1abd6dc0/pydantic-1.10.26-cp312-cp312-win_amd64.whl", hash = "sha256:4482b299874dabb88a6c3759e3d85c6557c407c3b586891f7d808d8a38b66b9c", size = 1937647, upload-time = "2025-12-18T15:46:57.905Z" },
{ url = "https://files.pythonhosted.org/packages/1f/98/556e82f00b98486def0b8af85da95e69d2be7e367cf2431408e108bc3095/pydantic-1.10.26-py3-none-any.whl", hash = "sha256:c43ad70dc3ce7787543d563792426a16fd7895e14be4b194b5665e36459dd917", size = 166975, upload-time = "2025-12-18T15:47:44.927Z" },
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[package.optional-dependencies]
@@ -2020,6 +2021,96 @@ email = [
{ name = "email-validator" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" },
{ url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" },
{ url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" },
{ url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" },
{ url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" },
{ url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" },
{ url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" },
{ url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" },
{ url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" },
{ url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" },
{ url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" },
{ url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" },
{ url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" },
{ url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
{ url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
{ url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
{ url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
{ url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
{ url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
{ url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
{ url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
{ url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
{ url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
{ url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
{ url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
{ url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
{ url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
{ url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
{ url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
{ url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
{ url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" },
{ url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" },
{ url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" },
{ url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" },
{ url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" },
{ url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" },
{ url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" },
{ url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" },
{ url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
{ url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
{ url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
{ url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
@@ -2312,7 +2403,7 @@ wheels = [
[[package]]
name = "requests"
version = "2.33.0"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -2320,9 +2411,9 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" },
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
@@ -2527,27 +2618,28 @@ wheels = [
[[package]]
name = "sse-starlette"
version = "2.3.6"
version = "3.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "starlette" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8c/f4/989bc70cb8091eda43a9034ef969b25145291f3601703b82766e5172dfed/sse_starlette-2.3.6.tar.gz", hash = "sha256:0382336f7d4ec30160cf9ca0518962905e1b69b72d6c1c995131e0a703b436e3", size = 18284, upload-time = "2025-05-30T13:34:12.914Z" }
sdist = { url = "https://files.pythonhosted.org/packages/14/2f/9223c24f568bb7a0c03d751e609844dce0968f13b39a3f73fbb3a96cd27a/sse_starlette-3.3.3.tar.gz", hash = "sha256:72a95d7575fd5129bd0ae15275ac6432bb35ac542fdebb82889c24bb9f3f4049", size = 32420, upload-time = "2026-03-17T20:05:55.529Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/05/78850ac6e79af5b9508f8841b0f26aa9fd329a1ba00bf65453c2d312bcc8/sse_starlette-2.3.6-py3-none-any.whl", hash = "sha256:d49a8285b182f6e2228e2609c350398b2ca2c36216c2675d875f81e93548f760", size = 10606, upload-time = "2025-05-30T13:34:11.703Z" },
{ url = "https://files.pythonhosted.org/packages/78/e2/b8cff57a67dddf9a464d7e943218e031617fb3ddc133aeeb0602ff5f6c85/sse_starlette-3.3.3-py3-none-any.whl", hash = "sha256:c5abb5082a1cc1c6294d89c5290c46b5f67808cfdb612b7ec27e8ba061c22e8d", size = 14329, upload-time = "2026-03-17T20:05:54.35Z" },
]
[[package]]
name = "starlette"
version = "0.47.3"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/15/b9/cc3017f9a9c9b6e27c5106cc10cc7904653c3eec0729793aec10479dd669/starlette-0.47.3.tar.gz", hash = "sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9", size = 2584144, upload-time = "2025-08-24T13:36:42.122Z" }
sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/fd/901cfa59aaa5b30a99e16876f11abe38b59a1a2c51ffb3d7142bb6089069/starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51", size = 72991, upload-time = "2025-08-24T13:36:40.887Z" },
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
]
[[package]]
@@ -2686,6 +2778,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "urllib3"
version = "2.6.3"