diff --git a/routstr/auth.py b/routstr/auth.py index e74a94a5..3864a506 100644 --- a/routstr/auth.py +++ b/routstr/auth.py @@ -91,11 +91,20 @@ def redemption_error_to_http_exception(error: Exception) -> HTTPException: status_code = 400 if "already spent" in lowered: message = "Cashu token already spent" - elif "insufficient" in lowered or "melt fee" in lowered: + elif ( + "insufficient" in lowered + or "melt fee" in lowered + or "exceed token amount" in lowered + or "estimate fees" in lowered + ): message = "Token value is too small to cover swap fees" elif "failed to melt" in lowered: message = "Failed to swap token from foreign mint" - elif "invalid" in lowered or "decode" in lowered: + elif ("invalid" in lowered or "decode" in lowered) and "token" in lowered: + # Anchor the broad buckets to "token" so internal faults whose text + # merely contains "invalid"/"decode" (e.g. "invalid literal", + # SQLAlchemy "Invalid …") fall through to the generic 500 below + # instead of masquerading as a 401 token error. message = "Invalid Cashu token" status_code = 401 elif isinstance(error, ValueError): diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index 51afb080..c3d9c5d6 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -4049,7 +4049,7 @@ class BaseUpstreamProvider: if "mint error" in error_message.lower(): return create_error_response( "mint_error", - f"CASHU mint error: {error_message}", + "CASHU mint error while processing token", 422, request=request, token=x_cashu_token, @@ -4057,7 +4057,7 @@ class BaseUpstreamProvider: return create_error_response( "cashu_error", - f"CASHU token processing failed: {error_message}", + "CASHU token processing failed", 400, request=request, token=x_cashu_token, @@ -4717,7 +4717,7 @@ class BaseUpstreamProvider: if "mint error" in error_message.lower(): return create_error_response( "mint_error", - f"CASHU mint error: {error_message}", + "CASHU mint error while processing token", 422, request=request, token=x_cashu_token, @@ -4725,7 +4725,7 @@ class BaseUpstreamProvider: return create_error_response( "cashu_error", - f"CASHU token processing failed: {error_message}", + "CASHU token processing failed", 400, request=request, token=x_cashu_token, diff --git a/tests/unit/test_auth_cashu.py b/tests/unit/test_auth_cashu.py index 61707698..2eff6c40 100644 --- a/tests/unit/test_auth_cashu.py +++ b/tests/unit/test_auth_cashu.py @@ -75,6 +75,13 @@ async def test_failed_first_cashu_redemption_rolls_back_empty_api_key( 400, "Token value is too small to cover swap fees", ), + ( + ValueError( + "Failed to estimate fees: Fees (7 sat) exceed token amount (5 sat)" + ), + 400, + "Token value is too small to cover swap fees", + ), ( ValueError( "Failed to melt token from foreign mint http://foreign:3338: boom" @@ -158,3 +165,37 @@ async def test_unexpected_redemption_error_returns_internal_error( assert error_detail["code"] == "internal_error" assert "/var/lib/secret" not in error_detail["message"] assert await session.get(ApiKey, hashed_key) is None + + +@pytest.mark.asyncio +async def test_internal_error_with_invalid_keyword_does_not_masquerade( + session: AsyncSession, +) -> None: + """A non-wallet fault whose text merely contains "invalid" (but not + "token") must fall through to a generic 500, not a 401 token error. + + Guards the anchored `"invalid"/"decode"` + `"token"` gate against stdlib/ + driver strings like "Invalid isoformat string" leaking as token errors.""" + token = "cashuAinternal_fault_mentions_invalid" + hashed_key = hashlib.sha256(token.encode()).hexdigest() + token_obj = SimpleNamespace(mint="http://mint:3338", unit="sat") + + from routstr.core.settings import settings + + with ( + patch.object(settings, "cashu_mints", ["http://mint:3338"]), + patch("routstr.auth.deserialize_token_from_string", return_value=token_obj), + patch( + "routstr.auth.credit_balance", + new=AsyncMock( + side_effect=RuntimeError("Invalid isoformat string: '2020-13-99'") + ), + ), + ): + with pytest.raises(HTTPException) as exc_info: + await validate_bearer_key(token, session) + + assert exc_info.value.status_code == 500 + detail = cast(dict[str, dict[str, str]], exc_info.value.detail) + assert detail["error"]["code"] == "internal_error" + assert await session.get(ApiKey, hashed_key) is None