diff --git a/lib/__tests__/throwIfNotOk.test.ts b/lib/__tests__/throwIfNotOk.test.ts new file mode 100644 index 00000000..bde9b08a --- /dev/null +++ b/lib/__tests__/throwIfNotOk.test.ts @@ -0,0 +1,58 @@ +/// + +import { throwIfNotOk } from '@/lib/api'; + +/** + * Regression guard for SOLID-W3: ensureWebhookSubscription (and all other + * fetch calls in lib/api.ts) previously threw the raw Response object when the + * server returned a non-2xx status. TanStack Mutation's onError passed it + * directly to Sentry.captureException, which serialised the Response keys + * (_bodyBlob, _bodyInit, ok, status…) as the exception title rather than a + * human-readable message. + * + * throwIfNotOk ensures every failed fetch produces a proper Error instance. + */ +describe('throwIfNotOk', () => { + const makeResponse = (status: number, statusText: string, ok: boolean): Response => + ({ + ok, + status, + statusText, + url: 'https://api.example.com/test', + }) as unknown as Response; + + it('does nothing when the response is OK', () => { + const response = makeResponse(200, 'OK', true); + expect(() => throwIfNotOk(response)).not.toThrow(); + }); + + it('throws an Error instance (not the Response object) on a non-OK response', () => { + const response = makeResponse(401, 'Unauthorized', false); + expect(() => throwIfNotOk(response)).toThrow(Error); + }); + + it('includes the HTTP status code in the error message', () => { + const response = makeResponse(401, 'Unauthorized', false); + expect(() => throwIfNotOk(response)).toThrow('401'); + }); + + it('includes the status text in the error message', () => { + const response = makeResponse(503, 'Service Unavailable', false); + expect(() => throwIfNotOk(response)).toThrow('Service Unavailable'); + }); + + it('includes the URL in the error message', () => { + const response = makeResponse(500, 'Internal Server Error', false); + expect(() => throwIfNotOk(response)).toThrow('https://api.example.com/test'); + }); + + it('throws for 4xx responses', () => { + const response = makeResponse(403, 'Forbidden', false); + expect(() => throwIfNotOk(response)).toThrow(Error); + }); + + it('throws for 5xx responses', () => { + const response = makeResponse(500, 'Internal Server Error', false); + expect(() => throwIfNotOk(response)).toThrow(Error); + }); +}); diff --git a/lib/api.ts b/lib/api.ts index 32ac3257..6430f374 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -165,6 +165,14 @@ import { generateClientNonceData } from './utils/cardDetailsReveal'; import { decryptSecret, generateSessionId } from './utils/rainCardSecrets'; import { revealWirexCardWithSession } from './utils/wirexCardReveal'; +// Throws a proper Error when a fetch response is not OK, so callers always +// receive an Error instance rather than a raw Response object. +export function throwIfNotOk(response: Response): void { + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText} (${response.url})`); + } +} + // Helper function to get platform-specific headers export const getPlatformHeaders = () => { const headers: Record = {}; @@ -290,7 +298,7 @@ export const refreshToken = async () => { }, ); - if (!response.ok) throw response; + throwIfNotOk(response); return response; }; @@ -317,7 +325,7 @@ export const signUp = async ( credentials: 'include', body: JSON.stringify(body), }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -336,7 +344,7 @@ export const updateSafeAddress = async (safeAddress: string) => { body: JSON.stringify({ safeAddress }), }, ); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -355,7 +363,7 @@ export const addReferrer = async (referralCode: string) => { body: JSON.stringify({ referralCode }), }, ); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -374,7 +382,7 @@ export const updateUserCredentialId = async (credentialId: string) => { body: JSON.stringify({ credentialId }), }, ); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -389,7 +397,7 @@ export const logout = async () => { }, credentials: 'include', }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -408,7 +416,7 @@ export const updateExternalWalletAddress = async (externalWalletAddress: string) body: JSON.stringify({ externalWalletAddress }), }, ); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -544,7 +552,7 @@ export const createKycLink = async ( body: JSON.stringify(body), }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -563,7 +571,7 @@ export const getKycLink = async (kycLinkId: string): Promise => { }, ); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -584,7 +592,7 @@ export const getKycLinkFromBridge = async ( }, ); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -606,7 +614,7 @@ export const submitPersonaKyc = async ( body: JSON.stringify({ personaInquiryId }), }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -635,7 +643,7 @@ export const personaSimulateAction = async ( }, ); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -686,7 +694,7 @@ export const submitRainKyc = async (formData: FormData): Promise => { if (response.status === 404) return null; - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -1002,7 +1010,7 @@ export const getCustomerFromBridge = async (): Promise => { if (response.status === 404) return null; - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -1219,7 +1227,7 @@ export const getCardDetails = async (): Promise = // Response that appears as a raw LogBox error in development. if (response.status === 404) return null; - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -1236,7 +1244,7 @@ export const getCardBalance = async (): Promise => { }, }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -1268,7 +1276,7 @@ export const getCardContracts = async (): Promise => }, }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -1298,7 +1306,7 @@ export const getCardCollateralAvailable = async ( }, ); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -1319,7 +1327,7 @@ export const getOnrampAutomation = async (): Promise => { headers: wirexBankHeaders(), }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -1399,7 +1407,7 @@ export const activateWirexBankAccount = async ( body: JSON.stringify({ accountType }), }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -1413,7 +1421,7 @@ export const initWirexWalletLink = async (): Promise }, ); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -1664,7 +1672,7 @@ export const getMppCredentials = async (): Promise => { }, ); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -1687,7 +1695,7 @@ export const getWebProvisioningToken = async (): Promise { filterValue: username, }), }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -1880,7 +1888,7 @@ export const fetchPoints = async (): Promise => { }, ); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -1900,7 +1908,7 @@ export const fetchReferralSummary = async (): Promise => { }, ); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -1927,7 +1935,7 @@ export const fetchLeaderboardUsers = async (params: { }, ); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -1946,7 +1954,7 @@ export const fetchRewardsUserData = async (): Promise => { }, credentials: 'include', }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -1961,7 +1969,7 @@ export const optInToRewards = async (): Promise => { }, credentials: 'include', }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -1990,7 +1998,7 @@ export const activateTierTrial = async (): Promise => { credentials: 'include', }, ); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -2010,7 +2018,7 @@ export const fetchTierBenefits = async (): Promise => { credentials: 'include', }, ); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -2033,7 +2041,7 @@ export const fetchProductFeeRates = async (): Promise => { }, credentials: 'include', }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -2053,7 +2061,7 @@ export const fetchProductFeeQuote = async ( credentials: 'include', body: JSON.stringify({ product, baseAmountUsd }), }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -2078,7 +2086,7 @@ export const recordSwapFee = async ( credentials: 'include', body: JSON.stringify(params), }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -2105,7 +2113,7 @@ export const recordStocksFee = async ( body: JSON.stringify(params), }, ); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -2118,7 +2126,7 @@ export const fetchRewardsConfig = async (): Promise => { }, credentials: 'include', }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -2165,7 +2173,7 @@ export const createMercuryoTransaction = async ( }, ); - if (!response.ok) throw response; + throwIfNotOk(response); const data: { widgetUrl: string } = await response.json(); return data.widgetUrl; @@ -2204,7 +2212,7 @@ export const fetchOnramperSession = async (): Promise => { }, ); - if (!response.ok) throw response; + throwIfNotOk(response); const data = await response.json(); @@ -2227,7 +2235,7 @@ export const bridgeDeposit = async ( body: JSON.stringify(bridge), }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -2248,7 +2256,7 @@ export const bridgeDepositTransactions = async ( }, ); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -2303,7 +2311,7 @@ export const createBridgeTransfer = async (params: { body: JSON.stringify(params), }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -2347,7 +2355,7 @@ export const createDeposit = async (deposit: Deposit): Promise<{ transactionHash body: JSON.stringify(deposit), }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -2366,7 +2374,7 @@ export const depositTransactions = async (safeAddress: string): Promise => { }, ); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -2503,7 +2511,7 @@ export const freezeCard = async (): Promise<{ message: string }> => { credentials: 'include', }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -2521,7 +2529,7 @@ export const unfreezeCard = async (): Promise<{ message: string }> => { credentials: 'include', }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -2540,7 +2548,7 @@ export const withdrawFromCard = async (body: CardWithdrawal): Promise => { credentials: 'include', }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -2711,7 +2719,7 @@ export const getCardTransaction = async ( // retrying, and the detail screen falls back to exactly that. if (response.status === 404) return null; - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -2895,7 +2903,7 @@ export const setupTotp = async (): Promise<{ credentials: 'include', }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -2920,7 +2928,7 @@ export const verifyTotp = async ( body: JSON.stringify({ code, context }), }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -2941,7 +2949,7 @@ export const getTotpStatus = async (): Promise<{ verified: boolean }> => { credentials: 'include', }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -2962,7 +2970,7 @@ export const createActivityEvent = async ( body: JSON.stringify(event), }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -2984,7 +2992,7 @@ export const fetchActivityEvents = async ( }, ); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -3009,7 +3017,7 @@ export const updateActivityEvent = async ( }, ); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -3030,7 +3038,7 @@ export const bulkUpsertActivityEvent = async ( body: JSON.stringify(events), }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -3075,7 +3083,7 @@ export const syncActivities = async ( credentials: 'include', }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -3106,7 +3114,7 @@ export const requestCardSecrets = async ( credentials: 'include', }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -3130,7 +3138,7 @@ export const updateCardPin = async ( body: JSON.stringify({ encryptedPin }), }); - if (!response.ok) throw response; + throwIfNotOk(response); const text = await response.text(); return text ? JSON.parse(text) : {}; @@ -3149,7 +3157,7 @@ export const getCardPin = async (sessionIdBase64: string): Promise => { }); if (response.status === 404 || response.status === 204) return null; - if (!response.ok) throw response; + throwIfNotOk(response); const text = await response.text(); if (!text) return null; @@ -3519,7 +3527,7 @@ export const fetchPromotionsBanner = async (): Promise }, ); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -3538,7 +3546,7 @@ export const fetchActivityEvent = async (clientTxId: string): Promise(path: string, body?: unknown): Promise => { credentials: 'include', body: body === undefined ? undefined : JSON.stringify(body), }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -3846,7 +3854,7 @@ export const fetchAgent = async (): Promise => { headers: agentJsonHeaders(), credentials: 'include', }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -3864,7 +3872,7 @@ export const fetchAgentHasDeposited = async (): Promise => { }, credentials: 'include', }); - if (!response.ok) throw response; + throwIfNotOk(response); const json = (await response.json()) as { totalDocs?: number; docs?: unknown[] }; return (json.totalDocs ?? json.docs?.length ?? 0) > 0; }; @@ -3875,7 +3883,7 @@ export const fetchAgentApiKeys = async (): Promise => { headers: agentJsonHeaders(), credentials: 'include', }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -3886,7 +3894,7 @@ export const generateAgentApiKey = async (name?: string): Promise => { headers: agentJsonHeaders(), credentials: 'include', }); - if (!response.ok) throw response; + throwIfNotOk(response); }; export const fetchAddressBook = async (): Promise => { @@ -3911,7 +3919,7 @@ export const fetchAddressBook = async (): Promise => { credentials: 'include', }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -3928,7 +3936,7 @@ export const addToAddressBook = async (data: AddressBookRequest): Promise => { }, ); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -3976,7 +3984,7 @@ export const getHoldingFundsPointsMultiplier = }, ); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -4011,7 +4019,7 @@ export const getWebhookStatus = async (): Promise => { }, ); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -4037,7 +4045,7 @@ export const ensureWebhookSubscription = async (): Promise { credentials: 'include', body: JSON.stringify({ token, platform }), }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -4072,7 +4080,7 @@ export const removePushToken = async (token: string) => { credentials: 'include', body: JSON.stringify({ token }), }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -4088,7 +4096,7 @@ export const trackUserPlatform = async (platform: typeof Platform.OS) => { credentials: 'include', body: JSON.stringify({ platform }), }); - if (!response.ok) throw response; + throwIfNotOk(response); }; /** @@ -4116,7 +4124,7 @@ export const recordAppOpen = async ( deviceId, }), }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -4138,7 +4146,7 @@ export const markStoreReviewPrompted = async ( body: JSON.stringify({ platform }), }, ); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); }; @@ -4158,7 +4166,7 @@ export const fetchSavingsSummary = async ( credentials: 'include', }); - if (!response.ok) throw response; + throwIfNotOk(response); return response.json(); };