Compare commits

...

2 commits

Author SHA1 Message Date
89f0f8ac3a test(accounts): cover admin add-account endpoint
10 integration tests for POST /api/v1/admin/accounts: unconstrained Open
write + escaped description metadata, explicit-currency path, duplicate->409,
invalid-prefix->400, invalid-characters->400 (parametrized), super-user-only
->403. Adds the add_chart_account helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 23:25:27 +02:00
87a45ee4d5 test(harness): split-layout ledger + disable rate limiter
The test harness was never updated to the post-server-deploy#4 split ledger
layout, so libra's per-user account opens (routed to accounts/users.beancount
by fava_client._infer_target_file) 500'd as a 'non-source file' and fell back
to DB-only — breaking the balance test and contributing to settlement errors.
Make the harness ledger a faithful split (root includes accounts/chart.beancount
+ accounts/users.beancount; title stays in root so the slug still matches).

Also raise lnbits_rate_limit_no for the session: the full suite fires >200
req/min and the default limiter 429'd fixture setup intermittently (10-11
errors). The limiter is built once at app creation, so setting it in the
session settings fixture (before the app fixture) disables it suite-wide.

Net: full suite goes from 1 failed / ~10 errors to fully green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 23:25:27 +02:00
3 changed files with 194 additions and 2 deletions

View file

@ -108,6 +108,9 @@ def _settings_cleanup(settings: Settings) -> None:
settings.lnbits_user_activation_by_invitation_code = False settings.lnbits_user_activation_by_invitation_code = False
settings.lnbits_register_reusable_activation_code = "" settings.lnbits_register_reusable_activation_code = ""
settings.lnbits_register_one_time_activation_codes = [] settings.lnbits_register_one_time_activation_codes = []
# Keep the rate limiter disabled across per-test settings resets (the
# limiter itself is fixed at app-creation time, but keep the value coherent).
settings.lnbits_rate_limit_no = 1_000_000
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
@ -133,6 +136,12 @@ def settings() -> Iterator[Settings]:
lnbits_settings.lnbits_admin_ui = True lnbits_settings.lnbits_admin_ui = True
lnbits_settings.lnbits_extensions_default_install = [] lnbits_settings.lnbits_extensions_default_install = []
lnbits_settings.lnbits_extensions_deactivate_all = False lnbits_settings.lnbits_extensions_deactivate_all = False
# The full suite fires >200 requests/minute; the default rate limit (200/min)
# otherwise 429s fixture setup intermittently. The limiter is built once at
# app creation from this value (lnbits/app.py register_new_ratelimiter), and
# this fixture runs before the `app` fixture, so raising it here disables it
# for the session.
lnbits_settings.lnbits_rate_limit_no = 1_000_000
yield lnbits_settings yield lnbits_settings
@ -170,13 +179,32 @@ option "render_commas" "TRUE"
2020-01-01 open Equity:Opening-Balances EUR,SATS 2020-01-01 open Equity:Opening-Balances EUR,SATS
2020-01-01 open Income:Generic EUR,SATS 2020-01-01 open Income:Generic EUR,SATS
2020-01-01 open Expenses:Generic EUR,SATS 2020-01-01 open Expenses:Generic EUR,SATS
include "accounts/chart.beancount"
include "accounts/users.beancount"
""" """
# Split-layout include targets, mirroring the production fava layout
# (aiolabs/server-deploy#4). libra's fava_client routes Open directives by
# account name (fava_client._infer_target_file): per-user accounts
# (:User-xxxxxxxx) to accounts/users.beancount, everything else to
# accounts/chart.beancount. Both must exist as Fava *source* files (i.e. be
# included) or /api/source writes 500 with "non-source file". The title stays
# in the root ledger above so Fava's slug still matches LEDGER_SLUG (scalar
# options don't propagate from includes — see aiolabs/server-deploy#9).
CHART_SEED = "; Admin-mutable chart of accounts (libra appends Open directives).\n"
USERS_SEED = "; Per-user account opens (libra appends at signup).\n"
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def fava_ledger_path(tmp_path_factory: pytest.TempPathFactory) -> Path: def fava_ledger_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""Session-scoped .beancount file Fava reads from.""" """Session-scoped split ledger Fava reads from: a root file that includes
accounts/chart.beancount (admin add-account target) and
accounts/users.beancount (per-user opens target)."""
ledger_dir = tmp_path_factory.mktemp("libra-ledger") ledger_dir = tmp_path_factory.mktemp("libra-ledger")
(ledger_dir / "accounts").mkdir()
(ledger_dir / "accounts" / "chart.beancount").write_text(CHART_SEED)
(ledger_dir / "accounts" / "users.beancount").write_text(USERS_SEED)
ledger = ledger_dir / f"{LEDGER_SLUG}.beancount" ledger = ledger_dir / f"{LEDGER_SLUG}.beancount"
ledger.write_text(MINIMAL_LEDGER) ledger.write_text(MINIMAL_LEDGER)
return ledger return ledger

View file

@ -13,7 +13,7 @@ separate ISO code field — this matches `models.ExpenseEntry` / `ReceivableEntr
from decimal import Decimal from decimal import Decimal
from typing import Any, Optional, Union from typing import Any, Optional, Union
from httpx import AsyncClient from httpx import AsyncClient, Response
Amount = Union[Decimal, int, float, str] Amount = Union[Decimal, int, float, str]
@ -106,6 +106,26 @@ async def grant_permission(
return r.json() return r.json()
async def add_chart_account(
client: AsyncClient,
*,
super_user_headers: dict,
name: str,
description: Optional[str] = None,
) -> Response:
"""Super user adds a chart-of-accounts entry via the admin endpoint
(POST /api/v1/admin/accounts). Returns the raw Response so callers can
assert on status codes (201 / 400 / 409 / 403)."""
body: dict[str, Any] = {"name": name}
if description is not None:
body["description"] = description
return await client.post(
"/libra/api/v1/admin/accounts",
headers=super_user_headers,
json=body,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Entries — user side # Entries — user side
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -0,0 +1,144 @@
"""Admin chart-of-accounts endpoint — POST /api/v1/admin/accounts.
Covers the endpoint wired into the UI's "Add Account" dialog:
- Writes an Open directive to accounts/chart.beancount via Fava /api/source,
*unconstrained* by currency (the directive needs no currency list), with
provenance + description metadata (escaped for Beancount).
- Mirrors the account into libra's DB (synced_to_libra_db).
- Rejects duplicates with 409, malformed names with 400, and non-super-users
with 403.
The harness ledger is the split layout (root includes accounts/chart.beancount)
so the endpoint's hardcoded target_file resolves — see conftest.CHART_SEED.
"""
import re
from pathlib import Path
from uuid import uuid4
import pytest
from .helpers import add_chart_account
def _chart_text(fava_ledger_path: Path) -> str:
return (fava_ledger_path.parent / "accounts" / "chart.beancount").read_text()
def _unique(prefix: str = "Expenses:Test") -> str:
# Capitalized leaf (valid Beancount component) unique per call so the
# session-scoped ledger doesn't collide across tests.
return f"{prefix}:T{uuid4().hex[:8].upper()}"
@pytest.mark.anyio
async def test_add_chart_account_writes_unconstrained_open_with_escaped_meta(
client, super_user_headers, fava_ledger_path,
):
"""Happy path: 201, the Open directive carries no currency constraint, the
description metadata is escaped, and the account is synced into libra's DB."""
name = _unique()
r = await add_chart_account(
client,
super_user_headers=super_user_headers,
name=name,
description='has a "quote" and ok',
)
assert r.status_code == 201, f"expected 201, got {r.status_code}: {r.text}"
body = r.json()
assert body["account_name"] == name
assert body["synced_to_libra_db"] is True
chart = _chart_text(fava_ledger_path)
# Open present and UNCONSTRAINED: the account name is followed directly by
# end-of-line, not " EUR, SATS, USD".
assert re.search(rf"^\d{{4}}-\d{{2}}-\d{{2}} open {re.escape(name)}$", chart, re.MULTILINE), (
f"expected an unconstrained Open for {name}, chart was:\n{chart}"
)
# Description metadata is escaped so the quote can't break the ledger.
assert r'description: "has a \"quote\" and ok"' in chart
assert 'source: "admin-ui"' in chart
@pytest.mark.anyio
async def test_add_chart_account_with_explicit_currencies_constrains_open(
client, super_user_headers, fava_ledger_path,
):
"""API callers may still pass an explicit currency constraint (the UI never
does). When provided, it lands on the Open directive."""
name = _unique()
r = await client.post(
"/libra/api/v1/admin/accounts",
headers=super_user_headers,
json={"name": name, "currencies": ["EUR", "SATS"]},
)
assert r.status_code == 201, f"expected 201, got {r.status_code}: {r.text}"
chart = _chart_text(fava_ledger_path)
assert re.search(rf"open {re.escape(name)} EUR, SATS$", chart, re.MULTILINE), (
f"expected a currency-constrained Open for {name}, chart was:\n{chart}"
)
@pytest.mark.anyio
async def test_add_chart_account_duplicate_returns_409(
client, super_user_headers,
):
"""Adding the same account twice: first 201, second 409 (not a false success)."""
name = _unique()
first = await add_chart_account(client, super_user_headers=super_user_headers, name=name)
assert first.status_code == 201, f"first add: {first.status_code} {first.text}"
second = await add_chart_account(client, super_user_headers=super_user_headers, name=name)
assert second.status_code == 409, f"expected 409, got {second.status_code}: {second.text}"
assert "already exists" in second.json().get("detail", "").lower()
@pytest.mark.anyio
async def test_add_chart_account_invalid_prefix_returns_400(
client, super_user_headers, fava_ledger_path,
):
"""A root outside the five valid types is rejected and never written."""
before = _chart_text(fava_ledger_path)
r = await add_chart_account(client, super_user_headers=super_user_headers, name="Foo:Bar")
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
assert _chart_text(fava_ledger_path) == before, "rejected account must not be written"
@pytest.mark.anyio
@pytest.mark.parametrize(
"bad_name",
[
"Expenses:Foo Bar", # space
"Expenses:foo", # lowercase sub-component start
"Expenses:Foo!", # punctuation
"Expenses:", # no sub-account
"Expenses:Foo::Bar", # empty component
],
)
async def test_add_chart_account_invalid_characters_returns_400(
client, super_user_headers, fava_ledger_path, bad_name,
):
"""Malformed account names are rejected server-side (the UI guard can be
bypassed via the API) and never reach the ledger."""
before = _chart_text(fava_ledger_path)
r = await add_chart_account(client, super_user_headers=super_user_headers, name=bad_name)
assert r.status_code == 400, f"expected 400 for {bad_name!r}, got {r.status_code}: {r.text}"
assert _chart_text(fava_ledger_path) == before, "rejected account must not be written"
@pytest.mark.anyio
async def test_add_chart_account_requires_super_user(
client, configured_user, fava_ledger_path,
):
"""A regular user's wallet admin-key passes require_admin_key but fails the
super-user identity check 403, nothing written."""
_user, wallet = configured_user
name = _unique()
before = _chart_text(fava_ledger_path)
r = await client.post(
"/libra/api/v1/admin/accounts",
headers={"X-Api-Key": wallet.adminkey, "Content-type": "application/json"},
json={"name": name},
)
assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}"
assert _chart_text(fava_ledger_path) == before, "unauthorized add must not be written"