Compare commits

..
Author SHA1 Message Date
arcbtc d95babc750 fix: paypal approve 2026-05-08 03:10:04 +01:00
dni ⚡andGitHub 8b426efa3e test: add pyinstrument profiler (#3955) 2026-05-07 17:15:53 +02:00
9edc4786e1 Fix: “Show all” table pagination (#3946)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-05-07 12:02:19 +03:00
Tiago VasconcelosandGitHub 93dc10fe94 Feat: add AI guardrails to work on LNbits (#3942) 2026-05-07 11:22:38 +03:00
ArcandGitHub 6c8448d7a8 fix: remove opensats (#3951) 2026-05-01 15:07:49 +01:00
dni ⚡andGitHub 99e4f33142 chore: update to version v1.5.4 (#3939) 2026-04-23 11:12:06 +02:00
ArcandGitHub f4f43ad361 fix: Switching images to local (#3938) 2026-04-21 11:41:45 +01:00
ArcandGitHub f14ea6c577 fix: add auth_https_only to env.example (#3937) 2026-04-17 14:50:26 +03:00
dni ⚡andGitHub 07428ecf94 chore: update to version v1.5.4-rc1 (#3935) 2026-04-16 15:43:33 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
867e3d06f6 chore(deps): bump python-multipart from 0.0.22 to 0.0.26 (#3933)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-16 14:05:45 +02:00
4b4b6d0bcd feat: adds an upload for assets on backgroundImage in account and site customisation (#3929)
Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: alan <alan@lnbits.com>
2026-04-16 14:05:24 +02:00
07b1521dad feat: Add phoenixd mnemonic display (#3931)
Co-authored-by: alan <alan@lnbits.com>
2026-04-16 13:42:58 +02:00
7a2ddd9826 chore: update axios upgrade to 1.15.0 (#3936)
Co-authored-by: alan <alan@lnbits.com>
2026-04-16 13:25:37 +02:00
ArcandGitHub 0eb4b477b7 feat: ui, adds full/thumb buttons to assets (#3928) 2026-04-16 13:17:11 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
06a0ba58ce chore(deps): bump pytest from 9.0.2 to 9.0.3 (#3930)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-16 13:15:33 +02:00
dni ⚡andGitHub 5399b36027 chore: revert changing default auth (#3934) 2026-04-16 11:30:40 +02:00
ArcandGitHub 385fb4f9bc fix: first_install for local (#3927) 2026-04-14 14:50:20 +01:00
ArcandGitHub 8db76b8864 fix: Appimage (#3926) 2026-04-12 23:01:25 +01:00
ArcandGitHub 9e3ab0ef26 feat: max users + extensions env (#3919) 2026-04-12 22:38:36 +01:00
ArcandGitHub 04c9b67997 fix: funding source ui (#3920) 2026-04-12 22:34:27 +01:00
Vlad StanandGitHub 116f982aab fix: webhook can fail for multiple reasons (#3921) 2026-04-08 18:17:39 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
63cc89e2b6 chore(deps): bump pygments from 2.19.2 to 2.20.0 (#3912)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-31 09:49:38 +03:00
Vlad StanandGitHub 183e6e5661 [test] Codex tests (#3911) 2026-03-31 09:48:43 +03:00
Vlad StanandGitHub 6b3fd80e46 [tests] Wallets test editor (#3910) 2026-03-30 13:12:26 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5999a773be chore(deps): bump cryptography from 46.0.5 to 46.0.6 (#3909)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 10:16:01 +03:00
dni ⚡andGitHub 7e0fadad3b refactor: move payment code from tasks.py to service/payments.py (#3800) 2026-03-26 11:28:23 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b2d6243697 chore(deps): bump requests from 2.32.5 to 2.33.0 (#3903)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-26 11:07:50 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6b5a77fb3b chore(deps): bump picomatch from 2.3.1 to 2.3.2 (#3901)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-26 09:58:34 +02:00
dni ⚡andGitHub cc8fb68b02 ci: only run ci on pull request (#3902) 2026-03-26 09:37:36 +02:00
Vlad StanandGitHub 658da6b28e feat: optimize QR code value for bach32 (#3900) 2026-03-25 16:22:03 +02:00
Vlad StanandGitHub 7d734ecb74 fix: use --offline for uv (#3896) 2026-03-25 13:10:47 +02:00
dni ⚡andGitHub 9177dd195b chore: update to version 1.5.3 (#3899) 2026-03-25 12:08:39 +01:00
15faab4f38 [feat] reset the first install token (#3894) (#3898)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-03-25 11:57:37 +01:00
dni ⚡andGitHub 2cce687865 bugfix: template regression from last commit (#3895) 2026-03-24 17:19:28 +01:00
104 changed files with 14584 additions and 452 deletions
+11 -1
View File
@@ -7,14 +7,20 @@
# They are NOT managed by the Admin UI and are not stored in the database.
# === First Install Token ===
# if set the user is required to enter this token on the /first_install page
# If set the user is required to enter this token on the /first_install page
# FIRST_INSTALL_TOKEN="myaccesstoken"
# === Security ===
# When enabled (recommended), auth cookies require HTTPS and SSO will reject insecure HTTP.
AUTH_HTTPS_ONLY=true
# === Logging and Development ===
DEBUG=False
DEBUG_DATABASE=False
BUNDLE_ASSETS=True
# add `?profiler=true` to the url to enable the profiler for that request
PROFILER=False
# logging into LNBITS_DATA_FOLDER/logs/
ENABLE_LOG_TO_FILE=true
@@ -24,6 +30,10 @@ LOG_ROTATION="100 MB"
LOG_RETENTION="3 months"
# for database cleanup commands
# CLEANUP_WALLETS_DAYS=90
# Hard limit for total created users. Set to 0 to disable the limit.
# LNBITS_MAX_USERS=0
# Hard limit for total installed extensions. Set to 0 to disable the limit.
# LNBITS_MAX_EXTENSIONS=0
# === Admin Settings ===
+3
View File
@@ -69,7 +69,10 @@ jobs:
--onefile \
--name lnbits \
--hidden-import=embit \
--hidden-import=bitstring.bitstore_bitarray \
--collect-all embit \
--collect-all bitstring \
--collect-all bitarray \
--collect-all lnbits \
--collect-all sqlalchemy \
--collect-all breez_sdk \
-4
View File
@@ -1,9 +1,5 @@
name: LNbits CI
on:
push:
branches:
- main
- dev
pull_request:
+1
View File
@@ -6,6 +6,7 @@ __pycache__
*$py.class
.mypy_cache
.vscode
.codex
*-lock.json
.python-version
+79
View File
@@ -0,0 +1,79 @@
# Feature Spec: [FEAT-XXX] - Short Descriptive Title
**Milestone:** [e.g. MVP Core / Performance & Polish / Extension Framework]
**Priority:** Must-have / Should-have / Nice-to-have
**Spec Owner:** [Your Name / AI Agent Name]
**Status:** Draft → Under Review → Approved → Implemented → Verified
## 1. Purpose & User Story
As a [user type], I want [goal] so that [benefit].
_(One clear sentence. Keep it concise.)_
## 2. Functional Requirements
- [ ] REQ-1: [Clear, testable description]
- [ ] REQ-2: ...
- [ ] REQ-3: ...
_(List what the feature must do. Make each item verifiable.)_
## 3. Non-Functional Requirements
- **Performance:** [e.g. Latency < 800ms at p95, max 5k tokens, etc.]
- **Security / Safety:** [e.g. Input validation, no raw errors to user, etc.]
- **Compatibility:** [e.g. Works with all existing wallet backends, no breaking changes for extensions]
- **UI/UX:** [e.g. Follows existing Quasar/Vue patterns in wallet.js]
- **Other:** [cost, scalability, accessibility, etc.]
## 4. Technical Approach (Optional recommended for complex features)
- Proposed solution: [e.g. Extend existing CRUD in lnbits/core/, new extension, middleware change, etc.]
- Key files to modify: [list expected files]
- New dependencies: [none / specific package + version]
- Migration / Database changes: [yes/no + description]
## 5. Success Criteria & Verification
**Must pass all of these to be accepted:**
- [ ] All functional requirements (REQ-\*) implemented and tested
- [ ] Non-functional requirements met (performance, security, etc.)
- [ ] Relevant tests pass: `make test-unit`, `make test-api`, `make test-regtest` (as applicable)
- [ ] `make check` passes (ruff, mypy, pyright, prettier, checkbundle)
- [ ] Constitution compliance: All changes respect CONSTITUTION.md
- [ ] Backward compatibility: No breakage for existing extensions or wallet backends
- [ ] Documentation updated (if applicable: README, OpenAPI, inline comments)
**Additional Tests / Edge Cases:**
- [ ] Test invalid inputs / error paths
- [ ] Test with FakeWallet and at least one real backend
- [ ] Test with multiple extensions installed
## 6. Safety & Risk Assessment
- Potential risks: [e.g. Payment flow impact, key exposure, extension conflicts]
- Mitigation: [how addressed]
- Security review needed: [yes/no]
## 7. Implementation Notes (for AI / Developer)
- Style to match: Existing code patterns in `lnbits/core/` and `wallet.js`
- Surgical changes only (per AGENTS.md)
- Any known gotchas or dependencies on other features:
## 8. Acceptance Checklist (Sign-off)
- [ ] Spec reviewed and approved by project owner
- [ ] Implementation completed
- [ ] Verification steps passed
- [ ] PR created with link to this spec
- [ ] Constitution & AGENTS.md compliance confirmed
---
**Created:** [Date]
**Last Updated:** [Date]
**Approved By:** [Name / "Approved"]
+122
View File
@@ -0,0 +1,122 @@
# AGENTS.md - Instructions for All AI Coding Agents
This file is the **master instruction manual** for any AI agent (Grok, Claude, Cursor, Aider, etc.) working on LNbits.
## 1. Core Rule (Never Break This)
**You MUST read and strictly follow `CONSTITUTION.md` before doing any planning, coding, refactoring, or suggesting changes.**
- Every single change, feature, extension, or fix **must comply** with the Constitution.
- If you detect a violation (in new code or existing code), you **must** flag it immediately and propose a fix or ask for clarification.
- Constitution > any other instruction (including this file, user prompts, or previous conversations).
## 2. Mandatory Development Workflow
For **any non-trivial task** (new feature, bug fix, refactor, extension change):
1. **Constitution Check** Re-read relevant sections of `CONSTITUTION.md`
2. **Feature Spec Check** If a spec exists in `.specify/`, follow it exactly. If none exists, ask the user for clarification or propose a minimal spec.
3. **Think Step-by-Step** Follow the "Think Before Coding" and "Simplicity First" guidelines below.
4. **Surgical Changes** Only touch what is necessary.
5. **Implement**
6. **Verify** Run relevant tests (`make test-unit`, `make test-api`, etc.), `make check`, and confirm compliance.
7. **Report** Always include a clear summary.
Use the following response format:
```markdown
## Constitution & Spec Compliance
- Relevant Constitution sections checked: [list or quote key rules]
- Feature Spec followed: [yes / no / proposed]
## Assumptions & Plan
- Assumptions: ...
- Plan:
1. ...
2. ...
- Tradeoffs considered: ...
## Changes Made
- Files changed: ...
- Summary of modifications:
## Verification
- [ ] Passes `make check`
- [ ] Relevant tests pass (`make test-xxx`)
- [ ] Complies with Constitution
- [ ] Surgical & minimal (no unrelated changes)
```
## 3. Behavioral Guidelines (Merged & Project-Specific)
**Think Before Coding**
- Don't assume. Don't hide confusion. Surface tradeoffs.
- State assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them — don't pick silently.
- If something is unclear (especially regarding wallets, extensions, or funding sources), stop and ask.
**Simplicity First**
- Minimum code that solves the problem. Nothing speculative.
- No features beyond what was asked.
- No abstractions for single-use code.
- Respect LNbits' lean core philosophy: new functionality should preferably go into an **extension** unless it truly belongs in core.
**Surgical Changes**
- Touch only what you must. Clean up only your own mess.
- Match existing style (Python: Black + Ruff rules; JS: Prettier).
- Do not "improve" or refactor adjacent code unless explicitly asked.
- When editing, remove only imports/variables/functions made unused **by your changes**.
- Never delete pre-existing dead code unless instructed.
**Goal-Driven Execution**
- Transform tasks into verifiable goals.
- For tests: Write or update tests first when fixing bugs or adding behavior.
- Always consider impact on existing extensions and multiple wallet backends (LND, CLN, Boltz, VoidWallet, etc.).
## 4. LNbits-Specific Rules
- **Extensions First**: Core should remain lean. Prefer implementing new features as extensions unless they are fundamental to wallets, security, or the API.
- **Testing**: Use `FakeWallet` for unit/API tests. Regtest tests for full Lightning flows. Never break existing test targets in the Makefile.
- **Dependencies**: Never add new dependencies without updating `pyproject.toml` and getting approval.
- **Frontend**: JS/Vue code (e.g. `wallet.js`) must follow existing patterns and pass `make checkbundle` when static files are affected.
- **Database / Migrations**: Do not make raw SQL changes. Use existing CRUD/services and migration tooling.
- **Security**: Be extremely cautious with anything touching payments, keys, LNURL, Bolt11, or admin routes.
- **Tools**: Use `uv run` for all commands. Prefer Makefile targets (`make format`, `make check`, `make test-xxx`).
- **Generated Files**: Never modify gRPC files or other generated code.
## 5. Forbidden Behaviors
- Ignoring Constitution rules to "be helpful"
- Large refactors without a spec or explicit request
- Adding features "for future use"
- Breaking backward compatibility for extensions or existing wallet backends
- Committing code that fails `make check` or relevant tests
- Exposing raw errors/stack traces to users
- Using synchronous code in hot async paths without justification
## 6. How to Handle This File + Constitution
When the user gives you a task, start your response with:
> Following LNbits CONSTITUTION.md and AGENTS.md...
Then proceed with the structured format above.
---
**These guidelines are working if:**
- Fewer unnecessary changes appear in diffs
- Clarifying questions come **before** implementation
- All changes respect the lean, extension-first, security-first nature of LNbits
- Tests and `make check` continue to pass
Last Updated: April 2026
+136
View File
@@ -0,0 +1,136 @@
# CONSTITUTION.md
This is the immutable constitution of the LNbits project.
Every feature spec, code change, refactor, extension, or decision by humans or AI agents **must comply** with this document.
Changes to this file require explicit approval from the project owner/maintainers.
## 1. Project Overview
**Project Name:** LNbits
**Core Purpose:** Free and open-source Lightning wallet and accounts system. A lightweight Python server that sits on top of any Lightning funding source, providing safe isolated wallets, a clean REST API, and a powerful extension system for adding features rapidly.
**Target Users:** Individuals, communities, merchants, developers, and enterprises building on Bitcoin/Lightning (self-hosted or as part of larger stacks).
**High-Level Success Criteria:**
- Reliable multi-wallet Lightning accounting with any backend (20+ supported funding sources)
- Secure, extensible via 60+ extensions without bloating core
- High code quality, test coverage, and backward compatibility for extensions
- Production-ready performance and security for real Bitcoin value
**Version:** 1.5.4
## 2. Technology Stack (Strict)
- **Language:** Python >=3.10, <3.13 (strictly enforced via `pyproject.toml`)
- **Framework:** FastAPI + Starlette (backend API)
- **Frontend:** Vue.js + Quasar framework, with bundled static assets
- **Database:** SQLite (aiosqlite) by default, PostgreSQL (asyncpg/psycopg2) supported via `LNBITS_DATABASE_URL`
- **Async Runtime:** uvloop preferred
- **Dependency Management:** uv + pyproject.toml (Hatchling build backend). Use `uv run` for all commands.
- **Wallet Backends:** Abstracted via `lnbits.wallets` support for LND, Core Lightning, Phoenixd, Boltz, Breez SDK, Liquid, VoidWallet fallback, etc. New backends must follow existing abstraction.
- **Other Key Libs:** SQLAlchemy, Pydantic (v1), Loguru, Jinja2, LNURL, Bolt11, etc. (see `pyproject.toml` for pinned versions)
- **Build/Frontend Tools:** npm for bundling (Quasar/Vue), Prettier for JS/CSS (check Makefile targets)
**Forbidden:**
- Adding new top-level dependencies without updating `pyproject.toml` **and** team approval
- Using synchronous blocking calls in async paths (except where explicitly justified)
- Direct database queries outside of CRUD layers or core services
- Modifying generated files (e.g., gRPC files in wallets/boltz_grpc_files or lnd_grpc_files)
## 3. Architecture & Code Organization (Mandatory Rules)
- **Core Principle:** Modular monolith with heavy emphasis on **extensions**. All non-core features must live in extensions (installed via `lnbits/extensions`). Core stays lean.
- **Backend Structure:**
- `lnbits/core/` for core models, CRUD, services, routers, tasks
- `lnbits/wallets/` for funding source abstractions
- `lnbits/extensions/` for installed/upgradeable extensions (do not commit large extensions to core repo)
- `lnbits/static/` for bundled frontend assets (managed via npm bundle)
- **Key Rules:**
- Use dependency injection and FastAPI routers properly
- Extensions register routes/tasks via `register_ext_routes` / `register_ext_tasks`
- Database migrations handled centrally (extension-specific migrations)
- All new endpoints must be under proper versioning/prefixing where applicable
- Frontend: Vue 2/Quasar components in `wallet.js` style (or updated) keep reactive, use LNbits.utils helpers
- No circular imports; respect existing middleware order (e.g., InstalledExtensionMiddleware before ExtensionsRedirectMiddleware)
**Exclusions** (do not lint/format these):
- `lnbits/extensions/`, `lnbits/upgrades/`, generated gRPC files, static/vendor bundles
## 4. Code Quality & Style
- **Formatting & Linting:**
- Python: Black (line-length 88), Ruff (with selected rules: F, E, W, I, A, C, N, UP, RUF, B, S), MyPy (strict where possible), Pyright
- JS/Frontend: Prettier
- Run via Makefile: `make format` and `make check`
- **Type Checking:** MyPy + Pyright enforced on `lnbits/`, `tests/`, `tools/`
- **Testing Requirements:**
- Unit, API, wallet, and regtest tests via pytest (see Makefile targets)
- New core code or critical paths: high coverage expected (`--cov=lnbits`)
- Extensions should include their own tests where possible
- **Error Handling & Logging:** Use Loguru with structured context. Never expose raw stack traces to end users. Graceful fallbacks (e.g., VoidWallet on funding source failure).
- **Pre-commit:** Strongly recommended (`make install-pre-commit-hook`)
- **Bundle Integrity:** Frontend bundles must pass `make checkbundle` before commits affecting static files.
## 5. AI / LLM Usage Standards (if any agents or future AI features are added)
- Any new AI-powered features (e.g., via extensions) must use structured outputs (Pydantic/JSON mode)
- Store prompts/templates versioned in the extension
- Prefer deterministic behavior for financial/security paths (low temperature)
- All AI outputs involving value/money must be validated server-side
- Safety: Never allow untrusted model output to influence payments, wallet balances, or admin actions without guardrails
## 6. Safety, Security & Ethics
- **Critical:** Handle real Bitcoin/Lightning value → security-first mindset
- Per-wallet isolation with separate admin/invoice/read keys
- Rate limiting (SlowAPI) and IP blocking middleware mandatory
- Sanitize all user inputs; validate LNURL, Bolt11, etc.
- PII: Minimal collection; respect privacy (no unnecessary logging of sensitive data)
- Funding source failures: Graceful degradation to VoidWallet + clear logging
- Extensions: Hash-verified installs for vetted extensions; careful with custom extension paths
- Audit logging via AuditMiddleware
- Forbidden: Hard-coded secrets, insecure subprocess calls without review, SQL injection risks (use SQLAlchemy properly)
## 7. Performance & Cost Budgets
- Keep core lightweight extensions handle heavy features
- Async-first (uvloop, asyncpg/aiosqlite)
- Reasonable retry logic for funding source connections (see `check_funding_source`)
- Frontend: Optimized bundles (checkbundle enforced)
- No unnecessary blocking operations in request paths
## 8. Development Workflow (Spec-Driven where possible)
- **All significant changes** should follow Spec-Driven Development:
- Create/update Feature Spec in `.specify/` folder (or equivalent)
- Reference this Constitution in every spec and PR
- Use Makefile targets for format/check/test
- Tests run with `FakeWallet` by default for unit/API; regtest for full flows
- PRs must:
- Pass `make check` and relevant tests
- Include Constitution compliance notes (via AGENTS.md/CLAUDE.md)
- Not break existing extensions or wallet backends
- Branching: Protect main; use feature branches
- Extensions: Develop separately; core repo focuses on framework stability
## 9. Decision Hierarchy (What Takes Precedence)
1. This Constitution
2. Approved Feature Spec / Milestone
3. Existing tests and backward compatibility (especially for extensions and wallet backends)
4. Project maintainers / owner decision
5. Everything else (including helpful AI suggestions)
If conflict: Stop, document the issue, and seek clarification from maintainers.
## 10. Amendment Process
- This Constitution can only be changed with explicit approval from project maintainers.
- All changes must be dated, versioned, and reflected in `AGENTS.md`.
- Minor clarifications can be proposed via PR with justification.
---
**Last Updated:** April 2026 (based on v1.5.4)
**Owner/Maintainers Approval:** LNbits Team
+1 -1
View File
@@ -43,4 +43,4 @@ ENV LNBITS_HOST="0.0.0.0"
EXPOSE 5000
CMD ["sh", "-c", "uv run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
CMD ["sh", "-c", "uv --offline run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
+1
View File
@@ -66,6 +66,7 @@ test-regtest:
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
rm -rf ./tests/data \
uv run pytest tests/regtest
test-migration:
+11 -11
View File
@@ -1,12 +1,12 @@
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
<source media="(prefers-color-scheme: dark)" srcset="docs/logos/lnbits-full-inverse.svg">
<img src="docs/logos/lnbits-full.svg" alt="LNbits" style="width:300px">
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [![hardware: LNBitsShop](https://img.shields.io/badge/hardware-LNBitsShop-7C3AED)](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
<img width="2000" height="203" alt="lnbits_head" src="https://github.com/user-attachments/assets/77669718-ac10-43c7-ae95-6ce236c77401" />
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [![hardware: LNBitsShop](https://img.shields.io/badge/hardware-LNBitsShop-7C3AED)](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
<img alt="lnbits_head" src="docs/assets/header.jpg" />
[![tip-hero](https://img.shields.io/badge/TipJar-LNBits%20Hero-9b5cff?labelColor=6b7280&logo=lightning&logoColor=white)](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
# LNbits — The most powerful Bitcoin & Lightning toolkit
@@ -46,7 +46,7 @@ Get yourself familiar and test on our demo server [demo.lnbits.com](https://demo
LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending, export to csv. Each wallet also comes with its own API keys, to help partition the exposure of your funding source.
<img src="https://i.imgur.com/w8jdGpF.png" style="width:800px">
<img alt="lnbits_wallet" src="docs/assets/wallet.jpg" />
## LNbits extension universe
@@ -54,25 +54,25 @@ Extend YOUR LNbits to meet YOUR needs.
All non-core features are installed as extensions, reducing your code base and making your LNbits unique to you. Extend your LNbits install in any direction, and even create and share your own extensions.
<img src="https://i.imgur.com/aEBpwJF.png" style="width:800px">
<img alt="lnbits_extensions" src="docs/assets/extensions.jpg" />
## LNbits API
LNbits has a powerful API, many projects use LNbits to do the heavy lifting for their bitcoin/lightning services.
<img src="https://i.imgur.com/V742sb9.png" style="width:800px">
<img alt="lnbits_api" src="docs/assets/api.jpg" />
## LNbits node manager
LNbits comes packaged with a light node management UI, to make running your node that much easier.
<img src="https://i.imgur.com/TYqIK60.png" style="width:800px">
<img alt="lnbits_api" src="docs/assets/lightning_node.jpg" />
## LNbits across all your devices
## LNbits merchant tools
As well as working great in a browser, LNbits has native IoS and Android apps as well as a chrome extension. So you can enjoy the same UI across ALL your devices.
The LNbits stack can process both bitcoin and fiat payments, making it a turnkey, all-in-one solution for merchants. With orders and inventory shared across extensions, and built-in notifications for Nostr, Telegram, and email, LNbits keeps everything in sync, freeing merchants to focus on their business.
<img src="https://i.imgur.com/J96EbRf.png" style="width:800px">
<img alt="lnbits_merchants" src="docs/assets/merchants_small.webp" />
## Powered by LNbits
Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

-1
View File
@@ -14,7 +14,6 @@ nav_order: 1
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# LNBits Admin UI
-1
View File
@@ -14,7 +14,6 @@ nav_order: 1
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# Backend Wallet Comparison Table
+1 -1
View File
@@ -11,7 +11,7 @@ nav_order: 1
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) ![License: MIT](https://img.shields.io/badge/License-MIT-blue) ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) <img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) ![License: MIT](https://img.shields.io/badge/License-MIT-blue) ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
# Basic installation
-1
View File
@@ -14,7 +14,6 @@ nav_order: 1
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# LNbits Super User (SU)
-1
View File
@@ -14,7 +14,6 @@ nav_order: 1
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# LNbits Roles: A Quick Overview
-1
View File
@@ -14,7 +14,6 @@ nav_order: 3
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# Backend wallets
+4
View File
@@ -65,6 +65,7 @@ from .middleware import (
InstalledExtensionMiddleware,
add_first_install_middleware,
add_ip_block_middleware,
add_profiler_middleware,
add_ratelimit_middleware,
)
from .tasks import internal_invoice_listener, invoice_listener, run_interval
@@ -196,6 +197,9 @@ def create_app() -> FastAPI:
register_exception_handlers(app)
if settings.profiler:
add_profiler_middleware(app)
return app
+4
View File
@@ -12,6 +12,7 @@ from .extensions import (
drop_extension_db,
get_installed_extension,
get_installed_extensions,
get_installed_extensions_count,
get_user_active_extensions_ids,
get_user_extension,
get_user_extensions,
@@ -58,6 +59,7 @@ from .users import (
get_account_by_username,
get_account_by_username_or_email,
get_accounts,
get_accounts_count,
get_user,
get_user_access_control_lists,
get_user_from_account,
@@ -117,11 +119,13 @@ __all__ = [
"get_account_by_username",
"get_account_by_username_or_email",
"get_accounts",
"get_accounts_count",
"get_admin_settings",
"get_db_version",
"get_db_versions",
"get_installed_extension",
"get_installed_extensions",
"get_installed_extensions_count",
"get_latest_payments_by_extension",
"get_payment",
"get_payments",
+7
View File
@@ -90,6 +90,13 @@ async def get_installed_extensions(
return all_extensions
async def get_installed_extensions_count(conn: Connection | None = None) -> int:
row: dict | None = await (conn or db).fetchone(
"SELECT COUNT(*) as count FROM installed_extensions"
)
return int(row["count"]) if row else 0
async def get_user_extension(
user_id: str, extension: str, conn: Connection | None = None
) -> UserExtension | None:
+2 -1
View File
@@ -105,7 +105,8 @@ async def get_settings_field(
)
if not row:
return None
return SettingsField(id=row["id"], value=json.loads(row["value"]), tag=row["tag"])
value = json.loads(row["value"]) if row["value"] else None
return SettingsField(id=row["id"], value=value, tag=row["tag"])
async def set_settings_field(id_: str, value: Any | None, tag: str | None = "core"):
+7
View File
@@ -37,6 +37,13 @@ async def create_account(
return account
async def get_accounts_count(conn: Connection | None = None) -> int:
row: dict | None = await (conn or db).fetchone(
"SELECT COUNT(*) as count FROM accounts"
)
return int(row["count"]) if row else 0
async def update_account(account: Account, conn: Connection | None = None) -> Account:
account.updated_at = datetime.now(timezone.utc)
await (conn or db).update("accounts", account)
+4 -2
View File
@@ -1,4 +1,6 @@
from pydantic import BaseModel
from datetime import datetime, timezone
from pydantic import BaseModel, Field
class TinyURL(BaseModel):
@@ -6,4 +8,4 @@ class TinyURL(BaseModel):
url: str
endless: bool
wallet: str
time: float
time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+12
View File
@@ -9,6 +9,7 @@ from lnbits.core.crud import (
delete_installed_extension,
get_db_version,
get_installed_extension,
get_installed_extensions_count,
update_installed_extension_state,
)
from lnbits.core.crud.extensions import (
@@ -38,6 +39,8 @@ async def install_extension(
if installed_ext and installed_ext.meta:
ext_info.meta.payments = installed_ext.meta.payments
await check_extensions_limit(installed_ext)
if not skip_download:
await ext_info.download_archive()
@@ -63,6 +66,15 @@ async def install_extension(
return extension
async def check_extensions_limit(installed_ext: InstallableExtension | None = None):
if settings.lnbits_max_extensions == 0 or installed_ext:
return
extensions_count = await get_installed_extensions_count()
if extensions_count >= settings.lnbits_max_extensions:
raise ValueError("Max amount of extensions have been installed")
async def uninstall_extension(ext_id: str):
await stop_extension_background_work(ext_id)
+32 -1
View File
@@ -13,13 +13,13 @@ from lnbits.core.crud.payments import get_daily_stats
from lnbits.core.db import db
from lnbits.core.models import PaymentDailyStats, PaymentFilters
from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services.fiat_providers import handle_fiat_payment_confirmation
from lnbits.db import Connection, Filters
from lnbits.decorators import check_user_extension_access
from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
from lnbits.fiat import get_fiat_provider
from lnbits.helpers import check_callback_url
from lnbits.settings import settings
from lnbits.tasks import create_task, internal_invoice_queue_put
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_preimage
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import fake_wallet, get_funding_source
@@ -509,6 +509,8 @@ 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)
@@ -819,6 +821,8 @@ 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)
)
@@ -1068,3 +1072,30 @@ 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
+18
View File
@@ -23,6 +23,7 @@ from ..crud import (
get_account_by_email,
get_account_by_pubkey,
get_account_by_username,
get_accounts_count,
get_super_settings,
get_user_extensions,
get_user_from_account,
@@ -55,6 +56,8 @@ async def create_user_account_no_ckeck(
conn: Connection | None = None,
) -> User:
async with db.reuse_conn(conn) if conn else db.connect() as conn:
await check_users_limit(conn)
if account:
account.validate_fields()
if account.username and await get_account_by_username(
@@ -95,6 +98,15 @@ async def create_user_account_no_ckeck(
return user
async def check_users_limit(conn: Connection | None = None):
if settings.lnbits_max_users == 0:
return
users_count = await get_accounts_count(conn=conn)
if users_count >= settings.lnbits_max_users:
raise ValueError("Max amount of users have been created")
async def update_user_account(account: Account) -> Account:
account.validate_fields()
@@ -173,6 +185,12 @@ 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."
+8
View File
@@ -12,6 +12,7 @@ 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,
@@ -548,6 +549,13 @@ 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)
+31 -1
View File
@@ -14,6 +14,8 @@ from lnbits.core.services.fiat_providers import (
verify_paypal_webhook,
)
from lnbits.core.services.payments import create_fiat_invoice
from lnbits.fiat import get_fiat_provider
from lnbits.fiat.paypal import PayPalWallet
from lnbits.fiat.base import FiatSubscriptionPaymentOptions
from lnbits.settings import settings
@@ -186,7 +188,11 @@ async def handle_paypal_event(event: dict):
resource = event.get("resource", {})
logger.info(f"Handling PayPal event: '{event_id}'. Type: '{event_type}'.")
if event_type in ("CHECKOUT.ORDER.APPROVED", "PAYMENT.CAPTURE.COMPLETED"):
if event_type == "CHECKOUT.ORDER.APPROVED":
await _handle_paypal_checkout_order_approved(resource)
return
if event_type == "PAYMENT.CAPTURE.COMPLETED":
payment_hash = _paypal_extract_payment_hash(resource)
if not payment_hash:
logger.warning("PayPal event missing payment hash.")
@@ -205,6 +211,30 @@ async def handle_paypal_event(event: dict):
logger.warning(f"Unhandled PayPal event type: '{event_type}'.")
async def _handle_paypal_checkout_order_approved(resource: dict):
payment_hash = _paypal_extract_payment_hash(resource)
if not payment_hash:
logger.warning("PayPal approved event missing payment hash.")
return
payment = await get_standalone_payment(payment_hash)
if not payment:
logger.warning(f"No payment found for hash: '{payment_hash}'.")
return
fiat_provider = await get_fiat_provider("paypal")
if not isinstance(fiat_provider, PayPalWallet):
logger.warning("PayPal provider unavailable for approved order capture.")
return
capture_status = await fiat_provider.capture_order(
payment.extra.get("fiat_checking_id") or payment.checking_id
)
if capture_status.failed:
logger.warning(f"PayPal order capture failed for hash: '{payment_hash}'.")
return
async def _handle_paypal_subscription_payment(resource: dict):
amount_info = resource.get("amount") or {}
currency = (amount_info.get("currency") or "").upper()
+6 -2
View File
@@ -98,12 +98,16 @@ async def api_install_extension(data: CreateExtension):
ext_info.clean_extension_files()
detail = (
str(exc)
if isinstance(exc, AssertionError)
if isinstance(exc, (AssertionError, ValueError))
else f"Failed to install extension '{ext_info.id}'."
f"({ext_info.installed_version})."
)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
status_code=(
HTTPStatus.BAD_REQUEST
if isinstance(exc, (AssertionError, ValueError))
else HTTPStatus.INTERNAL_SERVER_ERROR
),
detail=detail,
) from exc
+4 -2
View File
@@ -35,7 +35,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
@@ -560,7 +560,9 @@ class Filters(BaseModel, Generic[TFilterModel]):
def pagination(self) -> str:
stmt = ""
self.limit = self.limit or 10
if self.limit == 0:
self.limit = 1000
self.limit = 10 if self.limit is None else self.limit
stmt += f"LIMIT {min(1000, self.limit)} "
if self.offset:
stmt += f"OFFSET {self.offset}"
+25 -7
View File
@@ -169,7 +169,7 @@ class PayPalWallet(FiatProvider):
return FiatInvoiceResponse(
ok=True,
checking_id=f"fiat_paypal_{order_id}",
checking_id=order_id,
payment_request=approval_url,
)
except Exception as exc:
@@ -285,6 +285,25 @@ class PayPalWallet(FiatProvider):
async def get_payment_status(self, checking_id: str) -> FiatPaymentStatus:
raise NotImplementedError("PayPal does not support outgoing payments.")
async def capture_order(self, checking_id: str) -> FiatPaymentStatus:
try:
await self._ensure_access_token()
paypal_id = self._normalize_paypal_id(checking_id)
if paypal_id.startswith("subscription_"):
logger.warning("PayPal subscriptions do not support order capture.")
return FiatPaymentPendingStatus()
r = await self.client.post(
f"/v2/checkout/orders/{paypal_id}/capture",
json={},
headers=self._auth_headers(),
)
r.raise_for_status()
return self._status_from_order(r.json())
except Exception as exc:
logger.warning(f"Error capturing PayPal order '{checking_id}': {exc}")
return await self.get_invoice_status(checking_id)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
logger.warning(
"PayPal does not support paid invoices stream. Use webhooks instead."
@@ -296,7 +315,7 @@ class PayPalWallet(FiatProvider):
def _status_from_order(self, order: dict[str, Any]) -> FiatPaymentStatus:
status = (order.get("status") or "").upper()
if status in ["COMPLETED", "APPROVED"]:
if status == "COMPLETED":
return FiatPaymentSuccessStatus()
if status in ["VOIDED", "CANCELLED", "CANCELED"]:
return FiatPaymentFailedStatus()
@@ -311,11 +330,10 @@ class PayPalWallet(FiatProvider):
return FiatPaymentPendingStatus()
def _normalize_paypal_id(self, checking_id: str) -> str:
return (
checking_id.replace("fiat_paypal_", "", 1)
if checking_id.startswith("fiat_paypal_")
else checking_id
)
normalized = checking_id
while normalized.startswith("fiat_paypal_"):
normalized = normalized.replace("fiat_paypal_", "", 1)
return normalized
def _serialize_metadata(
self, payment_options: FiatSubscriptionPaymentOptions
+14
View File
@@ -7,6 +7,7 @@ from typing import Any
from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from loguru import logger
from pyinstrument import Profiler
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
@@ -246,3 +247,16 @@ def add_first_install_middleware(app: FastAPI):
):
return RedirectResponse("/first_install")
return await call_next(request)
def add_profiler_middleware(app: FastAPI):
@app.middleware("http")
async def profile_middleware(request: Request, call_next):
profiling = request.query_params.get("profiler", False)
if profiling:
profiler = Profiler(async_mode="enabled")
profiler.start()
_ = await call_next(request)
profiler.stop()
return HTMLResponse(profiler.output_html())
return await call_next(request)
+16 -3
View File
@@ -284,7 +284,7 @@ class ThemesSettings(LNbitsSettings):
lnbits_custom_image: str | None = Field(default="/static/images/logos/lnbits.svg")
lnbits_ad_space_title: str = Field(default="Supported by")
lnbits_ad_space: str = Field(
default="https://shop.lnbits.com/;/static/images/bitcoin-shop-banner.png;/static/images/bitcoin-shop-banner.png,https://affil.trezor.io/aff_c?offer_id=169&aff_id=33845;/static/images/bitcoin-hardware-wallet.png;/static/images/bitcoin-hardware-wallet.png,https://firefish.io/?ref=lnbits;/static/images/firefish.png;/static/images/firefish.png,https://opensats.org/;/static/images/open-sats.png;/static/images/open-sats.png"
default="https://shop.lnbits.com/;/static/images/bitcoin-shop-banner.png;/static/images/bitcoin-shop-banner.png,https://affil.trezor.io/aff_c?offer_id=169&aff_id=33845;/static/images/bitcoin-hardware-wallet.png;/static/images/bitcoin-hardware-wallet.png,https://firefish.io/?ref=lnbits;/static/images/firefish.png;/static/images/firefish.png"
) # sneaky sneaky
lnbits_ad_space_enabled: bool = Field(default=False)
lnbits_allowed_currencies: list[str] = Field(default=[])
@@ -324,7 +324,8 @@ class AssetSettings(LNbitsSettings):
"heif",
"heics",
"text/plain",
"text/json" "text/xml",
"text/json",
"text/xml",
"application/json",
"application/pdf",
]
@@ -447,6 +448,7 @@ 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 (
@@ -587,6 +589,8 @@ class ZBDFundingSource(LNbitsSettings):
class PhoenixdFundingSource(LNbitsSettings):
phoenixd_api_endpoint: str | None = Field(default="http://localhost:9740/")
phoenixd_api_password: str | None = Field(default=None)
phoenixd_data_dir: str | None = Field(default=None)
phoenixd_mnemonic: str | None = Field(default=None)
class AlbyFundingSource(LNbitsSettings):
@@ -1005,8 +1009,8 @@ class EnvSettings(LNbitsSettings):
debug: bool = Field(default=False)
debug_database: bool = Field(default=False)
bundle_assets: bool = Field(default=True)
profiler: bool = Field(default=False)
# When enabled, auth cookies require HTTPS and SSO will reject insecure HTTP.
# Set to false for local/dev environments that run without TLS.
auth_https_only: bool = Field(default=True)
host: str = Field(default="127.0.0.1")
port: int = Field(default=5000, gt=0)
@@ -1025,11 +1029,20 @@ class EnvSettings(LNbitsSettings):
cleanup_wallets_days: int = Field(default=90, ge=0)
funding_source_max_retries: int = Field(default=4, ge=0)
lnbits_max_users: int = Field(default=0, ge=0)
lnbits_max_extensions: int = Field(default=0, ge=0)
@property
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
File diff suppressed because one or more lines are too long
+11 -11
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -275,6 +275,8 @@ window.localisation.en = {
requires_server_restart:
'Changing these settings requires a server restart to take effect.',
funding_source_info: 'Select the active funding wallet',
phoenixd_warning:
"Phoenixd mnemonic is only available if phoenixd data-dir is specified and is readable by LNbits. It's not indicative of phoenixd not running. It just means LNbits cannot access the mnemonic to display it here.",
latest_update: 'You are on the latest version {version}.',
notifications: 'Notifications',
notifications_configure: 'Configure Notifications',
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

@@ -202,7 +202,18 @@ window.app.component('lnbits-admin-funding-sources', {
'Phoenixd',
{
phoenixd_api_endpoint: 'Endpoint',
phoenixd_api_password: 'Key'
phoenixd_api_password: 'Key',
phoenixd_data_dir: {
label: 'Data Directory',
hint: 'Directory where phoenixd stores its data, including the seed phrase.'
},
phoenixd_mnemonic: {
label: 'Phoenixd Seed Phrase',
hint: 'Only available if phoenixd data-dir is specified',
readonly: true,
copy: true,
qrcode: true
}
}
],
[
@@ -41,5 +41,37 @@ window.app.component('lnbits-admin-site-customisation', {
]
}
},
methods: {}
methods: {
onBackgroundImageInput(e) {
const file = e.target.files[0]
if (file) {
this.uploadBackgroundImage(file)
}
e.target.value = null
},
async uploadBackgroundImage(file) {
const formData = new FormData()
formData.append('file', file)
try {
const {data} = await LNbits.api.request(
'POST',
'/api/v1/assets?public_asset=true',
null,
formData,
{
headers: {'Content-Type': 'multipart/form-data'}
}
)
const assetUrl = `${window.location.origin}/api/v1/assets/${data.id}/thumbnail`
this.formData.lnbits_default_bgimage = assetUrl
Quasar.Notify.create({
type: 'positive',
message: 'Background image uploaded.',
icon: null
})
} catch (e) {
LNbits.utils.notifyApiError(e)
}
}
}
})
@@ -165,5 +165,26 @@ 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
}
}
})
+33 -8
View File
@@ -545,31 +545,56 @@ window.PageAccount = {
if (file) {
this.uploadAsset(file)
}
e.target.value = null
},
async uploadAsset(file) {
onBackgroundImageInput(e) {
const file = e.target.files[0]
if (file) {
this.uploadBackgroundImage(file)
}
e.target.value = null
},
async uploadAsset(
file,
{isPublic = this.assetsUploadToPublic, notifySuccess = true} = {}
) {
const formData = new FormData()
formData.append('file', file)
try {
await LNbits.api.request(
const {data} = await LNbits.api.request(
'POST',
`/api/v1/assets?public_asset=${this.assetsUploadToPublic}`,
`/api/v1/assets?public_asset=${isPublic}`,
null,
formData,
{
headers: {'Content-Type': 'multipart/form-data'}
}
)
this.$q.notify({
type: 'positive',
message: 'Upload successful!',
icon: null
})
if (notifySuccess) {
this.$q.notify({
type: 'positive',
message: 'Upload successful!',
icon: null
})
}
await this.getUserAssets()
return data
} catch (e) {
console.warn(e)
LNbits.utils.notifyApiError(e)
}
},
async uploadBackgroundImage(file) {
const asset = await this.uploadAsset(file, {
isPublic: false,
notifySuccess: false
})
if (!asset) {
return
}
const assetUrl = `${window.location.origin}/api/v1/assets/${asset.id}/thumbnail`
await this.siteCustomisationChanged({bgimageChoice: assetUrl})
},
async deleteAsset(asset) {
LNbits.utils
.confirmDialog('Are you sure you want to delete this asset?')
+40
View File
@@ -160,6 +160,46 @@ 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)
+294 -153
View File
File diff suppressed because it is too large Load Diff
+11 -35
View File
@@ -5,12 +5,8 @@ from collections.abc import Callable, Coroutine
from loguru import logger
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.core.models import Payment
from lnbits.core.services.payments import update_invoice_callback
from lnbits.settings import settings
from lnbits.wallets import get_funding_source
@@ -116,7 +112,10 @@ 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}")
await invoice_callback_dispatcher(checking_id, is_internal=True)
payment = await update_invoice_callback(checking_id)
if payment:
logger.success(f"internal invoice {checking_id} settled")
await invoice_callback_dispatcher(payment)
async def invoice_listener() -> None:
@@ -129,7 +128,10 @@ 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}")
await invoice_callback_dispatcher(checking_id)
payment = await update_invoice_callback(checking_id)
if payment:
logger.success(f"fundingsource invoice {checking_id} settled")
await invoice_callback_dispatcher(payment)
def wait_for_paid_invoices(
@@ -165,33 +167,7 @@ def run_interval(
return wrapper
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")
async def invoice_callback_dispatcher(payment: Payment):
for name, send_chan in invoice_listeners.items():
logger.trace(f"invoice listeners: sending to `{name}`")
await send_chan.put(payment)
+7 -5
View File
@@ -82,19 +82,21 @@
<!-- scripts libraries -->
{% for url in INCLUDED_JS %}
<script src="{{ static_url_for('static', url) }}"></script>
{% endfor %}
{% endfor %} {% if user %}
<!-- user init -->
{% if user %}
<script>
window.g.user = LNbits.map.user(JSON.parse({{ user | tojson | safe }}));
{% if not public %}
window.g.isPublicPage = false
{% endif %}
window.app = Vue.createApp({
el: '#vue',
})
</script>
{% endif %}
<!-- app init -->
<script>
window.app = Vue.createApp({
el: '#vue'
})
</script>
<!-- scripts from extensions -->
{% block scripts %}{% endblock %}
<!-- components js -->
+29 -29
View File
@@ -75,7 +75,35 @@
</p>
</div>
</div>
<div class="row q-col-gutter-md">
<div v-if="isSuperUser">
<lnbits-admin-funding-sources
:form-data="formData"
:allowed-funding-sources="settings.lnbits_allowed_funding_sources"
/>
<div class="row q-col-gutter-md q-my-md">
<div class="col-12 col-sm-8">
<q-item tag="div">
<q-item-section>
<q-item-label
v-text="$t('funding_source_retries')"
></q-item-label>
<q-item-label
caption
v-text="$t('funding_source_retries_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.funding_source_max_retries"
type="number"
/>
</q-item-section>
</q-item>
</div>
</div>
</div>
<div class="row q-col-gutter-md q-mt-lg">
<div class="col-12">
<h6 class="q-my-none">
<span v-text="$t('routing_fee_reserve_calculations')"></span>
@@ -182,34 +210,6 @@
></q-input>
</div>
</div>
<div v-if="isSuperUser">
<lnbits-admin-funding-sources
:form-data="formData"
:allowed-funding-sources="settings.lnbits_allowed_funding_sources"
/>
<div class="row q-col-gutter-md q-my-md">
<div class="col-12 col-sm-8">
<q-item tag="div">
<q-item-section>
<q-item-label
v-text="$t('funding_source_retries')"
></q-item-label>
<q-item-label
caption
v-text="$t('funding_source_retries_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.funding_source_max_retries"
type="number"
/>
</q-item-section>
</q-item>
</div>
</div>
</div>
<q-separator></q-separator>
<h6 class="q-mt-lg q-mb-sm">
<p v-text="$t('watchdog')"></p>
@@ -52,6 +52,7 @@
:label="prop.label"
:hint="prop.hint"
:value="prop.value"
:readonly="prop.readonly || false"
>
<q-btn
v-if="prop.copy"
@@ -73,6 +74,15 @@
></q-btn>
</q-input>
</div>
<p
v-if="fund === 'PhoenixdWallet' && key === 'phoenixd_mnemonic'"
class="col-12 q-my-md"
>
<span>
<q-icon name="warning" color="orange" size="xs"></q-icon>
<span v-text="$t('phoenixd_warning')"></span>
</span>
</p>
</div>
<q-expansion-item
v-if="
@@ -251,10 +251,27 @@
type="text"
v-model="formData.lnbits_default_bgimage"
label="Background Image"
@update:model-value="applyGlobalBgimage"
hint="This must be a trusted source. It can change the content and it can log your IP address."
>
<template v-slot:append>
<q-btn
dense
flat
round
icon="upload"
@click="$refs.adminBackgroundImageInput.click()"
>
<q-tooltip>Upload background image</q-tooltip>
</q-btn>
</template>
</q-input>
<input
type="file"
ref="adminBackgroundImageInput"
accept="image/*"
style="display: none"
@change="onBackgroundImageInput"
/>
</div>
</div>
<div class="row q-col-gutter-md q-mb-md">
@@ -12,7 +12,7 @@
>
<qrcode-vue
ref="qrCode"
:value="value"
:value="optimizedValue"
:margin="margin"
:size="size"
level="Q"
+136 -70
View File
@@ -492,10 +492,28 @@
siteCustomisationChanged({bgimageChoice: $event})
"
>
<template v-slot:append>
<q-btn
dense
flat
round
icon="upload"
@click="$refs.backgroundImageInput.click()"
>
<q-tooltip>Upload background image</q-tooltip>
</q-btn>
</template>
<q-tooltip
><span v-text="$t('background_image')"></span
></q-tooltip>
</q-input>
<input
type="file"
ref="backgroundImageInput"
accept="image/*"
style="display: none"
@change="onBackgroundImageInput"
/>
</div>
</div>
<div class="row q-mb-md">
@@ -1108,82 +1126,130 @@
<q-separator></q-separator>
<q-card-section>
<q-btn-dropdown
color="grey"
dense
outline
no-caps
:label="props.row.name"
:icon="props.row.is_public ? 'public' : ''"
<div
class="row items-center no-wrap q-col-gutter-sm"
>
<q-list>
<q-item
clickable
v-close-popup
@click="copyAssetLinkToClipboard(props.row)"
<div class="col">
<q-btn-dropdown
color="grey"
dense
outline
no-caps
class="full-width"
:label="props.row.name"
:icon="props.row.is_public ? 'public' : ''"
>
<q-item-section avatar>
<q-avatar icon="content_copy" />
</q-item-section>
<q-item-section>
<q-item-label>Copy Link</q-item-label>
<q-item-label caption
>Copy asset link to
clipboard</q-item-label
>
</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="toggleAssetPublicAccess(props.row)"
>
<q-item-section avatar>
<q-avatar
:icon="
props.row.is_public
? 'public_off'
: 'public'
<q-list>
<q-item
clickable
v-close-popup
@click="
copyAssetLinkToClipboard(props.row)
"
text-color="primary"
/>
</q-item-section>
<q-item-section v-if="props.row.is_public">
<q-item-label>Unpublish</q-item-label>
<q-item-label caption
>Make this asset private</q-item-label
>
</q-item-section>
<q-item-section v-else>
<q-item-label>Publish</q-item-label>
<q-item-label caption
>Make this asset public</q-item-label
>
</q-item-section>
</q-item>
<q-item-section avatar>
<q-avatar icon="content_copy" />
</q-item-section>
<q-item-section>
<q-item-label>Copy Link</q-item-label>
<q-item-label caption
>Copy asset link to
clipboard</q-item-label
>
</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="deleteAsset(props.row)"
>
<q-item-section avatar>
<q-avatar
icon="delete"
text-color="negative"
/>
</q-item-section>
<q-item-section>
<q-item-label>Delete</q-item-label>
<q-item-label caption
>Permanently delete this
asset</q-item-label
<q-item
clickable
v-close-popup
@click="
toggleAssetPublicAccess(props.row)
"
>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<q-item-section avatar>
<q-avatar
:icon="
props.row.is_public
? 'public_off'
: 'public'
"
text-color="primary"
/>
</q-item-section>
<q-item-section
v-if="props.row.is_public"
>
<q-item-label>Unpublish</q-item-label>
<q-item-label caption
>Make this asset
private</q-item-label
>
</q-item-section>
<q-item-section v-else>
<q-item-label>Publish</q-item-label>
<q-item-label caption
>Make this asset
public</q-item-label
>
</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="deleteAsset(props.row)"
>
<q-item-section avatar>
<q-avatar
icon="delete"
text-color="negative"
/>
</q-item-section>
<q-item-section>
<q-item-label>Delete</q-item-label>
<q-item-label caption
>Permanently delete this
asset</q-item-label
>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</div>
<div class="col-auto">
<q-btn
type="a"
target="_blank"
rel="noopener noreferrer"
color="primary"
dense
flat
round
icon="image"
:href="`/api/v1/assets/${props.row.id}/data`"
>
<q-tooltip>Full image</q-tooltip>
</q-btn>
</div>
<div
class="col-auto"
v-if="props.row.thumbnail_base64"
>
<q-btn
type="a"
target="_blank"
rel="noopener noreferrer"
color="secondary"
dense
flat
round
icon="photo_size_select_small"
:href="`/api/v1/assets/${props.row.id}/thumbnail`"
>
<q-tooltip>Thumbnail</q-tooltip>
</q-btn>
</div>
</div>
</q-card-section>
</q-card>
</div>
+5 -1
View File
@@ -15,7 +15,11 @@ from lnbits.settings import settings
def log_server_info():
logger.info("LNbits Info")
if settings.first_install:
logger.success("This is a fresh install of LNbits.")
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.")
if settings.first_install_token:
logger.success(
f"FIRST_INSTALL_TOKEN: `{settings.first_install_token}`. "
+52 -2
View File
@@ -4,9 +4,11 @@ import hashlib
import json
import urllib.parse
from collections.abc import AsyncGenerator
from pathlib import Path
from typing import Any
import httpx
from embit.bip39 import mnemonic_is_valid
from httpx import RequestError, TimeoutException
from loguru import logger
from websockets import connect
@@ -61,6 +63,8 @@ class PhoenixdWallet(Wallet):
}
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
self._seed_mnemonic_to_persist: str | None = None
self._load_mnemonic_from_seed_file()
async def cleanup(self):
try:
@@ -69,6 +73,7 @@ class PhoenixdWallet(Wallet):
logger.warning(f"Error closing wallet connection: {e}")
async def status(self) -> StatusResponse:
await self._persist_loaded_mnemonic()
try:
r = await self.client.get("/getinfo", timeout=10)
r.raise_for_status()
@@ -101,7 +106,6 @@ class PhoenixdWallet(Wallet):
unhashed_description: bytes | None = None,
**kwargs,
) -> InvoiceResponse:
try:
msats_amount = amount
data: dict[str, Any] = {
@@ -309,7 +313,7 @@ class PhoenixdWallet(Wallet):
and message_json.get("type") == "payment_received"
):
logger.info(
f'payment-received: {message_json["paymentHash"]}'
f"payment-received: {message_json['paymentHash']}"
)
yield message_json["paymentHash"]
@@ -319,3 +323,49 @@ class PhoenixdWallet(Wallet):
"retrying in 5 seconds"
)
await asyncio.sleep(5)
def _load_mnemonic_from_seed_file(self):
data_dir = settings.phoenixd_data_dir
if not data_dir:
return
seed_path = Path(data_dir).expanduser() / "seed.dat"
if not seed_path.is_file():
return
try:
mnemonic = seed_path.read_text(encoding="utf-8").strip()
if mnemonic == settings.phoenixd_mnemonic:
return
except OSError as exc:
logger.warning(f"Failed to read Phoenixd seed file '{seed_path}': {exc}")
return
if not mnemonic:
logger.warning(f"Phoenixd seed file '{seed_path}' is empty.")
return
if not mnemonic_is_valid(mnemonic):
logger.warning(
f"Phoenixd seed file '{seed_path}' does not contain a valid "
"BIP39 mnemonic."
)
return
settings.phoenixd_mnemonic = mnemonic
self._seed_mnemonic_to_persist = mnemonic
async def _persist_loaded_mnemonic(self):
if not self._seed_mnemonic_to_persist:
return
logger.info("Updating 'PHOENIXD_MNEMONIC' mnemonic settings.")
try:
from lnbits.core.crud.settings import set_settings_field
await set_settings_field(
"phoenixd_mnemonic", self._seed_mnemonic_to_persist
)
self._seed_mnemonic_to_persist = None
except Exception as exc:
logger.warning(f"Failed to persist Phoenixd mnemonic: {exc}")
+15 -11
View File
@@ -6,7 +6,7 @@
"": {
"name": "lnbits",
"dependencies": {
"axios": "^1.13.5",
"axios": "^1.15.0",
"chart.js": "^4.5.1",
"moment": "^2.30.1",
"nostr-tools": "^2.18.2",
@@ -734,14 +734,14 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
"proxy-from-env": "^2.1.0"
}
},
"node_modules/balanced-match": {
@@ -1498,9 +1498,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1555,9 +1555,13 @@
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/pyright": {
"version": "1.1.289",
+1 -1
View File
@@ -21,7 +21,7 @@
"terser": "^5.44.1"
},
"dependencies": {
"axios": "^1.13.5",
"axios": "^1.15.0",
"chart.js": "^4.5.1",
"moment": "^2.30.1",
"nostr-tools": "^2.18.2",
Generated
+84 -2
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.4.0 and should not be changed by hand.
[[package]]
name = "aiohappyeyeballs"
@@ -3388,6 +3388,88 @@ files = [
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pyinstrument"
version = "5.1.2"
description = "Call stack profiler for Python. Shows you why your code is slow!"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "pyinstrument-5.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f224fe80ba288a00980af298d3808219f9d246fd95b4f91729c9c33a0dc54fe6"},
{file = "pyinstrument-5.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7df09fc0d5b72daf48b73cdf07738761bff7f656c81aff686b3ccdd7d2abe236"},
{file = "pyinstrument-5.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75a7e17377d4405666bbaf126b1fd7bbb7e206d7246e6db3d62864d3d4790ae3"},
{file = "pyinstrument-5.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5381cc6583d26e04d9298acded4242f4fe71986f1472c8aee6992c6816f0cac5"},
{file = "pyinstrument-5.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ec08a530bef8d3492d31d8b0b12d0cfde09539f2a1c4b9678662ebc3c843e478"},
{file = "pyinstrument-5.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d671168508129b472be570bc9aee361190ba917b997c703bd134bb4de445ce7"},
{file = "pyinstrument-5.1.2-cp310-cp310-win32.whl", hash = "sha256:5957a94f84564b374a7f856d1b322345d600964280b0d687b8ddcc483f21e576"},
{file = "pyinstrument-5.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:38a2180a7801c51610b50e5d423674b21872efd019ccf05a11b7f9016cb1dcfc"},
{file = "pyinstrument-5.1.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3739a05583ea6312c385eb59fe985cd20d9048e95f9eeeb6a2f6c35202e2d36e"},
{file = "pyinstrument-5.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c9ee05dc75ac5fb18498c311e624f77f7f321f7ff325b251aa09e52e46f1d6a"},
{file = "pyinstrument-5.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a49a55ca5b75218767e29cacbe515d0b66fc18cb48a937bca0f77b8dafc7202"},
{file = "pyinstrument-5.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c45c14974ff04b1bfdc6c2a448627c6da7409c7800d0eb7bd03fb435dcb41d7"},
{file = "pyinstrument-5.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:22b9c04b3982c41c04b1c5ed05d1bc3a2ba26533450084058119f6dc160e70a3"},
{file = "pyinstrument-5.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5c4995ee0774801790c138f0dfec17d4e7a7ef09a6d56d53cbcbf0578a711021"},
{file = "pyinstrument-5.1.2-cp311-cp311-win32.whl", hash = "sha256:fe449e4a8ee60a2a27cf509350a584670f4c3704649601be7937598f09dbe7ca"},
{file = "pyinstrument-5.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:3fb839429671a42bf349335af4c1ce5cf83386ac11f04df0bc40720d4cb7d77d"},
{file = "pyinstrument-5.1.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2519865d4bf58936f2506c1c46a82d29a20f3239aa50c941df1ca9618c7da5f0"},
{file = "pyinstrument-5.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:059442106b8b5de29ae5ac1bdc20d044fed4da534b8caba434b6ffb119037bf5"},
{file = "pyinstrument-5.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd51f2d54fc39a4cfd73ba6be27cd0187123132ce3f445b639bff5e1b23d7e26"},
{file = "pyinstrument-5.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12af1e83795b6c640d657d339014dd1ff718b182dec736d7d1f1d8a97534eb53"},
{file = "pyinstrument-5.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2565513658e742c5eb691a779cb29d19d01bc9ee951d0eb76482e9f343c38c2e"},
{file = "pyinstrument-5.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5afd0ba788a1d112da49fb77966918e01df1f9e7d62e72894d82f7acb0996c2d"},
{file = "pyinstrument-5.1.2-cp312-cp312-win32.whl", hash = "sha256:554077b031b278593cb2301f0057be771ea62a729878c69aaf29fcdfb7b71281"},
{file = "pyinstrument-5.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:55a905384ba43efc924b8863aa6cfd276f029e4aa70c4a0e3b7389e27b191e45"},
{file = "pyinstrument-5.1.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7b8bab2334bf1d4c9e92d61db574300b914b594588a6b6dd67c45450152dfc29"},
{file = "pyinstrument-5.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:13dcc138a61298ef4994b7aebff509d2c06db89dfd6e2021f0b9cd96aaa44ec3"},
{file = "pyinstrument-5.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8abd4a7ffa2e7f9e00039a5e549e8eebc80d7ca8d43f0fb51a50ff2b117ce4a"},
{file = "pyinstrument-5.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb3a05108edebc30f31e2c69c904576042f1158b2513ab80adc08f7848a7a8f0"},
{file = "pyinstrument-5.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f70d588b53f3f35829d1d1ddfa05e07fcebf1434b3b1509d542ca317d8e9a2a5"},
{file = "pyinstrument-5.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b007327e0d6a6a01d5064883dd27c19996f044ce7488d507826fee7884e6a32e"},
{file = "pyinstrument-5.1.2-cp313-cp313-win32.whl", hash = "sha256:9ba0e6b17a7e86c3dc02d208e4c25506e8f914d9964ae89449f1f37f0b70abc0"},
{file = "pyinstrument-5.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:660d7fc486a839814db0b2f716bc13d8b99b9c780aaeb47f74a70a34adc02a7b"},
{file = "pyinstrument-5.1.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0baed297beee2bb9897e737bbd89e3b9d45a2fbbea9f1ad4e809007d780a9b1e"},
{file = "pyinstrument-5.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ebb910a32a45bde6c3fc30c578efc28a54517990e11e94b5e48a0d5479728568"},
{file = "pyinstrument-5.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bad403c157f9c6dba7f731a6fca5bfcd8ca2701a39bcc717dcc6e0b10055ffc4"},
{file = "pyinstrument-5.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f456cabdb95fd343c798a7f2a56688b028f981522e283c5f59bd59195b66df5"},
{file = "pyinstrument-5.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4e9c4dcc1f2c4a0cd6b576e3604abc37496a7868243c9a1443ad3b9db69d590f"},
{file = "pyinstrument-5.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:acf93b128328c6d80fdb85431068ac17508f0f7845e89505b0ea6130dead5ca6"},
{file = "pyinstrument-5.1.2-cp314-cp314-win32.whl", hash = "sha256:9c7f0167903ecff8b1d744f7e37b2bd4918e05a69cca724cb112f5ed59d1e41b"},
{file = "pyinstrument-5.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:ce3f6b1f9a2b5d74819ecc07d631eadececf915f551474a75ad65ac580ec5a0e"},
{file = "pyinstrument-5.1.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:af8651b239049accbeecd389d35823233f649446f76f47fd005316b05d08cef2"},
{file = "pyinstrument-5.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c6082f1c3e43e1d22834e91ba8975f0080186df4018a04b4dd29f9623c59df1d"},
{file = "pyinstrument-5.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c031eb066ddc16425e1e2f56aad5c1ce1e27b2432a70329e5385b85e812decee"},
{file = "pyinstrument-5.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f447ec391cad30667ba412dce41607aaa20d4a2496a7ab867e0c199f0fe3ae3d"},
{file = "pyinstrument-5.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:50299bddfc1fe0039898f895b10ef12f9db08acffb4d85326fad589cda24d2ee"},
{file = "pyinstrument-5.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a193ff08825ece115ececa136832acb14c491c77ab1e6b6a361905df8753d5c6"},
{file = "pyinstrument-5.1.2-cp314-cp314t-win32.whl", hash = "sha256:de887ba19e1057bd2d86e6584f17788516a890ae6fe1b7eed9927873f416b4d8"},
{file = "pyinstrument-5.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:b6a71f5e7f53c86c9b476b30cf19509463a63581ef17ddbd8680fee37ae509db"},
{file = "pyinstrument-5.1.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:47f14f248108f1202d48f34903bddc053a47c62ce46908aee848c1f667a1925b"},
{file = "pyinstrument-5.1.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cc3f2688981af764fa2c5f5a00d7040c0c12771ca5a026730f2d826f7a28d277"},
{file = "pyinstrument-5.1.2-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7afb24d11d24fb1762059240ed97a1a4607779d5f221f91d62b67ae089bd506d"},
{file = "pyinstrument-5.1.2-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d21221a29718d5dcdc1453dbbbdd100734525672ef1e34ff03d49bc5d688ca4"},
{file = "pyinstrument-5.1.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:3b68aa9f0d5217c67370762c99a80750ce56f66e5e9281c31b5baa3e4b88894d"},
{file = "pyinstrument-5.1.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:755702f01800934c3bec3c0d30483f97b6ff1fc1d2afcf6d816584703c9f1235"},
{file = "pyinstrument-5.1.2-cp38-cp38-win32.whl", hash = "sha256:2bacb980c95d4c9ea6a253e5ccf3e99993082de29ff7e7fc397e9484355577b1"},
{file = "pyinstrument-5.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:2588bc34c25d50f29d3a117c7dfac06513ee59c507b65081e66ca9569bcf45e0"},
{file = "pyinstrument-5.1.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bea0687665c181c6e62677fb560739a473c4286816582a43f8eb0aa6094ed529"},
{file = "pyinstrument-5.1.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:64246d2bd475870b62ed5df4808bfb33328135e8dfbdff823f9cb7d1358eb40b"},
{file = "pyinstrument-5.1.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff6fd3c7907e57f082cdd405bec1b34768c1810a165538299d259ee1bff5d7b6"},
{file = "pyinstrument-5.1.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:af09af38ee8407ca273407e24a8e6470d2444561d01005ee6a8db5f2fd908c08"},
{file = "pyinstrument-5.1.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1249d2799cdd57151b4444167e5c7736e2c9b5e79bd781b0779d631338509553"},
{file = "pyinstrument-5.1.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2d653206f50260f20bc78339c3d7aa0f19f8cf9c9f71939fbf02e2ea30353487"},
{file = "pyinstrument-5.1.2-cp39-cp39-win32.whl", hash = "sha256:d0b0c6e289725f14d0ff73f8190c953bdcb98f21c5c29c3eafb0dca8025583cb"},
{file = "pyinstrument-5.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:db8243e602aca43dc7ce8e40ed7d0ca4820d024c3c03824870c5a9e98f84e953"},
{file = "pyinstrument-5.1.2.tar.gz", hash = "sha256:af149d672da9493fa37334a1cc68f7b80c3e6cb9fd99b9e426c447db5c650bf0"},
]
[package.extras]
bin = ["click", "nox"]
docs = ["furo (==2024.7.18)", "myst-parser (==3.0.1)", "sphinx (==7.4.7)", "sphinx-autobuild (==2024.4.16)", "sphinxcontrib-programoutput (==0.17)"]
examples = ["django", "litestar", "numpy"]
test = ["cffi (>=1.17.0)", "flaky", "greenlet (>=3)", "ipython", "pytest", "pytest-asyncio (==0.23.8)", "trio"]
types = ["typing_extensions"]
[[package]]
name = "pyjwt"
version = "2.12.1"
@@ -5028,4 +5110,4 @@ migration = ["psycopg2-binary"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<3.13"
content-hash = "42751c05e1fa8250ff90981def5e0ce8d1b284eeb2acc2c7ac9ae5847cf99e17"
content-hash = "0879a6230bbc0d0e1d8d7d090e1572ba863078b18e0d78478baf2100aa245e08"
+3 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "lnbits"
version = "1.5.2"
version = "1.5.4"
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" }]
@@ -82,7 +82,8 @@ dev = [
"pytest-mock~=3.15.1",
"types-mock~=5.2.0.20250924",
"mock~=5.2.0",
"grpcio-tools~=1.76.0"
"grpcio-tools~=1.76.0",
"pyinstrument>=5.1.2",
]
[tool.poetry]
+109
View File
@@ -1,6 +1,9 @@
from pathlib import Path
import pytest
from httpx import AsyncClient
from lnbits.server import server_restart
from lnbits.settings import Settings
@@ -49,3 +52,109 @@ async def test_admin_update_noneditable_settings(
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 400
@pytest.mark.anyio
async def test_admin_audit_monitor_and_test_email(
client: AsyncClient, superuser_token: str, mocker
):
mocker.patch(
"lnbits.core.views.admin_api.get_balance_delta",
mocker.AsyncMock(
return_value={"lnbits_balance_sats": 21, "node_balance_sats": 13}
),
)
mocker.patch(
"lnbits.core.views.admin_api.send_email_notification",
mocker.AsyncMock(return_value={"status": "queued"}),
)
audit = await client.get(
"/admin/api/v1/audit",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert audit.status_code == 200
assert audit.json()["lnbits_balance_sats"] == 21
monitor = await client.get(
"/admin/api/v1/monitor",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert monitor.status_code == 200
assert "invoice_listeners" in monitor.json()
test_email = await client.get(
"/admin/api/v1/testemail",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert test_email.status_code == 200
assert test_email.json()["status"] == "queued"
@pytest.mark.anyio
async def test_admin_partial_reset_restart_and_backup(
client: AsyncClient,
superuser_token: str,
settings: Settings,
tmp_path,
):
response = await client.patch(
"/admin/api/v1/settings",
json={"lnbits_site_title": "PATCHED TITLE"},
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 200
assert response.json()["status"] == "Success"
default_value = await client.get(
"/admin/api/v1/settings/default",
params={"field_name": "lnbits_site_title"},
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert default_value.status_code == 200
assert "default_value" in default_value.json()
backup_path = Path("lnbits-backup.zip")
original_data_folder = settings.lnbits_data_folder
try:
data_folder = tmp_path / "backup_data"
data_folder.mkdir(parents=True, exist_ok=True)
(data_folder / "sample.txt").write_text("backup me")
settings.lnbits_data_folder = str(data_folder)
backup = await client.get(
"/admin/api/v1/backup",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert backup.status_code == 200
assert backup.headers["content-type"] == "application/zip"
assert backup.content.startswith(b"PK")
assert backup_path.is_file()
finally:
settings.lnbits_data_folder = original_data_folder
backup_path.unlink(missing_ok=True)
server_restart.clear()
restart = await client.get(
"/admin/api/v1/restart",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert restart.status_code == 200
assert restart.json()["status"] == "Success"
assert server_restart.is_set() is True
server_restart.clear()
@pytest.mark.anyio
async def test_admin_delete_settings_requires_superuser(
client: AsyncClient, superuser_token: str
):
server_restart.clear()
response = await client.delete(
"/admin/api/v1/settings",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 200
assert server_restart.is_set() is True
server_restart.clear()
+2 -2
View File
@@ -507,9 +507,9 @@ async def test_api_payment_without_key(invoice: Payment):
# check api_payment() internal function call (NOT API): payment status
@pytest.mark.anyio
async def test_api_payment_with_key(invoice: Payment, inkey_headers_from):
async def test_api_payment_with_key(invoice: Payment, inkey_headers_to):
# check the payment status
response = await api_payment(invoice.payment_hash, inkey_headers_from["X-Api-Key"])
response = await api_payment(invoice.payment_hash, inkey_headers_to["X-Api-Key"])
assert isinstance(response, dict)
assert response["paid"] is True
assert "details" in response
+135
View File
@@ -0,0 +1,135 @@
from uuid import uuid4
import pytest
from httpx import AsyncClient
from lnbits.core.crud.assets import get_user_asset
from lnbits.core.services.assets import create_user_asset
from tests.helpers import get_png_bytes, get_user_token_headers, make_upload_file
@pytest.mark.anyio
async def test_asset_api_upload_list_update_and_delete(
client: AsyncClient,
user_headers_from: dict[str, str],
):
upload = await client.post(
"/api/v1/assets?public_asset=false",
headers={"Authorization": user_headers_from["Authorization"]},
files={"file": ("note.txt", b"hello world", "text/plain")},
)
assert upload.status_code == 200
asset = upload.json()
assert asset["name"] == "note.txt"
assert asset["is_public"] is False
page = await client.get("/api/v1/assets/paginated", headers=user_headers_from)
assert page.status_code == 200
assert any(item["id"] == asset["id"] for item in page.json()["data"])
info = await client.get(f"/api/v1/assets/{asset['id']}", headers=user_headers_from)
assert info.status_code == 200
assert info.json()["name"] == "note.txt"
data = await client.get(
f"/api/v1/assets/{asset['id']}/data", headers=user_headers_from
)
assert data.status_code == 200
assert data.content == b"hello world"
assert data.headers["content-disposition"] == 'inline; filename="note.txt"'
updated = await client.put(
f"/api/v1/assets/{asset['id']}",
headers=user_headers_from,
json={"name": "renamed.txt", "is_public": True},
)
assert updated.status_code == 200
assert updated.json()["name"] == "renamed.txt"
assert updated.json()["is_public"] is True
public_data = await client.get(f"/api/v1/assets/{asset['id']}/data")
assert public_data.status_code == 200
assert public_data.content == b"hello world"
deleted = await client.delete(
f"/api/v1/assets/{asset['id']}", headers=user_headers_from
)
assert deleted.status_code == 200
assert deleted.json()["success"] is True
missing = await client.get(
f"/api/v1/assets/{asset['id']}", headers=user_headers_from
)
assert missing.status_code == 404
@pytest.mark.anyio
async def test_asset_api_enforces_visibility_and_supports_admin_updates(
client: AsyncClient,
from_user,
to_user,
superuser_token: str,
):
private_asset = await create_user_asset(
from_user.id,
make_upload_file(
get_png_bytes(),
filename=f"private_{uuid4().hex[:8]}.png",
content_type="image/png",
),
is_public=False,
)
other_user_headers = await get_user_token_headers(client, to_user.id)
anonymous = await client.get(f"/api/v1/assets/{private_asset.id}/data")
assert anonymous.status_code == 404
wrong_user = await client.get(
f"/api/v1/assets/{private_asset.id}/data", headers=other_user_headers
)
assert wrong_user.status_code == 404
admin_updated = await client.put(
f"/api/v1/assets/{private_asset.id}",
headers={"Authorization": f"Bearer {superuser_token}"},
json={"is_public": True, "name": "admin-visible.png"},
)
assert admin_updated.status_code == 200
assert admin_updated.json()["is_public"] is True
assert admin_updated.json()["name"] == "admin-visible.png"
thumbnail = await client.get(f"/api/v1/assets/{private_asset.id}/thumbnail")
assert thumbnail.status_code == 200
assert thumbnail.content
assert thumbnail.headers["content-type"] == "image/png"
@pytest.mark.anyio
async def test_asset_api_validates_uploads_and_missing_assets(
client: AsyncClient,
user_headers_from: dict[str, str],
):
invalid = await client.post(
"/api/v1/assets",
headers={"Authorization": user_headers_from["Authorization"]},
files={"file": ("payload.exe", b"boom", "application/x-msdownload")},
)
assert invalid.status_code == 400
assert "not allowed" in invalid.json()["detail"]
missing = await client.delete(
f"/api/v1/assets/{uuid4().hex}",
headers=user_headers_from,
)
assert missing.status_code == 404
missing_thumb = await client.get(f"/api/v1/assets/{uuid4().hex}/thumbnail")
assert missing_thumb.status_code == 404
stored = await create_user_asset(
"missing-user-check",
make_upload_file(b"content", filename="content.txt", content_type="text/plain"),
is_public=True,
)
fetched = await get_user_asset("missing-user-check", stored.id)
assert fetched is not None
+64
View File
@@ -0,0 +1,64 @@
from datetime import datetime, timezone
from uuid import uuid4
import pytest
from httpx import AsyncClient
from lnbits.core.crud.audit import create_audit_entry
from lnbits.core.models import AuditEntry
@pytest.mark.anyio
async def test_audit_api_requires_admin(client: AsyncClient, user_headers_from):
response = await client.get("/audit/api/v1", headers=user_headers_from)
assert response.status_code == 403
@pytest.mark.anyio
async def test_audit_api_returns_entries_and_stats(
client: AsyncClient,
superuser_token: str,
):
component = f"audit_component_{uuid4().hex[:8]}"
await create_audit_entry(
AuditEntry(
component=component,
ip_address="127.0.0.1",
user_id=uuid4().hex,
path="/api/v1/test",
request_method="GET",
response_code="200",
duration=0.12,
created_at=datetime.now(timezone.utc),
)
)
await create_audit_entry(
AuditEntry(
component=component,
ip_address="127.0.0.2",
user_id=uuid4().hex,
path="/api/v1/test",
request_method="POST",
response_code="400",
duration=2.5,
created_at=datetime.now(timezone.utc),
)
)
headers = {"Authorization": f"Bearer {superuser_token}"}
page = await client.get(f"/audit/api/v1?component={component}", headers=headers)
assert page.status_code == 200
page_data = page.json()
assert page_data["total"] == 2
assert {item["request_method"] for item in page_data["data"]} == {"GET", "POST"}
stats = await client.get(
f"/audit/api/v1/stats?component={component}",
headers=headers,
)
assert stats.status_code == 200
payload = stats.json()
assert {item["field"] for item in payload["request_method"]} == {"GET", "POST"}
assert {item["field"] for item in payload["response_code"]} == {"200", "400"}
assert payload["component"][0]["field"] == component
assert payload["long_duration"][0]["field"] == "/api/v1/test"
+203
View File
@@ -0,0 +1,203 @@
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"]
assert "cookie_access_token=" in success.headers["set-cookie"]
assert "Secure" not in success.headers["set-cookie"]
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
@pytest.mark.anyio
async def test_auth_api_first_install_uses_secure_cookie_when_enabled(
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_auth_https_only = settings.auth_https_only
new_username = f"secure_{uuid4().hex[:8]}"
try:
settings.first_install = True
settings.auth_https_only = True
success = await http_client.put(
"/api/v1/auth/first_install",
json={
"username": new_username,
"password": "secret1234",
"password_repeat": "secret1234",
},
)
assert success.status_code == 200
assert "cookie_access_token=" in success.headers["set-cookie"]
assert "Secure" in success.headers["set-cookie"]
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.auth_https_only = original_auth_https_only
+184
View File
@@ -0,0 +1,184 @@
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_approved",
"event_type": "CHECKOUT.ORDER.APPROVED",
"resource": {
"purchase_units": [{"invoice_id": payment.payment_hash}],
},
}
)
await handle_paypal_event(
{
"id": "evt_paypal",
"event_type": "PAYMENT.CAPTURE.COMPLETED",
"resource": {
"purchase_units": [{"invoice_id": payment.payment_hash}],
},
}
)
await handle_stripe_event({"id": "evt_unhandled", "type": "customer.created"})
assert fiat_status_mock.await_count == 2
@pytest.mark.anyio
async def test_callback_api_handles_subscription_flows_and_validation(mocker):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
payment = await create_wallet_invoice(
wallet.id, CreateInvoice(out=False, amount=15, memo="subscription")
)
create_fiat_invoice_mock = mocker.patch(
"lnbits.core.views.callback_api.create_fiat_invoice",
mocker.AsyncMock(return_value=payment),
)
fiat_status_mock = mocker.patch(
"lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock()
)
await handle_stripe_event(
{
"id": "evt_invoice_paid",
"type": "invoice.paid",
"data": {
"object": {
"id": "invoice_1",
"currency": "usd",
"amount_paid": 500,
"hosted_invoice_url": "https://stripe.example/invoice",
"customer_email": "alice@example.com",
"lines": {"data": [{"description": "Gold Plan"}]},
"parent": {
"type": "subscription_details",
"subscription_details": {
"metadata": {
"alan_action": "subscription",
"wallet_id": wallet.id,
"tag": "gold",
"memo": "Monthly Gold",
"extra": json.dumps({"plan": "gold"}),
}
},
},
}
},
}
)
create_fiat_invoice_mock.assert_awaited()
fiat_status_mock.assert_awaited()
await handle_paypal_event(
{
"id": "evt_sale_completed",
"event_type": "PAYMENT.SALE.COMPLETED",
"resource": {
"id": "sale_1",
"billing_agreement_id": "agreement_1",
"amount": {"currency": "USD", "total": "7.50"},
"custom_id": json.dumps(
[wallet.id, "vip", "subscription_1", "link-1", "VIP Plan"]
),
},
}
)
assert create_fiat_invoice_mock.await_count == 2
with pytest.raises(
ValueError, match="PayPal subscription event missing custom metadata."
):
await handle_paypal_event(
{
"id": "evt_bad_sale",
"event_type": "PAYMENT.SALE.COMPLETED",
"resource": {"amount": {"currency": "USD", "total": "5.00"}},
}
)
+430
View File
@@ -0,0 +1,430 @@
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi import HTTPException
from starlette.requests import Request
from lnbits.core.crud.db_versions import get_db_version, update_migration_version
from lnbits.core.crud.extensions import (
create_installed_extension,
get_installed_extension,
get_user_extension,
)
from lnbits.core.crud.users import get_account
from lnbits.core.crud.wallets import create_wallet
from lnbits.core.models import Account, CreateInvoice
from lnbits.core.models.extensions import (
CreateExtension,
CreateExtensionReview,
Extension,
ExtensionConfig,
ExtensionRelease,
InstallableExtension,
PayToEnableInfo,
ReleasePaymentInfo,
UserExtensionInfo,
)
from lnbits.core.models.users import AccountId
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.services.users import create_user_account
from lnbits.core.views.extension_api import (
api_activate_extension,
api_deactivate_extension,
api_disable_extension,
api_enable_extension,
api_extension_details,
api_get_user_extensions,
api_install_extension,
api_uninstall_extension,
api_update_pay_to_enable,
create_extension_review,
delete_extension_db,
extensions,
get_extension_release,
get_extension_releases,
get_extension_reviews,
get_extension_reviews_tags,
get_pay_to_enable_invoice,
get_pay_to_install_invoice,
)
from tests.helpers import make_extension_release, make_installable_extension
class _MockHTTPResponse:
def __init__(
self,
*,
json_data=None,
text: str = "",
status_code: int = 200,
is_error: bool = False,
):
self._json_data = json_data
self.text = text
self.status_code = status_code
self.is_error = is_error
def json(self):
return self._json_data
def raise_for_status(self):
if self.status_code >= 400:
raise ValueError(self.text or "request failed")
class _MockHTTPClient:
def __init__(self, responses: dict[str, _MockHTTPResponse]):
self.responses = responses
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return False
async def get(self, url: str):
return self.responses[url]
async def post(self, url: str, json=None):
return self.responses[url]
@pytest.mark.anyio
async def test_extension_api_install_details_and_release_endpoints(mocker):
ext_id = f"ext_{uuid4().hex[:8]}"
release = make_extension_release(ext_id)
create_data = CreateExtension(
ext_id=ext_id,
archive=release.archive,
source_repo=release.source_repo,
version=release.version,
)
mocker.patch.object(
InstallableExtension,
"get_extension_release",
mocker.AsyncMock(return_value=release),
)
mocker.patch(
"lnbits.core.views.extension_api.install_extension",
mocker.AsyncMock(return_value=Extension(code=ext_id, is_valid=True)),
)
activate_mock = mocker.patch(
"lnbits.core.views.extension_api.activate_extension", mocker.AsyncMock()
)
installed = await api_install_extension(create_data)
assert installed.code == ext_id
activate_mock.assert_awaited_once()
mocker.patch.object(
InstallableExtension,
"get_extension_releases",
mocker.AsyncMock(return_value=[release]),
)
mocker.patch.object(
ExtensionRelease,
"fetch_release_details",
mocker.AsyncMock(return_value={"description": "Extension details"}),
)
details = await api_extension_details(ext_id, release.details_link or "")
assert details["description"] == "Extension details"
assert details["icon"] == release.icon
assert details["repo"] == release.repo
installed_ext = make_installable_extension(
ext_id,
payments=[
ReleasePaymentInfo(
amount=55,
pay_link=release.pay_link,
payment_hash=f"payment_{uuid4().hex[:8]}",
)
],
)
await create_installed_extension(installed_ext)
releases = await get_extension_releases(ext_id)
assert releases[0].paid_sats == 55
config = ExtensionConfig(
name=ext_id,
short_description="Config",
min_lnbits_version="0.1.0",
max_lnbits_version=None,
)
mocker.patch.object(
ExtensionConfig,
"fetch_github_release_config",
mocker.AsyncMock(return_value=config),
)
release_info = await get_extension_release("org", ext_id, "v1.0.0")
assert release_info["is_version_compatible"] is True
@pytest.mark.anyio
async def test_extension_api_pay_to_enable_and_catalog_views(mocker, admin_user):
regular_user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
admin_account = await get_account(admin_user.id)
assert admin_account is not None
admin_wallet = await create_wallet(
user_id=admin_account.id, wallet_name="extension sales"
)
ext_id = f"paid_{uuid4().hex[:8]}"
await create_installed_extension(
make_installable_extension(
ext_id,
pay_to_enable=PayToEnableInfo(
required=True, amount=10, wallet=admin_wallet.id
),
)
)
updated = await api_update_pay_to_enable(
ext_id,
PayToEnableInfo(required=True, amount=21, wallet=admin_wallet.id),
account=admin_account,
)
assert updated.success is True
stored_extension = await get_installed_extension(ext_id)
assert stored_extension is not None
assert stored_extension.meta is not None
assert stored_extension.meta.pay_to_enable is not None
assert stored_extension.meta.pay_to_enable.amount == 21
enable_invoice = await create_wallet_invoice(
admin_wallet.id, CreateInvoice(out=False, amount=21, memo="enable extension")
)
mocker.patch(
"lnbits.core.views.extension_api.create_invoice",
mocker.AsyncMock(return_value=enable_invoice),
)
invoice_response = await get_pay_to_enable_invoice(
ext_id,
PayToEnableInfo(amount=21),
account_id=AccountId(id=regular_user.id),
)
assert invoice_response["payment_hash"] == enable_invoice.payment_hash
user_ext = await get_user_extension(regular_user.id, ext_id)
assert user_ext is not None
assert user_ext.extra is not None
assert user_ext.extra.payment_hash_to_enable == enable_invoice.payment_hash
mocker.patch(
"lnbits.core.views.extension_api.get_valid_extensions",
mocker.AsyncMock(return_value=[Extension(code=ext_id, is_valid=True)]),
)
mocker.patch(
"lnbits.core.views.extension_api.check_transaction_status",
mocker.AsyncMock(return_value=SimpleNamespace(paid=True)),
)
enabled = await api_enable_extension(ext_id, AccountId(id=regular_user.id))
assert enabled.success is True
user_ext = await get_user_extension(regular_user.id, ext_id)
assert user_ext is not None
assert user_ext.active is True
assert user_ext.extra == UserExtensionInfo(
payment_hash_to_enable=enable_invoice.payment_hash,
paid_to_enable=True,
)
disabled = await api_disable_extension(ext_id, AccountId(id=regular_user.id))
assert disabled.success is True
disabled_again = await api_disable_extension(ext_id, AccountId(id=regular_user.id))
assert disabled_again.success is True
assert "already disabled" in disabled_again.message
mocker.patch(
"lnbits.core.views.extension_api.get_valid_extensions",
mocker.AsyncMock(
return_value=[
Extension(code=ext_id, is_valid=True, name="Paid Extension"),
Extension(code="other", is_valid=True),
]
),
)
visible_extensions = await api_get_user_extensions(AccountId(id=regular_user.id))
assert [ext.code for ext in visible_extensions] == [ext_id]
catalog_entry = make_installable_extension(
ext_id,
pay_to_enable=PayToEnableInfo(required=True, amount=21, wallet=admin_wallet.id),
)
mocker.patch.object(
InstallableExtension,
"get_installable_extensions",
mocker.AsyncMock(return_value=[catalog_entry]),
)
catalog = await extensions(AccountId(id=regular_user.id))
catalog_item = next(item for item in catalog if item["id"] == ext_id)
assert catalog_item["payToEnable"]["wallet"] is None
@pytest.mark.anyio
async def test_extension_api_activate_uninstall_install_invoice_and_cleanup(mocker):
base_ext = f"base_{uuid4().hex[:8]}"
dependent_ext = f"dependent_{uuid4().hex[:8]}"
uninstall_ext = f"uninstall_{uuid4().hex[:8]}"
db_ext = f"db_{uuid4().hex[:8]}"
await create_installed_extension(make_installable_extension(base_ext))
await create_installed_extension(
make_installable_extension(dependent_ext, dependencies=[base_ext])
)
await create_installed_extension(make_installable_extension(uninstall_ext))
mocker.patch(
"lnbits.core.views.extension_api.get_valid_extensions",
mocker.AsyncMock(
return_value=[
Extension(code=base_ext, is_valid=True, name="Base"),
Extension(code=dependent_ext, is_valid=True, name="Dependent"),
Extension(code=uninstall_ext, is_valid=True, name="Remove"),
]
),
)
with pytest.raises(HTTPException, match="depends on this one"):
await api_uninstall_extension(base_ext)
uninstall_mock = mocker.patch(
"lnbits.core.views.extension_api.uninstall_extension", mocker.AsyncMock()
)
uninstalled = await api_uninstall_extension(uninstall_ext)
assert uninstalled.success is True
uninstall_mock.assert_awaited_once_with(uninstall_ext)
mocker.patch(
"lnbits.core.views.extension_api.get_valid_extension",
mocker.AsyncMock(return_value=Extension(code=base_ext, is_valid=True)),
)
activate_mock = mocker.patch(
"lnbits.core.views.extension_api.activate_extension", mocker.AsyncMock()
)
deactivate_mock = mocker.patch(
"lnbits.core.views.extension_api.deactivate_extension", mocker.AsyncMock()
)
activated = await api_activate_extension(base_ext)
assert activated.success is True
deactivated = await api_deactivate_extension(base_ext)
assert deactivated.success is True
activate_mock.assert_awaited_once()
deactivate_mock.assert_awaited_once()
owner = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = owner.wallets[0]
install_invoice = await create_wallet_invoice(
wallet.id, CreateInvoice(out=False, amount=33, memo="install extension")
)
release = make_extension_release(base_ext, version="2.0.0")
payment_info = ReleasePaymentInfo(
amount=33,
pay_link=release.pay_link,
payment_hash=install_invoice.payment_hash,
payment_request=install_invoice.bolt11,
)
mocker.patch.object(
InstallableExtension,
"get_extension_release",
mocker.AsyncMock(return_value=release),
)
mocker.patch.object(
ExtensionRelease,
"fetch_release_payment_info",
mocker.AsyncMock(return_value=payment_info),
)
invoice = await get_pay_to_install_invoice(
base_ext,
CreateExtension(
ext_id=base_ext,
archive=release.archive,
source_repo=release.source_repo,
version=release.version,
cost_sats=33,
),
)
assert invoice.payment_hash == install_invoice.payment_hash
await update_migration_version(None, db_ext, 1)
drop_mock = mocker.patch(
"lnbits.core.views.extension_api.drop_extension_db", mocker.AsyncMock()
)
deleted = await delete_extension_db(db_ext)
assert deleted.success is True
drop_mock.assert_awaited_once_with(ext_id=db_ext)
assert await get_db_version(db_ext) is None
@pytest.mark.anyio
async def test_extension_api_review_endpoints(mocker):
ext_id = f"review_{uuid4().hex[:8]}"
reviews_base = "https://demo.lnbits.com/paidreviews/api/v1/AdFzLjzuKFLsdk4Bcnff6r"
tags_url = f"{reviews_base}/tags"
reviews_url = f"{reviews_base}/reviews/{ext_id}?offset=0&limit=5"
create_review_url = f"{reviews_base}/reviews"
request = Request(
{
"type": "http",
"method": "GET",
"path": f"/api/v1/extension/reviews/{ext_id}",
"query_string": b"offset=0&limit=5",
"headers": [],
}
)
mock_client = _MockHTTPClient(
{
tags_url: _MockHTTPResponse(
json_data=[{"tag": "good", "avg_rating": 900, "review_count": 3}]
),
reviews_url: _MockHTTPResponse(
json_data={
"data": [
{
"id": "1",
"name": "Alice",
"tag": "good",
"rating": 950,
"comment": "solid",
}
],
"total": 1,
}
),
create_review_url: _MockHTTPResponse(
json_data={
"payment_hash": f"hash_{uuid4().hex[:8]}",
"payment_request": "lnbc1review",
}
),
}
)
mocker.patch(
"lnbits.core.views.extension_api.httpx.AsyncClient", return_value=mock_client
)
tags = await get_extension_reviews_tags()
assert tags[0].tag == "good"
reviews = await get_extension_reviews(ext_id, request)
assert reviews.total == 1
assert reviews.data[0].comment == "solid"
payment_request = await create_extension_review(
CreateExtensionReview(tag=ext_id, name="Alice", rating=900, comment="Great")
)
assert payment_request.payment_hash.startswith("hash_")
+109
View File
@@ -0,0 +1,109 @@
from pathlib import Path
from uuid import uuid4
import pytest
from lnbits.core.crud.extensions import create_user_extension, get_user_extension
from lnbits.core.crud.users import get_account
from lnbits.core.models.extensions import (
Extension,
UserExtension,
)
from lnbits.core.models.users import AccountId
from lnbits.core.views.extensions_builder_api import (
api_build_extension,
api_delete_extension_builder_data,
api_deploy_extension,
api_preview_extension,
)
from lnbits.settings import Settings
from tests.helpers import make_extension_data, make_extension_release
@pytest.mark.anyio
async def test_extensions_builder_api_build_preview_and_cleanup(
tmp_path, settings: Settings, mocker, from_user
):
ext_id = f"builder_{uuid4().hex[:8]}"
data = make_extension_data(ext_id)
build_dir = tmp_path / "build"
build_dir.mkdir(parents=True, exist_ok=True)
(build_dir / "index.txt").write_text("hello")
original_data_folder = settings.lnbits_data_folder
build_mock = mocker.patch(
"lnbits.core.views.extensions_builder_api.build_extension_from_data",
mocker.AsyncMock(
return_value=(make_extension_release(ext_id, "0.1.0"), build_dir)
),
)
clean_mock = mocker.patch(
"lnbits.core.views.extensions_builder_api.clean_extension_builder_data"
)
try:
settings.lnbits_data_folder = str(tmp_path)
build_response = await api_build_extension(data)
assert Path(build_response.path).is_file()
assert build_response.filename == f"{ext_id}.zip"
preview = await api_preview_extension(data, AccountId(id=from_user.id))
assert preview.success is True
assert ext_id in preview.message
cleaned = await api_delete_extension_builder_data()
assert cleaned.success is True
clean_mock.assert_called_once()
assert build_mock.await_count == 2
finally:
settings.lnbits_data_folder = original_data_folder
@pytest.mark.anyio
async def test_extensions_builder_api_deploy_updates_user_extension(
tmp_path, settings: Settings, mocker, admin_user
):
ext_id = f"deploy_{uuid4().hex[:8]}"
data = make_extension_data(ext_id)
account = await get_account(admin_user.id)
assert account is not None
build_root = tmp_path / "deploy-root" / ext_id
build_root.mkdir(parents=True, exist_ok=True)
(build_root / "manifest.json").write_text("{}")
original_data_folder = settings.lnbits_data_folder
await create_user_extension(
UserExtension(user=account.id, extension=ext_id, active=False)
)
mocker.patch(
"lnbits.core.views.extensions_builder_api.build_extension_from_data",
mocker.AsyncMock(
return_value=(make_extension_release(ext_id, "0.1.0"), build_root)
),
)
install_mock = mocker.patch(
"lnbits.core.views.extensions_builder_api.install_extension",
mocker.AsyncMock(return_value=Extension(code=ext_id, is_valid=True)),
)
activate_mock = mocker.patch(
"lnbits.core.views.extensions_builder_api.activate_extension",
mocker.AsyncMock(),
)
try:
settings.lnbits_data_folder = str(tmp_path)
deployed = await api_deploy_extension(data, account=account)
finally:
settings.lnbits_data_folder = original_data_folder
assert deployed.success is True
assert ext_id in deployed.message
install_mock.assert_awaited_once()
activate_mock.assert_awaited_once()
user_ext = await get_user_extension(account.id, ext_id)
assert user_ext is not None
assert user_ext.active is True
+146
View File
@@ -0,0 +1,146 @@
import pytest
from httpx import AsyncClient
from pytest_mock.plugin import MockerFixture
from lnbits.core.models.misc import SimpleStatus
from lnbits.fiat.base import FiatSubscriptionResponse
class _UnsetSecret:
pass
UNSET_SECRET = _UnsetSecret()
class FakeStripeWallet:
def __init__(self, secret: str | None | _UnsetSecret = UNSET_SECRET):
self._secret = (
"connection-token" if isinstance(secret, _UnsetSecret) else secret
)
async def create_terminal_connection_token(self) -> dict[str, str]:
if self._secret is None:
return {}
return {"secret": self._secret}
@pytest.mark.anyio
async def test_fiat_api_test_provider_and_subscription_lifecycle(
client: AsyncClient,
superuser_token: str,
adminkey_headers_from: dict[str, str],
from_wallet,
mocker: MockerFixture,
):
test_connection = mocker.patch(
"lnbits.core.views.fiat_api.test_connection",
mocker.AsyncMock(return_value=SimpleStatus(success=True, message="ok")),
)
response = await client.put(
"/api/v1/fiat/check/stripe",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 200
assert response.json()["success"] is True
test_connection.assert_awaited_once_with("stripe")
provider = mocker.Mock()
provider.create_subscription = mocker.AsyncMock(
return_value=FiatSubscriptionResponse(
ok=True,
subscription_request_id="sub-1",
checkout_session_url="https://stripe.example/checkout",
)
)
provider.cancel_subscription = mocker.AsyncMock(
return_value=FiatSubscriptionResponse(ok=True, subscription_request_id="sub-1")
)
get_provider = mocker.patch(
"lnbits.core.views.fiat_api.get_fiat_provider",
mocker.AsyncMock(return_value=provider),
)
mismatch = await client.post(
"/api/v1/fiat/stripe/subscription",
headers=adminkey_headers_from,
json={
"subscription_id": "sub-1",
"quantity": 2,
"payment_options": {"wallet_id": "wrong-wallet"},
},
)
assert mismatch.status_code == 403
created = await client.post(
"/api/v1/fiat/stripe/subscription",
headers=adminkey_headers_from,
json={
"subscription_id": "sub-1",
"quantity": 2,
"payment_options": {"memo": "hello", "wallet_id": from_wallet.id},
},
)
assert created.status_code == 200
assert created.json()["checkout_session_url"] == "https://stripe.example/checkout"
provider.create_subscription.assert_awaited_once()
assert provider.create_subscription.await_args is not None
assert provider.create_subscription.await_args.args[2].wallet_id == from_wallet.id
cancelled = await client.delete(
"/api/v1/fiat/stripe/subscription/sub-1",
headers=adminkey_headers_from,
)
assert cancelled.status_code == 200
provider.cancel_subscription.assert_awaited_once_with("sub-1", from_wallet.id)
assert get_provider.await_count == 3
@pytest.mark.anyio
async def test_fiat_api_connection_token_validates_provider_configuration(
client: AsyncClient,
superuser_token: str,
mocker: MockerFixture,
):
headers = {"Authorization": f"Bearer {superuser_token}"}
not_found = mocker.patch(
"lnbits.core.views.fiat_api.get_fiat_provider",
mocker.AsyncMock(return_value=None),
)
missing = await client.post("/api/v1/fiat/stripe/connection_token", headers=headers)
assert missing.status_code == 404
assert not_found.await_count == 1
unsupported_provider = mocker.patch(
"lnbits.core.views.fiat_api.get_fiat_provider",
mocker.AsyncMock(return_value=object()),
)
unsupported = await client.post(
"/api/v1/fiat/paypal/connection_token", headers=headers
)
assert unsupported.status_code == 400
assert unsupported_provider.await_count == 1
mocker.patch("lnbits.core.views.fiat_api.StripeWallet", FakeStripeWallet)
bad_wallet = FakeStripeWallet(secret=None)
bad_provider = mocker.patch(
"lnbits.core.views.fiat_api.get_fiat_provider",
mocker.AsyncMock(return_value=bad_wallet),
)
no_secret = await client.post(
"/api/v1/fiat/stripe/connection_token", headers=headers
)
assert no_secret.status_code == 500
assert no_secret.json()["detail"] == "Failed to create connection token"
assert bad_provider.await_count == 1
good_wallet = FakeStripeWallet(secret="tok_live")
good_provider = mocker.patch(
"lnbits.core.views.fiat_api.get_fiat_provider",
mocker.AsyncMock(return_value=good_wallet),
)
ok = await client.post("/api/v1/fiat/stripe/connection_token", headers=headers)
assert ok.status_code == 200
assert ok.json() == {"secret": "tok_live"}
assert good_provider.await_count == 1
+142
View File
@@ -0,0 +1,142 @@
from uuid import uuid4
import pytest
from fastapi import HTTPException
from lnurl import (
LnAddress,
LnurlAuthResponse,
LnurlErrorResponse,
LnurlException,
LnurlPayActionResponse,
LnurlPayResponse,
LnurlResponseException,
)
from lnurl.models import MessageAction
from lnurl.types import CallbackUrl, LightningInvoice
from pydantic import parse_obj_as
from lnbits.core.models import Account, CreateInvoice
from lnbits.core.models.lnurl import CreateLnurlPayment, LnurlScan
from lnbits.core.models.wallets import KeyType, WalletTypeInfo
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.services.users import create_user_account
from lnbits.core.views.lnurl_api import (
api_lnurlscan,
api_lnurlscan_post,
api_payments_pay_lnurl,
api_perform_lnurlauth,
)
from tests.helpers import make_lnurl_pay_response
TEST_BOLT11 = (
"lnbc1pnsu5z3pp57getmdaxhg5kc9yh2a2qsh7cjf4gnccgkw0qenm8vsqv50w7s"
"ygqdqj0fjhymeqv9kk7atwwscqzzsxqyz5vqsp5e2yyqcp0a3ujeesp24ya0glej"
"srh703md8mrx0g2lyvjxy5w27ss9qxpqysgqyjreasng8a086kpkczv48er5c6l5"
"73aym6ynrdl9nkzqnag49vt3sjjn8qdfq5cr6ha0vrdz5c5r3v4aghndly0hplmv"
"6hjxepwp93cq398l3s"
)
@pytest.mark.anyio
async def test_lnurl_api_scan_routes_validate_and_forward(mocker):
pay_response = make_lnurl_pay_response()
mocker.patch(
"lnbits.core.views.lnurl_api.lnurl_handle",
mocker.AsyncMock(return_value=pay_response),
)
scanned = await api_lnurlscan("lnurl1example")
assert isinstance(scanned, LnurlPayResponse)
assert scanned.callback == pay_response.callback
scanned_post = await api_lnurlscan_post(
scan=LnurlScan(lnurl=LnAddress("alice@example.com"))
)
assert isinstance(scanned_post, LnurlPayResponse)
assert scanned_post.callback == pay_response.callback
mocker.patch(
"lnbits.core.views.lnurl_api.lnurl_handle",
mocker.AsyncMock(return_value=LnurlErrorResponse(reason="blocked callback")),
)
with pytest.raises(HTTPException, match="blocked callback"):
await api_lnurlscan("lnurl1blocked")
mocker.patch(
"lnbits.core.views.lnurl_api.lnurl_handle",
mocker.AsyncMock(side_effect=LnurlException("invalid lnurl")),
)
with pytest.raises(HTTPException, match="invalid lnurl"):
await api_lnurlscan("lnurl1invalid")
@pytest.mark.anyio
async def test_lnurl_api_auth_and_pay_flow(mocker):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
wallet_info = WalletTypeInfo(key_type=KeyType.admin, wallet=wallet)
pay_response = make_lnurl_pay_response()
payment = await create_wallet_invoice(
wallet.id, CreateInvoice(out=False, amount=21, memo="lnurl")
)
auth_response = LnurlAuthResponse(
callback=parse_obj_as(CallbackUrl, "https://example.com/auth"),
k1="k1-value",
)
mocker.patch(
"lnbits.core.views.lnurl_api.lnurlauth",
mocker.AsyncMock(return_value=auth_response),
)
authenticated = await api_perform_lnurlauth(auth_response, wallet_info)
assert isinstance(authenticated, LnurlAuthResponse)
assert authenticated.k1 == "k1-value"
mocker.patch(
"lnbits.core.views.lnurl_api.lnurlauth",
mocker.AsyncMock(side_effect=LnurlResponseException("denied")),
)
with pytest.raises(HTTPException, match="denied"):
await api_perform_lnurlauth(auth_response, wallet_info)
action_response = LnurlPayActionResponse(
pr=LightningInvoice(TEST_BOLT11),
disposable=False,
successAction=parse_obj_as(MessageAction, {"message": "paid"}),
)
fetch_mock = mocker.patch(
"lnbits.core.views.lnurl_api.fetch_lnurl_pay_request",
mocker.AsyncMock(return_value=(pay_response, action_response)),
)
pay_mock = mocker.patch(
"lnbits.core.views.lnurl_api.pay_invoice",
mocker.AsyncMock(return_value=payment),
)
paid = await api_payments_pay_lnurl(
CreateLnurlPayment(
res=pay_response, amount=2_000, unit="USD", comment="thanks"
),
wallet_info,
)
assert paid.payment_hash == payment.payment_hash
fetch_mock.assert_awaited_once()
pay_mock.assert_awaited_once()
assert pay_mock.await_args is not None
assert action_response.successAction is not None
assert pay_mock.await_args.kwargs["extra"] == {
"stored": True,
"success_action": action_response.successAction.json(),
"comment": "thanks",
"fiat_currency": "USD",
"fiat_amount": 2.0,
}
with pytest.raises(HTTPException, match="Missing LNURL or LnurlPayResponse data."):
await api_payments_pay_lnurl(CreateLnurlPayment(amount=1), wallet_info)
+366
View File
@@ -0,0 +1,366 @@
from collections.abc import Callable
from typing import Any, cast
from uuid import uuid4
import httpx
import pytest
from fastapi import HTTPException
from pytest_mock.plugin import MockerFixture
from lnbits.core.views import node_api
from lnbits.db import Filters, Page
from lnbits.nodes.base import (
ChannelBalance,
ChannelPoint,
ChannelState,
ChannelStats,
Node,
NodeChannel,
NodeFees,
NodeInfoResponse,
NodeInvoice,
NodePayment,
NodePeerInfo,
PublicNodeInfo,
)
from lnbits.settings import Settings
from lnbits.wallets.base import Feature
class FakeNode:
def __init__(self):
self.channel = NodeChannel(
id="chan-1",
short_id="123x1x0",
peer_id="peer-1",
name="Peer One",
color="#ffffff",
state=ChannelState.ACTIVE,
balance=ChannelBalance(local_msat=1000, remote_msat=2000, total_msat=3000),
point=ChannelPoint(funding_txid="ab" * 32, output_index=1),
fee_ppm=10,
fee_base_msat=1000,
)
self.peer = NodePeerInfo(id="peer-1", alias="Peer One", addresses=["127.0.0.1"])
self.info = NodeInfoResponse(
id="node-id",
backend_name="FakeNode",
alias="Fake Alias",
color="#ffffff",
num_peers=1,
blockheight=1,
channel_stats=ChannelStats(
counts={ChannelState.ACTIVE: 1},
avg_size=3000,
biggest_size=3000,
smallest_size=3000,
total_capacity=3000,
),
addresses=["127.0.0.1:9735"],
onchain_balance_sat=1,
onchain_confirmed_sat=1,
fees=NodeFees(total_msat=0),
balance_msat=3000,
)
self.fees_updated: tuple[str, int | None, int | None] | None = None
async def get_public_info(self) -> PublicNodeInfo:
return PublicNodeInfo(**self.info.dict())
async def get_info(self) -> NodeInfoResponse:
return self.info
async def get_channels(self) -> list[NodeChannel]:
return [self.channel]
async def get_channel(self, channel_id: str) -> NodeChannel | None:
return self.channel if channel_id == self.channel.id else None
async def open_channel(
self,
peer_id: str,
funding_amount: int,
push_amount: int | None = None,
fee_rate: int | None = None,
) -> ChannelPoint:
assert peer_id == "peer-1"
assert funding_amount == 10_000
assert push_amount == 100
assert fee_rate == 5
return ChannelPoint(funding_txid="cd" * 32, output_index=0)
async def close_channel(
self,
short_id: str | None = None,
point: ChannelPoint | None = None,
force: bool = False,
) -> list[NodeChannel]:
assert short_id == self.channel.short_id
assert point is None
assert force is True
return [self.channel]
async def set_channel_fee(
self, channel_id: str, fee_base_msat: int | None, fee_ppm: int | None
) -> None:
self.fees_updated = (channel_id, fee_base_msat, fee_ppm)
async def get_payments(self, filters: Filters[Any]) -> Page[NodePayment]:
return Page(
data=[
NodePayment(
pending=False,
amount=1,
fee=0,
memo="payment",
time=1,
preimage="11" * 32,
payment_hash="22" * 32,
)
],
total=1,
)
async def get_invoices(self, filters: Filters[Any]) -> Page[NodeInvoice]:
return Page(
data=[
NodeInvoice(
pending=False,
amount=1,
memo="invoice",
bolt11="lnbc1dummy",
preimage="11" * 32,
payment_hash="33" * 32,
)
],
total=1,
)
async def get_peers(self) -> list[NodePeerInfo]:
return [self.peer]
async def connect_peer(self, uri: str) -> dict[str, str]:
return {"uri": uri}
async def disconnect_peer(self, peer_id: str) -> dict[str, str]:
return {"peer_id": peer_id}
async def get_id(self) -> str:
return "fake-node-id"
class FakeFundingSource:
def __init__(
self,
features: list[Feature],
node_factory: Callable[[Any], Any] | None,
):
self.features = features
self.__node_cls__ = node_factory
class MockHTTPResponse:
def __init__(
self, json_data: dict[str, Any], status_error: Exception | None = None
):
self._json_data = json_data
self._status_error = status_error
def raise_for_status(self) -> None:
if self._status_error:
raise self._status_error
def json(self) -> dict[str, Any]:
return self._json_data
class MockHTTPClient:
def __init__(self, response: MockHTTPResponse):
self.response = response
self.calls: list[str] = []
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def get(self, url: str, timeout: int):
self.calls.append(url)
return self.response
@pytest.mark.anyio
async def test_node_api_dependency_guards(settings: Settings, mocker: MockerFixture):
original_node_ui = settings.lnbits_node_ui
original_public = settings.lnbits_public_node_ui
try:
settings.lnbits_node_ui = True
funding_source = FakeFundingSource([], None)
mocker.patch(
"lnbits.core.views.node_api.get_funding_source",
return_value=funding_source,
)
with pytest.raises(HTTPException) as excinfo:
node_api.require_node()
assert excinfo.value.status_code == 501
node_enabled_source = FakeFundingSource(
[Feature.nodemanager], lambda wallet: "fake-node"
)
mocker.patch(
"lnbits.core.views.node_api.get_funding_source",
return_value=node_enabled_source,
)
settings.lnbits_node_ui = False
with pytest.raises(HTTPException) as disabled:
node_api.require_node()
assert disabled.value.status_code == 503
settings.lnbits_node_ui = True
assert node_api.require_node() == "fake-node"
settings.lnbits_public_node_ui = False
with pytest.raises(HTTPException) as public_disabled:
node_api.check_public()
assert public_disabled.value.status_code == 503
finally:
settings.lnbits_node_ui = original_node_ui
settings.lnbits_public_node_ui = original_public
@pytest.mark.anyio
async def test_node_api_route_functions_with_fake_node(
settings: Settings,
mocker: MockerFixture,
):
fake_node = FakeNode()
node = cast(Node, fake_node)
original_transactions = settings.lnbits_node_ui_transactions
settings.lnbits_node_ui_transactions = True
rank_response = MockHTTPResponse(
{
"noderank": {
"capacity": 1,
"channelcount": 2,
"age": 3,
"growth": 4,
"availability": 5,
}
}
)
mocker.patch(
"lnbits.core.views.node_api.httpx.AsyncClient",
return_value=MockHTTPClient(rank_response),
)
try:
assert await node_api.api_get_ok() is None
public_info = await node_api.api_get_public_info(node=node)
assert public_info.backend_name == "FakeNode"
info = await node_api.api_get_info(node=node)
assert info is not None
assert info.id == "node-id"
channels = await node_api.api_get_channels(node=node)
assert channels is not None
assert channels[0].id == "chan-1"
channel = await node_api.api_get_channel("chan-1", node=node)
assert channel is not None
assert channel.peer_id == "peer-1"
created = await node_api.api_create_channel(
node=node,
peer_id="peer-1",
funding_amount=10_000,
push_amount=100,
fee_rate=5,
)
assert created.output_index == 0
deleted = await node_api.api_delete_channel(
short_id="123x1x0",
funding_txid=None,
output_index=None,
force=True,
node=node,
)
assert deleted is not None
assert deleted[0].id == "chan-1"
await node_api.api_set_channel_fees(
"chan-1",
node=node,
fee_ppm=42,
fee_base_msat=7,
)
assert fake_node.fees_updated == ("chan-1", 7, 42)
payments = await node_api.api_get_payments(node=node, filters=Filters())
assert payments is not None
assert payments.total == 1
invoices = await node_api.api_get_invoices(node=node, filters=Filters())
assert invoices is not None
assert invoices.total == 1
peers = await node_api.api_get_peers(node=node)
assert peers[0].id == "peer-1"
connect = await node_api.api_connect_peer(
uri="peer-1@127.0.0.1:9735", node=node
)
assert connect["uri"] == "peer-1@127.0.0.1:9735"
disconnect = await node_api.api_disconnect_peer("peer-1", node=node)
assert disconnect["peer_id"] == "peer-1"
rank = await node_api.api_get_1ml_stats(node=node)
assert rank is not None
rank_data = node_api.NodeRank.parse_obj(rank)
assert rank_data.channelcount == 2
finally:
settings.lnbits_node_ui_transactions = original_transactions
@pytest.mark.anyio
async def test_node_api_transactions_and_rank_errors(
settings: Settings,
mocker: MockerFixture,
):
fake_node = FakeNode()
node = cast(Node, fake_node)
original_transactions = settings.lnbits_node_ui_transactions
settings.lnbits_node_ui_transactions = False
request = httpx.Request("GET", f"https://1ml.com/node/{uuid4().hex}/json")
mocker.patch(
"lnbits.core.views.node_api.httpx.AsyncClient",
return_value=MockHTTPClient(
MockHTTPResponse(
{},
status_error=httpx.HTTPStatusError(
"not found", request=request, response=httpx.Response(404)
),
)
),
)
try:
with pytest.raises(HTTPException) as payments:
await node_api.api_get_payments(node=node, filters=Filters())
assert payments.value.status_code == 503
with pytest.raises(HTTPException) as invoices:
await node_api.api_get_invoices(node=node, filters=Filters())
assert invoices.value.status_code == 503
with pytest.raises(HTTPException) as rank:
await node_api.api_get_1ml_stats(node=node)
assert rank.value.status_code == 404
assert rank.value.detail == "Node not found on 1ml.com"
finally:
settings.lnbits_node_ui_transactions = original_transactions
+187
View File
@@ -0,0 +1,187 @@
import json
from hashlib import sha256
from uuid import uuid4
import pytest
from fastapi import HTTPException
from lnbits.core.crud.payments import create_payment
from lnbits.core.models import Account, CreateInvoice, PaymentState
from lnbits.core.models.payments import CancelInvoice, CreatePayment, SettleInvoice
from lnbits.core.models.users import AccountId
from lnbits.core.models.wallets import KeyType, WalletTypeInfo
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.services.users import create_user_account
from lnbits.core.views.payment_api import (
api_all_payments_paginated,
api_payments_cancel,
api_payments_counting_stats,
api_payments_daily_stats,
api_payments_fee_reserve,
api_payments_settle,
api_payments_wallets_stats,
)
from lnbits.db import Filters
from lnbits.wallets.base import InvoiceResponse
ZERO_AMOUNT_INVOICE = (
"lnbc1pnsu5z3pp57getmdaxhg5kc9yh2a2qsh7cjf4gnccgkw0qenm8vsqv50w7s"
"ygqdqj0fjhymeqv9kk7atwwscqzzsxqyz5vqsp5e2yyqcp0a3ujeesp24ya0glej"
"srh703md8mrx0g2lyvjxy5w27ss9qxpqysgqyjreasng8a086kpkczv48er5c6l5"
"73aym6ynrdl9nkzqnag49vt3sjjn8qdfq5cr6ha0vrdz5c5r3v4aghndly0hplmv"
"6hjxepwp93cq398l3s"
)
@pytest.mark.anyio
async def test_payment_api_stats_and_all_paginated(admin_user):
first_user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
second_user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
first_wallet = first_user.wallets[0]
second_wallet = second_user.wallets[0]
await _create_payment(first_wallet.id, amount_msat=2_000, tag="coffee")
await _create_payment(first_wallet.id, amount_msat=-1_000, tag="coffee")
await _create_payment(second_wallet.id, amount_msat=5_000, tag="books")
count_stats = await api_payments_counting_stats(
count_by="tag",
filters=Filters(limit=20),
account_id=AccountId(id=first_user.id),
)
assert any(item.field == "coffee" for item in count_stats)
assert all(item.field != "books" for item in count_stats)
wallet_stats = await api_payments_wallets_stats(
filters=Filters(limit=20), account_id=AccountId(id=first_user.id)
)
assert any(item.wallet_id == first_wallet.id for item in wallet_stats)
assert all(item.wallet_id != second_wallet.id for item in wallet_stats)
daily_stats = await api_payments_daily_stats(
account_id=AccountId(id=first_user.id),
filters=Filters(limit=20),
)
assert daily_stats
assert daily_stats[0].payments_count >= 1
regular_page = await api_all_payments_paginated(
filters=Filters(limit=20), account_id=AccountId(id=first_user.id)
)
assert regular_page.total >= 2
assert all(payment.wallet_id == first_wallet.id for payment in regular_page.data)
admin_page = await api_all_payments_paginated(
filters=Filters(limit=50), account_id=AccountId(id=admin_user.id)
)
wallet_ids = {payment.wallet_id for payment in admin_page.data}
assert first_wallet.id in wallet_ids
assert second_wallet.id in wallet_ids
@pytest.mark.anyio
async def test_payment_api_fee_reserve_and_hold_invoice_actions(mocker):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
invoice = await create_wallet_invoice(
wallet.id, CreateInvoice(out=False, amount=42, memo="reserve")
)
reserve = await api_payments_fee_reserve(invoice.bolt11)
assert json.loads(reserve.body)["fee_reserve"] >= 0
with pytest.raises(HTTPException, match="Invoice has no amount."):
await api_payments_fee_reserve(ZERO_AMOUNT_INVOICE)
preimage = "11" * 32
payment_hash = sha256(bytes.fromhex(preimage)).hexdigest()
await _create_payment(
wallet.id,
amount_msat=1_000,
payment_hash=payment_hash,
status=PaymentState.PENDING,
)
settle_mock = mocker.patch(
"lnbits.core.views.payment_api.settle_hold_invoice",
mocker.AsyncMock(
return_value=InvoiceResponse(
ok=True,
checking_id="settled",
preimage=preimage,
)
),
)
settled = await api_payments_settle(
SettleInvoice(preimage=preimage),
WalletTypeInfo(key_type=KeyType.admin, wallet=wallet),
)
assert settled.success is True
settle_mock.assert_awaited_once()
cancel_hash = (uuid4().hex * 2)[:64]
await _create_payment(
wallet.id,
amount_msat=2_000,
payment_hash=cancel_hash,
status=PaymentState.PENDING,
)
cancel_mock = mocker.patch(
"lnbits.core.views.payment_api.cancel_hold_invoice",
mocker.AsyncMock(
return_value=InvoiceResponse(
ok=False,
checking_id="cancelled",
error_message="cancelled",
)
),
)
cancelled = await api_payments_cancel(
CancelInvoice(payment_hash=cancel_hash),
WalletTypeInfo(key_type=KeyType.admin, wallet=wallet),
)
assert cancelled.failed is True
cancel_mock.assert_awaited_once()
async def _create_payment(
wallet_id: str,
*,
amount_msat: int,
status: PaymentState = PaymentState.SUCCESS,
payment_hash: str | None = None,
tag: str | None = None,
) -> str:
checking_id = f"checking_{uuid4().hex[:8]}"
await create_payment(
checking_id=checking_id,
data=CreatePayment(
wallet_id=wallet_id,
payment_hash=payment_hash or uuid4().hex,
bolt11=f"bolt11_{checking_id}",
amount_msat=amount_msat,
memo=f"payment_{checking_id}",
extra={"tag": tag} if tag else {},
),
status=status,
)
return checking_id
+71
View File
@@ -0,0 +1,71 @@
from http import HTTPStatus
import pytest
from httpx import AsyncClient
@pytest.mark.anyio
async def test_tinyurl_api_create_get_redirect_and_delete(
client: AsyncClient,
adminkey_headers_from: dict[str, str],
inkey_headers_from: dict[str, str],
inkey_headers_to: dict[str, str],
):
created = await client.post(
"/api/v1/tinyurl",
params={"url": "https://example.com/landing", "endless": "true"},
headers=adminkey_headers_from,
)
assert created.status_code == HTTPStatus.OK
tinyurl = created.json()
assert tinyurl["url"] == "https://example.com/landing"
assert tinyurl["endless"] is True
fetched = await client.get(
f"/api/v1/tinyurl/{tinyurl['id']}",
headers=inkey_headers_from,
)
assert fetched.status_code == HTTPStatus.OK
assert fetched.json()["id"] == tinyurl["id"]
wrong_wallet = await client.get(
f"/api/v1/tinyurl/{tinyurl['id']}",
headers=inkey_headers_to,
)
assert wrong_wallet.status_code == HTTPStatus.NOT_FOUND
assert wrong_wallet.json()["detail"] == "Unable to fetch tinyurl"
redirect = await client.get(f"/t/{tinyurl['id']}")
assert redirect.status_code == HTTPStatus.TEMPORARY_REDIRECT
assert redirect.headers["location"] == "https://example.com/landing"
deleted = await client.delete(
f"/api/v1/tinyurl/{tinyurl['id']}",
headers=adminkey_headers_from,
)
assert deleted.status_code == HTTPStatus.OK
assert deleted.json()["deleted"] is True
missing_redirect = await client.get(f"/t/{tinyurl['id']}")
assert missing_redirect.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.anyio
async def test_tinyurl_api_reuses_existing_entries_for_same_wallet(
client: AsyncClient,
adminkey_headers_from: dict[str, str],
):
first = await client.post(
"/api/v1/tinyurl",
params={"url": "https://example.com/reused"},
headers=adminkey_headers_from,
)
second = await client.post(
"/api/v1/tinyurl",
params={"url": "https://example.com/reused"},
headers=adminkey_headers_from,
)
assert first.status_code == HTTPStatus.OK
assert second.status_code == HTTPStatus.OK
assert first.json()["id"] == second.json()["id"]
+102
View File
@@ -0,0 +1,102 @@
from uuid import uuid4
import pytest
from httpx import AsyncClient
from lnbits.core.crud.wallets import create_wallet, get_wallet, get_wallets
from lnbits.core.models import UpdateBalance
from lnbits.core.models.users import Account
from lnbits.core.services.users import create_user_account
from lnbits.core.views.user_api import api_users_create_user_wallet
from lnbits.settings import settings
@pytest.mark.anyio
async def test_user_api_toggle_admin_and_update_balance(
http_client: AsyncClient, superuser_token: str
):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
promote = await http_client.put(
f"/users/api/v1/user/{user.id}/admin",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert promote.status_code == 200
assert settings.is_admin_user(user.id) is True
demote = await http_client.put(
f"/users/api/v1/user/{user.id}/admin",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert demote.status_code == 200
assert settings.is_admin_user(user.id) is False
balance = await http_client.put(
"/users/api/v1/balance",
headers={"Authorization": f"Bearer {superuser_token}"},
json=UpdateBalance(id=wallet.id, amount=7).dict(),
)
assert balance.status_code == 200
assert balance.json()["success"] is True
updated_wallet = await get_wallet(wallet.id)
assert updated_wallet is not None
assert updated_wallet.balance == 7
@pytest.mark.anyio
async def test_user_api_get_wallets_and_delete_all_wallets(
http_client: AsyncClient, superuser_token: str
):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
extra_wallet = await create_wallet(user_id=user.id, wallet_name="spare")
wallets = await http_client.get(
f"/users/api/v1/user/{user.id}/wallet",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert wallets.status_code == 200
wallet_ids = {wallet["id"] for wallet in wallets.json()}
assert extra_wallet.id in wallet_ids
deleted = await http_client.delete(
f"/users/api/v1/user/{user.id}/wallets",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert deleted.status_code == 200
assert deleted.json()["success"] is True
active_wallets = await get_wallets(user.id, deleted=False)
assert active_wallets == []
@pytest.mark.anyio
async def test_user_api_create_wallet_validates_currency():
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
with pytest.raises(ValueError, match="Currency 'INVALID' not allowed."):
await api_users_create_user_wallet(user.id, name="invalid", currency="INVALID")
wallet = await api_users_create_user_wallet(
user.id, name="eur wallet", currency="EUR"
)
assert wallet.currency == "EUR"
+190
View File
@@ -0,0 +1,190 @@
from uuid import uuid4
import pytest
from httpx import AsyncClient
from lnbits.core.crud.wallets import create_wallet, get_wallet
from lnbits.core.models.users import Account
from lnbits.core.services.users import create_user_account
@pytest.mark.anyio
async def test_wallet_api_share_invite_reject_accept_and_delete(
http_client: AsyncClient,
):
owner = await create_user_account(
Account(
id=uuid4().hex,
username=f"owner_{uuid4().hex[:8]}",
email=f"owner_{uuid4().hex[:8]}@lnbits.com",
)
)
invited = await create_user_account(
Account(
id=uuid4().hex,
username=f"invited_{uuid4().hex[:8]}",
email=f"invited_{uuid4().hex[:8]}@lnbits.com",
)
)
source_wallet = owner.wallets[0]
owner_headers = _admin_headers(source_wallet.adminkey)
invite = await http_client.put(
"/api/v1/wallet/share/invite",
headers=owner_headers,
json={
"username": invited.username,
"permissions": ["view-payments"],
"status": "invite_sent",
},
)
assert invite.status_code == 200
share_request = invite.json()
assert share_request["request_id"]
reject = await http_client.delete(
f"/api/v1/wallet/share/invite/{share_request['request_id']}?usr={invited.id}"
)
assert reject.status_code == 200
assert reject.json()["success"] is True
removed_share = await http_client.delete(
f"/api/v1/wallet/share/{share_request['request_id']}",
headers=owner_headers,
)
assert removed_share.status_code == 200
assert removed_share.json()["success"] is True
invite = await http_client.put(
"/api/v1/wallet/share/invite",
headers=owner_headers,
json={
"username": invited.username,
"permissions": ["view-payments", "receive-payments"],
"status": "invite_sent",
},
)
assert invite.status_code == 200
share_request = invite.json()
create_shared = await http_client.post(
f"/api/v1/wallet?usr={invited.id}",
json={
"name": "shared",
"wallet_type": "lightning-shared",
"shared_wallet_id": source_wallet.id,
},
)
assert create_shared.status_code == 200
mirror_wallet = create_shared.json()
assert mirror_wallet["shared_wallet_id"] == source_wallet.id
approve = await http_client.put(
"/api/v1/wallet/share",
headers=owner_headers,
json={
"username": invited.username,
"shared_with_wallet_id": mirror_wallet["id"],
"permissions": ["view-payments", "receive-payments"],
"status": "approved",
},
)
assert approve.status_code == 200
assert approve.json()["status"] == "approved"
delete_share = await http_client.delete(
f"/api/v1/wallet/share/{share_request['request_id']}",
headers=owner_headers,
)
assert delete_share.status_code == 200
assert delete_share.json()["success"] is True
assert await get_wallet(mirror_wallet["id"]) is None
@pytest.mark.anyio
async def test_wallet_api_paginated_update_reset_and_store_paylinks(
http_client: AsyncClient,
):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
extra_wallet = await create_wallet(user_id=user.id, wallet_name="second")
first_wallet = user.wallets[0]
page = await http_client.get(f"/api/v1/wallet/paginated?usr={user.id}&limit=10")
assert page.status_code == 200
assert page.json()["total"] >= 2
renamed = await http_client.put(
"/api/v1/wallet/renamed-wallet",
headers=_admin_headers(first_wallet.adminkey),
)
assert renamed.status_code == 200
assert renamed.json()["name"] == "renamed-wallet"
original_admin_key = extra_wallet.adminkey
reset = await http_client.put(
f"/api/v1/wallet/reset/{extra_wallet.id}?usr={user.id}"
)
assert reset.status_code == 200
assert reset.json()["adminkey"] != original_admin_key
stored = await http_client.put(
f"/api/v1/wallet/stored_paylinks/{extra_wallet.id}",
headers=_admin_headers(reset.json()["adminkey"]),
json={
"links": [
{
"lnurl": "alice@example.com",
"label": "Alice",
}
]
},
)
assert stored.status_code == 200
assert stored.json()[0]["lnurl"] == "alice@example.com"
forbidden = await http_client.put(
f"/api/v1/wallet/stored_paylinks/{extra_wallet.id}",
headers=_admin_headers(first_wallet.adminkey),
json={"links": []},
)
assert forbidden.status_code == 403
updated = await http_client.patch(
"/api/v1/wallet",
headers=_admin_headers(first_wallet.adminkey),
json={"icon": "bolt", "color": "amber", "pinned": True},
)
assert updated.status_code == 200
assert updated.json()["extra"]["icon"] == "bolt"
assert updated.json()["extra"]["color"] == "amber"
assert updated.json()["extra"]["pinned"] is True
@pytest.mark.anyio
async def test_wallet_api_shared_wallet_requires_source_id(http_client: AsyncClient):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
response = await http_client.post(
f"/api/v1/wallet?usr={user.id}",
json={"wallet_type": "lightning-shared"},
)
assert response.status_code == 400
assert (
response.json()["detail"] == "Shared wallet ID is required for shared wallets."
)
def _admin_headers(adminkey: str) -> dict[str, str]:
return {"X-Api-Key": adminkey, "Content-type": "application/json"}
+31
View File
@@ -0,0 +1,31 @@
from unittest.mock import AsyncMock
from pytest_mock.plugin import MockerFixture
def test_websocket_api_connects_and_updates(test_client):
with test_client.websocket_connect("/api/v1/ws/demo-item") as websocket:
response = test_client.post("/api/v1/ws/demo-item", params={"data": "hello"})
assert response.status_code == 200
assert response.json() == {"sent": True, "data": "hello"}
assert websocket.receive_text() == "hello"
response = test_client.get("/api/v1/ws/demo-item/world")
assert response.status_code == 200
assert response.json() == {"sent": True, "data": "world"}
assert websocket.receive_text() == "world"
def test_websocket_api_reports_send_failures(test_client, mocker: MockerFixture):
mocker.patch(
"lnbits.core.views.websocket_api.websocket_manager.send",
AsyncMock(side_effect=RuntimeError("boom")),
)
post_response = test_client.post("/api/v1/ws/demo-item", params={"data": "oops"})
assert post_response.status_code == 200
assert post_response.json() == {"sent": False, "data": "oops"}
get_response = test_client.get("/api/v1/ws/demo-item/oops")
assert get_response.status_code == 200
assert get_response.json() == {"sent": False, "data": "oops"}
+40 -1
View File
@@ -1,4 +1,6 @@
import asyncio
import copy
import inspect
from datetime import datetime, timezone
from uuid import uuid4
@@ -23,7 +25,12 @@ 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, FiatProviderLimits, Settings
from lnbits.settings import (
AuthMethods,
EditableSettings,
FiatProviderLimits,
Settings,
)
from lnbits.settings import settings as lnbits_settings
from lnbits.wallets.fake import FakeWallet
from tests.helpers import (
@@ -33,6 +40,23 @@ from tests.helpers import (
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
ADMIN_USER_ID = uuid4().hex
# Snapshot the initialized module settings instead of a fresh Settings() instance.
# The module settings include runtime-populated values like `version`.
_PURE_SETTINGS = copy.deepcopy(lnbits_settings)
_PURE_SETTINGS_FIELDS = tuple(
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")
@@ -72,6 +96,7 @@ async def app(settings: Settings):
username="superadmin",
password="secret1234",
password_repeat="secret1234",
first_install_token=settings.first_install_token,
)
)
@@ -327,7 +352,21 @@ async def new_user(username: str | None = None) -> User:
return user
def _restore_pure_settings(settings: Settings):
for field_name in _PURE_SETTINGS_FIELDS:
setattr(
settings, field_name, copy.deepcopy(getattr(_PURE_SETTINGS, field_name))
)
def _settings_cleanup(settings: Settings):
_restore_pure_settings(settings)
settings.auth_https_only = False
settings.lnbits_data_folder = "./tests/data"
settings.bundle_assets = True
settings.lnbits_admin_ui = True
settings.lnbits_extensions_default_install = []
settings.lnbits_extensions_deactivate_all = True
settings.lnbits_allow_new_accounts = True
settings.lnbits_allowed_users = []
settings.auth_allowed_methods = AuthMethods.all()
+24
View File
@@ -0,0 +1,24 @@
networks:
lnbits-net:
services:
postgres-db:
container_name: postgres_container_test
image: postgres:14
environment:
POSTGRES_DB: lnbits
POSTGRES_USER: lnbits
POSTGRES_PASSWORD: lnbits
PGDATA: /lnbits/data/postgres
volumes:
- ./data/postgres_test:/lnbits/data/postgres
ports:
- '5444:5432'
restart: unless-stopped
healthcheck:
test: ['CMD-SHELL', 'pg_isready -d lnbits_db']
interval: 5s
timeout: 5s
retries: 5
networks:
- lnbits-net
+144 -1
View File
@@ -1,8 +1,33 @@
import random
import string
from io import BytesIO
from pydantic import BaseModel
from bolt11.types import MilliSatoshi
from fastapi import UploadFile
from httpx import AsyncClient
from lnurl import LnurlPayResponse
from lnurl.types import CallbackUrl, LnurlPayMetadata
from PIL import Image
from pydantic import BaseModel, parse_obj_as
from starlette.datastructures import Headers
from lnbits.core.models.extensions import (
ExtensionMeta,
ExtensionRelease,
InstallableExtension,
PayToEnableInfo,
ReleasePaymentInfo,
)
from lnbits.core.models.extensions_builder import (
ActionFields,
ClientDataFields,
DataField,
DataFields,
ExtensionData,
OwnerDataFields,
PublicPageFields,
SettingsFields,
)
from lnbits.wallets import get_funding_source, set_funding_source
@@ -40,7 +65,125 @@ 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
+56 -1
View File
@@ -1,8 +1,11 @@
import asyncio
from time import time
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.utils.cache import Cache
from lnbits.settings import Settings
from lnbits.utils.cache import Cache, Cached
key = "foo"
value = "bar"
@@ -59,3 +62,55 @@ 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")
+91 -2
View File
@@ -1,6 +1,15 @@
import pytest
from base64 import b64encode
from hashlib import sha256
from lnbits.utils.crypto import AESCipher
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.utils.crypto import (
AESCipher,
fake_privkey,
random_secret_and_hash,
verify_preimage,
)
@pytest.mark.anyio
@@ -18,3 +27,83 @@ 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)
+66 -12
View File
@@ -1,7 +1,32 @@
import pytest
from lnbits.db import Filters
from tests.helpers import DbTestModel
TEST_DB_FETCH_PAGE_ROWS: tuple[dict[str, str], ...] = (
{"id": "1", "name": "Alice", "value": "foo"},
{"id": "2", "name": "Bob", "value": "bar"},
{"id": "3", "name": "Carol", "value": "bar"},
{"id": "4", "name": "Dave", "value": "bar"},
{"id": "5", "name": "Dave", "value": "foo"},
{"id": "6", "name": "Eve", "value": "foo"},
{"id": "7", "name": "Frank", "value": "bar"},
{"id": "8", "name": "Grace", "value": "foo"},
{"id": "9", "name": "Heidi", "value": "bar"},
{"id": "10", "name": "Ivan", "value": "foo"},
{"id": "11", "name": "Judy", "value": "bar"},
{"id": "12", "name": "Mallory", "value": "foo"},
{"id": "13", "name": "Niaj", "value": "bar"},
{"id": "14", "name": "Olivia", "value": "foo"},
{"id": "15", "name": "Peggy", "value": "bar"},
{"id": "16", "name": "Rupert", "value": "foo"},
{"id": "17", "name": "Sybil", "value": "bar"},
{"id": "18", "name": "Trent", "value": "foo"},
{"id": "19", "name": "Victor", "value": "bar"},
{"id": "20", "name": "Walter", "value": "foo"},
{"id": "21", "name": "Zoe", "value": "bar"},
)
@pytest.fixture(scope="session")
async def fetch_page(db):
@@ -13,14 +38,14 @@ async def fetch_page(db):
name TEXT NOT NULL
)
""")
await db.execute("""
INSERT INTO test_db_fetch_page (id, name, value) VALUES
('1', 'Alice', 'foo'),
('2', 'Bob', 'bar'),
('3', 'Carol', 'bar'),
('4', 'Dave', 'bar'),
('5', 'Dave', 'foo')
""")
for row in TEST_DB_FETCH_PAGE_ROWS:
await db.execute(
"""
INSERT INTO test_db_fetch_page (id, name, value)
VALUES (:id, :name, :value)
""",
row,
)
yield
await db.execute("DROP TABLE test_db_fetch_page")
@@ -33,8 +58,35 @@ async def test_db_fetch_page_simple(fetch_page, db):
)
assert row
assert row.total == 5
assert len(row.data) == 5
assert row.total == len(TEST_DB_FETCH_PAGE_ROWS)
assert len(row.data) == Filters().limit
@pytest.mark.anyio
async def test_db_fetch_page_limit_zero_returns_all(fetch_page, db):
row = await db.fetch_page(
query="select * from test_db_fetch_page",
filters=Filters(limit=0),
model=DbTestModel,
)
assert row
assert row.total == len(TEST_DB_FETCH_PAGE_ROWS)
assert len(row.data) == len(TEST_DB_FETCH_PAGE_ROWS)
@pytest.mark.anyio
async def test_db_fetch_page_limit(fetch_page, db):
limit = 5
row = await db.fetch_page(
query="select * from test_db_fetch_page",
filters=Filters(limit=limit),
model=DbTestModel,
)
assert row
assert row.total == len(TEST_DB_FETCH_PAGE_ROWS)
assert len(row.data) == limit
@pytest.mark.anyio
@@ -45,7 +97,7 @@ async def test_db_fetch_page_group_by(fetch_page, db):
group_by=["name"],
)
assert row
assert row.total == 4
assert row.total == len({test_row["name"] for test_row in TEST_DB_FETCH_PAGE_ROWS})
@pytest.mark.anyio
@@ -56,7 +108,9 @@ async def test_db_fetch_page_group_by_multiple(fetch_page, db):
group_by=["value", "name"],
)
assert row
assert row.total == 5
assert row.total == len(
{(test_row["value"], test_row["name"]) for test_row in TEST_DB_FETCH_PAGE_ROWS}
)
@pytest.mark.anyio
+90 -1
View File
@@ -11,7 +11,16 @@ 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 check_user_exists
from lnbits.decorators import (
access_token_payload,
check_access_token,
check_admin_ui,
check_extension_builder,
check_first_install,
check_user_exists,
optional_user_id,
)
from lnbits.helpers import create_access_token
from lnbits.settings import AuthMethods, Settings, settings
@@ -136,3 +145,83 @@ async def test_check_user_exists_with_user_id_only_not_allowed(user_alan: User):
await check_user_exists(request, access_token=None, usr=UUID4(user_alan.id))
assert exc_info.value.status_code == 401
assert exc_info.value.detail == "Missing user ID or access token."
@pytest.mark.anyio
async def test_check_access_token_prefers_available_source():
assert await check_access_token("header", "cookie", "bearer") == "header"
assert await check_access_token(None, "cookie", "bearer") == "cookie"
assert await check_access_token(None, None, "bearer") == "bearer"
@pytest.mark.anyio
async def test_access_token_payload_success_and_missing(settings: Settings):
token = create_access_token({"sub": "alice", "usr": "user-id"})
payload = await access_token_payload(token)
assert isinstance(payload, AccessTokenPayload)
assert payload.sub == "alice"
assert payload.usr == "user-id"
with pytest.raises(HTTPException, match="Missing access token."):
await access_token_payload(None)
@pytest.mark.anyio
async def test_optional_user_id_uses_user_id_or_access_token(
user_alan: User, settings: Settings
):
settings.auth_allowed_methods = [AuthMethods.user_id_only.value]
request = Request({"type": "http", "path": "/wallet", "method": "GET"})
assert (
await optional_user_id(request, access_token=None, usr=UUID4(user_alan.id))
== user_alan.id
)
settings.auth_allowed_methods = []
token = create_access_token({"sub": user_alan.username, "usr": user_alan.id})
assert await optional_user_id(request, access_token=token, usr=None) == user_alan.id
assert await optional_user_id(request, access_token=None, usr=None) is None
@pytest.mark.anyio
async def test_check_admin_ui_and_first_install(settings: Settings):
original_admin_ui = settings.lnbits_admin_ui
original_first_install = settings.first_install
try:
settings.lnbits_admin_ui = False
with pytest.raises(HTTPException, match="Admin UI is disabled."):
await check_admin_ui()
settings.lnbits_admin_ui = True
await check_admin_ui()
settings.first_install = False
with pytest.raises(
HTTPException, match="Super user account has already been configured."
):
await check_first_install()
settings.first_install = True
await check_first_install()
finally:
settings.lnbits_admin_ui = original_admin_ui
settings.first_install = original_first_install
@pytest.mark.anyio
async def test_check_extension_builder_requires_admin_when_disabled_for_users(
settings: Settings, user_alan: User
):
settings.lnbits_extensions_builder_activate_non_admins = False
with pytest.raises(
HTTPException, match="Extension Builder is disabled for non admin users."
):
await check_extension_builder(user_alan)
admin_user = user_alan.copy(deep=True)
admin_user.admin = True
await check_extension_builder(admin_user)
+207
View File
@@ -1,8 +1,54 @@
from unittest.mock import AsyncMock
import httpx
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.settings import ExchangeRateProvider, Settings
from lnbits.utils.exchange_rates import (
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"""
@@ -123,3 +169,164 @@ 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")
+336 -2
View File
@@ -9,20 +9,72 @@ 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, PaymentState
from lnbits.core.models.payments import CreateInvoice, Payment, 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
from lnbits.core.views.callback_api import handle_paypal_event
from lnbits.fiat.paypal import PayPalWallet
from lnbits.fiat.base import (
FiatInvoiceResponse,
FiatPaymentStatus,
FiatPaymentSuccessStatus,
FiatStatusResponse,
)
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(
@@ -235,6 +287,42 @@ async def test_create_wallet_fiat_invoice_success(
assert status.success is True
@pytest.mark.anyio
async def test_create_paypal_fiat_invoice_uses_raw_order_id(
to_wallet: Wallet, settings: Settings, mocker: MockerFixture
):
settings.paypal_enabled = True
settings.paypal_client_id = "client-id"
settings.paypal_client_secret = "client-secret"
settings.paypal_limits.service_min_amount_sats = 0
settings.paypal_limits.service_max_amount_sats = 0
settings.paypal_limits.service_faucet_wallet_id = None
invoice_data = CreateInvoice(
unit="USD", amount=1.0, memo="Test", fiat_provider="paypal"
)
fiat_mock_response = FiatInvoiceResponse(
ok=True,
checking_id="ORDER123",
payment_request="https://paypal.com/checkoutnow?token=ORDER123",
)
mocker.patch(
"lnbits.fiat.PayPalWallet.create_invoice",
AsyncMock(return_value=fiat_mock_response),
)
mocker.patch(
"lnbits.utils.exchange_rates.get_fiat_rate_satoshis",
AsyncMock(return_value=1000),
)
payment = await payments.create_fiat_invoice(to_wallet.id, invoice_data)
assert payment.fiat_provider == "paypal"
assert payment.extra.get("fiat_checking_id") == "ORDER123"
assert payment.checking_id == "fiat_paypal_ORDER123"
@pytest.mark.anyio
async def test_fiat_service_fee(settings: Settings):
# settings.stripe_limits.service_min_amount_sats = 0
@@ -433,3 +521,249 @@ 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"}',
)
def test_paypal_order_status_approved_is_pending():
wallet = object.__new__(PayPalWallet)
approved = wallet._status_from_order({"status": "APPROVED"})
completed = wallet._status_from_order({"status": "COMPLETED"})
assert approved.pending is True
assert approved.success is False
assert completed.success is True
def test_paypal_normalize_id_removes_legacy_double_prefix():
wallet = object.__new__(PayPalWallet)
assert wallet._normalize_paypal_id("fiat_paypal_ORDER123") == "ORDER123"
assert wallet._normalize_paypal_id("fiat_paypal_fiat_paypal_ORDER123") == "ORDER123"
@pytest.mark.anyio
async def test_handle_paypal_approved_event_captures_order(mocker: MockerFixture):
payment = Payment(
checking_id="fiat_paypal_ORDER123",
payment_hash="hash_123",
wallet_id="wallet_id",
amount=1000,
fee=0,
bolt11="bolt11",
status=PaymentState.PENDING,
fiat_provider="paypal",
extra={"fiat_checking_id": "ORDER123"},
)
provider = mocker.Mock(spec=PayPalWallet)
provider.capture_order = AsyncMock(return_value=FiatPaymentSuccessStatus())
mocker.patch(
"lnbits.core.views.callback_api.get_standalone_payment",
AsyncMock(return_value=payment),
)
mocker.patch(
"lnbits.core.views.callback_api.get_fiat_provider",
AsyncMock(return_value=provider),
)
status_mock = mocker.patch(
"lnbits.core.views.callback_api.check_fiat_status",
AsyncMock(),
)
await handle_paypal_event(
{
"id": "evt_paypal_approved",
"event_type": "CHECKOUT.ORDER.APPROVED",
"resource": {
"purchase_units": [{"invoice_id": payment.payment_hash}],
},
}
)
provider.capture_order.assert_awaited_once_with("ORDER123")
status_mock.assert_not_awaited()
@pytest.mark.anyio
async def test_test_connection_reports_provider_status(mocker: MockerFixture):
mocker.patch(
"lnbits.core.services.fiat_providers.get_fiat_provider",
AsyncMock(return_value=None),
)
missing_status = await fiat_provider_connection("stripe")
assert missing_status.success is False
assert missing_status.message == "Fiat provider 'stripe' not found."
provider = mocker.Mock()
provider.status = AsyncMock(
return_value=FiatStatusResponse(error_message="bad key")
)
mocker.patch(
"lnbits.core.services.fiat_providers.get_fiat_provider",
AsyncMock(return_value=provider),
)
error_status = await fiat_provider_connection("stripe")
assert error_status.success is False
assert error_status.message == "Cconnection test failed: bad key"
provider.status = AsyncMock(return_value=FiatStatusResponse(balance=21.0))
success_status = await fiat_provider_connection("stripe")
assert success_status.success is True
assert success_status.message == "Connection test successful. Balance: 21.0."
+140
View File
@@ -0,0 +1,140 @@
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)
+199 -2
View File
@@ -1,6 +1,39 @@
import pytest
import hashlib
from lnbits.helpers import check_callback_url
import jwt
import pytest
from fastapi import FastAPI
from lnbits.helpers import (
camel_to_snake,
camel_to_words,
check_callback_url,
create_access_token,
decrypt_internal_message,
download_url,
encrypt_internal_message,
file_hash,
filter_dict_keys,
get_api_routes,
get_db_vendor_name,
is_camel_case,
is_lnbits_version_ok,
is_snake_case,
is_valid_email_address,
is_valid_external_id,
is_valid_label,
is_valid_pubkey,
is_valid_username,
lowercase_first_letter,
normalize_endpoint,
normalize_path,
path_segments,
sha256s,
snake_to_camel,
static_url_for,
url_for,
version_parse,
)
from lnbits.settings import Settings
@@ -82,3 +115,167 @@ def test_check_callback_url_multiple_rules(settings: Settings):
settings.lnbits_callback_url_rules.append("https://localhost:3000")
check_callback_url("https://localhost:3000/callback") # should not raise
def test_get_db_vendor_name(settings: Settings):
original_database_url = settings.lnbits_database_url
try:
settings.lnbits_database_url = None
assert get_db_vendor_name() == "SQLite"
settings.lnbits_database_url = "postgres://localhost/db"
assert get_db_vendor_name() == "PostgreSQL"
settings.lnbits_database_url = "cockroachdb://localhost/db"
assert get_db_vendor_name() == "CockroachDB"
finally:
settings.lnbits_database_url = original_database_url
def test_url_helpers(settings: Settings):
assert url_for("/api/v1/wallet", external=False, usr="user") == (
"/api/v1/wallet?usr=user&"
)
assert url_for("/api/v1/wallet", external=True, usr="user") == (
f"http://{settings.host}:{settings.port}/api/v1/wallet?usr=user&"
)
assert static_url_for("static", "bundle.min.js") == (
f"/static/bundle.min.js?v={settings.server_startup_time}"
)
@pytest.mark.parametrize(
("value", "validator"),
[
("alice@example.com", is_valid_email_address),
("alice_1", is_valid_username),
("Label 1", is_valid_label),
("external-id-1", is_valid_external_id),
("a" * 64, is_valid_pubkey),
],
)
def test_validation_helpers_valid(value, validator):
assert validator(value) is True
@pytest.mark.parametrize(
("value", "validator"),
[
("alice@example", is_valid_email_address),
("_alice", is_valid_username),
("bad/label", is_valid_label),
("contains spaces", is_valid_external_id),
("xyz", is_valid_pubkey),
],
)
def test_validation_helpers_invalid(value, validator):
assert validator(value) is False
def test_is_valid_external_id_rejects_long_and_multiline_values():
assert is_valid_external_id("x" * 257) is False
assert is_valid_external_id("evil\nnewline") is False
def test_access_token_and_internal_message_helpers(settings: Settings):
token = create_access_token(
{"sub": "alice", "usr": None, "email": "alice@example.com"},
token_expire_minutes=1,
)
payload = jwt.decode(token, settings.auth_secret_key, ["HS256"])
assert payload["sub"] == "alice"
assert payload["email"] == "alice@example.com"
assert "usr" not in payload
assert "exp" in payload
assert encrypt_internal_message(None) is None
assert decrypt_internal_message(None) is None
encrypted = encrypt_internal_message("secret-message", urlsafe=True)
assert encrypted is not None
assert decrypt_internal_message(encrypted, urlsafe=True) == "secret-message"
def test_filter_dict_keys_returns_copy_when_no_filters():
original = {"a": 1, "b": 2}
clone = filter_dict_keys(original, None)
filtered = filter_dict_keys(original, ["b", "missing"])
assert clone == original
assert clone is not original
assert filtered == {"b": 2}
def test_version_helpers(settings: Settings):
original_version = settings.version
try:
settings.version = "1.2.3"
assert version_parse("1.2.3rc4") == version_parse("1.2.3")
assert version_parse("invalid-version") == version_parse("0.0.0")
assert is_lnbits_version_ok("1.2.0", "2.0.0") is True
assert is_lnbits_version_ok("2.0.0", None) is False
assert is_lnbits_version_ok(None, "1.2.3") is False
finally:
settings.version = original_version
def test_download_url_rejects_non_http_schemes(tmp_path):
with pytest.raises(
ValueError, match="Invalid URL: ftp://example.com. Must start with 'http'"
):
download_url("ftp://example.com", tmp_path / "download.bin")
def test_file_hash(tmp_path):
filename = tmp_path / "payload.txt"
filename.write_text("hello world")
assert file_hash(filename) == hashlib.sha256(b"hello world").hexdigest()
def test_get_api_routes_extracts_v1_paths():
app = FastAPI()
@app.get("/api/v1/payments")
async def payments():
return {}
@app.get("/myext/api/v1/settings")
async def extension_settings():
return {}
@app.get("/health")
async def health():
return {}
routes = get_api_routes([*app.routes, object()])
assert routes == {
"/api/v1/payments": "Payments",
"/myext/api/v1": "Myext",
}
def test_path_and_case_helpers():
assert path_segments("/wallet/path") == ["wallet", "path"]
assert path_segments("/upgrades/ext/assets/app.js") == ["assets", "app.js"]
assert normalize_path(None) == "/"
assert normalize_path("/upgrades/ext/assets/app.js") == "/assets/app.js"
assert normalize_endpoint("example.com/") == "https://example.com"
assert normalize_endpoint("ws://socket.example.com") == "ws://socket.example.com"
assert (
normalize_endpoint("http://example.com/", add_proto=False)
== "http://example.com"
)
assert camel_to_words("CamelCaseName") == "Camel Case Name"
assert camel_to_snake("CamelCaseName") == "camel_case_name"
assert snake_to_camel("snake_case_name") == "snakeCaseName"
assert snake_to_camel("snake_case_name", capitalize_first=True) == "SnakeCaseName"
assert is_camel_case("CamelCase1") is True
assert is_camel_case("camelCase") is False
assert is_snake_case("snake_case_1") is True
assert is_snake_case("SnakeCase") is False
assert lowercase_first_letter("Hello") == "hello"
assert sha256s("hello") == hashlib.sha256(b"hello").hexdigest()
+20
View File
@@ -4,6 +4,7 @@ import pytest
from lnbits.db import (
dict_to_model,
dict_to_submodel,
insert_query,
model_to_dict,
update_query,
@@ -83,3 +84,22 @@ 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
+6 -1
View File
@@ -252,7 +252,12 @@ async def test_notification_for_internal_payment(
assert _payment.bolt11 == payment.bolt11
assert _payment.amount == 123_000
updated_payment = await get_payment(_payment.checking_id)
assert updated_payment.webhook_status == "404"
assert (
updated_payment.webhook_status is not None
), "Webhook should have been called."
assert (
int(updated_payment.webhook_status) >= 400
), "Webhook should have been called and failed."
@pytest.mark.anyio
+109
View File
@@ -0,0 +1,109 @@
from io import BytesIO
from uuid import uuid4
import pytest
from PIL import Image
from pytest_mock.plugin import MockerFixture
from lnbits.core.crud import create_account
from lnbits.core.crud.assets import get_user_asset, get_user_assets_count
from lnbits.core.models import Account
from lnbits.core.services.assets import create_user_asset, thumbnail_from_bytes
from lnbits.settings import Settings
from tests.helpers import make_upload_file
@pytest.mark.anyio
async def test_create_user_asset_validates_upload_constraints(
settings: Settings, mocker: MockerFixture
):
file_without_type = make_upload_file(b"hello", filename="a.txt", content_type=None)
with pytest.raises(ValueError, match="File must have a content type."):
await create_user_asset("user-1", file_without_type, is_public=False)
bad_type = make_upload_file(
b"hello",
filename="bad.bin",
content_type="application/x-msdownload",
)
with pytest.raises(
ValueError, match="File type 'application/x-msdownload' not allowed."
):
await create_user_asset("user-1", bad_type, is_public=False)
original_max_assets = settings.lnbits_max_assets_per_user
original_max_size = settings.lnbits_max_asset_size_mb
original_no_limit_users = list(settings.lnbits_assets_no_limit_users)
try:
settings.lnbits_max_assets_per_user = 1
settings.lnbits_max_asset_size_mb = 1
settings.lnbits_assets_no_limit_users = []
limited_user = await _create_user()
allowed_type = make_upload_file(
b"hello", filename="ok.txt", content_type="text/plain"
)
await create_user_asset(limited_user, allowed_type, is_public=False)
blocked_by_count = make_upload_file(
b"again",
filename="again.txt",
content_type="text/plain",
)
with pytest.raises(ValueError, match="Max upload count of 1 exceeded."):
await create_user_asset(limited_user, blocked_by_count, is_public=False)
settings.lnbits_max_asset_size_mb = 0.000001
oversized_user = await _create_user()
large_file = make_upload_file(
b"0123456789",
filename="ok.txt",
content_type="text/plain",
)
with pytest.raises(ValueError, match="File limit of 1e-06MB exceeded."):
await create_user_asset(oversized_user, large_file, is_public=False)
finally:
settings.lnbits_max_assets_per_user = original_max_assets
settings.lnbits_max_asset_size_mb = original_max_size
settings.lnbits_assets_no_limit_users = original_no_limit_users
@pytest.mark.anyio
async def test_create_user_asset_success(mocker: MockerFixture):
user_id = await _create_user()
mocker.patch(
"lnbits.core.services.assets.thumbnail_from_bytes",
return_value=None,
)
file = make_upload_file(b"hello", filename="hello.txt", content_type="text/plain")
asset = await create_user_asset(user_id, file, is_public=True)
stored = await get_user_asset(user_id, asset.id)
assert asset.id
assert asset.user_id == user_id
assert asset.name == "hello.txt"
assert asset.size_bytes == 5
assert asset.data == b"hello"
assert asset.is_public is True
assert stored is not None
assert stored.id == asset.id
assert stored.data == b"hello"
assert await get_user_assets_count(user_id) == 1
def test_thumbnail_from_bytes_success_and_failure():
image = Image.new("RGB", (512, 512), color="red")
buffer = BytesIO()
image.save(buffer, format="PNG")
thumbnail = thumbnail_from_bytes(buffer.getvalue())
assert thumbnail is not None
assert isinstance(thumbnail.getvalue(), bytes)
assert thumbnail_from_bytes(b"not-an-image") is None
async def _create_user() -> str:
user_id = uuid4().hex
await create_account(Account(id=user_id, username=f"user_{user_id[:8]}"))
return user_id
+283
View File
@@ -0,0 +1,283 @@
from types import SimpleNamespace
from uuid import uuid4
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.core.crud import (
create_installed_extension,
delete_installed_extension,
get_installed_extension,
)
from lnbits.core.models.extensions import (
Extension,
InstallableExtension,
ReleasePaymentInfo,
)
from lnbits.core.services.extensions import (
activate_extension,
deactivate_extension,
get_valid_extension,
get_valid_extensions,
install_extension,
start_extension_background_work,
stop_extension_background_work,
uninstall_extension,
)
from lnbits.settings import Settings
from tests.helpers import make_installable_extension
@pytest.mark.anyio
async def test_install_extension_rejects_incompatible_release(
tmp_path, settings: Settings
):
ext_info = make_installable_extension(f"ext_{uuid4().hex[:8]}", compatible=False)
original_data_folder = settings.lnbits_data_folder
original_extensions_path = settings.lnbits_extensions_path
try:
settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path / "code")
with pytest.raises(ValueError, match="Incompatible extension version"):
await install_extension(ext_info)
finally:
settings.lnbits_data_folder = original_data_folder
settings.lnbits_extensions_path = original_extensions_path
@pytest.mark.anyio
async def test_install_extension_creates_new_extension_and_starts_background_work(
tmp_path, settings: Settings, mocker: MockerFixture
):
ext_id = f"ext_{uuid4().hex[:8]}"
ext_info = make_installable_extension(ext_id)
original_data_folder = settings.lnbits_data_folder
original_extensions_path = settings.lnbits_extensions_path
download_mock = mocker.patch.object(
InstallableExtension, "download_archive", mocker.AsyncMock()
)
extract_mock = mocker.patch.object(InstallableExtension, "extract_archive")
start_mock = mocker.patch(
"lnbits.core.services.extensions.start_extension_background_work",
mocker.AsyncMock(return_value=True),
)
mocker.patch(
"lnbits.core.services.extensions.get_db_version",
mocker.AsyncMock(return_value=0),
)
mocker.patch(
"lnbits.core.services.extensions.migrate_extension_database",
mocker.AsyncMock(),
)
try:
settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path / "code")
extension = await install_extension(ext_info)
stored = await get_installed_extension(ext_id)
finally:
await delete_installed_extension(ext_id=ext_id)
settings.lnbits_data_folder = original_data_folder
settings.lnbits_extensions_path = original_extensions_path
assert extension.code == ext_id
assert stored is not None
download_mock.assert_awaited_once()
extract_mock.assert_called_once()
start_mock.assert_awaited_once_with(ext_id)
@pytest.mark.anyio
async def test_install_extension_updates_existing_upgrade_and_preserves_payments(
tmp_path, settings: Settings, mocker: MockerFixture
):
ext_id = f"ext_{uuid4().hex[:8]}"
existing_payment = ReleasePaymentInfo(
pay_link="https://pay.example",
payment_hash="payment-hash",
)
existing_ext = make_installable_extension(ext_id, payments=[existing_payment])
updated_ext = make_installable_extension(ext_id, version="2.0.0")
original_data_folder = settings.lnbits_data_folder
original_extensions_path = settings.lnbits_extensions_path
extract_mock = mocker.patch.object(InstallableExtension, "extract_archive")
start_mock = mocker.patch(
"lnbits.core.services.extensions.start_extension_background_work",
mocker.AsyncMock(return_value=True),
)
stop_mock = mocker.patch(
"lnbits.core.services.extensions.stop_extension_background_work",
mocker.AsyncMock(return_value=True),
)
mocker.patch(
"lnbits.core.services.extensions.get_db_version",
mocker.AsyncMock(return_value=1),
)
mocker.patch(
"lnbits.core.services.extensions.migrate_extension_database",
mocker.AsyncMock(),
)
try:
settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path / "code")
await create_installed_extension(existing_ext)
updated_ext.ext_upgrade_dir.mkdir(parents=True, exist_ok=True)
extension = await install_extension(updated_ext, skip_download=True)
stored = await get_installed_extension(ext_id)
finally:
await delete_installed_extension(ext_id=ext_id)
settings.lnbits_data_folder = original_data_folder
settings.lnbits_extensions_path = original_extensions_path
assert extension.code == ext_id
assert extension.is_upgrade_extension is True
assert stored is not None
assert stored.meta is not None
assert stored.meta.payments == [existing_payment]
extract_mock.assert_called_once()
stop_mock.assert_awaited_once_with(ext_id)
start_mock.assert_awaited_once_with(ext_id)
@pytest.mark.anyio
async def test_uninstall_activate_and_deactivate_extensions(
tmp_path, settings: Settings, mocker: MockerFixture
):
ext_id = f"ext_{uuid4().hex[:8]}"
ext_info = make_installable_extension(ext_id)
original_data_folder = settings.lnbits_data_folder
original_extensions_path = settings.lnbits_extensions_path
original_deactivated = set(settings.lnbits_deactivated_extensions)
stop_mock = mocker.patch(
"lnbits.core.services.extensions.stop_extension_background_work",
mocker.AsyncMock(return_value=True),
)
start_mock = mocker.patch(
"lnbits.core.services.extensions.start_extension_background_work",
mocker.AsyncMock(return_value=True),
)
clean_mock = mocker.patch.object(InstallableExtension, "clean_extension_files")
register_routes_mock = mocker.patch(
"lnbits.core.services.extensions.core_app_extra.register_new_ext_routes"
)
try:
settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path / "code")
await create_installed_extension(ext_info)
await uninstall_extension(ext_id)
assert await get_installed_extension(ext_id) is None
assert ext_id in settings.lnbits_deactivated_extensions
await create_installed_extension(ext_info)
await activate_extension(Extension(code=ext_id, is_valid=True))
active_ext = await get_installed_extension(ext_id)
assert active_ext is not None
assert active_ext.active is True
await deactivate_extension(ext_id)
inactive_ext = await get_installed_extension(ext_id)
assert inactive_ext is not None
assert inactive_ext.active is False
assert ext_id in settings.lnbits_deactivated_extensions
finally:
await delete_installed_extension(ext_id=ext_id)
settings.lnbits_data_folder = original_data_folder
settings.lnbits_extensions_path = original_extensions_path
settings.lnbits_deactivated_extensions = original_deactivated
clean_mock.assert_called_once()
register_routes_mock.assert_called_once()
assert stop_mock.await_count == 2
assert start_mock.await_count == 1
@pytest.mark.anyio
async def test_stop_extension_background_work_handles_missing_and_async_stops(
mocker: MockerFixture,
):
import_module_mock = mocker.patch(
"lnbits.core.services.extensions.importlib.import_module",
return_value=object(),
)
assert await stop_extension_background_work("demoext") is False
called = {"stop": False}
async def demoext_stop():
called["stop"] = True
import_module_mock.return_value = SimpleNamespace(demoext_stop=demoext_stop)
assert await stop_extension_background_work("demoext") is True
assert called["stop"] is True
@pytest.mark.anyio
async def test_start_extension_background_work_handles_missing_and_sync_starts(
mocker: MockerFixture,
):
import_module_mock = mocker.patch(
"lnbits.core.services.extensions.importlib.import_module",
return_value=object(),
)
assert await start_extension_background_work("demoext") is False
called = {"start": False}
def demoext_start():
called["start"] = True
import_module_mock.return_value = SimpleNamespace(demoext_start=demoext_start)
assert await start_extension_background_work("demoext") is True
assert called["start"] is True
@pytest.mark.anyio
async def test_get_valid_extensions_and_single_extension_respect_settings(
tmp_path, settings: Settings
):
ext_id_one = f"ext_{uuid4().hex[:8]}"
ext_id_two = f"ext_{uuid4().hex[:8]}"
ext_one = make_installable_extension(ext_id_one)
ext_two = make_installable_extension(ext_id_two)
original_deactivated = set(settings.lnbits_deactivated_extensions)
original_deactivate_all = settings.lnbits_extensions_deactivate_all
original_data_folder = settings.lnbits_data_folder
original_extensions_path = settings.lnbits_extensions_path
try:
settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path / "code")
settings.lnbits_deactivated_extensions = {ext_id_two}
settings.lnbits_extensions_deactivate_all = False
await create_installed_extension(ext_one)
await create_installed_extension(ext_two)
valid_extensions = await get_valid_extensions(include_deactivated=False)
valid_codes = {ext.code for ext in valid_extensions}
assert ext_id_one in valid_codes
assert ext_id_two not in valid_codes
assert (
await get_valid_extension(ext_id_one, include_deactivated=True) is not None
)
settings.lnbits_extensions_deactivate_all = True
assert await get_valid_extensions(include_deactivated=False) == []
assert await get_valid_extension(ext_id_one, include_deactivated=False) is None
finally:
await delete_installed_extension(ext_id=ext_id_one)
await delete_installed_extension(ext_id=ext_id_two)
settings.lnbits_deactivated_extensions = original_deactivated
settings.lnbits_extensions_deactivate_all = original_deactivate_all
settings.lnbits_data_folder = original_data_folder
settings.lnbits_extensions_path = original_extensions_path
@@ -0,0 +1,107 @@
import hashlib
import zipfile
from pathlib import Path
from types import SimpleNamespace
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.core.models.extensions import ExtensionRelease
from lnbits.core.services.extensions_builder import (
build_extension_from_data,
clean_extension_builder_data,
zip_directory,
)
from lnbits.settings import Settings
from tests.helpers import make_extension_data
@pytest.mark.anyio
async def test_build_extension_from_data_orchestrates_builder_steps(
tmp_path, mocker: MockerFixture
):
data = make_extension_data()
release = ExtensionRelease(
name="stub",
version="0.1.0",
archive="https://example.com/stub.zip",
source_repo="org/repo",
is_github_release=True,
)
build_dir = tmp_path / "build"
fetch_mock = mocker.patch(
"lnbits.core.services.extensions_builder._fetch_extension_builder_stub",
mocker.AsyncMock(),
)
transform_mock = mocker.patch(
"lnbits.core.services.extensions_builder._transform_extension_builder_stub"
)
export_mock = mocker.patch(
"lnbits.core.services.extensions_builder._export_extension_data_json"
)
mocker.patch(
"lnbits.core.services.extensions_builder._get_extension_stub_release",
mocker.AsyncMock(return_value=release),
)
mocker.patch(
"lnbits.core.services.extensions_builder._copy_ext_stub_to_build_dir",
return_value=build_dir,
)
mocker.patch(
"lnbits.core.services.extensions_builder.uuid4",
return_value=SimpleNamespace(hex="seed"),
)
built_release, output_dir = await build_extension_from_data(data, "stub-ext")
assert output_dir == build_dir
assert built_release == release
assert built_release.hash == hashlib.sha256(b"seed").hexdigest()
assert built_release.icon == "/demoext/static/image/demoext.png"
assert built_release.is_github_release is False
fetch_mock.assert_awaited_once_with("stub-ext", release)
transform_mock.assert_called_once_with(data, build_dir)
export_mock.assert_called_once_with(data, build_dir)
def test_clean_extension_builder_data_recreates_working_directory(
settings: Settings, tmp_path
):
original_data_folder = settings.lnbits_data_folder
try:
settings.lnbits_data_folder = str(tmp_path)
working_dir = settings.extension_builder_working_dir_path
working_dir.mkdir(parents=True, exist_ok=True)
Path(working_dir, "stale.txt").write_text("stale")
clean_extension_builder_data()
assert working_dir.is_dir()
assert list(working_dir.iterdir()) == []
finally:
settings.lnbits_data_folder = original_data_folder
def test_zip_directory_skips_excluded_directories(tmp_path):
source_dir = tmp_path / "source"
zip_path = tmp_path / "archive.zip"
(source_dir / "nested").mkdir(parents=True)
(source_dir / "node_modules").mkdir()
(source_dir / "__pycache__").mkdir()
(source_dir / "root.txt").write_text("root")
(source_dir / "nested" / "file.txt").write_text("nested")
(source_dir / "node_modules" / "ignored.txt").write_text("ignored")
(source_dir / "__pycache__" / "ignored.pyc").write_text("ignored")
from_builder = "lnbits.core.services.extensions_builder._is_excluded_dir"
with pytest.MonkeyPatch.context() as monkeypatch:
monkeypatch.setattr(
from_builder,
lambda path: "node_modules" in path or "__pycache__" in path,
)
zip_directory(source_dir, zip_path)
with zipfile.ZipFile(zip_path) as archive:
names = sorted(archive.namelist())
assert names == ["nested/file.txt", "root.txt"]
+171
View File
@@ -0,0 +1,171 @@
from types import SimpleNamespace
from typing import Any, cast
from uuid import uuid4
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.core.crud import create_account, create_wallet, get_total_balance
from lnbits.core.models import Account
from lnbits.core.models.misc import BalanceDelta
from lnbits.core.services.funding_source import (
check_balance_delta_changed,
check_server_balance_against_node,
get_balance_delta,
switch_to_voidwallet,
)
from lnbits.core.services.payments import update_wallet_balance
from lnbits.settings import Settings
@pytest.mark.anyio
async def test_switch_to_voidwallet_returns_when_already_using_voidwallet(
settings: Settings, mocker: MockerFixture
):
original_backend_class = settings.lnbits_backend_wallet_class
try:
settings.lnbits_backend_wallet_class = "FakeWallet"
mocker.patch(
"lnbits.core.services.funding_source.get_funding_source",
return_value=type("VoidWallet", (), {})(),
)
set_funding_source = mocker.patch(
"lnbits.core.services.funding_source.set_funding_source"
)
await switch_to_voidwallet()
set_funding_source.assert_not_called()
assert settings.lnbits_backend_wallet_class == "FakeWallet"
finally:
settings.lnbits_backend_wallet_class = original_backend_class
@pytest.mark.anyio
async def test_switch_to_voidwallet_updates_backend_class(
settings: Settings, mocker: MockerFixture
):
original_backend_class = settings.lnbits_backend_wallet_class
try:
settings.lnbits_backend_wallet_class = "FakeWallet"
mocker.patch(
"lnbits.core.services.funding_source.get_funding_source",
return_value=type("FakeWallet", (), {})(),
)
set_funding_source = mocker.patch(
"lnbits.core.services.funding_source.set_funding_source"
)
await switch_to_voidwallet()
set_funding_source.assert_called_once_with("VoidWallet")
assert settings.lnbits_backend_wallet_class == "VoidWallet"
finally:
settings.lnbits_backend_wallet_class = original_backend_class
@pytest.mark.anyio
async def test_get_balance_delta(mocker: MockerFixture):
baseline_balance = await get_total_balance()
await _create_wallet_with_balance(11)
funding_source = SimpleNamespace(
status=mocker.AsyncMock(
return_value=SimpleNamespace(balance_msat=7_000, error_message=None)
)
)
mocker.patch(
"lnbits.core.services.funding_source.get_funding_source",
return_value=funding_source,
)
delta = await get_balance_delta()
expected_balance_sats = (baseline_balance + 11_000) // 1000
assert delta.lnbits_balance_sats == expected_balance_sats
assert delta.node_balance_sats == 7
assert delta.delta_sats == expected_balance_sats - 7
@pytest.mark.anyio
async def test_check_server_balance_against_node_notifies_and_switches(
settings: Settings, mocker: MockerFixture
):
original_switch = settings.lnbits_watchdog_switch_to_voidwallet
original_notification = settings.lnbits_notification_watchdog
original_delta = settings.lnbits_watchdog_delta
try:
settings.lnbits_watchdog_switch_to_voidwallet = True
settings.lnbits_notification_watchdog = True
settings.lnbits_watchdog_delta = 5
mocker.patch(
"lnbits.core.services.funding_source.get_funding_source",
return_value=type("FakeWallet", (), {})(),
)
mocker.patch(
"lnbits.core.services.funding_source.get_balance_delta",
mocker.AsyncMock(
return_value=BalanceDelta(
lnbits_balance_sats=12,
node_balance_sats=1,
)
),
)
enqueue = mocker.patch(
"lnbits.core.services.funding_source.enqueue_admin_notification"
)
switch = mocker.patch(
"lnbits.core.services.funding_source.switch_to_voidwallet",
mocker.AsyncMock(),
)
await check_server_balance_against_node()
enqueue.assert_called_once()
switch.assert_awaited_once()
finally:
settings.lnbits_watchdog_switch_to_voidwallet = original_switch
settings.lnbits_notification_watchdog = original_notification
settings.lnbits_watchdog_delta = original_delta
@pytest.mark.anyio
async def test_check_balance_delta_changed_tracks_and_notifies(
settings: Settings, mocker: MockerFixture
):
settings_any = cast(Any, settings)
original_latest = settings.latest_balance_delta_sats
original_threshold = settings.notification_balance_delta_threshold_sats
try:
settings_any.latest_balance_delta_sats = None
settings.notification_balance_delta_threshold_sats = 3
mocker.patch(
"lnbits.core.services.funding_source.get_balance_delta",
mocker.AsyncMock(
side_effect=[
BalanceDelta(lnbits_balance_sats=12, node_balance_sats=10),
BalanceDelta(lnbits_balance_sats=20, node_balance_sats=10),
]
),
)
enqueue = mocker.patch(
"lnbits.core.services.funding_source.enqueue_admin_notification"
)
await check_balance_delta_changed()
enqueue.assert_not_called()
assert settings.latest_balance_delta_sats == 2
await check_balance_delta_changed()
enqueue.assert_called_once()
assert settings.latest_balance_delta_sats == 10
finally:
settings_any.latest_balance_delta_sats = original_latest
settings.notification_balance_delta_threshold_sats = original_threshold
async def _create_wallet_with_balance(amount: int):
user_id = uuid4().hex
await create_account(Account(id=user_id, username=f"user_{user_id[:8]}"))
wallet = await create_wallet(user_id=user_id, wallet_name="wallet")
await update_wallet_balance(wallet=wallet, amount=amount)
return wallet
+182
View File
@@ -0,0 +1,182 @@
from uuid import uuid4
import pytest
from bolt11.types import MilliSatoshi
from lnurl import (
LnAddress,
LnurlErrorResponse,
LnurlPayActionResponse,
LnurlResponseException,
LnurlSuccessResponse,
LnurlWithdrawResponse,
)
from lnurl.types import CallbackUrl, LightningInvoice
from pydantic import parse_obj_as
from pytest_mock.plugin import MockerFixture
from lnbits.core.crud import create_account, create_wallet, get_wallet
from lnbits.core.models import Account
from lnbits.core.models.lnurl import CreateLnurlPayment
from lnbits.core.models.wallets import Wallet
from lnbits.core.services.lnurl import (
fetch_lnurl_pay_request,
get_pr_from_lnurl,
perform_withdraw,
store_paylink,
)
from tests.helpers import make_lnurl_pay_response
TEST_BOLT11 = (
"lnbc1pnsu5z3pp57getmdaxhg5kc9yh2a2qsh7cjf4gnccgkw0qenm8vsqv50w7s"
"ygqdqj0fjhymeqv9kk7atwwscqzzsxqyz5vqsp5e2yyqcp0a3ujeesp24ya0glej"
"srh703md8mrx0g2lyvjxy5w27ss9qxpqysgqyjreasng8a086kpkczv48er5c6l5"
"73aym6ynrdl9nkzqnag49vt3sjjn8qdfq5cr6ha0vrdz5c5r3v4aghndly0hplmv"
"6hjxepwp93cq398l3s"
)
@pytest.mark.anyio
async def test_perform_withdraw_success_and_validation(mocker: MockerFixture):
withdraw_response = LnurlWithdrawResponse(
callback=parse_obj_as(CallbackUrl, "https://example.com/callback"),
k1="k1",
minWithdrawable=MilliSatoshi(1),
maxWithdrawable=MilliSatoshi(1000),
defaultDescription="test",
)
execute_withdraw_mock = mocker.patch(
"lnbits.core.services.lnurl.execute_withdraw",
mocker.AsyncMock(return_value=LnurlSuccessResponse()),
)
mocker.patch(
"lnbits.core.services.lnurl.handle",
mocker.AsyncMock(return_value=withdraw_response),
)
await perform_withdraw("lnurl", "bolt11")
execute_withdraw_mock.assert_awaited_once()
mocker.patch(
"lnbits.core.services.lnurl.check_callback_url",
side_effect=ValueError("blocked"),
)
with pytest.raises(LnurlResponseException, match="Invalid callback URL"):
await perform_withdraw("lnurl", "bolt11")
@pytest.mark.anyio
async def test_perform_withdraw_rejects_error_response(mocker: MockerFixture):
mocker.patch(
"lnbits.core.services.lnurl.handle",
mocker.AsyncMock(return_value=LnurlErrorResponse(reason="boom")),
)
with pytest.raises(LnurlResponseException, match="boom"):
await perform_withdraw("lnurl", "bolt11")
@pytest.mark.anyio
async def test_get_pr_from_lnurl_success_and_error(mocker: MockerFixture):
pay_response = make_lnurl_pay_response(min_sendable_msat=1, text="Test")
mocker.patch(
"lnbits.core.services.lnurl.handle",
mocker.AsyncMock(return_value=pay_response),
)
mocker.patch(
"lnbits.core.services.lnurl.execute_pay_request",
mocker.AsyncMock(
return_value=LnurlPayActionResponse(pr=LightningInvoice(TEST_BOLT11))
),
)
assert await get_pr_from_lnurl("lnurl", 1000, comment="hello") == TEST_BOLT11
mocker.patch(
"lnbits.core.services.lnurl.handle",
mocker.AsyncMock(return_value=LnurlErrorResponse(reason="nope")),
)
with pytest.raises(LnurlResponseException, match="nope"):
await get_pr_from_lnurl("lnurl", 1000)
@pytest.mark.anyio
async def test_fetch_lnurl_pay_request_converts_currency_and_stores_paylink(
mocker: MockerFixture,
):
pay_response = make_lnurl_pay_response(min_sendable_msat=1, text="Test")
action_response = LnurlPayActionResponse(
pr=LightningInvoice(TEST_BOLT11), disposable=False
)
mocker.patch(
"lnbits.core.services.lnurl.fiat_amount_as_satoshis",
mocker.AsyncMock(return_value=100),
)
execute_mock = mocker.patch(
"lnbits.core.services.lnurl.execute_pay_request",
mocker.AsyncMock(return_value=action_response),
)
store_paylink_mock = mocker.patch(
"lnbits.core.services.lnurl.store_paylink",
mocker.AsyncMock(),
)
wallet = _make_wallet()
data = CreateLnurlPayment(res=pay_response, amount=2500, unit="USD", comment="hi")
response, action = await fetch_lnurl_pay_request(data, wallet=wallet)
assert response == pay_response
assert action == action_response
execute_mock.assert_awaited_once()
assert execute_mock.await_args is not None
assert execute_mock.await_args.kwargs["msat"] == 100_000
store_paylink_mock.assert_awaited_once_with(
pay_response, action_response, wallet, None
)
with pytest.raises(LnurlResponseException, match="No LNURL pay request provided."):
await fetch_lnurl_pay_request(CreateLnurlPayment(amount=1))
@pytest.mark.anyio
async def test_store_paylink_appends_and_updates_existing():
wallet = await _create_wallet()
pay_response = make_lnurl_pay_response(min_sendable_msat=1, text="Test")
action_response = LnurlPayActionResponse(
pr=LightningInvoice(TEST_BOLT11), disposable=False
)
await store_paylink(
pay_response, action_response, wallet, LnAddress("alice@example.com")
)
stored_wallet = await get_wallet(wallet.id)
assert stored_wallet is not None
assert len(stored_wallet.stored_paylinks.links) == 1
assert stored_wallet.stored_paylinks.links[0].lnurl == "alice@example.com"
first_used = stored_wallet.stored_paylinks.links[0].last_used
await store_paylink(
pay_response, action_response, wallet, LnAddress("alice@example.com")
)
stored_wallet = await get_wallet(wallet.id)
assert stored_wallet is not None
assert len(stored_wallet.stored_paylinks.links) == 1
assert stored_wallet.stored_paylinks.links[0].last_used >= first_used
def _make_wallet() -> Wallet:
return Wallet(
id="wallet-id",
user="user-id",
name="Wallet",
adminkey="admin-key",
inkey="invoice-key",
)
async def _create_wallet() -> Wallet:
user_id = uuid4().hex
await create_account(Account(id=user_id, username=f"user_{user_id[:8]}"))
return await create_wallet(user_id=user_id, wallet_name="Wallet")
+117
View File
@@ -0,0 +1,117 @@
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.core.services.nostr import fetch_nip5_details, send_nostr_dm
class FakeWebSocket:
def __init__(self):
self.sent: list[str] = []
self.closed = False
def send(self, message: str):
self.sent.append(message)
def close(self):
self.closed = True
class MockHTTPClient:
def __init__(self, response):
self.response = response
self.calls: list[str] = []
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def get(self, url: str):
self.calls.append(url)
return self.response
class MockHTTPResponse:
def __init__(self, json_data: dict, error: Exception | None = None):
self._json_data = json_data
self._error = error
def raise_for_status(self):
if self._error:
raise self._error
def json(self):
return self._json_data
@pytest.mark.anyio
async def test_send_nostr_dm_sends_to_available_relays_and_closes_connections(
mocker: MockerFixture,
):
event = mocker.Mock()
event.to_message.return_value = "nostr-message"
event.to_dict.return_value = {"id": "event-id"}
dm = mocker.Mock()
dm.to_event.return_value = event
mocker.patch("lnbits.core.services.nostr.EncryptedDirectMessage", return_value=dm)
ws_one = FakeWebSocket()
ws_two = FakeWebSocket()
mocker.patch(
"lnbits.core.services.nostr.create_connection",
side_effect=[ws_one, RuntimeError("boom"), ws_two],
)
mocker.patch("lnbits.core.services.nostr.asyncio.sleep", mocker.AsyncMock())
result = await send_nostr_dm(
"privkey",
"pubkey",
"hello",
["wss://relay-1", "wss://broken", "wss://relay-2"],
)
assert ws_one.sent == ["nostr-message"]
assert ws_two.sent == ["nostr-message"]
assert ws_one.closed is True
assert ws_two.closed is True
assert result == {"id": "event-id"}
@pytest.mark.anyio
async def test_fetch_nip5_details_returns_pubkey_and_relays(mocker: MockerFixture):
response = MockHTTPResponse(
{
"names": {"alice": "f" * 64},
"relays": {"f" * 64: ["wss://relay.example.com"]},
}
)
client = MockHTTPClient(response)
mocker.patch("lnbits.core.services.nostr.is_valid_url", return_value=True)
validate_identifier = mocker.patch("lnbits.core.services.nostr.validate_identifier")
validate_pub_key = mocker.patch("lnbits.core.services.nostr.validate_pub_key")
mocker.patch("lnbits.core.services.nostr.httpx.AsyncClient", return_value=client)
pubkey, relays = await fetch_nip5_details("alice@example.com")
validate_identifier.assert_called_once_with("alice")
validate_pub_key.assert_called_once_with("f" * 64)
assert client.calls == ["https://example.com/.well-known/nostr.json?name=alice"]
assert pubkey == "f" * 64
assert relays == ["wss://relay.example.com"]
@pytest.mark.anyio
async def test_fetch_nip5_details_rejects_invalid_values(mocker: MockerFixture):
with pytest.raises(ValueError, match="not enough values to unpack"):
await fetch_nip5_details("invalid")
mocker.patch("lnbits.core.services.nostr.is_valid_url", return_value=False)
with pytest.raises(ValueError, match="Invalid NIP5 domain"):
await fetch_nip5_details("alice@example.com")
mocker.patch("lnbits.core.services.nostr.is_valid_url", return_value=True)
client = MockHTTPClient(MockHTTPResponse({"names": {}}))
mocker.patch("lnbits.core.services.nostr.httpx.AsyncClient", return_value=client)
with pytest.raises(ValueError, match="NIP5 not name found"):
await fetch_nip5_details("alice@example.com")
+608
View File
@@ -0,0 +1,608 @@
import asyncio
from http import HTTPStatus
from types import SimpleNamespace
from unittest.mock import MagicMock
from uuid import uuid4
import httpx
import pytest
from pytest_mock.plugin import MockerFixture
from pywebpush import WebPushException
from lnbits.core.crud import (
create_account,
create_payment,
create_wallet,
create_webpush_subscription,
get_payment,
get_webpush_subscription,
update_payment,
update_wallet,
)
from lnbits.core.models import Account, CreatePayment, Payment, PaymentState, Wallet
from lnbits.core.models.notifications import NotificationType
from lnbits.core.models.users import UserExtra, UserNotifications
from lnbits.core.models.wallets import (
WalletPermission,
WalletSharePermission,
WalletShareStatus,
)
from lnbits.core.services.notifications import (
dispatch_webhook,
enqueue_admin_notification,
enqueue_user_notification,
process_next_notification,
send_admin_notification,
send_chat_payment_notification,
send_email,
send_email_notification,
send_nostr_notification,
send_nostr_notifications,
send_notification,
send_payment_notification,
send_payment_push_notification,
send_push_notification,
send_telegram_message,
send_telegram_notification,
send_user_notification,
send_ws_payment_notification,
)
from lnbits.settings import Settings
class MockHTTPClient:
def __init__(self, post_response=None, post_exception=None):
self.post_response = post_response
self.post_exception = post_exception
self.posts: list[tuple[str, dict]] = []
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return None
async def post(self, url, **kwargs):
self.posts.append((url, kwargs))
if self.post_exception:
raise self.post_exception
return self.post_response
@pytest.mark.anyio
async def test_enqueue_and_process_notifications(
settings: Settings, mocker: MockerFixture
):
queue: asyncio.Queue = asyncio.Queue()
admin_mock = mocker.patch(
"lnbits.core.services.notifications.send_admin_notification",
mocker.AsyncMock(),
)
user_mock = mocker.patch(
"lnbits.core.services.notifications.send_user_notification",
mocker.AsyncMock(),
)
mocker.patch("lnbits.core.services.notifications.notifications_queue", queue)
mocker.patch(
"lnbits.core.services.notifications._is_message_type_enabled",
return_value=True,
)
enqueue_admin_notification(NotificationType.settings_update, {"username": "alice"})
await process_next_notification()
assert admin_mock.await_count == 1
assert admin_mock.await_args is not None
assert admin_mock.await_args.args[0].startswith(f"[{settings.lnbits_site_title}]")
assert "alice" in admin_mock.await_args.args[0]
assert admin_mock.await_args.args[1] == NotificationType.settings_update.value
user_notifications = UserNotifications(email_address="alice@example.com")
enqueue_user_notification(
NotificationType.text_message,
{"message": "hello"},
user_notifications,
)
await process_next_notification()
assert user_mock.await_count == 1
assert user_mock.await_args is not None
assert user_mock.await_args.args[0] == user_notifications
assert "hello" in user_mock.await_args.args[1]
assert user_mock.await_args.args[2] == NotificationType.text_message.value
@pytest.mark.anyio
async def test_send_admin_and_user_notification_use_expected_targets(
settings: Settings, mocker: MockerFixture
):
send_mock = mocker.patch(
"lnbits.core.services.notifications.send_notification",
mocker.AsyncMock(),
)
original_chat_id = settings.lnbits_telegram_notifications_chat_id
original_identifiers = list(settings.lnbits_nostr_notifications_identifiers)
original_emails = list(settings.lnbits_email_notifications_to_emails)
try:
settings.lnbits_telegram_notifications_chat_id = "chat-id"
settings.lnbits_nostr_notifications_identifiers = ["alice@example.com"]
settings.lnbits_email_notifications_to_emails = ["admin@example.com"]
await send_admin_notification("hello", "settings_update")
await send_user_notification(
UserNotifications(
telegram_chat_id="user-chat",
nostr_identifier="bob@example.com",
email_address="bob@example.com",
),
"hello user",
"text_message",
)
finally:
settings.lnbits_telegram_notifications_chat_id = original_chat_id
settings.lnbits_nostr_notifications_identifiers = original_identifiers
settings.lnbits_email_notifications_to_emails = original_emails
assert send_mock.await_args_list[0].args == (
"chat-id",
["alice@example.com"],
["admin@example.com"],
"hello",
"settings_update",
)
assert send_mock.await_args_list[1].args == (
"user-chat",
["bob@example.com"],
["bob@example.com"],
"hello user",
"text_message",
)
@pytest.mark.anyio
async def test_send_notification_uses_available_channels_and_swallows_exceptions(
settings: Settings, mocker: MockerFixture
):
original_email_enabled = settings.lnbits_email_notifications_enabled
try:
settings.lnbits_email_notifications_enabled = True
mocker.patch.object(
type(settings),
"is_telegram_notifications_configured",
return_value=True,
)
mocker.patch.object(
type(settings),
"is_nostr_notifications_configured",
return_value=True,
)
telegram_mock = mocker.patch(
"lnbits.core.services.notifications.send_telegram_notification",
mocker.AsyncMock(side_effect=Exception("telegram boom")),
)
nostr_mock = mocker.patch(
"lnbits.core.services.notifications.send_nostr_notifications",
mocker.AsyncMock(return_value=["alice@example.com"]),
)
email_mock = mocker.patch(
"lnbits.core.services.notifications.send_email_notification",
mocker.AsyncMock(side_effect=Exception("email boom")),
)
await send_notification(
"chat-id",
["alice@example.com"],
["alice@example.com"],
"hello",
"text_message",
)
finally:
settings.lnbits_email_notifications_enabled = original_email_enabled
telegram_mock.assert_awaited_once()
nostr_mock.assert_awaited_once()
email_mock.assert_awaited_once()
@pytest.mark.anyio
async def test_send_nostr_notifications_and_single_notification(
mocker: MockerFixture,
):
send_mock = mocker.patch(
"lnbits.core.services.notifications.send_nostr_notification",
mocker.AsyncMock(side_effect=[None, Exception("boom"), None]),
)
result = await send_nostr_notifications(["ok-1", "bad", "ok-2"], "hello")
assert result == ["ok-1", "ok-2"]
assert send_mock.await_count == 3
fetch_mock = mocker.patch(
"lnbits.core.services.notifications.fetch_nip5_details",
mocker.AsyncMock(return_value=("pubkey", ["wss://relay"])),
)
normalize_mock = mocker.patch(
"lnbits.core.services.notifications.normalize_private_key",
return_value="server-private-key",
)
dm_mock = mocker.patch(
"lnbits.core.services.notifications.send_nostr_dm",
mocker.AsyncMock(),
)
await send_nostr_notification("alice@example.com", "hello")
fetch_mock.assert_awaited_once_with("alice@example.com")
normalize_mock.assert_called_once()
dm_mock.assert_awaited_once_with(
"server-private-key",
"pubkey",
"hello",
["wss://relay"],
)
@pytest.mark.anyio
async def test_send_telegram_message_and_wrapper(
settings: Settings, mocker: MockerFixture
):
response = httpx.Response(
200,
request=httpx.Request("POST", "https://api.telegram.org"),
json={"ok": True},
)
client = MockHTTPClient(post_response=response)
mocker.patch(
"lnbits.core.services.notifications.httpx.AsyncClient",
return_value=client,
)
result = await send_telegram_message("token", "chat-id", "hello")
assert result == {"ok": True}
assert client.posts[0][0].endswith("/bottoken/sendMessage")
original_token = settings.lnbits_telegram_notifications_access_token
try:
settings.lnbits_telegram_notifications_access_token = "wrapper-token"
wrapper_mock = mocker.patch(
"lnbits.core.services.notifications.send_telegram_message",
mocker.AsyncMock(return_value={"ok": True}),
)
await send_telegram_notification("chat-id", "hello")
finally:
settings.lnbits_telegram_notifications_access_token = original_token
wrapper_mock.assert_awaited_once_with("wrapper-token", "chat-id", "hello")
@pytest.mark.anyio
async def test_send_email_notification_and_send_email(
settings: Settings, mocker: MockerFixture
):
original_email_enabled = settings.lnbits_email_notifications_enabled
try:
settings.lnbits_email_notifications_enabled = False
disabled = await send_email_notification(["alice@example.com"], "hello")
assert disabled["status"] == "error"
settings.lnbits_email_notifications_enabled = True
send_email_mock = mocker.patch(
"lnbits.core.services.notifications.send_email",
mocker.AsyncMock(return_value=True),
)
enabled = await send_email_notification(["alice@example.com"], "hello")
assert enabled == {"status": "ok"}
send_email_mock.assert_awaited_once()
finally:
settings.lnbits_email_notifications_enabled = original_email_enabled
smtp_server = MagicMock()
smtp_context = MagicMock()
smtp_context.__enter__.return_value = smtp_server
smtp_context.__exit__.return_value = None
mocker.patch(
"lnbits.core.services.notifications.smtplib.SMTP",
return_value=smtp_context,
)
assert (
await send_email(
"smtp.example.com",
587,
"",
"password",
"from@example.com",
["to@example.com"],
"Subject",
"Body",
)
is True
)
smtp_server.starttls.assert_called_once()
smtp_server.login.assert_called_once_with("from@example.com", "password")
smtp_server.sendmail.assert_called_once()
with pytest.raises(ValueError, match="Invalid from email address"):
await send_email(
"smtp.example.com",
587,
"user",
"password",
"bad-email",
["to@example.com"],
"Subject",
"Body",
)
with pytest.raises(ValueError, match="No email addresses provided"):
await send_email(
"smtp.example.com",
587,
"user",
"password",
"from@example.com",
[],
"Subject",
"Body",
)
@pytest.mark.anyio
async def test_dispatch_webhook_marks_missing_invalid_and_failed_requests(
mocker: MockerFixture,
):
wallet = await _create_wallet()
payment = await _create_payment(wallet, webhook=None)
await dispatch_webhook(payment)
assert (await get_payment(payment.checking_id)).webhook_status == "-1"
invalid_payment = await _create_payment(wallet, webhook="https://invalid.example")
assert invalid_payment.webhook is not None
invalid_client = MockHTTPClient(
post_response=httpx.Response(
200,
request=httpx.Request("POST", invalid_payment.webhook),
json={"ok": True},
)
)
mocker.patch(
"lnbits.core.services.notifications.check_callback_url",
side_effect=ValueError("blocked"),
)
mocker.patch(
"lnbits.core.services.notifications.httpx.AsyncClient",
return_value=invalid_client,
)
await dispatch_webhook(invalid_payment)
assert (await get_payment(invalid_payment.checking_id)).webhook_status in {
"-1",
"200",
}
error_payment = await _create_payment(wallet, webhook="https://error.example")
assert error_payment.webhook is not None
mocker.patch(
"lnbits.core.services.notifications.check_callback_url",
return_value=None,
)
mocker.patch(
"lnbits.core.services.notifications.httpx.AsyncClient",
return_value=MockHTTPClient(
post_response=httpx.Response(
500,
request=httpx.Request("POST", error_payment.webhook),
)
),
)
await dispatch_webhook(error_payment)
assert (await get_payment(error_payment.checking_id)).webhook_status == "500"
request_payment = await _create_payment(wallet, webhook="https://request.example")
assert request_payment.webhook is not None
mocker.patch(
"lnbits.core.services.notifications.httpx.AsyncClient",
return_value=MockHTTPClient(
post_exception=httpx.RequestError(
"boom",
request=httpx.Request("POST", request_payment.webhook),
)
),
)
await dispatch_webhook(request_payment)
assert (await get_payment(request_payment.checking_id)).webhook_status == "-1"
@pytest.mark.anyio
async def test_send_payment_notification_fans_out_to_shared_wallet_and_webhook(
mocker: MockerFixture,
):
wallet = await _create_wallet(name="Primary Wallet")
shared_wallet = await _create_wallet(name="Shared Wallet")
wallet.extra.shared_with = [
WalletSharePermission(
request_id="share-1",
username="bob",
shared_with_wallet_id=shared_wallet.id,
permissions=[WalletPermission.VIEW_PAYMENTS],
status=WalletShareStatus.APPROVED,
)
]
await update_wallet(wallet)
payment = await _create_payment(wallet, webhook="https://webhook.example")
ws_mock = mocker.patch(
"lnbits.core.services.notifications.send_ws_payment_notification",
mocker.AsyncMock(),
)
chat_mock = mocker.patch(
"lnbits.core.services.notifications.send_chat_payment_notification",
mocker.AsyncMock(),
)
push_mock = mocker.patch(
"lnbits.core.services.notifications.send_payment_push_notification",
mocker.AsyncMock(),
)
dispatch_mock = mocker.patch(
"lnbits.core.services.notifications.dispatch_webhook",
mocker.AsyncMock(),
)
await send_payment_notification(wallet, payment)
assert [call.args[0].id for call in ws_mock.await_args_list] == [
wallet.id,
shared_wallet.id,
]
chat_mock.assert_awaited_once_with(wallet, payment)
push_mock.assert_awaited_once_with(wallet, payment)
dispatch_mock.assert_awaited_once_with(payment)
@pytest.mark.anyio
async def test_send_ws_payment_notification_and_chat_notifications(
settings: Settings, mocker: MockerFixture
):
user_notifications = UserNotifications(
telegram_chat_id="chat-id",
nostr_identifier="alice@example.com",
email_address="alice@example.com",
incoming_payments_sats=1,
outgoing_payments_sats=1,
)
wallet = await _create_wallet(user_notifications)
payment = await _create_payment(
wallet,
amount_msat=-2_000,
extra={"wallet_fiat_currency": "USD", "wallet_fiat_amount": 5.25},
)
websocket_mock = mocker.patch(
"lnbits.core.services.notifications.websocket_manager.send",
mocker.AsyncMock(),
)
await send_ws_payment_notification(wallet, payment)
assert [call.args[0] for call in websocket_mock.await_args_list] == [
wallet.inkey,
wallet.adminkey,
payment.payment_hash,
]
original_outgoing = settings.lnbits_notification_outgoing_payment_amount_sats
original_incoming = settings.lnbits_notification_incoming_payment_amount_sats
try:
settings.lnbits_notification_outgoing_payment_amount_sats = 1
settings.lnbits_notification_incoming_payment_amount_sats = 1
admin_mock = mocker.patch(
"lnbits.core.services.notifications.enqueue_admin_notification"
)
user_mock = mocker.patch(
"lnbits.core.services.notifications.enqueue_user_notification"
)
await send_chat_payment_notification(wallet, payment)
finally:
settings.lnbits_notification_outgoing_payment_amount_sats = original_outgoing
settings.lnbits_notification_incoming_payment_amount_sats = original_incoming
assert admin_mock.call_args.args[0] == NotificationType.outgoing_payment
assert "`5.25`*USD* / " in admin_mock.call_args.args[1]["fiat_value_fmt"]
assert user_mock.call_args.args[0] == NotificationType.outgoing_payment
@pytest.mark.anyio
async def test_send_payment_push_notification_and_cleanup_gone_subscriptions(
settings: Settings, mocker: MockerFixture
):
wallet = await _create_wallet()
payment = await _create_payment(wallet, amount_msat=2_000, memo="Thanks")
endpoint = f"https://push.example/{uuid4().hex}"
subscription = await create_webpush_subscription(
endpoint,
wallet.user,
'{"endpoint":"https://push.example"}',
"push.example",
)
send_push_mock = mocker.patch(
"lnbits.core.services.notifications.send_push_notification",
mocker.AsyncMock(),
)
await send_payment_push_notification(wallet, payment)
assert send_push_mock.await_args is not None
assert send_push_mock.await_args.args[0].endpoint == subscription.endpoint
assert send_push_mock.await_args.args[1] == f"LNbits: {wallet.name}"
assert "received 2 sats" in send_push_mock.await_args.args[2]
assert send_push_mock.await_args.args[3] == (
f"https://{subscription.host}/wallet?usr={wallet.user}&wal={wallet.id}"
)
original_privkey = settings.lnbits_webpush_privkey
try:
settings.lnbits_webpush_privkey = ""
exc = WebPushException("gone")
exc.response = SimpleNamespace(status_code=HTTPStatus.GONE, text="gone")
mocker.patch(
"lnbits.core.services.notifications.webpush",
side_effect=exc,
)
await send_push_notification(subscription, "Title", "Body")
finally:
settings.lnbits_webpush_privkey = original_privkey
assert await get_webpush_subscription(subscription.endpoint, wallet.user) is None
async def _create_wallet(
notifications: UserNotifications | None = None,
*,
name: str | None = None,
) -> Wallet:
account = Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
extra=UserExtra(notifications=notifications or UserNotifications()),
)
await create_account(account)
return await create_wallet(
user_id=account.id,
wallet_name=name or f"wallet_{account.id[:8]}",
)
async def _create_payment(
wallet: Wallet,
*,
amount_msat: int = 2_000,
status: PaymentState = PaymentState.SUCCESS,
webhook: str | None = None,
webhook_status: str | None = None,
memo: str | None = "memo",
extra: dict | None = None,
) -> Payment:
checking_id = f"checking_{uuid4().hex[:8]}"
payment = await create_payment(
checking_id=checking_id,
data=CreatePayment(
wallet_id=wallet.id,
payment_hash=uuid4().hex,
bolt11=f"bolt11-{checking_id}",
amount_msat=amount_msat,
memo=memo or "",
webhook=webhook,
extra=extra or {},
),
status=status,
)
if webhook_status is not None:
payment.webhook_status = webhook_status
await update_payment(payment)
return await get_payment(checking_id)
+456
View File
@@ -0,0 +1,456 @@
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from uuid import uuid4
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.core.crud import (
create_account,
create_payment,
create_wallet,
get_payment,
get_payments,
update_payment,
)
from lnbits.core.models import (
Account,
CreateInvoice,
CreatePayment,
PaymentState,
Wallet,
)
from lnbits.core.services.payments import (
calculate_fiat_amounts,
cancel_hold_invoice,
check_payment_status,
check_pending_payments,
check_time_limit_between_transactions,
check_transaction_status,
check_wallet_limits,
create_payment_request,
get_payments_daily_stats,
settle_hold_invoice,
update_pending_payment,
update_pending_payments,
update_wallet_balance,
)
from lnbits.db import Filters
from lnbits.exceptions import InvoiceError, PaymentError
from lnbits.settings import Settings
from lnbits.wallets.base import (
InvoiceResponse,
PaymentFailedStatus,
PaymentPendingStatus,
PaymentSuccessStatus,
)
@pytest.mark.anyio
async def test_create_payment_request_routes_by_invoice_type(mocker: MockerFixture):
wallet_payment = SimpleNamespace(checking_id="wallet")
fiat_payment = SimpleNamespace(checking_id="fiat")
wallet_mock = mocker.patch(
"lnbits.core.services.payments.create_wallet_invoice",
mocker.AsyncMock(return_value=wallet_payment),
)
fiat_mock = mocker.patch(
"lnbits.core.services.payments.create_fiat_invoice",
mocker.AsyncMock(return_value=fiat_payment),
)
assert (
await create_payment_request("wallet-1", CreateInvoice(amount=1))
== wallet_payment
)
assert (
await create_payment_request(
"wallet-1",
CreateInvoice(amount=1, fiat_provider="stripe"),
)
== fiat_payment
)
wallet_mock.assert_awaited_once()
fiat_mock.assert_awaited_once()
@pytest.mark.anyio
async def test_update_pending_payment_and_bulk_pending_updates(mocker: MockerFixture):
wallet = await _create_wallet()
failed_id = await _create_payment(wallet)
success_id = await _create_payment(wallet)
failed_payment = await get_payment(failed_id)
success_payment = await get_payment(success_id)
mocker.patch(
"lnbits.core.services.payments.check_payment_status",
mocker.AsyncMock(side_effect=[PaymentFailedStatus(), PaymentSuccessStatus()]),
)
await update_pending_payment(failed_payment)
await update_pending_payment(success_payment)
assert (await get_payment(failed_id)).status == PaymentState.FAILED
assert (await get_payment(success_id)).status == PaymentState.SUCCESS
bulk_wallet = await _create_wallet()
bulk_failed_id = await _create_payment(bulk_wallet)
bulk_success_id = await _create_payment(bulk_wallet)
mocker.patch(
"lnbits.core.services.payments.check_payment_status",
mocker.AsyncMock(side_effect=[PaymentFailedStatus(), PaymentSuccessStatus()]),
)
await update_pending_payments(bulk_wallet.id)
bulk_statuses = {
(await get_payment(bulk_failed_id)).status,
(await get_payment(bulk_success_id)).status,
}
assert bulk_statuses == {PaymentState.FAILED, PaymentState.SUCCESS}
@pytest.mark.anyio
async def test_check_pending_payments_skips_voidwallet_and_updates_recent_items(
mocker: MockerFixture,
):
class VoidWallet:
pass
class FakeWalletSource:
pass
mocker.patch(
"lnbits.core.services.payments.get_funding_source",
return_value=VoidWallet(),
)
sleep_mock = mocker.patch(
"lnbits.core.services.payments.asyncio.sleep", mocker.AsyncMock()
)
await check_pending_payments()
sleep_mock.assert_not_awaited()
existing_pending = await get_payments(pending=True, exclude_uncheckable=True)
wallet = await _create_wallet()
checking_id = await _create_payment(wallet)
mocker.patch(
"lnbits.core.services.payments.get_funding_source",
return_value=FakeWalletSource(),
)
mocker.patch(
"lnbits.core.services.payments.check_payment_status",
mocker.AsyncMock(return_value=PaymentSuccessStatus()),
)
try:
await check_pending_payments()
finally:
for payment in existing_pending:
payment.status = PaymentState.PENDING
await update_payment(payment)
assert (await get_payment(checking_id)).status == PaymentState.SUCCESS
assert sleep_mock.await_count >= 1
@pytest.mark.anyio
async def test_update_wallet_balance_validates_credit_and_debit(
settings: Settings, mocker: MockerFixture
):
wallet = await _create_wallet()
wallet.balance_msat = 20_000
with pytest.raises(ValueError, match="Amount cannot be 0."):
await update_wallet_balance(wallet, 0)
with pytest.raises(ValueError, match="can not go into negative balance"):
await update_wallet_balance(wallet, -30)
payment_secret = (uuid4().hex * 2)[:64]
payment_hash = (uuid4().hex * 2)[:64]
mocker.patch(
"lnbits.core.services.payments.random_secret_and_hash",
return_value=(payment_secret, payment_hash),
)
mocker.patch(
"lnbits.core.services.payments.fake_privkey",
return_value="privkey",
)
mocker.patch(
"lnbits.core.services.payments.bolt11_encode",
return_value="encoded-bolt11",
)
await update_wallet_balance(wallet, -10)
debit_payment = await get_payment("internal_" + payment_hash)
assert debit_payment is not None
assert debit_payment.amount == -10_000
assert debit_payment.status == PaymentState.SUCCESS
original_max_balance = settings.lnbits_wallet_limit_max_balance
try:
settings.lnbits_wallet_limit_max_balance = 21
with pytest.raises(ValueError, match="amount exceeds maximum balance"):
await update_wallet_balance(wallet, 5)
settings.lnbits_wallet_limit_max_balance = 0
queue_mock = mocker.patch(
"lnbits.tasks.internal_invoice_queue_put",
mocker.AsyncMock(),
)
await update_wallet_balance(wallet, 5)
finally:
settings.lnbits_wallet_limit_max_balance = original_max_balance
credit_payments = [
payment
for payment in await get_payments(wallet_id=wallet.id, incoming=True)
if payment.memo == "Admin credit"
]
assert credit_payments
assert credit_payments[0].status == PaymentState.SUCCESS
queue_mock.assert_awaited_once_with(credit_payments[0].checking_id)
@pytest.mark.anyio
async def test_check_wallet_limits_and_time_limit(
settings: Settings, mocker: MockerFixture
):
time_limit_mock = mocker.patch(
"lnbits.core.services.payments.check_time_limit_between_transactions",
mocker.AsyncMock(),
)
daily_limit_mock = mocker.patch(
"lnbits.core.services.payments.check_wallet_daily_withdraw_limit",
mocker.AsyncMock(),
)
await check_wallet_limits("wallet-1", 1_000)
time_limit_mock.assert_awaited_once_with("wallet-1", None)
daily_limit_mock.assert_awaited_once_with("wallet-1", 1_000, None)
wallet = await _create_wallet()
await _create_payment(wallet, amount_msat=-2_000)
original_limit = settings.lnbits_wallet_limit_secs_between_trans
try:
settings.lnbits_wallet_limit_secs_between_trans = 30
with pytest.raises(PaymentError) as exc_info:
await check_time_limit_between_transactions(wallet.id)
assert "30 seconds between payments" in exc_info.value.message
other_wallet = await _create_wallet()
assert await check_time_limit_between_transactions(other_wallet.id) is None
finally:
settings.lnbits_wallet_limit_secs_between_trans = original_limit
@pytest.mark.anyio
async def test_calculate_fiat_amounts_handles_conversion_and_errors(
mocker: MockerFixture,
):
wallet = await _create_wallet()
wallet.currency = "EUR"
mocker.patch(
"lnbits.core.services.payments.fiat_amount_as_satoshis",
mocker.AsyncMock(return_value=200),
)
sat_to_fiat_mock = mocker.patch(
"lnbits.core.services.payments.satoshis_amount_as_fiat",
mocker.AsyncMock(return_value=1.5),
)
amount_sat, fiat_amounts = await calculate_fiat_amounts(2.0, wallet, "USD")
assert amount_sat == 200
assert fiat_amounts["fiat_currency"] == "USD"
assert fiat_amounts["wallet_fiat_currency"] == "EUR"
assert fiat_amounts["wallet_fiat_amount"] == 1.5
sat_to_fiat_mock.side_effect = Exception("boom")
amount_sat, fiat_amounts = await calculate_fiat_amounts(10, wallet, "sat", extra={})
assert amount_sat == 10
assert fiat_amounts == {}
@pytest.mark.anyio
async def test_check_transaction_status_and_payment_status(mocker: MockerFixture):
wallet = await _create_wallet()
missing_hash = uuid4().hex
assert (await check_transaction_status(wallet.id, missing_hash)).pending is True
success_hash = uuid4().hex
success_id = await _create_payment(
wallet,
status=PaymentState.SUCCESS,
payment_hash=success_hash,
fee=-123,
)
success_status = await check_transaction_status(wallet.id, success_hash)
assert success_status.success is True
assert success_status.fee_msat == -123
pending_hash = uuid4().hex
await _create_payment(wallet, payment_hash=pending_hash)
mocker.patch(
"lnbits.core.services.payments.check_payment_status",
mocker.AsyncMock(return_value=PaymentFailedStatus()),
)
assert (await check_transaction_status(wallet.id, pending_hash)).failed is True
internal_success = await get_payment(success_id)
internal_success.checking_id = "internal_" + internal_success.payment_hash
internal_success.status = PaymentState.SUCCESS.value
assert (await check_payment_status(internal_success)).success is True
internal_failed = await get_payment(success_id)
internal_failed.checking_id = "internal_" + internal_failed.payment_hash
internal_failed.status = PaymentState.FAILED.value
assert (await check_payment_status(internal_failed)).failed is True
internal_fiat = await get_payment(success_id)
internal_fiat.checking_id = "fiat_" + internal_fiat.payment_hash
internal_fiat.status = PaymentState.PENDING.value
internal_fiat.fiat_provider = "stripe"
mocker.patch(
"lnbits.core.services.payments.check_fiat_status",
mocker.AsyncMock(return_value=SimpleNamespace(paid=True)),
)
assert (await check_payment_status(internal_fiat)).success is True
outgoing = await get_payment(success_id)
outgoing.checking_id = "external-out"
outgoing.amount = -2_000
incoming = await get_payment(success_id)
incoming.checking_id = "external-in"
incoming.amount = 2_000
funding_source = SimpleNamespace(
get_payment_status=mocker.AsyncMock(return_value=PaymentSuccessStatus()),
get_invoice_status=mocker.AsyncMock(return_value=PaymentPendingStatus()),
)
mocker.patch(
"lnbits.core.services.payments.get_funding_source",
return_value=funding_source,
)
assert (await check_payment_status(outgoing)).success is True
assert (await check_payment_status(incoming)).pending is True
@pytest.mark.anyio
async def test_get_payments_daily_stats_fills_missing_dates():
wallet = await _create_wallet()
user_id = wallet.user
now = datetime.now(timezone.utc).replace(hour=12, minute=0, second=0, microsecond=0)
await _create_payment(
wallet,
amount_msat=2_000,
status=PaymentState.SUCCESS,
time=now - timedelta(days=2),
)
await _create_payment(
wallet,
amount_msat=-500,
status=PaymentState.SUCCESS,
fee=100,
time=now,
)
stats = await get_payments_daily_stats(Filters(), user_id=user_id)
assert [point.date.date() for point in stats[-3:]] == [
(now - timedelta(days=2)).date(),
(now - timedelta(days=1)).date(),
now.date(),
]
assert [point.balance for point in stats[-3:]] == [2, 2, 1]
assert stats[-1].fee == 0
@pytest.mark.anyio
async def test_settle_and_cancel_hold_invoice_persist_status(mocker: MockerFixture):
wallet = await _create_wallet()
checking_id = await _create_payment(wallet, payment_hash="33" * 32)
payment = await get_payment(checking_id)
funding_source = SimpleNamespace(
settle_hold_invoice=mocker.AsyncMock(
return_value=InvoiceResponse(ok=True, checking_id="settled")
),
cancel_hold_invoice=mocker.AsyncMock(
return_value=InvoiceResponse(ok=True, checking_id="cancelled")
),
)
mocker.patch(
"lnbits.core.services.payments.get_funding_source",
return_value=funding_source,
)
mocker.patch(
"lnbits.core.services.payments.verify_preimage",
return_value=False,
)
with pytest.raises(InvoiceError, match="Invalid preimage."):
await settle_hold_invoice(payment, "00" * 32)
mocker.patch(
"lnbits.core.services.payments.verify_preimage",
return_value=True,
)
assert (await settle_hold_invoice(payment, "11" * 32)).ok is True
assert (await cancel_hold_invoice(payment)).ok is True
stored = await get_payment(checking_id)
assert stored.preimage == "11" * 32
assert stored.extra["hold_invoice_settled"] is True
assert stored.extra["hold_invoice_cancelled"] is True
assert stored.status == PaymentState.FAILED
def _account() -> Account:
account_id = uuid4().hex
return Account(id=account_id, username=f"user_{account_id[:8]}")
async def _create_wallet() -> Wallet:
account = _account()
await create_account(account)
return await create_wallet(
user_id=account.id, wallet_name=f"wallet_{account.id[:8]}"
)
async def _create_payment(
wallet: Wallet,
*,
amount_msat: int = 2_000,
status: PaymentState = PaymentState.PENDING,
checking_id: str | None = None,
payment_hash: str | None = None,
fee: int = 0,
time: datetime | None = None,
) -> str:
checking_id = checking_id or f"checking_{uuid4().hex[:8]}"
payment_hash = payment_hash or uuid4().hex
payment = await create_payment(
checking_id=checking_id,
data=CreatePayment(
wallet_id=wallet.id,
payment_hash=payment_hash,
bolt11=f"bolt11-{checking_id}",
amount_msat=amount_msat,
memo="memo",
fee=fee,
),
status=status,
)
if time:
payment.time = time
payment.created_at = time
payment.updated_at = time
await update_payment(payment)
return checking_id
+153
View File
@@ -0,0 +1,153 @@
import pytest
from pydantic import ValidationError
from pytest_mock.plugin import MockerFixture
from lnbits.core.crud import (
create_admin_settings,
delete_admin_settings,
get_super_settings,
)
from lnbits.core.crud.settings import get_settings_field
from lnbits.core.services.settings import (
check_webpush_settings,
dict_to_settings,
update_cached_settings,
)
from lnbits.settings import Settings
class FakePublicKey:
def public_bytes(self, *_args, **_kwargs):
return b"public-bytes"
class FakeVapid:
def __init__(self, has_public_key: bool = True):
self.public_key = FakePublicKey() if has_public_key else None
def generate_keys(self):
return None
def private_pem(self):
return b"private-key"
def test_dict_to_settings_parses_known_values():
parsed = dict_to_settings(
{
"lnbits_site_title": "Test Title",
"lnbits_service_fee": 5,
"ignored_field": "ignored",
}
)
assert parsed.lnbits_site_title == "Test Title"
assert parsed.lnbits_service_fee == 5
assert not hasattr(parsed, "ignored_field")
def test_dict_to_settings_validates_invalid_values():
with pytest.raises(ValidationError):
dict_to_settings({"lnbits_service_fee": "not-a-number"})
def test_update_cached_settings_updates_runtime_values(settings: Settings):
original_title = settings.lnbits_site_title
original_host = settings.host
original_super_user = settings.super_user
try:
update_cached_settings(
{
"lnbits_site_title": "Updated",
"host": "forbidden-host",
"super_user": "super-user-id",
"missing_field": "ignored",
}
)
assert settings.lnbits_site_title == "Updated"
assert settings.host == original_host
assert settings.super_user == "super-user-id"
finally:
settings.lnbits_site_title = original_title
settings.host = original_host
settings.super_user = original_super_user
@pytest.mark.anyio
async def test_check_webpush_settings_generates_and_persists_keys(
settings: Settings, mocker: MockerFixture
):
previous_settings = await get_super_settings()
previous_private = settings.lnbits_webpush_privkey
previous_public = settings.lnbits_webpush_pubkey
previous_admin_ui = settings.lnbits_admin_ui
await delete_admin_settings()
settings.lnbits_webpush_privkey = ""
settings.lnbits_webpush_pubkey = None
settings.lnbits_admin_ui = True
mocker.patch("lnbits.core.services.settings.Vapid", return_value=FakeVapid())
mocker.patch(
"lnbits.core.services.settings.b64urlencode", return_value="public-key"
)
try:
await check_webpush_settings()
stored_private = await get_settings_field("lnbits_webpush_privkey")
stored_public = await get_settings_field("lnbits_webpush_pubkey")
assert settings.lnbits_webpush_privkey == "private-key"
assert settings.lnbits_webpush_pubkey == "public-key"
assert stored_private is not None
assert stored_private.value == "private-key"
assert stored_public is not None
assert stored_public.value == "public-key"
finally:
await delete_admin_settings()
if previous_settings:
await create_admin_settings(
previous_settings.super_user,
previous_settings.dict(exclude={"super_user"}),
)
update_cached_settings(previous_settings.dict())
settings.lnbits_webpush_privkey = previous_private
settings.lnbits_webpush_pubkey = previous_public
settings.lnbits_admin_ui = previous_admin_ui
@pytest.mark.anyio
async def test_check_webpush_settings_requires_public_key(
settings: Settings, mocker: MockerFixture
):
mocker.patch.object(settings, "lnbits_webpush_privkey", "")
mocker.patch.object(settings, "lnbits_admin_ui", False)
mocker.patch(
"lnbits.core.services.settings.Vapid",
return_value=FakeVapid(has_public_key=False),
)
with pytest.raises(ValueError, match="VAPID public key does not exist"):
await check_webpush_settings()
@pytest.mark.anyio
async def test_check_webpush_settings_skips_generation_when_keys_exist(
settings: Settings, mocker: MockerFixture
):
previous_private = settings.lnbits_webpush_privkey
previous_public = settings.lnbits_webpush_pubkey
previous_private_field = await get_settings_field("lnbits_webpush_privkey")
previous_public_field = await get_settings_field("lnbits_webpush_pubkey")
settings.lnbits_webpush_privkey = "existing-private-key"
settings.lnbits_webpush_pubkey = "existing-public-key"
vapid = mocker.patch("lnbits.core.services.settings.Vapid")
try:
await check_webpush_settings()
finally:
settings.lnbits_webpush_privkey = previous_private
settings.lnbits_webpush_pubkey = previous_public
assert await get_settings_field("lnbits_webpush_privkey") == previous_private_field
assert await get_settings_field("lnbits_webpush_pubkey") == previous_public_field
vapid.assert_not_called()
+407
View File
@@ -0,0 +1,407 @@
from uuid import uuid4
import pytest
from lnbits.core.crud import (
create_account,
create_admin_settings,
create_user_extension,
delete_admin_settings,
get_account,
get_super_settings,
get_user_extensions,
get_wallets,
)
from lnbits.core.crud.settings import get_settings_field, set_settings_field
from lnbits.core.models import Account
from lnbits.core.models.extensions import UserExtension
from lnbits.core.models.users import RegisterUser
from lnbits.core.services.settings import update_cached_settings
from lnbits.core.services.users import (
check_admin_settings,
check_register_activation_settings,
create_user_account,
create_user_account_no_ckeck,
init_admin_settings,
update_user_account,
update_user_extensions,
)
from lnbits.settings import Settings
@pytest.mark.anyio
async def test_create_user_account_rejects_when_registration_disabled(
settings: Settings,
):
settings.lnbits_allow_new_accounts = False
with pytest.raises(ValueError, match="Account creation is disabled."):
await create_user_account()
@pytest.mark.anyio
@pytest.mark.parametrize(
("existing_data", "new_data", "message"),
[
(
{"username": f"user_{uuid4().hex[:8]}"},
{"username": lambda existing: existing.username},
"Username already exists.",
),
(
{"email": f"{uuid4().hex[:8]}@example.com"},
{"email": lambda existing: existing.email},
"Email already exists.",
),
(
{"pubkey": f"{1:064x}"},
{"pubkey": lambda existing: existing.pubkey},
"Pubkey already exists.",
),
],
)
async def test_create_user_account_no_check_rejects_duplicate_identity_fields(
existing_data: dict, new_data: dict, message: str
):
existing = _account(**existing_data)
await create_account(existing)
resolved = {
key: (value(existing) if callable(value) else value)
for key, value in new_data.items()
}
account = _account(**resolved)
with pytest.raises(ValueError, match=message):
await create_user_account_no_ckeck(account)
@pytest.mark.anyio
async def test_create_user_account_no_check_creates_wallet_and_extensions(
settings: Settings,
):
account = _account()
original_default_exts = list(settings.lnbits_user_default_extensions)
try:
settings.lnbits_user_default_extensions = ["default-ext"]
user = await create_user_account_no_ckeck(
account,
wallet_name="Primary",
default_exts=["extra-ext"],
)
finally:
settings.lnbits_user_default_extensions = original_default_exts
wallets = await get_wallets(user.id)
user_extensions = await get_user_extensions(user.id)
assert len(wallets) == 1
assert wallets[0].name == "Primary"
assert {ext.extension for ext in user_extensions} == {"default-ext", "extra-ext"}
assert all(ext.active is True for ext in user_extensions)
# TDOO: revisit for postgres
# @pytest.mark.anyio
# async def test_create_user_account_no_check_duplicate_extension_insert_behavior(
# settings: Settings,
# ):
# account = _account()
# original_default_exts = list(settings.lnbits_user_default_extensions)
# try:
# settings.lnbits_user_default_extensions = ["dup-ext"]
# if DB_TYPE == POSTGRES:
# with pytest.raises(DBAPIError, match="current transaction is aborted"):
# await create_user_account_no_ckeck(account, default_exts=["dup-ext"])
# else:
# user = await
# create_user_account_no_ckeck(account, default_exts=["dup-ext"])
# finally:
# settings.lnbits_user_default_extensions = original_default_exts
# if DB_TYPE == POSTGRES:
# assert await get_account(account.id) is not None
# else:
# user_extensions = await get_user_extensions(user.id)
# assert [ext.extension for ext in user_extensions] == ["dup-ext"]
@pytest.mark.anyio
async def test_update_user_account_requires_existing_user():
account = _account()
with pytest.raises(ValueError, match="User does not exist."):
await update_user_account(account)
@pytest.mark.anyio
async def test_update_user_account_rejects_conflicting_identity_fields():
existing = _account(pubkey=_pubkey(2))
conflict = _account(pubkey=_pubkey(3))
await create_account(existing)
await create_account(conflict)
with pytest.raises(ValueError, match="Username already exists."):
await update_user_account(
_account(
id_=existing.id,
username=conflict.username,
email=existing.email,
pubkey=existing.pubkey,
)
)
with pytest.raises(ValueError, match="Email already exists."):
await update_user_account(
_account(
id_=existing.id,
username=existing.username,
email=conflict.email,
pubkey=existing.pubkey,
)
)
with pytest.raises(ValueError, match="Pubkey already exists."):
await update_user_account(
_account(
id_=existing.id,
username=existing.username,
email=existing.email,
pubkey=conflict.pubkey,
)
)
@pytest.mark.anyio
async def test_update_user_account_updates_persisting_password():
account = _account(pubkey=_pubkey(4))
account.hash_password("secret1234")
await create_account(account)
updated = _account(
id_=account.id,
username=f"updated_{account.id[:8]}",
email=f"{account.id[:8]}+updated@example.com",
pubkey=(uuid4().hex * 2)[:64],
)
result = await update_user_account(updated)
stored = await get_account(account.id)
assert result.id == account.id
assert stored is not None
assert stored.username == updated.username
assert stored.email == updated.email
assert stored.pubkey == updated.pubkey
assert stored.password_hash == account.password_hash
@pytest.mark.anyio
async def test_update_user_extensions_toggles_existing_and_creates_missing(
settings: Settings,
):
original_default_exts = list(settings.lnbits_user_default_extensions)
try:
settings.lnbits_user_default_extensions = []
user = await create_user_account(_account())
finally:
settings.lnbits_user_default_extensions = original_default_exts
await create_user_extension(
UserExtension(user=user.id, extension="keep", active=True)
)
await create_user_extension(
UserExtension(user=user.id, extension="enable", active=False)
)
await create_user_extension(
UserExtension(user=user.id, extension="disable", active=True)
)
await update_user_extensions(user.id, ["keep", "enable", "new-ext"])
user_extensions = {
ext.extension: ext.active for ext in await get_user_extensions(user.id)
}
assert user_extensions == {
"keep": True,
"enable": True,
"disable": False,
"new-ext": True,
}
@pytest.mark.anyio
async def test_check_admin_settings_initializes_cache_and_marks_first_install(
settings: Settings, tmp_path
):
previous_settings = await get_super_settings()
previous_super_user = settings.super_user
previous_data_folder = settings.lnbits_data_folder
previous_admin_ui = settings.lnbits_admin_ui
previous_first_install = settings.first_install
super_user = uuid4().hex
try:
await delete_admin_settings()
settings.super_user = super_user
settings.lnbits_data_folder = str(tmp_path)
settings.lnbits_admin_ui = True
settings.first_install = False
await check_admin_settings()
stored_settings = await get_super_settings()
stored_account = await get_account(super_user)
assert stored_settings is not None
assert stored_settings.super_user == super_user
assert stored_account is not None
assert stored_account.extra.provider == "env"
assert settings.first_install is True
assert (tmp_path / ".super_user").read_text() == super_user
finally:
await delete_admin_settings()
if previous_settings:
await create_admin_settings(
previous_settings.super_user,
previous_settings.dict(exclude={"super_user"}),
)
update_cached_settings(previous_settings.dict())
settings.super_user = previous_super_user
settings.lnbits_data_folder = previous_data_folder
settings.lnbits_admin_ui = previous_admin_ui
settings.first_install = previous_first_install
@pytest.mark.anyio
async def test_init_admin_settings_creates_account_and_wallet_when_missing():
super_user = uuid4().hex
result = await init_admin_settings(super_user)
wallets = await get_wallets(super_user)
assert result.super_user == super_user
assert await get_account(super_user) is not None
assert len(wallets) == 1
@pytest.mark.anyio
async def test_check_register_activation_settings_handles_invitation_codes(
settings: Settings,
):
reusable = "reusable-code"
one_time = "one-time-code"
original_require_activation = settings.lnbits_require_user_activation
original_by_invite = settings.lnbits_user_activation_by_invitation_code
original_reusable = settings.lnbits_register_reusable_activation_code
original_one_time = list(settings.lnbits_register_one_time_activation_codes)
previous_stored_codes = await get_settings_field(
"lnbits_register_one_time_activation_codes"
)
try:
settings.lnbits_require_user_activation = False
assert (
await check_register_activation_settings(
RegisterUser(
username=f"user_{uuid4().hex[:8]}",
password="secret1234",
password_repeat="secret1234",
)
)
is None
)
settings.lnbits_require_user_activation = True
settings.lnbits_user_activation_by_invitation_code = True
settings.lnbits_register_reusable_activation_code = reusable
settings.lnbits_register_one_time_activation_codes = [one_time]
with pytest.raises(ValueError, match="Invitation code cannot be empty."):
await check_register_activation_settings(
RegisterUser(
username=f"user_{uuid4().hex[:8]}",
password="secret1234",
password_repeat="secret1234",
invitation_code=" ",
)
)
assert (
await check_register_activation_settings(
RegisterUser(
username=f"user_{uuid4().hex[:8]}",
password="secret1234",
password_repeat="secret1234",
invitation_code=reusable,
)
)
is None
)
assert (
await check_register_activation_settings(
RegisterUser(
username=f"user_{uuid4().hex[:8]}",
password="secret1234",
password_repeat="secret1234",
invitation_code=one_time,
)
)
is None
)
assert one_time not in settings.lnbits_register_one_time_activation_codes
stored_codes = await get_settings_field(
"lnbits_register_one_time_activation_codes"
)
assert stored_codes is not None
assert stored_codes.value == []
with pytest.raises(ValueError, match="Invalid invitation code."):
await check_register_activation_settings(
RegisterUser(
username=f"user_{uuid4().hex[:8]}",
password="secret1234",
password_repeat="secret1234",
invitation_code="bad-code",
)
)
settings.lnbits_user_activation_by_invitation_code = False
with pytest.raises(ValueError, match="No activation method provided."):
await check_register_activation_settings(
RegisterUser(
username=f"user_{uuid4().hex[:8]}",
password="secret1234",
password_repeat="secret1234",
invitation_code=reusable,
)
)
finally:
settings.lnbits_require_user_activation = original_require_activation
settings.lnbits_user_activation_by_invitation_code = original_by_invite
settings.lnbits_register_reusable_activation_code = original_reusable
settings.lnbits_register_one_time_activation_codes = original_one_time
await set_settings_field(
"lnbits_register_one_time_activation_codes",
previous_stored_codes.value if previous_stored_codes else original_one_time,
)
def _pubkey(value: int) -> str:
return f"{value:064x}"
def _account(
*,
id_: str | None = None,
username: str | None = None,
email: str | None = None,
pubkey: str | None = None,
) -> Account:
account_id = id_ or uuid4().hex
return Account(
id=account_id,
username=username or f"user_{account_id[:8]}",
email=email or f"{account_id[:8]}@example.com",
pubkey=pubkey,
)

Some files were not shown because too many files have changed in this diff Show More