Compare commits
No commits in common. "89f0f8ac3a8cee2014ef502771a8757791f46f89" and "cd5a6edb7dc2629dd3a4b55c9073136d02a363ba" have entirely different histories.
89f0f8ac3a
...
cd5a6edb7d
3 changed files with 2 additions and 194 deletions
|
|
@ -108,9 +108,6 @@ 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")
|
||||||
|
|
@ -136,12 +133,6 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -179,32 +170,13 @@ 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 split ledger Fava reads from: a root file that includes
|
"""Session-scoped .beancount file Fava reads from."""
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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, Response
|
from httpx import AsyncClient
|
||||||
|
|
||||||
Amount = Union[Decimal, int, float, str]
|
Amount = Union[Decimal, int, float, str]
|
||||||
|
|
||||||
|
|
@ -106,26 +106,6 @@ 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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
"""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"
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue