@@ -12,9 +12,12 @@ import {
1212 telemetryMock ,
1313 workflowAuthzMockFns ,
1414} from '@sim/testing'
15+ import { toRecord } from '@sim/utils/object'
1516import { beforeEach , describe , expect , it , vi } from 'vitest'
1617
1718const mocks = vi . hoisted ( ( ) => ( {
19+ activateExternalWebhookSubscription : vi . fn ( ) ,
20+ cleanupExternalWebhook : vi . fn ( ) ,
1821 authorizeCredentialUseForAuth : vi . fn ( ) ,
1922 configurePolling : vi . fn ( ) ,
2023 createExternalWebhookSubscription : vi . fn ( ) ,
@@ -35,7 +38,8 @@ vi.mock('@/lib/webhooks/env-resolver', () => ({
3538 resolveEnvVarsInObject : mocks . resolveEnvVarsInObject ,
3639} ) )
3740vi . mock ( '@/lib/webhooks/provider-subscriptions' , ( ) => ( {
38- cleanupExternalWebhook : vi . fn ( ) ,
41+ activateExternalWebhookSubscription : mocks . activateExternalWebhookSubscription ,
42+ cleanupExternalWebhook : mocks . cleanupExternalWebhook ,
3943 createExternalWebhookSubscription : mocks . createExternalWebhookSubscription ,
4044 shouldRecreateExternalWebhookSubscription : mocks . shouldRecreateExternalWebhookSubscription ,
4145} ) )
@@ -462,3 +466,118 @@ describe('POST /api/webhooks credential references', () => {
462466 expect ( mocks . authorizeCredentialUseForAuth ) . not . toHaveBeenCalled ( )
463467 } )
464468} )
469+
470+ describe ( 'POST /api/webhooks subscription replacement recovery' , ( ) => {
471+ beforeEach ( setupUpsertMocks )
472+ it . each ( [ false , true ] ) (
473+ 'retains recoverable state when prior cleanup fails: %s' ,
474+ async ( priorCleanupFails ) => {
475+ let cleanupUnavailable = priorCleanupFails
476+ let activationMustFail = ! priorCleanupFails
477+ let nextExternalId = 0
478+ let persisted : Record < string , unknown > = {
479+ id : 'webhook-1' ,
480+ workflowId : 'workflow-1' ,
481+ blockId : 'block-1' ,
482+ path : 'inbound-orders' ,
483+ provider : 'plane' ,
484+ isActive : true ,
485+ archivedAt : null ,
486+ providerConfig : {
487+ autoRegister : true ,
488+ projectId : 'prior' ,
489+ externalId : 'external-old' ,
490+ webhookSecret : 'old-secret' ,
491+ } ,
492+ }
493+ const external = new Map < string , { active : boolean } > ( [ [ 'external-old' , { active : true } ] ] )
494+ const desired = { autoRegister : true , projectId : 'next' }
495+ mocks . getProviderHandler . mockReturnValue ( {
496+ createSubscription : async ( ) => undefined ,
497+ activateSubscription : async ( ) => undefined ,
498+ } )
499+ mocks . shouldRecreateExternalWebhookSubscription . mockImplementation (
500+ ( { previousConfig, nextConfig } ) => previousConfig . projectId !== nextConfig . projectId
501+ )
502+ const defaultSet = dbChainMockFns . set . getMockImplementation ( )
503+ dbChainMockFns . set . mockImplementation ( ( ...args : unknown [ ] ) => {
504+ persisted = { ...persisted , ...structuredClone ( toRecord ( args [ 0 ] ) ) }
505+ return defaultSet ?.( ...args )
506+ } )
507+ dbChainMockFns . returning . mockImplementation ( async ( ) => [ structuredClone ( persisted ) ] )
508+ mocks . createExternalWebhookSubscription . mockImplementation ( async ( _request , row ) => {
509+ const externalId = `external-${ ++ nextExternalId } `
510+ external . set ( externalId , { active : false } )
511+ return {
512+ updatedProviderConfig : {
513+ ...toRecord ( row . providerConfig ) ,
514+ externalId,
515+ webhookSecret : 'new-secret' ,
516+ } ,
517+ externalSubscriptionCreated : true ,
518+ }
519+ } )
520+ mocks . activateExternalWebhookSubscription . mockImplementation ( async ( _request , row ) => {
521+ const resource = external . get ( String ( toRecord ( row . providerConfig ) . externalId ) )
522+ if ( ! resource ) throw new Error ( 'provider subscription missing' )
523+ if ( activationMustFail ) {
524+ activationMustFail = false
525+ throw new Error ( 'activation unavailable' )
526+ }
527+ resource . active = true
528+ } )
529+ mocks . cleanupExternalWebhook . mockImplementation (
530+ async ( row , _workflow , _requestId , options ) => {
531+ const externalId = String ( toRecord ( row . providerConfig ) . externalId )
532+ if ( cleanupUnavailable && externalId === 'external-old' ) {
533+ if ( options ?. throwOnError ) throw new Error ( 'cleanup unavailable' )
534+ return
535+ }
536+ external . delete ( externalId )
537+ }
538+ )
539+ function queueCurrentRows ( ) : void {
540+ queueTableRows ( workflow , [
541+ { id : 'workflow-1' , userId : 'actor-1' , workspaceId : 'workspace-1' } ,
542+ ] )
543+ queueTableRows ( webhook , [ { id : 'webhook-1' } ] )
544+ queueTableRows ( webhook , [ structuredClone ( persisted ) ] )
545+ }
546+ function replacementRequest ( ) {
547+ return createMockRequest ( 'POST' , {
548+ workflowId : 'workflow-1' ,
549+ path : 'inbound-orders' ,
550+ provider : 'plane' ,
551+ providerConfig : desired ,
552+ } )
553+ }
554+ queueCurrentRows ( )
555+ expect ( ( await POST ( replacementRequest ( ) ) ) . status ) . toBe ( 500 )
556+ expect ( external . size ) . toBe ( 1 )
557+ const retainedConfig = toRecord ( persisted . providerConfig )
558+ if ( priorCleanupFails ) {
559+ expect ( retainedConfig ) . toEqual ( {
560+ autoRegister : true ,
561+ projectId : 'prior' ,
562+ externalId : 'external-old' ,
563+ webhookSecret : 'old-secret' ,
564+ } )
565+ expect ( external . get ( 'external-old' ) ?. active ) . toBe ( true )
566+ } else {
567+ expect ( retainedConfig . projectId ) . toBe ( 'next' )
568+ expect ( retainedConfig . webhookSecret ) . toBe ( 'new-secret' )
569+ expect ( external . has ( 'external-old' ) ) . toBe ( false )
570+ expect ( external . get ( String ( retainedConfig . externalId ) ) ?. active ) . toBe ( false )
571+ }
572+ cleanupUnavailable = false
573+ queueCurrentRows ( )
574+ expect ( ( await POST ( replacementRequest ( ) ) ) . status ) . toBe ( 200 )
575+ expect ( external . size ) . toBe ( 1 )
576+ const recoveredConfig = toRecord ( persisted . providerConfig )
577+ expect ( recoveredConfig . projectId ) . toBe ( 'next' )
578+ expect ( recoveredConfig . webhookSecret ) . toBe ( 'new-secret' )
579+ expect ( external . get ( String ( recoveredConfig . externalId ) ) ?. active ) . toBe ( true )
580+ if ( ! priorCleanupFails ) expect ( recoveredConfig . externalId ) . toBe ( retainedConfig . externalId )
581+ }
582+ )
583+ } )
0 commit comments