Skip to content

Commit be9cea3

Browse files
committed
fix(plane): narrow actions and recover webhook activation failures
1 parent b2e3c6d commit be9cea3

455 files changed

Lines changed: 13648 additions & 114528 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/docs/content/docs/integrations/plane.mdx‎

Lines changed: 2965 additions & 18820 deletions
Large diffs are not rendered by default.

‎apps/sim/app/api/webhooks/route.test.ts‎

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@ import {
1212
telemetryMock,
1313
workflowAuthzMockFns,
1414
} from '@sim/testing'
15+
import { toRecord } from '@sim/utils/object'
1516
import { beforeEach, describe, expect, it, vi } from 'vitest'
1617

1718
const 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
}))
3740
vi.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+
})

‎apps/sim/app/api/webhooks/route.ts‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
import { captureServerEvent } from '@/lib/posthog/server'
2828
import { resolveEnvVarsInObject } from '@/lib/webhooks/env-resolver'
2929
import {
30+
activateExternalWebhookSubscription,
3031
cleanupExternalWebhook,
3132
createExternalWebhookSubscription,
3233
shouldRecreateExternalWebhookSubscription,
@@ -500,8 +501,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
500501
}
501502
}
502503

504+
const cleanupBeforeCreate = Boolean(
505+
existingWebhook &&
506+
shouldRecreateSubscription &&
507+
(getProviderHandler(provider).activateSubscription ||
508+
getProviderHandler(existingWebhook.provider).activateSubscription)
509+
)
503510
if (!existingWebhook || shouldRecreateSubscription) {
504511
try {
512+
if (cleanupBeforeCreate) {
513+
await cleanupExternalWebhook(existingWebhook, workflowRecord, requestId, {
514+
throwOnError: true,
515+
})
516+
}
505517
const result = await createExternalWebhookSubscription(
506518
request,
507519
createTempWebhookData(),
@@ -597,7 +609,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
597609
throw dbError
598610
}
599611

600-
if (existingWebhook && shouldRecreateSubscription) {
612+
if (savedWebhook) {
613+
await activateExternalWebhookSubscription(
614+
request,
615+
savedWebhook,
616+
workflowRecord,
617+
userId,
618+
requestId
619+
)
620+
}
621+
622+
if (existingWebhook && shouldRecreateSubscription && !cleanupBeforeCreate) {
601623
try {
602624
await cleanupExternalWebhook(existingWebhook, workflowRecord, requestId)
603625
} catch (cleanupError) {

0 commit comments

Comments
 (0)