Add integration test suite

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>
This commit is contained in:
Padreug 2026-06-07 09:47:09 +02:00
commit 7a4b3022c2
14 changed files with 3710 additions and 0 deletions

View file

@ -0,0 +1,202 @@
"""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}"
)