@@ -2,12 +2,13 @@ import { credential } from '@sim/db/schema'
22import { createDeferred } from '@sim/testing/helpers/deferred'
33import { queueTableRows , resetDbChainMock } from '@sim/testing/mocks/database.mock'
44import { encryptionMock , encryptionMockFns } from '@sim/testing/mocks/encryption.mock'
5- import { beforeEach , describe , expect , it , vi } from 'vitest'
5+ import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
66import {
77 CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE ,
88 RAMP_SERVICE_ACCOUNT_PROVIDER_ID ,
99} from '@/lib/credentials/client-credential-accounts/descriptors'
1010import * as clientAccounts from '@/lib/credentials/client-credential-accounts/server'
11+ import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
1112import {
1213 forgetRejectedClientCredentialToken ,
1314 resolveServiceAccountToken ,
@@ -152,3 +153,173 @@ describe('client credential token cache', () => {
152153 }
153154 } )
154155} )
156+
157+ describe ( 'client credential token refresh after a failed mint' , ( ) => {
158+ const refused = ( ) =>
159+ new TokenServiceAccountValidationError ( 'invalid_credentials' , 401 , { step : 'mint' } )
160+ const unavailable = ( status = 502 , retryAfterMs ?: number ) =>
161+ new TokenServiceAccountValidationError (
162+ 'provider_unavailable' ,
163+ status ,
164+ { step : 'mint' } ,
165+ {
166+ retryAfterMs,
167+ }
168+ )
169+
170+ /** Resolves with fake timers running, so an in-mint retry pause elapses without real waiting. */
171+ async function resolveFor ( credentialId : string ) {
172+ queueTableRows ( credential , [ storedCredential ] )
173+ const outcome = resolveServiceAccountToken ( credentialId , RAMP_SERVICE_ACCOUNT_PROVIDER_ID ) . then (
174+ ( value ) => ( { value } ) ,
175+ ( error : unknown ) => ( { error } )
176+ )
177+ await vi . runAllTimersAsync ( )
178+ return outcome
179+ }
180+
181+ function minter ( ) {
182+ const mint = vi . fn < clientAccounts . ClientCredentialAccountMinter > ( )
183+ vi . spyOn ( clientAccounts , 'getClientCredentialAccountMinter' ) . mockReturnValue ( mint )
184+ return mint
185+ }
186+
187+ beforeEach ( ( ) => {
188+ vi . useFakeTimers ( )
189+ vi . setSystemTime ( new Date ( '2026-01-01T00:00:00Z' ) )
190+ } )
191+
192+ afterEach ( ( ) => {
193+ vi . useRealTimers ( )
194+ } )
195+
196+ it ( 'keeps serving a still-valid token while its early refresh fails transiently' , async ( ) => {
197+ const mint = minter ( )
198+ mint . mockResolvedValueOnce ( { accessToken : 'current-token' , expiresInSeconds : 600 } )
199+ await expect ( resolveFor ( 'ramp-refresh-outage' ) ) . resolves . toEqual ( {
200+ value : { accessToken : 'current-token' } ,
201+ } )
202+ mint . mockRejectedValue ( unavailable ( ) )
203+ vi . advanceTimersByTime ( 6 * 60_000 )
204+
205+ await expect ( resolveFor ( 'ramp-refresh-outage' ) ) . resolves . toEqual ( {
206+ value : { accessToken : 'current-token' } ,
207+ } )
208+ const askedDuringRefresh = mint . mock . calls . length
209+ await expect ( resolveFor ( 'ramp-refresh-outage' ) ) . resolves . toEqual ( {
210+ value : { accessToken : 'current-token' } ,
211+ } )
212+ expect ( askedDuringRefresh ) . toBeGreaterThan ( 1 )
213+ expect ( mint ) . toHaveBeenCalledTimes ( askedDuringRefresh )
214+ } )
215+
216+ it ( 'reports the failed refresh once the cached token has run out' , async ( ) => {
217+ const mint = minter ( )
218+ mint . mockResolvedValueOnce ( { accessToken : 'current-token' , expiresInSeconds : 600 } )
219+ await resolveFor ( 'ramp-refresh-expired' )
220+ mint . mockRejectedValue ( unavailable ( ) )
221+ vi . advanceTimersByTime ( 600_000 - 20_000 )
222+
223+ await expect ( resolveFor ( 'ramp-refresh-expired' ) ) . resolves . toEqual ( {
224+ error : expect . objectContaining ( { code : 'provider_unavailable' } ) ,
225+ } )
226+ } )
227+
228+ it ( 'stops serving the cached token once the provider refuses the credentials' , async ( ) => {
229+ const mint = minter ( )
230+ mint . mockResolvedValueOnce ( { accessToken : 'current-token' , expiresInSeconds : 600 } )
231+ await resolveFor ( 'ramp-refresh-refused' )
232+ mint . mockRejectedValue ( refused ( ) )
233+ vi . advanceTimersByTime ( 6 * 60_000 )
234+
235+ await expect ( resolveFor ( 'ramp-refresh-refused' ) ) . resolves . toEqual ( {
236+ error : expect . objectContaining ( { code : 'invalid_credentials' } ) ,
237+ } )
238+ expect ( mint ) . toHaveBeenCalledTimes ( 2 )
239+ } )
240+
241+ it ( 'retries a quick transient failure once inside the mint' , async ( ) => {
242+ const mint = minter ( )
243+ mint . mockRejectedValueOnce ( unavailable ( 503 ) ) . mockResolvedValueOnce ( token )
244+
245+ await expect ( resolveFor ( 'ramp-retry-once' ) ) . resolves . toEqual ( {
246+ value : { accessToken : token . accessToken } ,
247+ } )
248+ expect ( mint ) . toHaveBeenCalledTimes ( 2 )
249+ } )
250+
251+ it ( 'does not retry an attempt that took as long as a provider timeout' , async ( ) => {
252+ const mint = minter ( )
253+ mint . mockImplementation ( async ( ) => {
254+ vi . setSystemTime ( Date . now ( ) + 10_000 )
255+ throw unavailable ( )
256+ } )
257+
258+ await expect ( resolveFor ( 'ramp-timed-out' ) ) . resolves . toEqual ( {
259+ error : expect . objectContaining ( { code : 'provider_unavailable' } ) ,
260+ } )
261+ expect ( mint ) . toHaveBeenCalledOnce ( )
262+ } )
263+
264+ it ( 'replays a refusal of the credentials for 30 seconds without retrying it' , async ( ) => {
265+ const mint = minter ( )
266+ mint . mockRejectedValue ( refused ( ) )
267+
268+ await resolveFor ( 'ramp-refused' )
269+ vi . advanceTimersByTime ( 29_000 )
270+ await expect ( resolveFor ( 'ramp-refused' ) ) . resolves . toEqual ( {
271+ error : expect . objectContaining ( { code : 'invalid_credentials' } ) ,
272+ } )
273+ expect ( mint ) . toHaveBeenCalledOnce ( )
274+
275+ vi . advanceTimersByTime ( 2_000 )
276+ await resolveFor ( 'ramp-refused' )
277+ expect ( mint ) . toHaveBeenCalledTimes ( 2 )
278+ } )
279+
280+ it . each ( [
281+ [ 'as long as a throttled provider asks' , 120_000 , 120_000 ] ,
282+ [ 'no longer than five minutes, whatever the provider asks' , 3_600_000 , 300_000 ] ,
283+ ] ) ( 'waits %s before asking again' , async ( _case , retryAfterMs , waitMs ) => {
284+ const mint = minter ( )
285+ const credentialId = `ramp-throttled-${ retryAfterMs } `
286+ mint . mockRejectedValueOnce ( unavailable ( 429 , retryAfterMs ) ) . mockResolvedValueOnce ( token )
287+
288+ await expect ( resolveFor ( credentialId ) ) . resolves . toEqual ( {
289+ error : expect . objectContaining ( { code : 'provider_unavailable' , status : 429 } ) ,
290+ } )
291+ vi . advanceTimersByTime ( waitMs - 1_000 )
292+ await expect ( resolveFor ( credentialId ) ) . resolves . toEqual ( {
293+ error : expect . objectContaining ( { status : 429 } ) ,
294+ } )
295+ expect ( mint ) . toHaveBeenCalledOnce ( )
296+
297+ vi . advanceTimersByTime ( 2_000 )
298+ await expect ( resolveFor ( credentialId ) ) . resolves . toEqual ( {
299+ value : { accessToken : token . accessToken } ,
300+ } )
301+ } )
302+
303+ it ( 'backs off an outage briefly at first, longer while it lasts, and never past 30 seconds' , async ( ) => {
304+ const mint = minter ( )
305+ mint . mockRejectedValue ( unavailable ( ) )
306+
307+ await resolveFor ( 'ramp-outage' )
308+ const firstAttempt = mint . mock . calls . length
309+ vi . advanceTimersByTime ( 6_001 )
310+ await resolveFor ( 'ramp-outage' )
311+ const secondAttempt = mint . mock . calls . length
312+ expect ( secondAttempt ) . toBeGreaterThan ( firstAttempt )
313+
314+ vi . advanceTimersByTime ( 6_001 )
315+ await resolveFor ( 'ramp-outage' )
316+ expect ( mint ) . toHaveBeenCalledTimes ( secondAttempt )
317+
318+ for ( let attempt = 0 ; attempt < 6 ; attempt ++ ) {
319+ const asked = mint . mock . calls . length
320+ vi . advanceTimersByTime ( 30_001 )
321+ await resolveFor ( 'ramp-outage' )
322+ expect ( mint . mock . calls . length ) . toBeGreaterThan ( asked )
323+ }
324+ } )
325+ } )
0 commit comments