diff --git a/tests/helpers.py b/tests/helpers.py index 80ad343..02d8f78 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -13,7 +13,7 @@ separate ISO code field — this matches `models.ExpenseEntry` / `ReceivableEntr from decimal import Decimal from typing import Any, Optional, Union -from httpx import AsyncClient +from httpx import AsyncClient, Response Amount = Union[Decimal, int, float, str] @@ -106,6 +106,26 @@ async def grant_permission( 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 # --------------------------------------------------------------------------- diff --git a/tests/test_admin_chart_accounts_api.py b/tests/test_admin_chart_accounts_api.py new file mode 100644 index 0000000..1f574f9 --- /dev/null +++ b/tests/test_admin_chart_accounts_api.py @@ -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"