@@ -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,156 @@ 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 ( [ 'create' , 'save' ] ) (
473+ 'preserves the live subscription when replacement fails at %s' ,
474+ async ( failure ) => {
475+ queueUpdatePathRows ( true , {
476+ autoRegister : true ,
477+ externalId : 'external-old' ,
478+ webhookSecret : 'old-secret' ,
479+ } )
480+ mocks . getProviderHandler . mockReturnValue ( { activateSubscription : async ( ) => undefined } )
481+ mocks . shouldRecreateExternalWebhookSubscription . mockReturnValue ( true )
482+ const external = new Set ( [ 'external-old' ] )
483+ mocks . createExternalWebhookSubscription . mockImplementation ( async ( ) => {
484+ if ( failure === 'create' ) throw new Error ( 'creation unavailable' )
485+ external . add ( 'external-new' )
486+ return {
487+ updatedProviderConfig : {
488+ autoRegister : true ,
489+ externalId : 'external-new' ,
490+ webhookSecret : 'new-secret' ,
491+ subscriptionActivationPending : true ,
492+ } ,
493+ externalSubscriptionCreated : true ,
494+ }
495+ } )
496+ if ( failure === 'save' )
497+ dbChainMockFns . set . mockImplementation ( ( ) => {
498+ throw new Error ( 'database unavailable' )
499+ } )
500+ mocks . cleanupExternalWebhook . mockImplementation ( async ( row ) => {
501+ external . delete ( String ( toRecord ( row . providerConfig ) . externalId ) )
502+ } )
503+ expect ( ( await POST ( upsertRequest ( { autoRegister : true } ) ) ) . status ) . toBe ( 500 )
504+ expect ( [ ...external ] ) . toEqual ( [ 'external-old' ] )
505+ }
506+ )
507+ it . each ( [ false , true ] ) (
508+ 'retains recoverable state when prior cleanup fails: %s' ,
509+ async ( priorCleanupFails ) => {
510+ let cleanupUnavailable = priorCleanupFails
511+ let activationMustFail = ! priorCleanupFails
512+ let nextExternalId = 0
513+ let persisted : Record < string , unknown > = {
514+ id : 'webhook-1' ,
515+ workflowId : 'workflow-1' ,
516+ blockId : 'block-1' ,
517+ path : 'inbound-orders' ,
518+ provider : 'plane' ,
519+ isActive : true ,
520+ archivedAt : null ,
521+ providerConfig : {
522+ autoRegister : true ,
523+ projectId : 'prior' ,
524+ externalId : 'external-old' ,
525+ webhookSecret : 'old-secret' ,
526+ } ,
527+ }
528+ const external = new Map < string , { active : boolean } > ( [ [ 'external-old' , { active : true } ] ] )
529+ const desired = { autoRegister : true , projectId : 'next' }
530+ mocks . getProviderHandler . mockReturnValue ( {
531+ createSubscription : async ( ) => undefined ,
532+ activateSubscription : async ( ) => undefined ,
533+ } )
534+ mocks . shouldRecreateExternalWebhookSubscription . mockImplementation (
535+ ( { previousConfig, nextConfig } ) => previousConfig . projectId !== nextConfig . projectId
536+ )
537+ const defaultSet = dbChainMockFns . set . getMockImplementation ( )
538+ dbChainMockFns . set . mockImplementation ( ( ...args : unknown [ ] ) => {
539+ persisted = { ...persisted , ...structuredClone ( toRecord ( args [ 0 ] ) ) }
540+ return defaultSet ?.( ...args )
541+ } )
542+ dbChainMockFns . returning . mockImplementation ( async ( ) => [ structuredClone ( persisted ) ] )
543+ mocks . createExternalWebhookSubscription . mockImplementation ( async ( _request , row ) => {
544+ const externalId = `external-${ ++ nextExternalId } `
545+ external . set ( externalId , { active : false } )
546+ return {
547+ updatedProviderConfig : {
548+ ...toRecord ( row . providerConfig ) ,
549+ externalId,
550+ webhookSecret : 'new-secret' ,
551+ subscriptionActivationPending : true ,
552+ } ,
553+ externalSubscriptionCreated : true ,
554+ }
555+ } )
556+ mocks . activateExternalWebhookSubscription . mockImplementation ( async ( _request , row ) => {
557+ const resource = external . get ( String ( toRecord ( row . providerConfig ) . externalId ) )
558+ if ( ! resource ) throw new Error ( 'provider subscription missing' )
559+ if ( activationMustFail ) {
560+ activationMustFail = false
561+ throw new Error ( 'activation unavailable' )
562+ }
563+ resource . active = true
564+ } )
565+ mocks . cleanupExternalWebhook . mockImplementation (
566+ async ( row , _workflow , _requestId , options ) => {
567+ const externalId = String ( toRecord ( row . providerConfig ) . externalId )
568+ if ( cleanupUnavailable && externalId === 'external-old' ) {
569+ if ( options ?. throwOnError ) throw new Error ( 'cleanup unavailable' )
570+ return
571+ }
572+ external . delete ( externalId )
573+ }
574+ )
575+ function queueCurrentRows ( ) : void {
576+ queueTableRows ( workflow , [
577+ { id : 'workflow-1' , userId : 'actor-1' , workspaceId : 'workspace-1' } ,
578+ ] )
579+ queueTableRows ( webhook , [ { id : 'webhook-1' } ] )
580+ queueTableRows ( webhook , [ structuredClone ( persisted ) ] )
581+ }
582+ function replacementRequest ( ) {
583+ return createMockRequest ( 'POST' , {
584+ workflowId : 'workflow-1' ,
585+ path : 'inbound-orders' ,
586+ provider : 'plane' ,
587+ providerConfig : desired ,
588+ } )
589+ }
590+ queueCurrentRows ( )
591+ expect ( ( await POST ( replacementRequest ( ) ) ) . status ) . toBe ( 500 )
592+ expect ( external . size ) . toBe ( 2 )
593+ const retainedConfig = toRecord ( persisted . providerConfig )
594+ if ( priorCleanupFails ) {
595+ expect ( retainedConfig . projectId ) . toBe ( 'next' )
596+ expect ( toRecord ( retainedConfig . previousSubscription ) . providerConfig ) . toEqual ( {
597+ autoRegister : true ,
598+ projectId : 'prior' ,
599+ externalId : 'external-old' ,
600+ webhookSecret : 'old-secret' ,
601+ } )
602+ expect ( external . get ( 'external-old' ) ?. active ) . toBe ( true )
603+ expect ( external . get ( String ( retainedConfig . externalId ) ) ?. active ) . toBe ( true )
604+ } else {
605+ expect ( retainedConfig . projectId ) . toBe ( 'next' )
606+ expect ( retainedConfig . webhookSecret ) . toBe ( 'new-secret' )
607+ expect ( external . get ( 'external-old' ) ?. active ) . toBe ( true )
608+ expect ( external . get ( String ( retainedConfig . externalId ) ) ?. active ) . toBe ( false )
609+ }
610+ cleanupUnavailable = false
611+ queueCurrentRows ( )
612+ expect ( ( await POST ( replacementRequest ( ) ) ) . status ) . toBe ( 200 )
613+ expect ( external . size ) . toBe ( 1 )
614+ const recoveredConfig = toRecord ( persisted . providerConfig )
615+ expect ( recoveredConfig . projectId ) . toBe ( 'next' )
616+ expect ( recoveredConfig . webhookSecret ) . toBe ( 'new-secret' )
617+ expect ( external . get ( String ( recoveredConfig . externalId ) ) ?. active ) . toBe ( true )
618+ if ( ! priorCleanupFails ) expect ( recoveredConfig . externalId ) . toBe ( retainedConfig . externalId )
619+ }
620+ )
621+ } )
0 commit comments