diff --git a/routstr/balance.py b/routstr/balance.py index 184c445a..83bda1c9 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -23,6 +23,7 @@ from .lightning import lightning_router from .wallet import ( classify_redemption_error, credit_balance, + is_mint_connection_error, recieve_token, send_to_lnurl, send_token, @@ -435,14 +436,10 @@ async def refund_wallet_endpoint( "has_refund_address": bool(key.refund_address), }, ) - if ( - "mint" in error_msg.lower() - or "connection" in error_msg.lower() - or "ConnectError" in str(type(e)) - ): - raise HTTPException(status_code=503, detail=f"Mint service unavailable: {error_msg}") + if is_mint_connection_error(e): + raise HTTPException(status_code=503, detail="Mint service unavailable") else: - raise HTTPException(status_code=500, detail=f"Refund failed: {error_msg}") + raise HTTPException(status_code=500, detail="Refund failed") await _refund_cache_set(bearer_value, result) diff --git a/tests/integration/test_wallet_refund.py b/tests/integration/test_wallet_refund.py index 1c62385d..1cbd6b4b 100644 --- a/tests/integration/test_wallet_refund.py +++ b/tests/integration/test_wallet_refund.py @@ -14,6 +14,7 @@ from httpx import AsyncClient from sqlmodel import select from routstr.core.db import ApiKey, CashuTransaction +from routstr.wallet import MintConnectionError @pytest.mark.integration @@ -503,26 +504,19 @@ async def test_mint_unavailability_handling( # The global mock in conftest.py is already in place, # so we need to temporarily modify it - from unittest.mock import patch + raw_error = "Mint unavailable: Connection refused" - # Make the send_token method raise an exception + # Make the send_token method raise a typed mint connection exception. with patch( "routstr.balance.send_token", - side_effect=Exception("Mint unavailable: Connection refused"), + side_effect=MintConnectionError(raw_error), ): - # The exception should propagate as a 503 error (Service Unavailable) - # But we need to handle it properly - try: - response = await authenticated_client.post("/v1/wallet/refund") - # If we get here, check the status code - assert response.status_code == 503 - assert "Mint service unavailable" in response.json()["detail"] - except Exception as e: - # If the exception propagates, that's also a failure scenario - assert "Mint unavailable" in str(e) + response = await authenticated_client.post("/v1/wallet/refund") + assert response.status_code == 503 + assert response.json()["detail"] == "Mint service unavailable" + assert raw_error not in response.text # Balance should remain unchanged (transaction should roll back) - # Note: Current implementation might not handle this perfectly wallet_response = await authenticated_client.get("/v1/wallet/") assert wallet_response.status_code == 200 assert wallet_response.json()["balance"] == 10_000_000 diff --git a/tests/unit/test_balance.py b/tests/unit/test_balance.py index e5ac57c4..31ceb339 100644 --- a/tests/unit/test_balance.py +++ b/tests/unit/test_balance.py @@ -340,7 +340,10 @@ async def test_apikey_refund_restores_balance_on_mint_failure() -> None: with ( patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)), - patch("routstr.balance.send_token", AsyncMock(side_effect=Exception("mint down"))), + patch( + "routstr.balance.send_token", + AsyncMock(side_effect=MintConnectionError("raw mint outage detail")), + ), patch("routstr.balance.store_cashu_transaction", AsyncMock()), patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)), patch("routstr.balance._refund_cache_set", AsyncMock()), @@ -354,10 +357,46 @@ async def test_apikey_refund_restores_balance_on_mint_failure() -> None: ) assert exc_info.value.status_code == 503 + assert exc_info.value.detail == "Mint service unavailable" + assert "raw mint outage detail" not in exc_info.value.detail # Verify two exec calls: debit + restore assert session.exec.await_count == 2 +@pytest.mark.asyncio +async def test_apikey_refund_generic_failure_is_sanitized_500() -> None: + """Unexpected send-side failures restore balance without leaking exception text.""" + from fastapi import HTTPException + + key = _make_api_key(balance=5000, refund_currency="sat") + raw_error = "database secret token raw-mint-response" + + session = MagicMock() + session.get = AsyncMock(return_value=key) + session.exec = AsyncMock(side_effect=[_update_result(1), _update_result(1)]) + session.commit = AsyncMock() + + with ( + patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)), + patch("routstr.balance.send_token", AsyncMock(side_effect=RuntimeError(raw_error))), + patch("routstr.balance.store_cashu_transaction", AsyncMock()), + patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)), + patch("routstr.balance._refund_cache_set", AsyncMock()), + patch("routstr.balance.logger"), + ): + with pytest.raises(HTTPException) as exc_info: + await refund_wallet_endpoint( + authorization="Bearer sk-testhash", + x_cashu=None, + session=session, + ) + + assert exc_info.value.status_code == 500 + assert exc_info.value.detail == "Refund failed" + assert raw_error not in exc_info.value.detail + assert session.exec.await_count == 2 + + # --------------------------------------------------------------------------- # no-create guarantee: fresh Cashu/unknown sk- tokens must not create API keys # ---------------------------------------------------------------------------