diff --git a/routstr/auth.py b/routstr/auth.py index 3864a506..60678e7d 100644 --- a/routstr/auth.py +++ b/routstr/auth.py @@ -81,34 +81,55 @@ async def check_and_reset_limit(key: ApiKey, session: AsyncSession) -> bool: 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. + Uses the same failure taxonomy as the X-Cashu redemption path + (``token_already_spent``/``invalid_token``/``mint_error``/``cashu_error``, + see ``create_error_response`` in ``payment/helpers.py``) so both paths that + redeem a token agree on the classification (carried in ``type``) and status. + Expected wallet errors (``ValueError``) and known mint failures map to a + 4xx with a stable message; anything else is an internal fault and maps to a + generic 500. Raw error text never reaches the client — it stays in logs. """ lowered = str(error).lower() - status_code = 400 + # (type, status, message) — type mirrors the X-Cashu taxonomy strings. if "already spent" in lowered: - message = "Cashu token already spent" + error_type, status_code, message = ( + "token_already_spent", + 400, + "Cashu token already spent", + ) 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" + error_type, status_code, message = ( + "mint_error", + 422, + "Token value is too small to cover swap fees", + ) elif "failed to melt" in lowered: - message = "Failed to swap token from foreign mint" + error_type, status_code, message = ( + "mint_error", + 422, + "Failed to swap token from foreign mint", + ) 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 + # instead of masquerading as a token error. + error_type, status_code, message = ( + "invalid_token", + 400, + "Invalid Cashu token", + ) elif isinstance(error, ValueError): - message = "Failed to redeem Cashu token" + error_type, status_code, message = ( + "cashu_error", + 400, + "Failed to redeem Cashu token", + ) else: return HTTPException( status_code=500, @@ -125,8 +146,8 @@ def redemption_error_to_http_exception(error: Exception) -> HTTPException: detail={ "error": { "message": message, - "type": "invalid_request_error", - "code": "token_redemption_failed", + "type": error_type, + "code": status_code, } }, ) @@ -396,9 +417,12 @@ async def validate_bearer_key( "Token redemption returned zero or negative amount", extra={"msats": msats, "key_hash": hashed_key[:8] + "..."}, ) - # Defense-in-depth: credit_balance now refuses to commit on a - # zero/negative redemption, but if a row was nonetheless - # persisted, drop it so we never leave an orphan zero-balance key. + # Defense-in-depth: credit_balance already raises + # ValueError("Redeemed token amount must be positive…") before + # returning (wallet.py), so this branch is only reachable if a + # zero/negative row was somehow persisted; drop it so we never + # leave an orphan zero-balance key. Reuse the shared taxonomy + # (cashu_error) so the envelope matches the mapper above. await session.delete(new_key) await session.commit() raise HTTPException( @@ -406,8 +430,8 @@ async def validate_bearer_key( detail={ "error": { "message": "Failed to redeem Cashu token: token yielded no value", - "type": "invalid_request_error", - "code": "token_redemption_failed", + "type": "cashu_error", + "code": 400, } }, ) diff --git a/tests/unit/test_auth_cashu.py b/tests/unit/test_auth_cashu.py index 2eff6c40..3710c745 100644 --- a/tests/unit/test_auth_cashu.py +++ b/tests/unit/test_auth_cashu.py @@ -60,11 +60,12 @@ async def test_failed_first_cashu_redemption_rolls_back_empty_api_key( @pytest.mark.parametrize( - ("error", "expected_status", "expected_message"), + ("error", "expected_status", "expected_type", "expected_message"), [ ( ValueError("Mint Error: Token already spent. (Code: 11001)"), 400, + "token_already_spent", "Cashu token already spent", ), ( @@ -72,31 +73,36 @@ async def test_failed_first_cashu_redemption_rolls_back_empty_api_key( "Token amount (5 sat) is insufficient to cover melt fees. " "Needed: 7 sat (amount: 5 + fee: 1 + input_fees: 1)" ), - 400, + 422, + "mint_error", "Token value is too small to cover swap fees", ), ( ValueError( "Failed to estimate fees: Fees (7 sat) exceed token amount (5 sat)" ), - 400, + 422, + "mint_error", "Token value is too small to cover swap fees", ), ( ValueError( "Failed to melt token from foreign mint http://foreign:3338: boom" ), - 400, + 422, + "mint_error", "Failed to swap token from foreign mint", ), ( ValueError("could not decode token"), - 401, + 400, + "invalid_token", "Invalid Cashu token", ), ( ValueError("some unexpected wallet condition"), 400, + "cashu_error", "Failed to redeem Cashu token", ), ], @@ -106,10 +112,12 @@ async def test_redemption_failure_returns_sanitized_error( session: AsyncSession, error: Exception, expected_status: int, + expected_type: str, expected_message: str, ) -> None: - """Redemption failures share one error code, expose stable sanitized - messages (no raw exception text), and leave no orphan ApiKey row.""" + """Redemption failures reuse the shared X-Cashu taxonomy (carried in + ``type``), 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") @@ -128,11 +136,12 @@ async def test_redemption_failure_returns_sanitized_error( await validate_bearer_key(token, session) assert exc_info.value.status_code == expected_status - detail = cast(dict[str, dict[str, str]], exc_info.value.detail) + detail = cast(dict[str, dict[str, object]], exc_info.value.detail) error_detail = detail["error"] - assert error_detail["code"] == "token_redemption_failed" + assert error_detail["type"] == expected_type + assert error_detail["code"] == expected_status assert error_detail["message"] == expected_message - assert str(error) not in error_detail["message"] + assert str(error) not in cast(str, error_detail["message"]) assert await session.get(ApiKey, hashed_key) is None