99} from '@sim/testing'
1010import { encryptionMock , encryptionMockFns } from '@sim/testing/mocks/encryption.mock'
1111import { getMockLogger } from '@sim/testing/mocks/logger.mock'
12+ import { redisConfigMockFns } from '@sim/testing/mocks/redis-config.mock'
1213import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
1314
1415const hoisted = vi . hoisted ( ( ) => ( {
@@ -71,6 +72,7 @@ vi.mock('@/lib/oauth/refresh-error-codes', () => ({
7172 isTerminalRefreshError : vi . fn ( ( ) => false ) ,
7273} ) )
7374
75+ import { CredentialRevokedError } from '@/lib/oauth/credential-revoked'
7476import {
7577 getCredentialTerminalRefreshError ,
7678 getOAuthToken ,
@@ -83,7 +85,10 @@ import {
8385import { isInstagramProvider , shouldProactivelyRefreshInstagramToken } from '@/lib/oauth/instagram'
8486import { isMicrosoftProvider } from '@/lib/oauth/microsoft'
8587import { getOAuthRefreshCoordinationIdentity } from '@/lib/oauth/refresh-coordination'
86- import { isTerminalRefreshError } from '@/lib/oauth/refresh-error-codes'
88+ import {
89+ isCredentialRevocationError ,
90+ isTerminalRefreshError ,
91+ } from '@/lib/oauth/refresh-error-codes'
8792import { fanOutSlackTokenChain } from '@/lib/oauth/slack'
8893import { markCredentialDead } from '@/lib/oauth/terminal-errors'
8994import { GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types'
@@ -434,6 +439,7 @@ describe('OAuth access-token refresh headroom', () => {
434439 afterEach ( ( ) => {
435440 vi . useRealTimers ( )
436441 vi . mocked ( isTerminalRefreshError ) . mockReturnValue ( false )
442+ vi . mocked ( isCredentialRevocationError ) . mockReturnValue ( false )
437443 vi . mocked ( isInstagramProvider ) . mockReturnValue ( false )
438444 vi . mocked ( shouldProactivelyRefreshInstagramToken ) . mockReturnValue ( false )
439445 vi . mocked ( isMicrosoftProvider ) . mockReturnValue ( false )
@@ -783,6 +789,98 @@ describe('OAuth access-token refresh headroom', () => {
783789 await expect ( concurrent ) . resolves . toEqual ( { accessToken : 'refreshed-access-token' } )
784790 expect ( mocks . refreshOAuthToken ) . toHaveBeenCalledOnce ( )
785791 } )
792+ describe ( 'when a refresh lease is held by a refresh no process is running' , ( ) => {
793+ beforeEach ( async ( ) => {
794+ const { withLeaderLock } = await vi . importActual <
795+ typeof import ( '@/lib/concurrency/leader-lock' )
796+ > ( '@/lib/concurrency/leader-lock' )
797+ mocks . withLeaderLock . mockImplementation ( withLeaderLock )
798+ redisConfigMockFns . mockAcquireLock . mockResolvedValueOnce ( false )
799+ } )
800+
801+ it . each ( [
802+ {
803+ label : 'serves a Google token still valid after the wait' ,
804+ row : { providerId : 'google-drive' , remainingMs : 100_000 } ,
805+ token : 'original-access-token' ,
806+ } ,
807+ {
808+ label : 'fails a Google token with under 30 seconds left after the wait' ,
809+ row : { providerId : 'google-drive' , remainingMs : 50_000 } ,
810+ token : null ,
811+ } ,
812+ {
813+ label : 'fails a provider whose refresh can invalidate the stored chain' ,
814+ row : { providerId : 'confluence' , remainingMs : 100_000 } ,
815+ token : null ,
816+ } ,
817+ ] ) ( '$label' , async ( { row, token } ) => {
818+ queueCredentialAccount ( { ...createOAuthAccount ( row . remainingMs ) , providerId : row . providerId } )
819+ const pending = resolveCredentialTokenBundle ( RAW_CREDENTIAL_ID , RAW_USER_ID , 'test' )
820+ await vi . advanceTimersByTimeAsync ( 31_000 )
821+ await expect ( pending ) . resolves . toEqual ( token === null ? null : { accessToken : token } )
822+ expect ( mocks . refreshOAuthToken ) . not . toHaveBeenCalled ( )
823+ } )
824+ } )
825+
826+ it ( 'fails a Google chain the provider revokes even while its access token is valid' , async ( ) => {
827+ vi . mocked ( isTerminalRefreshError ) . mockReturnValue ( true )
828+ vi . mocked ( isCredentialRevocationError ) . mockReturnValue ( true )
829+ mocks . refreshOAuthToken . mockResolvedValue ( { ok : false , errorCode : 'invalid_grant' } )
830+ const row = createOAuthAccount ( 100_000 )
831+ queueTableRows ( account , [ row ] )
832+ queueTableRows ( account , [ row ] )
833+ await expect ( refreshTokenIfNeeded ( 'test' , row , RAW_ACCOUNT_ID ) ) . rejects . toBeInstanceOf (
834+ CredentialRevokedError
835+ )
836+ expect ( dbChainMockFns . set ) . toHaveBeenCalledWith (
837+ expect . objectContaining ( { refreshRevokedCode : 'invalid_grant' } )
838+ )
839+ } )
840+
841+ it ( 'answers a Microsoft caller from its valid token while refresh-token aging refreshes' , async ( ) => {
842+ vi . mocked ( isMicrosoftProvider ) . mockReturnValue ( true )
843+ const provider = createDeferred < unknown > ( )
844+ mocks . refreshOAuthToken . mockReturnValue ( provider . promise )
845+ const row = {
846+ ...createOAuthAccount ( 3_600_000 ) ,
847+ providerId : 'microsoft-teams' ,
848+ refreshTokenExpiresAt : new Date ( now . getTime ( ) + 24 * 60 * 60_000 ) ,
849+ }
850+ await expect ( refreshTokenIfNeeded ( 'test' , row , RAW_ACCOUNT_ID ) ) . resolves . toEqual ( {
851+ accessToken : 'original-access-token' ,
852+ refreshed : false ,
853+ } )
854+ await vi . waitFor ( ( ) => expect ( mocks . refreshOAuthToken ) . toHaveBeenCalledOnce ( ) )
855+ expect ( dbChainMockFns . set ) . not . toHaveBeenCalled ( )
856+ provider . resolve ( {
857+ ok : true ,
858+ accessToken : 'refreshed-access-token' ,
859+ refreshToken : 'rotated-refresh-token' ,
860+ expiresIn : 3600 ,
861+ } )
862+ await vi . waitFor ( ( ) =>
863+ expect ( dbChainMockFns . set ) . toHaveBeenCalledWith (
864+ expect . objectContaining ( { accessToken : 'refreshed-access-token' } )
865+ )
866+ )
867+ } )
868+
869+ it ( 'spreads refresh-ahead start across accounts expiring at the same moment' , async ( ) => {
870+ const row = createOAuthAccount ( 270_000 )
871+ /** Chosen because their hashes fall on opposite sides of the 30s jitter cut, so a hash-rule change fails loudly. */
872+ await expect ( refreshTokenIfNeeded ( 'test' , row , 'account-2' ) ) . resolves . toEqual ( {
873+ accessToken : 'original-access-token' ,
874+ refreshed : false ,
875+ } )
876+ await vi . waitFor ( ( ) => expect ( mocks . refreshOAuthToken ) . toHaveBeenCalledOnce ( ) )
877+ await expect ( refreshTokenIfNeeded ( 'test' , row , 'account-c' ) ) . resolves . toEqual ( {
878+ accessToken : 'original-access-token' ,
879+ refreshed : false ,
880+ } )
881+ await flushMicrotasks ( 20 )
882+ expect ( mocks . refreshOAuthToken ) . toHaveBeenCalledOnce ( )
883+ } )
786884} )
787885
788886describe ( 'Google service-account token minting' , ( ) => {
@@ -797,7 +895,11 @@ describe('Google service-account token minting', () => {
797895 const driveScope = 'https://www.googleapis.com/auth/drive.readonly'
798896 const now = new Date ( '2026-09-09T18:00:00.000Z' )
799897
800- beforeEach ( ( ) => {
898+ beforeEach ( async ( ) => {
899+ const { coalesceLocally } = await vi . importActual <
900+ typeof import ( '@/lib/concurrency/singleflight' )
901+ > ( '@/lib/concurrency/singleflight' )
902+ mocks . coalesceLocally . mockImplementation ( coalesceLocally )
801903 resetDbChainMock ( )
802904 vi . useFakeTimers ( )
803905 vi . setSystemTime ( now )
@@ -896,6 +998,19 @@ describe('Google service-account token minting', () => {
896998 expect ( fetchMock ) . toHaveBeenCalledTimes ( 1 )
897999 } )
8981000
1001+ it ( 'shares one key read across concurrent mints' , async ( ) => {
1002+ queueTableRows ( credential , [ row ] )
1003+ await expect (
1004+ Promise . all (
1005+ Array . from ( { length : 6 } , ( _ , index ) =>
1006+ getServiceAccountToken ( 'credential-1' , [ driveScope ] , `member-${ index } @example.com` )
1007+ )
1008+ )
1009+ ) . resolves . toEqual ( Array ( 6 ) . fill ( 'google-access-token' ) )
1010+ expect ( dbChainMockFns . select ) . toHaveBeenCalledTimes ( 1 )
1011+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 6 )
1012+ } )
1013+
8991014 it ( 'preserves cancellation through the service-account resolver registry' , async ( ) => {
9001015 const controller = new AbortController ( )
9011016 const reason = new DOMException ( 'Caller cancelled' , 'AbortError' )
0 commit comments