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:
parent
9c88993c13
commit
7a4b3022c2
14 changed files with 3710 additions and 0 deletions
202
tests/test_settings_auth_api.py
Normal file
202
tests/test_settings_auth_api.py
Normal 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}"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue