22 * @vitest -environment node
33 */
44import { dbChainMockFns , resetDbChainMock } from '@sim/testing'
5- import { beforeEach , describe , expect , it , vi } from 'vitest'
5+ import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
66
77const mocks = vi . hoisted ( ( ) => ( {
88 getBilling : vi . fn ( ) ,
99 isAvailable : vi . fn ( ) ,
1010 getAdapter : vi . fn ( ) ,
1111 decryptSecret : vi . fn ( ) ,
12+ encryptSecret : vi . fn ( ) ,
1213} ) )
1314
1415vi . mock ( '@/lib/billing/core/workspace-access' , ( ) => ( {
@@ -25,15 +26,44 @@ vi.mock('@/lib/credential-groups/provider-registry', () => ({
2526
2627vi . mock ( '@/lib/core/security/encryption' , ( ) => ( {
2728 decryptSecret : mocks . decryptSecret ,
28- encryptSecret : vi . fn ( ) ,
29+ encryptSecret : mocks . encryptSecret ,
2930} ) )
3031
3132import { resolveManagedOAuthToken } from '@/lib/credentials/managed-oauth'
3233
34+ function mondayCredentialRow ( ) {
35+ return {
36+ id : 'credential-1' ,
37+ workspaceId : 'workspace-1' ,
38+ type : 'managed_oauth' ,
39+ providerId : 'monday' ,
40+ authorizationAppId : 'monday:monday-client-1' ,
41+ managedOauthScopeVersion : 1 ,
42+ managedOauthStatus : 'active' ,
43+ grantedScopes : [ 'boards:read' , 'me:read' ] ,
44+ encryptedOauthTokenSet : 'encrypted-token-set' ,
45+ accessTokenExpiresAt : new Date ( '2026-09-01T11:00:00.000Z' ) ,
46+ refreshTokenExpiresAt : null ,
47+ credentialGroupId : 'group-1' ,
48+ credentialGroupEnrollmentId : 'enrollment-1' ,
49+ }
50+ }
51+
52+ function mondayTokenResolutionParams ( ) {
53+ return {
54+ credentialId : 'credential-1' ,
55+ workspaceId : 'workspace-1' ,
56+ expectedProviderId : 'monday' ,
57+ requiredScopes : [ 'boards:read' , 'me:read' ] ,
58+ }
59+ }
60+
3361describe ( 'managed OAuth token resolution' , ( ) => {
3462 beforeEach ( ( ) => {
3563 vi . clearAllMocks ( )
3664 resetDbChainMock ( )
65+ vi . useFakeTimers ( )
66+ vi . setSystemTime ( new Date ( '2026-09-01T12:00:00.000Z' ) )
3767 mocks . getBilling . mockResolvedValue ( { plan : 'enterprise' } )
3868 mocks . isAvailable . mockResolvedValue ( true )
3969 mocks . decryptSecret . mockResolvedValue ( {
@@ -53,6 +83,10 @@ describe('managed OAuth token resolution', () => {
5383 } )
5484 } )
5585
86+ afterEach ( ( ) => {
87+ vi . useRealTimers ( )
88+ } )
89+
5690 it ( 'uses a non-expiring Slack access token without entering refresh' , async ( ) => {
5791 dbChainMockFns . limit . mockResolvedValueOnce ( [
5892 {
@@ -80,4 +114,98 @@ describe('managed OAuth token resolution', () => {
80114 ) . resolves . toEqual ( { accessToken : 'xoxp-slack-token' , refreshed : false } )
81115 expect ( dbChainMockFns . transaction ) . not . toHaveBeenCalled ( )
82116 } )
117+
118+ it ( 'refreshes an expired Monday credential and persists its rotated token set' , async ( ) => {
119+ const row = mondayCredentialRow ( )
120+ dbChainMockFns . limit . mockResolvedValueOnce ( [ row ] ) . mockResolvedValueOnce ( [ row ] )
121+ dbChainMockFns . returning . mockResolvedValueOnce ( [ { id : row . id } ] )
122+ mocks . decryptSecret . mockResolvedValue ( {
123+ decrypted : JSON . stringify ( {
124+ type : 'managed-oauth-token-set' ,
125+ version : 1 ,
126+ tokenType : 'Bearer' ,
127+ accessToken : 'expired-access-token' ,
128+ refreshToken : 'old-refresh-token' ,
129+ } ) ,
130+ } )
131+ mocks . encryptSecret . mockResolvedValue ( { encrypted : 'encrypted-rotated-token-set' } )
132+ const refreshToken = vi . fn ( ) . mockResolvedValue ( {
133+ ok : true ,
134+ accessToken : 'new-access-token' ,
135+ refreshToken : 'rotated-refresh-token' ,
136+ expiresIn : 3600 ,
137+ } )
138+ mocks . getAdapter . mockReturnValue ( {
139+ getPolicy : vi . fn ( ) . mockResolvedValue ( {
140+ authorizationAppId : row . authorizationAppId ,
141+ scopeVersion : 1 ,
142+ } ) ,
143+ hasRequiredScopes : vi . fn ( ) . mockReturnValue ( true ) ,
144+ refreshToken,
145+ isTerminalRefreshError : vi . fn ( ) . mockReturnValue ( false ) ,
146+ } )
147+
148+ await expect ( resolveManagedOAuthToken ( mondayTokenResolutionParams ( ) ) ) . resolves . toEqual ( {
149+ accessToken : 'new-access-token' ,
150+ refreshed : true ,
151+ } )
152+
153+ expect ( refreshToken ) . toHaveBeenCalledWith ( 'old-refresh-token' )
154+ const [ serializedTokenSet ] = mocks . encryptSecret . mock . calls [ 0 ] as [ string ]
155+ expect ( JSON . parse ( serializedTokenSet ) ) . toEqual ( {
156+ type : 'managed-oauth-token-set' ,
157+ version : 1 ,
158+ tokenType : 'Bearer' ,
159+ accessToken : 'new-access-token' ,
160+ refreshToken : 'rotated-refresh-token' ,
161+ } )
162+ expect ( dbChainMockFns . set ) . toHaveBeenCalledWith (
163+ expect . objectContaining ( {
164+ encryptedOauthTokenSet : 'encrypted-rotated-token-set' ,
165+ accessTokenExpiresAt : new Date ( '2026-09-01T13:00:00.000Z' ) ,
166+ lastRefreshedAt : new Date ( '2026-09-01T12:00:00.000Z' ) ,
167+ } )
168+ )
169+ } )
170+
171+ it ( 'marks an expired Monday credential for reauthorization after a terminal refresh error' , async ( ) => {
172+ const row = mondayCredentialRow ( )
173+ dbChainMockFns . limit . mockResolvedValueOnce ( [ row ] ) . mockResolvedValueOnce ( [ row ] )
174+ mocks . decryptSecret . mockResolvedValue ( {
175+ decrypted : JSON . stringify ( {
176+ type : 'managed-oauth-token-set' ,
177+ version : 1 ,
178+ tokenType : 'Bearer' ,
179+ accessToken : 'expired-access-token' ,
180+ refreshToken : 'old-refresh-token' ,
181+ } ) ,
182+ } )
183+ const refreshToken = vi . fn ( ) . mockResolvedValue ( {
184+ ok : false ,
185+ errorCode : 'invalid_grant' ,
186+ message : 'Refresh token rejected' ,
187+ } )
188+ const isTerminalRefreshError = vi . fn ( ) . mockReturnValue ( true )
189+ mocks . getAdapter . mockReturnValue ( {
190+ getPolicy : vi . fn ( ) . mockResolvedValue ( {
191+ authorizationAppId : row . authorizationAppId ,
192+ scopeVersion : 1 ,
193+ } ) ,
194+ hasRequiredScopes : vi . fn ( ) . mockReturnValue ( true ) ,
195+ refreshToken,
196+ isTerminalRefreshError,
197+ } )
198+
199+ await expect ( resolveManagedOAuthToken ( mondayTokenResolutionParams ( ) ) ) . rejects . toMatchObject ( {
200+ code : 'MANAGED_CREDENTIAL_NEEDS_REAUTH' ,
201+ statusCode : 401 ,
202+ } )
203+
204+ expect ( refreshToken ) . toHaveBeenCalledWith ( 'old-refresh-token' )
205+ expect ( isTerminalRefreshError ) . toHaveBeenCalledWith ( 'invalid_grant' )
206+ expect ( dbChainMockFns . set ) . toHaveBeenCalledWith (
207+ expect . objectContaining ( { managedOauthStatus : 'needs_reauth' } )
208+ )
209+ expect ( mocks . encryptSecret ) . not . toHaveBeenCalled ( )
210+ } )
83211} )
0 commit comments