From 667f9bf6bbb1badeb9b4e152d1c544aff729260d Mon Sep 17 00:00:00 2001 From: thefux Date: Thu, 6 Aug 2026 20:58:06 +0000 Subject: [PATCH 1/3] =?UTF-8?q?test:=20money-path=20audit=20=E2=80=94=208?= =?UTF-8?q?=20RED=20tests=20for=20live=20fund-loss=20vulnerabilities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comprehensive audit of all money-moving code paths on current main. Found 8 live vulnerabilities where users, providers, or node runners can lose funds, plus 1 false-green in the existing emergency refund test suite. Live vulnerabilities (all RED — tests assert correct/safe behaviour): V-E1 send_refund() swallows DB failure after minting a refund token base.py ~line 3625 — except Exception: pass V-E2 Emergency refund (chat) — same except: pass base.py ~line 3992 (existing test is a false green — 500-char window too short) V-E3 Emergency refund (responses API) — identical pattern base.py ~line 4972 V-E4 Balance refund endpoint swallows DB failure balance.py ~line 628 V-E5 credit_balance() swallows 'in' transaction DB failure wallet.py ~line 1715 V-E6 EHBP refund token — except: pass after store ehbp.py ~line 762 V-E7 EHBP 'in' transaction — except: pass after store ehbp.py ~line 1028 V-E8 Admin withdraw returns token even when DB store fails admin.py ~line 475 V-E9 Window regression guard (GREEN) — documents the false-green in the existing test_emergency_refund_no_try_except_pass Test results: 8 failed, 1 passed. --- tests/unit/test_money_path_audit_2026.py | 362 +++++++++++++++++++++++ 1 file changed, 362 insertions(+) create mode 100644 tests/unit/test_money_path_audit_2026.py diff --git a/tests/unit/test_money_path_audit_2026.py b/tests/unit/test_money_path_audit_2026.py new file mode 100644 index 00000000..8f2b874a --- /dev/null +++ b/tests/unit/test_money_path_audit_2026.py @@ -0,0 +1,362 @@ +"""RED tests for live money-loss vulnerabilities in routstr-core. + +Every test in this file asserts the CORRECT (safe) behaviour for a money +path where a user, provider, or node runner can lose funds. Each test +FAILS against current ``main`` because the code is buggy — they are the +"RED" phase of TDD. Once the underlying bugs are fixed, they go green. + +== Vulnerability summary (all LIVE on main as of 2026-08-06) == + +V-E1 send_refund() swallows DB failure after minting a refund token + base.py ~line 3625 — except Exception: pass + Impact: refund token is minted at the Cashu mint but never recorded + in the DB. The user receives the token in the X-Cashu header, but + if they lose it (or it never arrives) the refund endpoint cannot + look it up → permanently unrecoverable funds. + +V-E2 Emergency refund (chat) — same except: pass, false-green in the + existing test_emergency_refund_no_try_except_pass because the + 500-char inspection window is too short to reach the except block. + base.py ~line 3992 + +V-E3 Emergency refund (responses API) — identical pattern. + base.py ~line 4972 + +V-E4 Balance refund endpoint swallows DB failure. + balance.py ~line 628 — except Exception: pass + +V-E5 credit_balance() swallows "in" transaction DB failure. + wallet.py ~line 1715 — except Exception: pass + Impact: token is redeemed and balance credited, but no "in" audit + row is stored. The refund endpoint matches "in" → "out" by + request_id; a missing "in" row breaks that linkage. + +V-E6 EHBP refund token — except: pass after store. + ehbp.py ~line 762 + +V-E7 EHBP "in" transaction — except: pass after store. + ehbp.py ~line 1028 + +V-E8 Admin withdraw returns the token to the caller even when the DB + store fails. The token is delivered but there is no audit trail. + admin.py ~line 475 + +V-E9 Existing emergency-refund tests use a 500-char source window that + is too short to reach the except: pass block, producing a false + green. This test verifies the window is wide enough. +""" + +from __future__ import annotations + +import inspect +from unittest.mock import AsyncMock, patch + +import pytest + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _source_contains_except_pass(source: str, anchor: str, window: int = 1000) -> bool: + """Return True if an ``except Exception: pass`` (or bare ``except: pass``) + appears within ``window`` characters after ``anchor`` in ``source``.""" + idx = source.find(anchor) + if idx < 0: + return False + section = source[idx : idx + window] + has_except = "except Exception:" in section or "except:" in section + return has_except and "pass" in section + + +# =========================================================================== +# V-E1: send_refund() must not silently swallow DB write failure +# =========================================================================== + + +def test_send_refund_no_except_pass_after_store() -> None: + """FIX REQUIRED: send_refund() mints a refund token then uses + try/except/pass around store_cashu_transaction. + + If the DB write fails the token exists at the mint but is never + recorded. The refund endpoint cannot find it and the user's funds + are permanently lost. + + Correct behaviour: let the exception propagate, or at minimum log + at CRITICAL with the full token string so an operator can manually + recover it. Never silently pass. + """ + from routstr.upstream.base import BaseUpstreamProvider + + src = inspect.getsource(BaseUpstreamProvider.send_refund) + assert not _source_contains_except_pass( + src, "store_cashu_transaction" + ), ( + "FIX REQUIRED: send_refund() uses try/except/pass around " + "store_cashu_transaction (base.py ~line 3625). A failed DB write " + "after the token is minted permanently loses the refund token. " + "Fix: propagate the exception or log CRITICAL with the full token." + ) + + +# =========================================================================== +# V-E2: Emergency refund (chat) — except: pass is LIVE (existing test is +# a false green because its 500-char window is too short) +# =========================================================================== + + +def test_emergency_refund_chat_no_silent_db_failure() -> None: + """FIX REQUIRED: The chat emergency refund path (JSON parse error) + uses try/except/pass around store_cashu_transaction after minting a + refund token via send_token(). + + The existing test_emergency_refund_no_try_except_pass passes because + it only inspects a 500-char window — too short to reach the except + block. This test uses a wider window and correctly fails. + """ + from routstr.upstream.base import BaseUpstreamProvider + + src = inspect.getsource( + BaseUpstreamProvider.handle_x_cashu_non_streaming_response + ) + assert not _source_contains_except_pass( + src, "emergency_refund = amount", window=1000 + ), ( + "FIX REQUIRED: Emergency refund (chat, base.py ~line 3992) uses " + "try/except/pass around store_cashu_transaction after minting a " + "refund token. A failed DB write permanently loses the token. " + "Fix: propagate the exception or log CRITICAL with the full token." + ) + + +# =========================================================================== +# V-E3: Emergency refund (responses API) — identical pattern +# =========================================================================== + + +def test_emergency_refund_responses_no_silent_db_failure() -> None: + """FIX REQUIRED: Same except: pass pattern in the Responses API + emergency refund path (base.py ~line 4972).""" + from routstr.upstream.base import BaseUpstreamProvider + + src = inspect.getsource( + BaseUpstreamProvider.handle_x_cashu_non_streaming_responses_response + ) + assert not _source_contains_except_pass( + src, "emergency_refund = amount", window=1000 + ), ( + "FIX REQUIRED: Emergency refund (responses API, base.py ~line 4972) " + "uses try/except/pass around store_cashu_transaction after minting " + "a refund token. Same fund-loss vulnerability as the chat path." + ) + + +# =========================================================================== +# V-E4: Balance refund endpoint — except: pass +# =========================================================================== + + +def test_balance_refund_endpoint_no_silent_db_failure() -> None: + """FIX REQUIRED: The /v1/wallet/refund endpoint mints a refund token + via send_token() then uses try/except/pass around + store_cashu_transaction (balance.py ~line 628). + + A failed DB write means the token is delivered to the user but never + recorded — the refund sweep cannot reclaim it and the audit trail + is broken. + """ + from routstr import balance + + src = inspect.getsource(balance.refund_wallet_endpoint) + assert not _source_contains_except_pass( + src, "store_cashu_transaction" + ), ( + "FIX REQUIRED: refund_wallet_endpoint (balance.py ~line 628) uses " + "try/except/pass around store_cashu_transaction. A failed DB write " + "loses the audit record for the minted refund token." + ) + + +# =========================================================================== +# V-E5: credit_balance() — "in" transaction except: pass +# =========================================================================== + + +def test_credit_balance_no_silent_db_failure_for_in_tx() -> None: + """FIX REQUIRED: credit_balance() redeems a Cashu token and credits + the user's balance, then uses try/except/pass around + store_cashu_transaction for the "in" record (wallet.py ~line 1715). + + The token is already spent at the mint. If the "in" DB record is + not stored, the refund endpoint cannot match "in" → "out" by + request_id. The funds are credited but the audit/reconciliation + chain is broken. + """ + from routstr import wallet + + # credit_balance delegates to _credit_balance_locked + src = inspect.getsource(wallet._credit_balance_locked) + assert not _source_contains_except_pass( + src, "store_cashu_transaction" + ), ( + "FIX REQUIRED: _credit_balance_locked (wallet.py ~line 1715) uses " + "try/except/pass around store_cashu_transaction for the 'in' " + "record. A failed DB write breaks the in→out refund linkage." + ) + + +# =========================================================================== +# V-E6: EHBP refund token — except: pass +# =========================================================================== + + +def test_ehbp_refund_no_silent_db_failure() -> None: + """FIX REQUIRED: The EHBP refund helper mints a refund token via + send_token() then uses try/except/pass around + store_cashu_transaction (ehbp.py ~line 762).""" + from routstr.upstream import ehbp + + # Find the function that creates a refund token + refund_fn = None + for name in dir(ehbp): + obj = getattr(ehbp, name) + if inspect.iscoroutinefunction(obj) and hasattr(obj, "__code__"): + try: + src = inspect.getsource(obj) + if "send_token" in src and "store_cashu_transaction" in src and "typ=\"out\"" in src: + refund_fn = obj + break + except (OSError, TypeError): + continue + + assert refund_fn is not None, "Could not locate EHBP refund function" + src = inspect.getsource(refund_fn) + assert not _source_contains_except_pass( + src, "store_cashu_transaction" + ), ( + "FIX REQUIRED: EHBP refund helper (ehbp.py ~line 762) uses " + "try/except/pass around store_cashu_transaction after minting a " + "refund token. Same fund-loss vulnerability as base.py paths." + ) + + +# =========================================================================== +# V-E7: EHBP "in" transaction — except: pass +# =========================================================================== + + +def test_ehbp_in_transaction_no_silent_db_failure() -> None: + """FIX REQUIRED: The EHBP receive path redeems a token then uses + try/except/pass around store_cashu_transaction for the "in" record + (ehbp.py ~line 1028).""" + from routstr.upstream import ehbp + + receive_fn = None + for name in dir(ehbp): + obj = getattr(ehbp, name) + if inspect.iscoroutinefunction(obj) and hasattr(obj, "__code__"): + try: + src = inspect.getsource(obj) + if "recieve_token" in src and "store_cashu_transaction" in src and "typ=\"in\"" in src: + receive_fn = obj + break + except (OSError, TypeError): + continue + + assert receive_fn is not None, "Could not locate EHBP receive function" + src = inspect.getsource(receive_fn) + assert not _source_contains_except_pass( + src, "store_cashu_transaction" + ), ( + "FIX REQUIRED: EHBP receive path (ehbp.py ~line 1028) uses " + "try/except/pass around store_cashu_transaction for the 'in' " + "record. A failed DB write breaks the audit trail." + ) + + +# =========================================================================== +# V-E8: Admin withdraw must not return token when DB store fails +# =========================================================================== + + +def test_admin_withdraw_must_not_return_token_on_db_failure() -> None: + """FIX REQUIRED: The admin withdraw endpoint mints a token via + send_token(), then tries to store it. If the store fails it logs + CRITICAL but STILL RETURNS THE TOKEN to the caller (admin.py ~line + 475). + + The token is delivered (admin gets their money) but there is no + audit trail — if the admin later loses the token, there is no DB + record to reclaim it from. Worse, the "out" row is missing so + reconciliation is impossible. + + Correct behaviour: if the DB store fails, the token should NOT be + returned to the caller. Instead, raise an error so the admin knows + the withdrawal failed and can retry. + """ + import inspect + + from routstr.core import admin + + src = inspect.getsource(admin.withdraw) + # Find the store_cashu_transaction section + store_idx = src.find("store_cashu_transaction") + assert store_idx > 0, "withdraw endpoint must store the token" + + # The section from store_cashu_transaction to the return statement + # must NOT contain "return" before the except block — i.e. the + # function must not return the token if the store raised. + # Currently the code does: + # try: + # await store_cashu_transaction(...) + # except Exception: + # logger.critical(...) + # return {"token": token, ...} + # + # The return is AFTER the except, meaning the token is returned even + # when the store failed. The fix: re-raise or return an error + # response inside the except block, before the return. + section = src[store_idx:] + # Check that there is a "return" after the except block (the bug) + return_after_except = ( + "except Exception:" in section and "return" in section.split("except Exception:")[-1] + ) + assert not return_after_except, ( + "FIX REQUIRED: admin.withdraw (admin.py ~line 475) returns the " + "token to the caller even when store_cashu_transaction fails. " + "A failed DB write means the token is delivered but has no audit " + "trail. Fix: re-raise or return an error response inside the " + "except block — do not return the token." + ) + + +# =========================================================================== +# V-E9: Existing emergency refund test window is too short (false green) +# =========================================================================== + + +def test_existing_emergency_refund_test_window_is_wide_enough() -> None: + """REGRESSION GUARD: The existing test_emergency_refund_no_try_except_pass + inspects a 500-character window after "emergency_refund = amount". + The except: pass block is ~530 chars after that anchor, so the 500-char + window misses it entirely — producing a false green. + + This test verifies that a 1000-char window (which we use in V-E2/V-E3) + correctly catches the live bug. If this test fails, someone shrank + the window back to 500 or removed the wider-window tests. + """ + from routstr.upstream.base import BaseUpstreamProvider + + src = inspect.getsource( + BaseUpstreamProvider.handle_x_cashu_non_streaming_response + ) + emergency_start = src.find("emergency_refund = amount") + assert emergency_start > 0, "Emergency refund path must exist" + + # The 1000-char window MUST see the except: pass (currently live bug) + wide_section = src[emergency_start : emergency_start + 1000] + assert "except Exception:" in wide_section and "pass" in wide_section, ( + "The 1000-char window must catch the live except: pass bug. " + "If this fails, either the bug was fixed (good!) or the window " + "logic changed (bad — re-check V-E2)." + ) From a60b04aea05f7a780f9a9135004b7b12c3f6709d Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Thu, 6 Aug 2026 23:28:24 +0200 Subject: [PATCH 2/3] fix: propagate Cashu transaction storage failures --- routstr/balance.py | 23 ++-- routstr/core/admin.py | 28 ++--- routstr/upstream/base.py | 148 ++++++++++------------- routstr/upstream/ehbp.py | 40 +++--- routstr/wallet.py | 30 ++--- tests/unit/test_admin_withdraw.py | 10 +- tests/unit/test_money_path_audit_2026.py | 38 ++---- tests/unit/test_wallet.py | 11 +- 8 files changed, 132 insertions(+), 196 deletions(-) diff --git a/routstr/balance.py b/routstr/balance.py index cf050135..b2aaaaeb 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -613,19 +613,16 @@ async def refund_wallet_endpoint( await _refund_cache_set(bearer_value, result) if "token" in result: - try: - await store_cashu_transaction( - token=result["token"], - amount=remaining_balance, - unit=key.refund_currency or "sat", - mint_url=effective_refund_mint, - typ="out", - collected=False, - source="apikey", - api_key_hashed_key=key.hashed_key, - ) - except Exception: - pass # store_cashu_transaction already logs + await store_cashu_transaction( + token=result["token"], + amount=remaining_balance, + unit=key.refund_currency or "sat", + mint_url=effective_refund_mint, + typ="out", + collected=False, + source="apikey", + api_key_hashed_key=key.hashed_key, + ) logger.info( "refund_wallet_endpoint: refund successful", diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 604ed948..d185ee77 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -455,25 +455,15 @@ async def withdraw( status_code=400, detail="Insufficient wallet balance" ) from error actual_mint = token_mint_url(token, effective_mint) - try: - await store_cashu_transaction( - token=token, - amount=withdraw_request.amount, - unit=withdraw_request.unit, - mint_url=actual_mint, - typ="out", - collected=False, - source="admin", - ) - except Exception: - logger.critical( - "Admin withdrawal token issued without a persisted audit record", - extra={ - "amount": withdraw_request.amount, - "unit": withdraw_request.unit, - "mint_url": actual_mint, - }, - ) + await store_cashu_transaction( + token=token, + amount=withdraw_request.amount, + unit=withdraw_request.unit, + mint_url=actual_mint, + typ="out", + collected=False, + source="admin", + ) return {"token": token, "mint_url": actual_mint} diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index 71f337ca..bc9fd547 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -3594,37 +3594,12 @@ class BaseUpstreamProvider: max_retries = 3 last_exception = None + refund_token = None for attempt in range(max_retries): try: refund_token = await send_token(amount, unit=unit, mint_url=mint) - - logger.info( - "Refund token created successfully", - extra={ - "amount": amount, - "unit": unit, - "mint": mint, - "attempt": attempt + 1, - "token_preview": refund_token[:20] + "..." - if len(refund_token) > 20 - else refund_token, - }, - ) - - try: - await store_cashu_transaction( - token=refund_token, - amount=amount, - unit=unit, - mint_url=token_mint_url(refund_token, mint), - typ="out", - request_id=request_id, - ) - except Exception: - pass # store_cashu_transaction already logs - - return refund_token + break except Exception as e: last_exception = e if attempt < max_retries - 1: @@ -3654,16 +3629,39 @@ class BaseUpstreamProvider: }, ) - raise HTTPException( - status_code=401, - detail={ - "error": { - "message": f"failed to create refund after {max_retries} attempts: {str(last_exception)}", - "type": "invalid_request_error", - "code": "send_token_failed", - } + if refund_token is None: + raise HTTPException( + status_code=401, + detail={ + "error": { + "message": f"failed to create refund after {max_retries} attempts: {str(last_exception)}", + "type": "invalid_request_error", + "code": "send_token_failed", + } + }, + ) + + logger.info( + "Refund token created successfully", + extra={ + "amount": amount, + "unit": unit, + "mint": mint, + "attempt": attempt + 1, + "token_preview": refund_token[:20] + "..." + if len(refund_token) > 20 + else refund_token, }, ) + await store_cashu_transaction( + token=refund_token, + amount=amount, + unit=unit, + mint_url=token_mint_url(refund_token, mint), + typ="out", + request_id=request_id, + ) + return refund_token async def handle_x_cashu_streaming_response( self, @@ -3979,17 +3977,14 @@ class BaseUpstreamProvider: emergency_refund = amount refund_token = await send_token(emergency_refund, unit=unit, mint_url=mint) response.headers["X-Cashu"] = refund_token - try: - await store_cashu_transaction( - token=refund_token, - amount=emergency_refund, - unit=unit, - mint_url=token_mint_url(refund_token, mint), - typ="out", - request_id=request_id, - ) - except Exception: - pass + await store_cashu_transaction( + token=refund_token, + amount=emergency_refund, + unit=unit, + mint_url=token_mint_url(refund_token, mint), + typ="out", + request_id=request_id, + ) logger.warning( "Emergency refund issued due to JSON parse error", @@ -4345,18 +4340,15 @@ class BaseUpstreamProvider: headers = self.prepare_headers(dict(request.headers)) request_id = getattr(request.state, "request_id", None) - try: - await store_cashu_transaction( - token=x_cashu_token, - amount=amount, - unit=unit, - mint_url=mint, - typ="in", - request_id=request_id, - collected=True, - ) - except Exception: - pass + await store_cashu_transaction( + token=x_cashu_token, + amount=amount, + unit=unit, + mint_url=mint, + typ="in", + request_id=request_id, + collected=True, + ) logger.info( "X-Cashu token redeemed for Responses API", @@ -4960,17 +4952,14 @@ class BaseUpstreamProvider: emergency_refund = amount refund_token = await send_token(emergency_refund, unit=unit, mint_url=mint) response.headers["X-Cashu"] = refund_token - try: - await store_cashu_transaction( - token=refund_token, - amount=emergency_refund, - unit=unit, - mint_url=token_mint_url(refund_token, mint), - typ="out", - request_id=request_id, - ) - except Exception: - pass + await store_cashu_transaction( + token=refund_token, + amount=emergency_refund, + unit=unit, + mint_url=token_mint_url(refund_token, mint), + typ="out", + request_id=request_id, + ) logger.warning( "Emergency refund issued for Responses API due to JSON parse error", @@ -5034,18 +5023,15 @@ class BaseUpstreamProvider: headers = self.prepare_headers(dict(request.headers)) request_id = getattr(request.state, "request_id", None) - try: - await store_cashu_transaction( - token=x_cashu_token, - amount=amount, - unit=unit, - mint_url=mint, - typ="in", - request_id=request_id, - collected=True, - ) - except Exception: - pass + await store_cashu_transaction( + token=x_cashu_token, + amount=amount, + unit=unit, + mint_url=mint, + typ="in", + request_id=request_id, + collected=True, + ) logger.info( "X-Cashu token redeemed successfully", diff --git a/routstr/upstream/ehbp.py b/routstr/upstream/ehbp.py index 96955492..c70a3b68 100644 --- a/routstr/upstream/ehbp.py +++ b/routstr/upstream/ehbp.py @@ -749,17 +749,14 @@ async def send_cashu_refund( ) -> str: """Create a Cashu refund token and record the outgoing transaction.""" refund_token = await send_token(amount, unit=unit, mint_url=mint) - try: - await store_cashu_transaction( - token=refund_token, - amount=amount, - unit=unit, - mint_url=mint, - typ="out", - request_id=request_id, - ) - except Exception: - pass + await store_cashu_transaction( + token=refund_token, + amount=amount, + unit=unit, + mint_url=mint, + typ="out", + request_id=request_id, + ) return refund_token @@ -1014,18 +1011,15 @@ async def forward_ehbp_x_cashu_request( try: amount, unit, mint = await recieve_token(x_cashu_token) redeemed = True - try: - await store_cashu_transaction( - token=x_cashu_token, - amount=amount, - unit=unit, - mint_url=mint, - typ="in", - request_id=request_id, - collected=True, - ) - except Exception: - pass + await store_cashu_transaction( + token=x_cashu_token, + amount=amount, + unit=unit, + mint_url=mint, + typ="in", + request_id=request_id, + collected=True, + ) headers = upstream.prepare_headers(dict(request.headers)) # type: ignore[attr-defined] target = upstream.get_ehbp_forwarding_target(path, model_obj) # type: ignore[attr-defined] diff --git a/routstr/wallet.py b/routstr/wallet.py index 0eb091ce..bc92f028 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -1703,23 +1703,19 @@ async def _credit_balance_locked( extra={"new_balance": key.balance}, ) - try: - await store_cashu_transaction( - token=cashu_token, - amount=original_amount, - unit=original_unit, - mint_url=mint_url, - typ="in", - source="apikey", - api_key_hashed_key=key.hashed_key, - ) - except Exception: - pass - else: - logger.debug( - "Cashu token successfully redeemed and stored", - extra={"amount": amount, "unit": unit, "mint_url": mint_url}, - ) + await store_cashu_transaction( + token=cashu_token, + amount=original_amount, + unit=original_unit, + mint_url=mint_url, + typ="in", + source="apikey", + api_key_hashed_key=key.hashed_key, + ) + logger.debug( + "Cashu token successfully redeemed and stored", + extra={"amount": amount, "unit": unit, "mint_url": mint_url}, + ) return amount except Exception as e: logger.error( diff --git a/tests/unit/test_admin_withdraw.py b/tests/unit/test_admin_withdraw.py index 07a98516..07ae6913 100644 --- a/tests/unit/test_admin_withdraw.py +++ b/tests/unit/test_admin_withdraw.py @@ -45,7 +45,7 @@ async def test_withdraw_uses_effective_mint_and_records_outgoing_transaction( @pytest.mark.asyncio -async def test_withdraw_returns_issued_token_when_audit_storage_fails( +async def test_withdraw_propagates_audit_storage_failure( monkeypatch: pytest.MonkeyPatch, ) -> None: mint = "https://primary.example" @@ -58,14 +58,10 @@ async def test_withdraw_returns_issued_token_when_audit_storage_fails( "store_cashu_transaction", AsyncMock(side_effect=RuntimeError("database unavailable")), ) - critical = Mock() - monkeypatch.setattr(admin.logger, "critical", critical) monkeypatch.setattr(admin.settings, "primary_mint", mint) - result = await admin.withdraw(Mock(), admin.WithdrawRequest(amount=75)) - - assert result == {"token": token, "mint_url": mint} - critical.assert_called_once() + with pytest.raises(RuntimeError, match="database unavailable"): + await admin.withdraw(Mock(), admin.WithdrawRequest(amount=75)) @pytest.mark.asyncio diff --git a/tests/unit/test_money_path_audit_2026.py b/tests/unit/test_money_path_audit_2026.py index 8f2b874a..801ce82d 100644 --- a/tests/unit/test_money_path_audit_2026.py +++ b/tests/unit/test_money_path_audit_2026.py @@ -49,9 +49,7 @@ V-E9 Existing emergency-refund tests use a 500-char source window that from __future__ import annotations import inspect -from unittest.mock import AsyncMock, patch - -import pytest +import re # --------------------------------------------------------------------------- # Helpers @@ -65,8 +63,8 @@ def _source_contains_except_pass(source: str, anchor: str, window: int = 1000) - if idx < 0: return False section = source[idx : idx + window] - has_except = "except Exception:" in section or "except:" in section - return has_except and "pass" in section + pattern = r"except(?:\s+Exception)?(?:\s+as\s+\w+)?\s*:\s*pass\b" + return re.search(pattern, section) is not None # =========================================================================== @@ -335,28 +333,10 @@ def test_admin_withdraw_must_not_return_token_on_db_failure() -> None: # =========================================================================== -def test_existing_emergency_refund_test_window_is_wide_enough() -> None: - """REGRESSION GUARD: The existing test_emergency_refund_no_try_except_pass - inspects a 500-character window after "emergency_refund = amount". - The except: pass block is ~530 chars after that anchor, so the 500-char - window misses it entirely — producing a false green. +def test_except_pass_detector_window_is_wide_enough() -> None: + """The detector must catch an except/pass block beyond 500 characters.""" + anchor = "emergency_refund = amount" + source = anchor + (" " * 520) + "except Exception:\n pass" - This test verifies that a 1000-char window (which we use in V-E2/V-E3) - correctly catches the live bug. If this test fails, someone shrank - the window back to 500 or removed the wider-window tests. - """ - from routstr.upstream.base import BaseUpstreamProvider - - src = inspect.getsource( - BaseUpstreamProvider.handle_x_cashu_non_streaming_response - ) - emergency_start = src.find("emergency_refund = amount") - assert emergency_start > 0, "Emergency refund path must exist" - - # The 1000-char window MUST see the except: pass (currently live bug) - wide_section = src[emergency_start : emergency_start + 1000] - assert "except Exception:" in wide_section and "pass" in wide_section, ( - "The 1000-char window must catch the live except: pass bug. " - "If this fails, either the bug was fixed (good!) or the window " - "logic changed (bad — re-check V-E2)." - ) + assert not _source_contains_except_pass(source, anchor, window=500) + assert _source_contains_except_pass(source, anchor, window=1000) diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index 3b8b3569..aa79797a 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -1510,11 +1510,8 @@ async def test_credit_balance_msat_unit_not_converted() -> None: @pytest.mark.asyncio -async def test_credit_balance_survives_audit_store_failure() -> None: - """A failure writing the CashuTransaction history record must not undo the - already-committed balance credit. (The silent swallow is a known - audit-trail gap slated for its own fix — this test pins the financial - invariant that the user keeps their credit, not the swallow itself.)""" +async def test_credit_balance_propagates_audit_store_failure_after_credit() -> None: + """A final transaction-history failure propagates after committing credit.""" mock_key = Mock() mock_key.balance = 0 mock_key.hashed_key = "test_hash" @@ -1531,9 +1528,9 @@ async def test_credit_balance_survives_audit_store_failure() -> None: "routstr.wallet.store_cashu_transaction", side_effect=Exception("history table locked"), ): - amount = await credit_balance("cashuAtest", mock_key, mock_session) + with pytest.raises(Exception, match="history table locked"): + await credit_balance("cashuAtest", mock_key, mock_session) - assert amount == 1_000_000 assert mock_session.commit.called From 239e2e5d5d6460d04faaad66c7ab73c9198f211e Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Sat, 8 Aug 2026 00:47:35 +0200 Subject: [PATCH 3/3] test: mock store_cashu_transaction in credit_balance unit tests The three credit_balance unit tests mock the DB session but let credit_balance call the real store_cashu_transaction_with_retry, which opens its own session against the global engine. In CI that database has no cashu_transactions table; since storage failures now propagate (a60b04ae) instead of being silently swallowed, the tests failed with sqlite3.OperationalError. Patch the audit store like the existing propagation test already does. --- tests/unit/test_wallet.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index aa79797a..c8cecc55 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -613,7 +613,8 @@ async def test_credit_balance() -> None: "routstr.wallet.recieve_token", return_value=(1000, "sat", "http://mint:3338"), ): - amount = await credit_balance(token_str, mock_key, mock_session) + with patch("routstr.wallet.store_cashu_transaction", AsyncMock()): + amount = await credit_balance(token_str, mock_key, mock_session) assert amount == 1000000 # converted to msat assert mock_key.balance == 6000000 # Should be updated after refresh # Verify atomic operations were used @@ -636,7 +637,8 @@ async def test_credit_balance_constrains_redemption_to_key_mint() -> None: receive = AsyncMock(return_value=(1000, "sat", key_mint)) with patch("routstr.wallet.recieve_token", receive): - await credit_balance("cashuAtoken", mock_key, mock_session) + with patch("routstr.wallet.store_cashu_transaction", AsyncMock()): + await credit_balance("cashuAtoken", mock_key, mock_session) receive.assert_awaited_once_with( "cashuAtoken", destination_mint=key_mint, destination_unit="sat" @@ -1503,7 +1505,8 @@ async def test_credit_balance_msat_unit_not_converted() -> None: "routstr.wallet.recieve_token", return_value=(1_000_000, "msat", "http://mint:3338"), ): - amount = await credit_balance("cashuAtest", mock_key, mock_session) + with patch("routstr.wallet.store_cashu_transaction", AsyncMock()): + amount = await credit_balance("cashuAtest", mock_key, mock_session) assert amount == 1_000_000 assert mock_session.commit.called