diff --git a/routstr/auth.py b/routstr/auth.py index 78527639..e74a94a5 100644 --- a/routstr/auth.py +++ b/routstr/auth.py @@ -78,6 +78,51 @@ async def check_and_reset_limit(key: ApiKey, session: AsyncSession) -> bool: return False +def redemption_error_to_http_exception(error: Exception) -> HTTPException: + """Map a Cashu token redemption failure to a sanitized client-facing error. + + Known redemption failure patterns (from the wallet or the mint) and expected + wallet errors (ValueError) map to 400/401 with a stable message under a + single "token_redemption_failed" code. Anything else is an internal fault + and maps to a generic 500. Raw error text never reaches the client — it + stays in server logs. + """ + lowered = str(error).lower() + status_code = 400 + if "already spent" in lowered: + message = "Cashu token already spent" + elif "insufficient" in lowered or "melt fee" 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: + message = "Invalid Cashu token" + status_code = 401 + elif isinstance(error, ValueError): + message = "Failed to redeem Cashu token" + else: + return HTTPException( + status_code=500, + detail={ + "error": { + "message": "Internal error during token redemption", + "type": "api_error", + "code": "internal_error", + } + }, + ) + return HTTPException( + status_code=status_code, + detail={ + "error": { + "message": message, + "type": "invalid_request_error", + "code": "token_redemption_failed", + } + }, + ) + + async def validate_bearer_key( bearer_key: str, session: AsyncSession, @@ -334,7 +379,8 @@ async def validate_bearer_key( "error_type": type(credit_error).__name__, }, ) - raise credit_error + await session.rollback() + raise redemption_error_to_http_exception(credit_error) from credit_error if msats <= 0: logger.error( @@ -346,7 +392,16 @@ async def validate_bearer_key( # persisted, drop it so we never leave an orphan zero-balance key. await session.delete(new_key) await session.commit() - raise Exception("Token redemption failed") + raise HTTPException( + status_code=400, + detail={ + "error": { + "message": "Failed to redeem Cashu token: token yielded no value", + "type": "invalid_request_error", + "code": "token_redemption_failed", + } + }, + ) await session.refresh(new_key) await session.commit() @@ -379,7 +434,7 @@ async def validate_bearer_key( status_code=401, detail={ "error": { - "message": f"Invalid or expired Cashu key: {str(e)}", + "message": "Invalid or expired Cashu key", "type": "invalid_request_error", "code": "invalid_api_key", } diff --git a/tests/unit/test_auth_cashu.py b/tests/unit/test_auth_cashu.py index cf1c98de..45f4a7a3 100644 --- a/tests/unit/test_auth_cashu.py +++ b/tests/unit/test_auth_cashu.py @@ -57,3 +57,102 @@ async def test_failed_first_cashu_redemption_rolls_back_empty_api_key( await validate_bearer_key(token, session) assert await session.get(ApiKey, hashed_key) is None + + +@pytest.mark.parametrize( + ("error", "expected_status", "expected_message"), + [ + ( + ValueError("Mint Error: Token already spent. (Code: 11001)"), + 400, + "Cashu token already spent", + ), + ( + ValueError( + "Token amount (5 sat) is insufficient to cover melt fees. " + "Needed: 7 sat (amount: 5 + fee: 1 + input_fees: 1)" + ), + 400, + "Token value is too small to cover swap fees", + ), + ( + ValueError( + "Failed to melt token from foreign mint http://foreign:3338: boom" + ), + 400, + "Failed to swap token from foreign mint", + ), + ( + ValueError("could not decode token"), + 401, + "Invalid Cashu token", + ), + ( + ValueError("some unexpected wallet condition"), + 400, + "Failed to redeem Cashu token", + ), + ], +) +@pytest.mark.asyncio +async def test_redemption_failure_returns_sanitized_error( + session: AsyncSession, + error: Exception, + expected_status: int, + expected_message: str, +) -> None: + """Redemption failures share one error code, expose stable sanitized + messages (no raw exception text), and leave no orphan ApiKey row.""" + token = "cashuAredemption_fails_with_specific_error" + 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=error), + ), + ): + with pytest.raises(HTTPException) as exc_info: + await validate_bearer_key(token, session) + + assert exc_info.value.status_code == expected_status + error_detail = exc_info.value.detail["error"] + assert error_detail["code"] == "token_redemption_failed" + assert error_detail["message"] == expected_message + assert str(error) not in error_detail["message"] + assert await session.get(ApiKey, hashed_key) is None + + +@pytest.mark.asyncio +async def test_unexpected_redemption_error_returns_internal_error( + session: AsyncSession, +) -> None: + """Unexpected (non-wallet) failures surface as generic 500s without + leaking internal details, instead of masquerading as token errors.""" + token = "cashuAredemption_fails_with_internal_error" + 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("db exploded at /var/lib/secret")), + ), + ): + with pytest.raises(HTTPException) as exc_info: + await validate_bearer_key(token, session) + + assert exc_info.value.status_code == 500 + error_detail = exc_info.value.detail["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