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>
This commit is contained in:
parent
c0c8acbe30
commit
4d63e08a69
5 changed files with 305 additions and 123 deletions
|
|
@ -210,3 +210,69 @@ async def test_double_reject_returns_404_on_second_call(
|
|||
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}"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue