33 */
44import {
55 authOAuthUtilsMock ,
6+ authOAuthUtilsMockFns ,
67 dbChainMockFns ,
78 drizzleOrmMock ,
89 flattenMockConditions ,
@@ -17,6 +18,7 @@ import { DrizzleQueryError } from 'drizzle-orm/errors'
1718import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
1819import * as connectorTokens from '@/lib/knowledge/connectors/access-token'
1920import { executeSync , isConnectorRunnableStatus } from '@/lib/knowledge/connectors/sync-engine'
21+ import { CREDENTIAL_REVOKED_SYNC_ERROR } from '@/lib/knowledge/connectors/sync-limits'
2022import {
2123 classifySuspectListing ,
2224 evaluateListingSafety ,
@@ -3007,6 +3009,131 @@ describe('executeSync heartbeats during the listing phase', () => {
30073009 }
30083010 )
30093011
3012+ /** A locked OAuth connector whose token resolution the test controls. */
3013+ function primeOAuthRunUpToToken ( ) {
3014+ const oauthConnector = {
3015+ ...CONNECTOR ,
3016+ connectorType : 'oauth' ,
3017+ credentialId : 'cred-1' ,
3018+ accessMode : 'workspace' ,
3019+ }
3020+ queueTableRows ( schemaMock . knowledgeConnector , [ oauthConnector ] )
3021+ for ( let i = 0 ; i < 20 ; i ++ )
3022+ queueTableRows ( schemaMock . knowledgeConnector , [
3023+ { id : 'c-1' , connectorArchivedAt : null , connectorDeletedAt : null , kbDeletedAt : null } ,
3024+ ] )
3025+ queueTableRows ( schemaMock . knowledgeBase , [ { userId : 'u-1' , workspaceId : 'ws-1' } ] )
3026+ dbChainMockFns . returning . mockReset ( )
3027+ dbChainMockFns . returning . mockResolvedValueOnce ( [ oauthConnector ] )
3028+ /** The terminal write lands on the row this run still holds. */
3029+ dbChainMockFns . returning . mockResolvedValueOnce ( [ { id : 'c-1' } ] )
3030+ const tokenUser = vi
3031+ . spyOn ( connectorTokens , 'resolveConnectorTokenUserId' )
3032+ . mockResolvedValueOnce ( 'u-1' )
3033+ const resolveToken = vi
3034+ . spyOn ( connectorTokens , 'resolveConnectorAccessToken' )
3035+ . mockResolvedValueOnce ( null )
3036+ return ( ) => {
3037+ tokenUser . mockRestore ( )
3038+ resolveToken . mockRestore ( )
3039+ }
3040+ }
3041+
3042+ it ( 'unschedules a connector whose credential the source rejected instead of retrying it' , async ( ) => {
3043+ const restore = primeOAuthRunUpToToken ( )
3044+ /** Rejected at token resolution and still rejected when the run records its outcome. */
3045+ authOAuthUtilsMockFns . mockGetCredentialTerminalRefreshError
3046+ . mockResolvedValueOnce ( 'invalid_grant' )
3047+ . mockResolvedValueOnce ( 'invalid_grant' )
3048+ try {
3049+ const result = await executeSync ( 'c-1' , {
3050+ billingAttribution : { workspaceId : 'ws-1' } as never ,
3051+ } )
3052+ expect ( result . skipReason ) . toBe ( 'credential_revoked' )
3053+ expect ( result . error ) . toBeUndefined ( )
3054+ expect ( authOAuthUtilsMockFns . mockGetCredentialTerminalRefreshError ) . toHaveBeenCalledWith (
3055+ 'cred-1'
3056+ )
3057+ expect ( dbChainMockFns . set ) . toHaveBeenCalledWith (
3058+ expect . objectContaining ( {
3059+ status : 'error' ,
3060+ nextSyncAt : null ,
3061+ lastSyncError : CREDENTIAL_REVOKED_SYNC_ERROR ,
3062+ syncLockToken : null ,
3063+ syncLockLeaseAt : null ,
3064+ } )
3065+ )
3066+ expect ( dbChainMockFns . set ) . not . toHaveBeenCalledWith (
3067+ expect . objectContaining ( { consecutiveFailures : expect . any ( Number ) } )
3068+ )
3069+ } finally {
3070+ restore ( )
3071+ }
3072+ } )
3073+
3074+ it ( 'takes the failure ladder when the credential was reauthorized while the run was failing' , async ( ) => {
3075+ const restore = primeOAuthRunUpToToken ( )
3076+ /** Rejected at token resolution, repaired by the time the run records its outcome. */
3077+ authOAuthUtilsMockFns . mockGetCredentialTerminalRefreshError
3078+ . mockResolvedValueOnce ( 'invalid_grant' )
3079+ . mockResolvedValueOnce ( null )
3080+ try {
3081+ const result = await executeSync ( 'c-1' , {
3082+ billingAttribution : { workspaceId : 'ws-1' } as never ,
3083+ } )
3084+ expect ( result . skipReason ) . toBeUndefined ( )
3085+ expect ( result . error ) . toContain ( 'rejected by the source' )
3086+ expect ( dbChainMockFns . set ) . toHaveBeenCalledWith (
3087+ expect . objectContaining ( { status : 'error' , consecutiveFailures : 1 } )
3088+ )
3089+ expect ( dbChainMockFns . set ) . not . toHaveBeenCalledWith (
3090+ expect . objectContaining ( { lastSyncError : CREDENTIAL_REVOKED_SYNC_ERROR } )
3091+ )
3092+ } finally {
3093+ restore ( )
3094+ }
3095+ } )
3096+
3097+ it ( 'reports a run that could not record the unschedule as failed, not skipped' , async ( ) => {
3098+ const restore = primeOAuthRunUpToToken ( )
3099+ /** Rejected at token resolution and still rejected when the run records its outcome. */
3100+ authOAuthUtilsMockFns . mockGetCredentialTerminalRefreshError
3101+ . mockResolvedValueOnce ( 'invalid_grant' )
3102+ . mockResolvedValueOnce ( 'invalid_grant' )
3103+ /** The terminal write fails after the lock CAS consumed the first result. */
3104+ dbChainMockFns . returning . mockReset ( )
3105+ dbChainMockFns . returning . mockResolvedValueOnce ( [
3106+ { ...CONNECTOR , connectorType : 'oauth' , credentialId : 'cred-1' , accessMode : 'workspace' } ,
3107+ ] )
3108+ dbChainMockFns . returning . mockRejectedValueOnce ( new Error ( 'connection reset' ) )
3109+ try {
3110+ const result = await executeSync ( 'c-1' , {
3111+ billingAttribution : { workspaceId : 'ws-1' } as never ,
3112+ } )
3113+ expect ( result . skipReason ) . toBeUndefined ( )
3114+ expect ( result . error ) . toContain ( 'connection reset' )
3115+ } finally {
3116+ restore ( )
3117+ }
3118+ } )
3119+
3120+ it ( 'keeps the failure ladder for a credential that resolved no token without a terminal error' , async ( ) => {
3121+ const restore = primeOAuthRunUpToToken ( )
3122+ authOAuthUtilsMockFns . mockGetCredentialTerminalRefreshError . mockResolvedValueOnce ( null )
3123+ try {
3124+ const result = await executeSync ( 'c-1' , {
3125+ billingAttribution : { workspaceId : 'ws-1' } as never ,
3126+ } )
3127+ expect ( result . skipReason ) . toBeUndefined ( )
3128+ expect ( result . error ) . toContain ( 'Failed to obtain access token' )
3129+ expect ( dbChainMockFns . set ) . toHaveBeenCalledWith (
3130+ expect . objectContaining ( { status : 'error' , consecutiveFailures : 1 } )
3131+ )
3132+ } finally {
3133+ restore ( )
3134+ }
3135+ } )
3136+
30103137 it . each ( [
30113138 { acl : undefined , incomplete : true } ,
30123139 { acl : [ 'invalid-token' ] , incomplete : true } ,
0 commit comments