Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions apps/sim/app/api/webhooks/trigger/[path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,13 @@ async function handleWebhookDelivery(
? Number(slackRequestTimestamp) * 1000
: undefined

/**
* Depends only on the path, so the read-only lookup overlaps the body stream
* read below; a challenge short-circuit simply abandons the result.
*/
const webhookLookupPromise = findAllWebhooksForPath({ requestId, path })
webhookLookupPromise.catch(() => {})

const parseResult = await parseWebhookBody(request, requestId)

// Check if parseWebhookBody returned an error response
Expand All @@ -159,8 +166,8 @@ async function handleWebhookDelivery(
return challengeResponse
}

// Find all webhooks for this path (multiple webhooks in one workflow may share a path)
const allWebhooksForPath = await findAllWebhooksForPath({ requestId, path })
// Multiple webhooks in one workflow may share a path
const allWebhooksForPath = await webhookLookupPromise

const pathWebhooks = allWebhooksForPath.filter(({ webhook: foundWebhook }) =>
acceptsPathWebhookDelivery(foundWebhook.provider)
Expand Down
210 changes: 210 additions & 0 deletions apps/sim/background/webhook-execution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,216 @@ describe('executeWebhookJob fault vs error handling', () => {
)
})

it('reuses ingest-loaded rows and skips duplicate account checks with warm context', async () => {
mockExecuteWorkflowCore.mockResolvedValue({
success: true,
status: 'completed',
output: {},
logs: [],
executionState: {
blockStates: {},
executedBlocks: [],
blockLogs: [],
decisions: {},
completedLoops: [],
activeExecutionPath: [],
},
})
const warmContext = {
workflowRecord: { id: 'workflow-1', workspaceId: 'workspace-1', userId: 'user-1' },
webhookRecord: { id: 'webhook-1', providerConfig: { warm: true } },
} as unknown as NonNullable<Parameters<typeof executeWebhookJob>[2]>

await executeWebhookJob(payload, undefined, warmContext)

expect(executionPreprocessingMockFns.mockPreprocessExecution).toHaveBeenCalledWith(
expect.objectContaining({
workflowRecord: expect.objectContaining({ id: 'workflow-1' }),
trustWorkflowRecord: true,
skipAccountChecks: true,
})
)
expect(dbChainMockFns.limit).not.toHaveBeenCalled()
expect(mockResolveWebhookRecordProviderConfig).toHaveBeenCalledWith(
expect.objectContaining({ id: 'webhook-1', providerConfig: { warm: true } }),
'user-1',
'workspace-1',
expect.any(Object)
)
})

it('forwards the resolved credential owner into formatInput when the credential matches', async () => {
const formatInput = vi.fn().mockResolvedValue({ input: { event: {} } })
mockGetProviderHandler.mockReturnValue({ formatInput })
const { resolveOAuthAccountId } = await import('@/lib/oauth/credential-service')
vi.mocked(resolveOAuthAccountId).mockResolvedValue({
accountId: 'account-1',
} as never)
dbChainMockFns.limit.mockResolvedValue([{ userId: 'owner-1' }])
mockExecuteWorkflowCore.mockResolvedValue({
success: true,
status: 'completed',
output: {},
logs: [],
executionState: {
blockStates: {},
executedBlocks: [],
blockLogs: [],
decisions: {},
completedLoops: [],
activeExecutionPath: [],
},
})
const warmContext = {
workflowRecord: { id: 'workflow-1', workspaceId: 'workspace-1', userId: 'user-1' },
webhookRecord: { id: 'webhook-1', providerConfig: { credentialId: 'credential-1' } },
} as unknown as NonNullable<Parameters<typeof executeWebhookJob>[2]>

await executeWebhookJob({ ...payload, credentialId: 'credential-1' }, undefined, warmContext)

expect(formatInput).toHaveBeenCalledWith(
expect.objectContaining({ credentialOwnerUserId: 'owner-1' })
)
})

it('forwards the payload syncInteraction into formatInput', async () => {
const formatInput = vi.fn().mockResolvedValue({ input: { event: {} } })
mockGetProviderHandler.mockReturnValue({ formatInput })
mockExecuteWorkflowCore.mockResolvedValue({
success: true,
status: 'completed',
output: {},
logs: [],
executionState: {
blockStates: {},
executedBlocks: [],
blockLogs: [],
decisions: {},
completedLoops: [],
activeExecutionPath: [],
},
})

await executeWebhookJob({
...payload,
syncInteraction: { loadingViewId: 'V-loading' },
})

expect(formatInput).toHaveBeenCalledWith(
expect.objectContaining({ syncInteraction: { loadingViewId: 'V-loading' } })
)
})

it('loads rows and keeps account checks without warm context', async () => {
mockExecuteWorkflowCore.mockResolvedValue({
success: true,
status: 'completed',
output: {},
logs: [],
executionState: {
blockStates: {},
executedBlocks: [],
blockLogs: [],
decisions: {},
completedLoops: [],
activeExecutionPath: [],
},
})

await executeWebhookJob(payload)

expect(executionPreprocessingMockFns.mockPreprocessExecution).toHaveBeenCalledWith(
expect.objectContaining({
workflowRecord: undefined,
trustWorkflowRecord: false,
skipAccountChecks: false,
})
)
expect(dbChainMockFns.limit).toHaveBeenCalled()
})

it('ignores warm rows whose ids do not match the payload', async () => {
mockExecuteWorkflowCore.mockResolvedValue({
success: true,
status: 'completed',
output: {},
logs: [],
executionState: {
blockStates: {},
executedBlocks: [],
blockLogs: [],
decisions: {},
completedLoops: [],
activeExecutionPath: [],
},
})
const warmContext = {
workflowRecord: { id: 'other-workflow' },
webhookRecord: { id: 'other-webhook' },
} as unknown as NonNullable<Parameters<typeof executeWebhookJob>[2]>

await executeWebhookJob(payload, undefined, warmContext)

expect(executionPreprocessingMockFns.mockPreprocessExecution).toHaveBeenCalledWith(
expect.objectContaining({
workflowRecord: undefined,
trustWorkflowRecord: false,
skipAccountChecks: false,
})
)
expect(dbChainMockFns.limit).toHaveBeenCalled()
})

it('logs phase timings and the executor-start metric for latency-tracked payloads', async () => {
mockExecuteWorkflowCore.mockResolvedValue({
success: true,
status: 'completed',
output: {},
logs: [],
executionState: {
blockStates: {},
executedBlocks: [],
blockLogs: [],
decisions: {},
completedLoops: [],
activeExecutionPath: [],
},
})

await executeWebhookJob({
...payload,
webhookReceivedAt: Date.now() - 100,
triggerTimestampMs: Date.now() - 500,
})

expect(webhookExecutionLogger.info).toHaveBeenCalledWith(
'[request-1] Webhook dispatch latency',
expect.objectContaining({
dispatchLatencyMs: expect.any(Number),
triggerAgeMs: expect.any(Number),
preprocessMs: expect.any(Number),
loadsMs: expect.any(Number),
providerConfigMs: expect.any(Number),
formatInputMs: expect.any(Number),
})
)

const coreOptions = mockExecuteWorkflowCore.mock.calls[0]?.[0] as {
callbacks: { onBlockStart?: () => Promise<void> }
}
expect(coreOptions.callbacks.onBlockStart).toBeTypeOf('function')
await coreOptions.callbacks.onBlockStart?.()
await coreOptions.callbacks.onBlockStart?.()
const executorStartCalls = webhookExecutionLogger.info.mock.calls.filter(
([message]: [string]) => String(message).includes('Webhook executor started')
)
expect(executorStartCalls).toHaveLength(1)
expect(executorStartCalls[0]?.[1]).toMatchObject({
executorStartLatencyMs: expect.any(Number),
executorStartTriggerAgeMs: expect.any(Number),
})
})

it('does not pass provider-config provenance absent from the trigger input', async () => {
mockGetEffectiveEnvironmentSnapshot.mockResolvedValue({
personalEncrypted: { WEBHOOK_SECRET: 'personal-ciphertext' },
Expand Down
Loading
Loading