@@ -166,7 +166,28 @@ describe('OAuth Utils', () => {
166166
167167 await expect (
168168 refreshTokenIfNeeded ( 'request-id' , mockCredential , 'credential-id' )
169- ) . rejects . toThrow ( 'Failed to refresh token' )
169+ ) . rejects . toThrow ( 'invalid_grant (google)' )
170+ } )
171+
172+ it ( 'should preserve the provider error description in the thrown error' , async ( ) => {
173+ const mockCredential = {
174+ id : 'credential-id' ,
175+ accessToken : 'expired-token' ,
176+ refreshToken : 'refresh-token' ,
177+ accessTokenExpiresAt : new Date ( Date . now ( ) - 3600 * 1000 ) ,
178+ providerId : 'google' ,
179+ }
180+
181+ mockRefreshOAuthToken . mockResolvedValueOnce ( {
182+ ok : false ,
183+ errorCode : 'invalid_grant' ,
184+ errorDescription : 'Token has been expired or revoked.' ,
185+ message : 'Failed' ,
186+ } )
187+
188+ await expect (
189+ refreshTokenIfNeeded ( 'request-id' , mockCredential , 'credential-id' )
190+ ) . rejects . toThrow ( 'invalid_grant (google: Token has been expired or revoked.)' )
170191 } )
171192
172193 it ( 'should not attempt refresh if no refresh token' , async ( ) => {
@@ -282,6 +303,121 @@ describe('OAuth Utils', () => {
282303 } )
283304 } )
284305
306+ describe ( 'Slack installation-scoped refresh' , ( ) => {
307+ const SLACK_ACCOUNT_ID = 'T08CM6ZNYBE-usr_U08USBQ9B1T-cbf46a7e-ca75-4a2e-bef5-fd467299eaae'
308+ const past = new Date ( Date . now ( ) - 3600 * 1000 )
309+ const future = new Date ( Date . now ( ) + 3600 * 1000 )
310+
311+ /** Select chain for getFreshestSlackChain: where() -> orderBy() -> limit(). */
312+ function mockSelectOrderedChain ( limitResult : unknown [ ] ) {
313+ const mockLimit = vi . fn ( ) . mockReturnValue ( limitResult )
314+ const mockOrderBy = vi . fn ( ) . mockReturnValue ( { limit : mockLimit } )
315+ const mockWhere = vi . fn ( ) . mockReturnValue ( { orderBy : mockOrderBy , limit : mockLimit } )
316+ const mockFrom = vi . fn ( ) . mockReturnValue ( { where : mockWhere } )
317+ mockDb . select . mockReturnValueOnce ( { from : mockFrom } )
318+ return { mockWhere, mockOrderBy, mockLimit }
319+ }
320+
321+ function slackCredential ( overrides : Record < string , unknown > = { } ) {
322+ return {
323+ id : 'row-1' ,
324+ resolvedCredentialId : 'row-1' ,
325+ accountId : SLACK_ACCOUNT_ID ,
326+ accessToken : 'stale-at' ,
327+ refreshToken : 'stale-rt' ,
328+ accessTokenExpiresAt : past ,
329+ providerId : 'slack' ,
330+ ...overrides ,
331+ }
332+ }
333+
334+ it ( 'locks per installation and refreshes with the freshest sibling refresh token' , async ( ) => {
335+ mockSelectOrderedChain ( [
336+ { accessToken : 'stale-at' , refreshToken : 'live-rt' , accessTokenExpiresAt : past } ,
337+ ] )
338+ mockRefreshOAuthToken . mockResolvedValueOnce ( {
339+ ok : true ,
340+ accessToken : 'new-at' ,
341+ expiresIn : 43200 ,
342+ refreshToken : 'new-rt' ,
343+ } )
344+ const { mockSet } = mockUpdateChain ( )
345+
346+ const result = await refreshTokenIfNeeded ( 'request-id' , slackCredential ( ) , 'row-1' )
347+
348+ expect ( result ) . toEqual ( { accessToken : 'new-at' , refreshed : true } )
349+ expect ( redisConfigMockFns . mockAcquireLock . mock . calls [ 0 ] [ 0 ] ) . toBe (
350+ 'oauth:refresh:slack:T08CM6ZNYBE'
351+ )
352+ expect ( mockRefreshOAuthToken ) . toHaveBeenCalledWith ( 'slack' , 'live-rt' )
353+ expect ( mockSet ) . toHaveBeenCalledWith (
354+ expect . objectContaining ( { accessToken : 'new-at' , refreshToken : 'new-rt' } )
355+ )
356+ } )
357+
358+ it ( 'returns the freshest sibling token without refreshing when it is still valid' , async ( ) => {
359+ mockSelectOrderedChain ( [
360+ { accessToken : 'sibling-at' , refreshToken : 'live-rt' , accessTokenExpiresAt : future } ,
361+ ] )
362+ const { mockSet } = mockUpdateChain ( )
363+
364+ const result = await refreshTokenIfNeeded ( 'request-id' , slackCredential ( ) , 'row-1' )
365+
366+ expect ( result ) . toEqual ( { accessToken : 'sibling-at' , refreshed : true } )
367+ expect ( mockRefreshOAuthToken ) . not . toHaveBeenCalled ( )
368+ expect ( mockSet ) . toHaveBeenCalledWith (
369+ expect . objectContaining ( { accessToken : 'sibling-at' , refreshToken : 'live-rt' } )
370+ )
371+ } )
372+
373+ it ( 'keeps per-row behavior for pasted custom-bot account ids' , async ( ) => {
374+ mockRefreshOAuthToken . mockResolvedValueOnce ( {
375+ ok : true ,
376+ accessToken : 'new-at' ,
377+ expiresIn : 43200 ,
378+ refreshToken : 'new-rt' ,
379+ } )
380+ mockUpdateChain ( )
381+
382+ const result = await refreshTokenIfNeeded (
383+ 'request-id' ,
384+ slackCredential ( { accountId : 'slack-bot-1764756583292' } ) ,
385+ 'row-1'
386+ )
387+
388+ expect ( result ) . toEqual ( { accessToken : 'new-at' , refreshed : true } )
389+ expect ( redisConfigMockFns . mockAcquireLock . mock . calls [ 0 ] [ 0 ] ) . toBe ( 'oauth:refresh:row-1' )
390+ expect ( mockRefreshOAuthToken ) . toHaveBeenCalledWith ( 'slack' , 'stale-rt' )
391+ } )
392+
393+ it ( 'dead-flags the installation, not the row, on terminal refresh errors' , async ( ) => {
394+ const fakeRedis = {
395+ set : vi . fn ( ) . mockResolvedValue ( 'OK' ) ,
396+ get : vi . fn ( ) . mockResolvedValue ( null ) ,
397+ del : vi . fn ( ) . mockResolvedValue ( 1 ) ,
398+ }
399+ redisConfigMockFns . mockGetRedisClient . mockReturnValue ( fakeRedis )
400+ mockSelectOrderedChain ( [
401+ { accessToken : 'stale-at' , refreshToken : 'live-rt' , accessTokenExpiresAt : past } ,
402+ ] )
403+ mockRefreshOAuthToken . mockResolvedValueOnce ( {
404+ ok : false ,
405+ errorCode : 'token_revoked' ,
406+ } )
407+
408+ await expect ( refreshTokenIfNeeded ( 'request-id' , slackCredential ( ) , 'row-1' ) ) . rejects . toThrow (
409+ 'token_revoked (slack)'
410+ )
411+
412+ expect ( fakeRedis . set ) . toHaveBeenCalledWith (
413+ 'oauth:dead:slack:T08CM6ZNYBE' ,
414+ 'token_revoked' ,
415+ 'EX' ,
416+ 3600
417+ )
418+ } )
419+ } )
420+
285421 describe ( 'resolveServiceAccountToken' , ( ) => {
286422 it ( 'throws loudly for an unknown provider (never silently attempts Google)' , async ( ) => {
287423 await expect ( resolveServiceAccountToken ( 'cred-1' , 'mystery-provider' ) ) . rejects . toThrow (
0 commit comments