113 passing tests + 3 skipped + 8 xfailed across 10 files, covering user expense and income flow, admin receivable/revenue, settings + auth gates, void/reject, manual payment requests, balance display, Lightning auth paths, reconciliation API, and pure-function units. Runs against a real Fava subprocess and full LNbits app via asgi_lifespan; the harness captures the auth-flow / settings / env-var disciplines surfaced during build-out (see tests/README.md and tests/conftest.py docstring). Eight xfailed/skipped tests carry full implementations gated behind issues #38, #39, #40 — they flip back on automatically when those land. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
202 lines
7.3 KiB
Python
202 lines
7.3 KiB
Python
"""Settings and per-user wallet endpoints, plus the auth gates around them.
|
|
|
|
Endpoints and their auth profiles:
|
|
|
|
- `GET /libra/api/v1/settings` — any authenticated user.
|
|
- `PUT /libra/api/v1/settings` — `check_super_user` (Bearer, super-user only).
|
|
- `GET /libra/api/v1/user/wallet` — `check_user_exists` (any authed user).
|
|
- `PUT /libra/api/v1/user/wallet` — `check_user_exists`.
|
|
- `GET /libra/api/v1/user-wallet/{user_id}` — `require_super_user` (libra
|
|
super-user via wallet admin-key auth).
|
|
|
|
Two distinct super-user auth flows live here side by side:
|
|
- LNbits-level `check_super_user` → Bearer token from username/password login.
|
|
- Libra-level `require_super_user` → wallet admin-key of the super-user-owned
|
|
wallet.
|
|
|
|
Tests use the `super_user_bearer_headers` fixture for the first, the
|
|
`super_user_headers` fixture for the second, and `?usr=<user_id>` for
|
|
non-admin authed calls.
|
|
"""
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_super_user_can_get_and_update_settings(
|
|
client, super_user_bearer_headers, libra_wallet, fava_process,
|
|
):
|
|
"""Super user round-trips through `GET /settings` → mutate → `PUT /settings`.
|
|
|
|
Verifies the Bearer-auth happy path and confirms `update_settings`
|
|
persists what we sent (modulo defaults libra fills in).
|
|
"""
|
|
r = await client.get(
|
|
"/libra/api/v1/settings", headers=super_user_bearer_headers,
|
|
)
|
|
assert r.status_code == 200, f"GET /settings: {r.status_code} {r.text}"
|
|
original = r.json()
|
|
assert original.get("libra_wallet_id") == libra_wallet.id, (
|
|
f"libra_wallet fixture should have configured wallet_id, got {original}"
|
|
)
|
|
|
|
new_timeout = 7.5
|
|
r = await client.put(
|
|
"/libra/api/v1/settings",
|
|
headers=super_user_bearer_headers,
|
|
json={
|
|
"libra_wallet_id": libra_wallet.id,
|
|
"fava_url": fava_process,
|
|
"fava_ledger_slug": "libra-test",
|
|
"fava_timeout": new_timeout,
|
|
},
|
|
)
|
|
assert r.status_code == 200, f"PUT /settings: {r.status_code} {r.text}"
|
|
assert float(r.json().get("fava_timeout", 0)) == pytest.approx(new_timeout)
|
|
|
|
# Reset to keep other tests' baseline intact.
|
|
await client.put(
|
|
"/libra/api/v1/settings",
|
|
headers=super_user_bearer_headers,
|
|
json={
|
|
"libra_wallet_id": libra_wallet.id,
|
|
"fava_url": fava_process,
|
|
"fava_ledger_slug": "libra-test",
|
|
"fava_timeout": original.get("fava_timeout", 5.0),
|
|
},
|
|
)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_put_settings_without_libra_wallet_id_returns_400(
|
|
client, super_user_bearer_headers,
|
|
):
|
|
"""The settings endpoint explicitly rejects updates with no wallet id.
|
|
|
|
This is the validation libra applies before any persistence so we don't
|
|
silently accept a settings row that breaks all entry endpoints.
|
|
"""
|
|
r = await client.put(
|
|
"/libra/api/v1/settings",
|
|
headers=super_user_bearer_headers,
|
|
json={"fava_url": "http://example.test"},
|
|
)
|
|
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
|
|
assert "wallet" in r.text.lower()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_put_settings_without_auth_returns_401(client, libra_wallet):
|
|
"""No auth at all → LNbits's `check_admin` rejects with 401."""
|
|
r = await client.put(
|
|
"/libra/api/v1/settings",
|
|
json={"libra_wallet_id": libra_wallet.id},
|
|
)
|
|
assert r.status_code == 401, f"expected 401, got {r.status_code}: {r.text}"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_regular_user_cannot_put_settings(
|
|
client, configured_user, libra_wallet,
|
|
):
|
|
"""A non-super user (regardless of auth method they try) cannot update
|
|
libra settings. Using `?usr=<id>` to mimic user-id login."""
|
|
user, _ = configured_user
|
|
|
|
r = await client.put(
|
|
f"/libra/api/v1/settings?usr={user.id}",
|
|
json={"libra_wallet_id": libra_wallet.id},
|
|
)
|
|
# `_check_account_exists` forbids user-id login for admin accounts and
|
|
# rejects regular users from `check_admin` paths — either 401 or 403
|
|
# is a valid no-access response here.
|
|
assert r.status_code in (401, 403), (
|
|
f"expected 401/403, got {r.status_code}: {r.text}"
|
|
)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_regular_user_can_get_and_update_own_user_wallet(
|
|
client, libra_user, libra_wallet, # noqa: ARG001 (libra_wallet ensures session setup)
|
|
):
|
|
"""A regular user (no admin perm) can read and update their own
|
|
`user_wallet_id` via `?usr=<id>`."""
|
|
user, wallet = libra_user
|
|
|
|
r = await client.get(f"/libra/api/v1/user/wallet?usr={user.id}")
|
|
assert r.status_code == 200, f"GET /user/wallet: {r.status_code} {r.text}"
|
|
|
|
r = await client.put(
|
|
f"/libra/api/v1/user/wallet?usr={user.id}",
|
|
json={"user_wallet_id": wallet.id},
|
|
)
|
|
assert r.status_code == 200, f"PUT /user/wallet: {r.status_code} {r.text}"
|
|
|
|
r = await client.get(f"/libra/api/v1/user/wallet?usr={user.id}")
|
|
assert r.json().get("user_wallet_id") == wallet.id, (
|
|
f"GET after PUT should echo wallet id, got {r.json()}"
|
|
)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_super_user_can_get_any_user_wallet(
|
|
client, super_user_headers, configured_user,
|
|
):
|
|
"""The `/user-wallet/{user_id}` endpoint (libra `require_super_user`,
|
|
wallet-admin-key auth) returns wallet info for any user."""
|
|
user, wallet = configured_user
|
|
|
|
r = await client.get(
|
|
f"/libra/api/v1/user-wallet/{user.id}", headers=super_user_headers,
|
|
)
|
|
assert r.status_code == 200, f"GET /user-wallet/{user.id}: {r.status_code} {r.text}"
|
|
payload = r.json()
|
|
assert payload.get("user_id") == user.id
|
|
assert payload.get("user_wallet_id") == wallet.id, (
|
|
f"expected user_wallet_id={wallet.id}, got {payload}"
|
|
)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_regular_user_cannot_use_super_only_user_wallet_endpoint(
|
|
client, configured_user, configured_user_b,
|
|
):
|
|
"""User B can't see user A's wallet info via the super-only admin
|
|
endpoint, even with B's own wallet admin-key."""
|
|
user_a, _ = configured_user
|
|
_, wallet_b = configured_user_b
|
|
|
|
r = await client.get(
|
|
f"/libra/api/v1/user-wallet/{user_a.id}",
|
|
headers={"X-Api-Key": wallet_b.adminkey, "Content-type": "application/json"},
|
|
)
|
|
assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}"
|
|
assert "super" in r.text.lower()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_unknown_currency_in_settings_does_not_corrupt(
|
|
client, super_user_bearer_headers, libra_wallet, fava_process,
|
|
):
|
|
"""Passing an unexpected field in the settings body shouldn't bring the
|
|
endpoint down — pydantic should ignore extras and persist the rest.
|
|
|
|
A canary for "what if the UI sends a slightly-stale settings shape?"
|
|
"""
|
|
r = await client.put(
|
|
"/libra/api/v1/settings",
|
|
headers=super_user_bearer_headers,
|
|
json={
|
|
"libra_wallet_id": libra_wallet.id,
|
|
"fava_url": fava_process,
|
|
"fava_ledger_slug": "libra-test",
|
|
"some_unexpected_field_": str(uuid4()),
|
|
},
|
|
)
|
|
# Either 200 (extras dropped) or 422 (strict validation) — both are
|
|
# acceptable defensive behaviours; just don't 500.
|
|
assert r.status_code in (200, 422), (
|
|
f"unexpected field should be ignored or rejected cleanly, "
|
|
f"got {r.status_code}: {r.text}"
|
|
)
|