When add_account reported the Open already existed, the endpoint raised 409 before the DB-mirror step — so an account present in the ledger but missing from libra's DB (a prior sync failure with no cross-DB atomicity, or an out-of-band open) was stranded: invisible to permissions with no recovery path. Now 409 only when the account is already in the DB too; otherwise sync it and return success. Adds a recovery test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
170 lines
7 KiB
Python
170 lines
7 KiB
Python
"""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_recovers_ledger_only_account(
|
|
client, super_user_headers,
|
|
):
|
|
"""An account present in the ledger but absent from libra's DB (prior sync
|
|
failure / out-of-band edit) is recovered (synced), not 409'd — otherwise it
|
|
would be permanently un-grantable with no path back.
|
|
|
|
Reproduce the ledger-only state by creating normally (so Fava parses the
|
|
Open) then deleting only the libra-DB row — appending to the ledger file
|
|
directly would race Fava's parse cache."""
|
|
from ..crud import db # the same singleton the app uses
|
|
|
|
name = _unique("Expenses:Recover")
|
|
first = await add_chart_account(client, super_user_headers=super_user_headers, name=name)
|
|
assert first.status_code == 201, f"setup create failed: {first.status_code} {first.text}"
|
|
|
|
await db.execute("DELETE FROM accounts WHERE name = :name", {"name": name})
|
|
|
|
r = await add_chart_account(client, super_user_headers=super_user_headers, name=name)
|
|
assert r.status_code == 201, f"expected 201 recovery, got {r.status_code}: {r.text}"
|
|
body = r.json()
|
|
assert body.get("already_existed") is True, body
|
|
assert body["synced_to_libra_db"] is True, body
|
|
|
|
|
|
@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"
|