libra/tests/test_void_reject_api.py
Padreug 4d63e08a69 fix(fava): serialize source mutations, share HTTP client, validate BQL input
Fava-client hardening cluster (CODE-REVIEW-2026-06 #7, #14, #15, #19
+ libra-#23, libra-#53):

- New FavaClient.transform_source_line does the whole
  read-checksum-modify-write under the global write lock and maps
  Fava 409/412 to ChecksumConflictError. The approve and reject
  endpoints used to do this dance with raw httpx and no lock — two
  concurrent mutations raced each other and every other ledger
  writer (libra-#23). They now route through the new method and
  translate conflicts to HTTP 409.
- update_entry_source / delete_entry raise ChecksumConflictError on
  409/412 instead of leaking raw HTTPStatusError.
- One shared httpx.AsyncClient per FavaClient (12 per-call
  instantiations removed — no more TCP handshake per request);
  closed via libra_stop. Health probes keep their 2s timeout
  per-request.
- Account names/patterns are validated against ^[A-Za-z0-9:_-]+$
  before interpolation into BQL string literals.
- The posting amount regexes are consolidated into module-level
  compiled patterns, all decimal-tolerant — the old integer-only
  SATS pattern silently dropped decimal-SATS postings (Fava's @@->@
  normalisation emits them) from balances.
- add-account no longer verifies its own write with a second
  serialized get_all_accounts round-trip (libra-#53):
  sync_single_account_from_beancount grows an assume_exists path.

New test: concurrent approve+reject must both land (was
lost-update/412 before the lock).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 15:42:44 +02:00

278 lines
9.9 KiB
Python

"""Reject / void pending entry flow — `POST /libra/api/v1/entries/{id}/reject`.
Captures the current (pre-issue #24) in-place mutation behaviour:
- Pending entries (`!` flag) can be rejected by a super user.
- Rejection appends `#voided` to the transaction line in the .beancount file
(no new transaction posted — this is the only in-place edit path in libra).
- Voided entries are filtered out of balance queries.
- The reject endpoint only matches pending entries; cleared (`*`) ones return
404 because the search loop filters by `flag == '!'`.
PR #34 changes whether the user's `/entries/user` listing surfaces voided rows.
The test `test_voided_entry_excluded_from_user_journal` documents the current
("filtered") behaviour; flip it if/when that change lands.
When the reversing-entry refactor in issue #24 ships, these tests will need to
move from "void via tag append" to "void via reversal transaction." The shape
of the tests should still hold — what changes is the on-disk evidence.
"""
from uuid import uuid4
import pytest
from .helpers import (
approve_entry,
get_balance,
list_user_entries,
post_expense,
reject_entry,
)
@pytest.mark.anyio
async def test_admin_can_reject_pending_expense(
client, super_user_headers, configured_user, standard_accounts,
):
"""Happy path: user submits expense → admin rejects → response includes
the entry id, balance still zero."""
_, wallet = configured_user
posted = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="15.00",
currency="EUR",
description=f"Reject me {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
result = await reject_entry(
client, super_user_headers=super_user_headers, entry_id=posted["id"],
)
assert result.get("entry_id") == posted["id"]
balance = await get_balance(client, wallet_inkey=wallet.inkey)
assert not balance.get("fiat_balances"), (
f"voided entry should not surface in balance, got {balance}"
)
@pytest.mark.anyio
async def test_voided_entry_visible_in_user_journal(
client, super_user_headers, configured_user, standard_accounts,
):
"""Post-commit-1c89e69 behaviour: rejected entries remain visible in
the user's `/entries/user` listing so the user can see their own
rejected history rather than having it silently disappear.
The UI is expected to render these with a 'voided' visual marker
(PR #34 webapp companion). The balance query still excludes them
via the separate `tags` filter — covered in
`test_admin_can_reject_pending_expense`.
"""
_, wallet = configured_user
tag = f"void-marker-{uuid4().hex[:6]}"
posted = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="20.00",
currency="EUR",
description=tag,
expense_account=standard_accounts["expense_food"]["name"],
)
await reject_entry(
client, super_user_headers=super_user_headers, entry_id=posted["id"],
)
listing = await list_user_entries(client, wallet_inkey=wallet.inkey)
entries = listing.get("entries", [])
descriptions = [e.get("description") or "" for e in entries]
assert any(tag in d for d in descriptions), (
f"voided entry should remain visible in user journal post-#34, "
f"got descriptions: {descriptions}"
)
voided = next(
(e for e in entries if tag in (e.get("description") or "")), None,
)
assert voided is not None
assert "voided" in voided.get("tags", []), (
f"voided entry should be tagged 'voided' for UI styling, "
f"got tags: {voided.get('tags')}"
)
@pytest.mark.anyio
async def test_reject_unknown_entry_returns_404(
client, super_user_headers,
):
"""An entry id that doesn't exist anywhere in the ledger 404s."""
bogus_id = uuid4().hex[:16]
r = await client.post(
f"/libra/api/v1/entries/{bogus_id}/reject",
headers=super_user_headers,
)
assert r.status_code == 404, f"expected 404, got {r.status_code}: {r.text}"
assert "not found" in r.text.lower()
@pytest.mark.anyio
async def test_reject_already_cleared_entry_returns_404(
client, super_user_headers, configured_user, standard_accounts,
):
"""The reject lookup filters by `flag == '!'` so already-approved
(cleared) entries are indistinguishable from non-existent ones —
both 404."""
_, wallet = configured_user
posted = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="11.00",
currency="EUR",
description=f"Approve-then-reject {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
await approve_entry(
client, super_user_headers=super_user_headers, entry_id=posted["id"],
)
r = await client.post(
f"/libra/api/v1/entries/{posted['id']}/reject",
headers=super_user_headers,
)
assert r.status_code == 404, f"expected 404, got {r.status_code}: {r.text}"
@pytest.mark.anyio
async def test_non_super_user_cannot_reject(
client, configured_user, standard_accounts,
):
"""Reject endpoint uses libra's `require_super_user` — wallet
admin-key of a non-super user is forbidden."""
_, wallet = configured_user
posted = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="13.00",
currency="EUR",
description=f"Forbidden reject {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
r = await client.post(
f"/libra/api/v1/entries/{posted['id']}/reject",
headers={"X-Api-Key": wallet.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_double_reject_returns_404_on_second_call(
client, super_user_headers, configured_user, standard_accounts,
):
"""After a successful reject the entry is no longer matched by the
lookup (it's still flag `!` but its journal-listing-filter behaviour
is "voided"). A second reject 404s rather than mutating again.
Documents the de-facto idempotency story: it's "first wins, repeat
fails cleanly" rather than "repeat is a no-op success." If the
reversing-entry refactor (#24) reshapes this, the test will reveal it.
"""
_, wallet = configured_user
posted = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="9.00",
currency="EUR",
description=f"Double reject {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
await reject_entry(
client, super_user_headers=super_user_headers, entry_id=posted["id"],
)
r = await client.post(
f"/libra/api/v1/entries/{posted['id']}/reject",
headers=super_user_headers,
)
# First reject succeeded; second reject either 404 (entry still flag !
# but matched-by-tag elsewhere) or 200 with idempotent no-op. Lock in
# whichever the current code does so a future change to the reject
# path forces a deliberate decision.
assert r.status_code in (200, 404), (
f"second reject should be deterministic, got {r.status_code}: {r.text}"
)
@pytest.mark.anyio
async def test_concurrent_approve_and_reject_are_serialized(
client, super_user_headers, configured_user, standard_accounts,
):
"""Two mutations of the same ledger source file fired concurrently must
BOTH land. Before libra-#23 each endpoint did its own read-modify-write
with raw httpx and no lock, so one writer overwrote the other's change
(or 412'd on the stale checksum). Now both route through
FavaClient.transform_source_line under the global write lock.
"""
import asyncio
_, wallet = configured_user
approve_tag = f"conc-approve-{uuid4().hex[:6]}"
reject_tag = f"conc-reject-{uuid4().hex[:6]}"
posted = {}
for tag in (approve_tag, reject_tag):
posted[tag] = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="10.00",
currency="EUR",
description=tag,
expense_account=standard_accounts["expense_food"]["name"],
)
# Force a Fava reload so the approve/reject lookups see both fresh
# pending entries (see #37).
await list_user_entries(client, wallet_inkey=wallet.inkey)
r_approve, r_reject = await asyncio.gather(
client.post(
f"/libra/api/v1/entries/{posted[approve_tag]['id']}/approve",
headers=super_user_headers,
),
client.post(
f"/libra/api/v1/entries/{posted[reject_tag]['id']}/reject",
headers=super_user_headers,
),
)
assert r_approve.status_code == 200, f"approve: {r_approve.text}"
assert r_reject.status_code == 200, f"reject: {r_reject.text}"
# Both mutations must be visible: one entry voided, the other cleared
# (a cleared entry no longer matches the pending-only reject lookup).
listing = await list_user_entries(client, wallet_inkey=wallet.inkey)
entries = listing.get("entries", [])
rejected = next(
(e for e in entries if reject_tag in (e.get("description") or "")), None,
)
assert rejected is not None and "voided" in rejected.get("tags", []), (
f"rejected entry lost its #voided tag: {rejected}"
)
second_reject = await client.post(
f"/libra/api/v1/entries/{posted[approve_tag]['id']}/reject",
headers=super_user_headers,
)
assert second_reject.status_code == 404, (
"approved entry should no longer match the pending-only reject "
f"lookup, got {second_reject.status_code}"
)