Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc7e3d4dc8 | ||
|
|
034ba44241 | ||
|
|
b0b8ea95f2 | ||
|
|
261350243f | ||
|
|
ed02802ad6 |
@@ -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
@@ -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='*'"]
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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}`. "
|
||||
|
||||
Generated
+3
-3
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"}},
|
||||
}
|
||||
)
|
||||
@@ -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_")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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"}
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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")
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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,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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user