Skip to content
Merged
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
121 changes: 121 additions & 0 deletions apps/sim/lib/webhooks/provider-subscriptions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* @vitest-environment node
*/
import type { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockGetEffectiveDecryptedEnv, mockGetProviderHandler } = vi.hoisted(() => ({
mockGetEffectiveDecryptedEnv: vi.fn(),
mockGetProviderHandler: vi.fn(),
}))

vi.mock('@/lib/environment/utils', () => ({
getEffectiveDecryptedEnv: mockGetEffectiveDecryptedEnv,
}))

vi.mock('@/lib/webhooks/providers', () => ({
getProviderHandler: mockGetProviderHandler,
}))

import { createExternalWebhookSubscription } from '@/lib/webhooks/provider-subscriptions'

describe('createExternalWebhookSubscription', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetEffectiveDecryptedEnv.mockResolvedValue({ ASHBY_API_KEY: 'real-secret-key' })
})

it('resolves {{ENV_VAR}} references in providerConfig before calling the provider', async () => {
const createSubscription = vi.fn().mockResolvedValue({
providerConfigUpdates: { externalId: 'ext-1' },
})
mockGetProviderHandler.mockReturnValue({ createSubscription })

const webhookData = {
provider: 'ashby',
providerConfig: { apiKey: '{{ASHBY_API_KEY}}', triggerId: 'ashby_application_submit' },
}
const workflow = { id: 'wf-1', workspaceId: 'ws-1' }

await createExternalWebhookSubscription(
{} as NextRequest,
webhookData,
workflow,
'user-1',
'req-1'
)

expect(mockGetEffectiveDecryptedEnv).toHaveBeenCalledWith('user-1', 'ws-1')
const passedWebhook = createSubscription.mock.calls[0][0].webhook
expect(passedWebhook.providerConfig.apiKey).toBe('real-secret-key')
})

it('persists the unresolved providerConfig, not the resolved one, back to the caller', async () => {
const createSubscription = vi.fn().mockResolvedValue({
providerConfigUpdates: { externalId: 'ext-1' },
})
mockGetProviderHandler.mockReturnValue({ createSubscription })

const webhookData = {
provider: 'ashby',
providerConfig: { apiKey: '{{ASHBY_API_KEY}}', triggerId: 'ashby_application_submit' },
}
const workflow = { id: 'wf-1', workspaceId: 'ws-1' }

const result = await createExternalWebhookSubscription(
{} as NextRequest,
webhookData,
workflow,
'user-1',
'req-1'
)

expect(result.updatedProviderConfig.apiKey).toBe('{{ASHBY_API_KEY}}')
expect(result.updatedProviderConfig.externalId).toBe('ext-1')
})

it('falls back to personal-only env resolution when workspaceId is not a string', async () => {
const createSubscription = vi.fn().mockResolvedValue({
providerConfigUpdates: { externalId: 'ext-1' },
})
mockGetProviderHandler.mockReturnValue({ createSubscription })

const webhookData = {
provider: 'ashby',
providerConfig: { apiKey: '{{ASHBY_API_KEY}}', triggerId: 'ashby_application_submit' },
}
const workflow = { id: 'wf-1', workspaceId: null }

await createExternalWebhookSubscription(
{} as NextRequest,
webhookData,
workflow,
'user-1',
'req-1'
)

expect(mockGetEffectiveDecryptedEnv).toHaveBeenCalledWith('user-1', undefined)
})

it('skips resolution and provider call entirely when the provider has no createSubscription', async () => {
mockGetProviderHandler.mockReturnValue({})

const webhookData = {
provider: 'slack',
providerConfig: { token: '{{SLACK_TOKEN}}' },
}
const workflow = { id: 'wf-1', workspaceId: 'ws-1' }

const result = await createExternalWebhookSubscription(
{} as NextRequest,
webhookData,
workflow,
'user-1',
'req-1'
)

expect(mockGetEffectiveDecryptedEnv).not.toHaveBeenCalled()
expect(result.externalSubscriptionCreated).toBe(false)
expect(result.updatedProviderConfig.token).toBe('{{SLACK_TOKEN}}')
})
})
19 changes: 18 additions & 1 deletion apps/sim/lib/webhooks/provider-subscriptions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import type { NextRequest } from 'next/server'
import { resolveWebhookProviderConfig } from '@/lib/webhooks/env-resolver'
import { getProviderHandler } from '@/lib/webhooks/providers'

const logger = createLogger('WebhookProviderSubscriptions')
Expand Down Expand Up @@ -88,6 +89,14 @@ export function shouldRecreateExternalWebhookSubscription({
* Ask the provider handler to create an external webhook subscription, if that
* provider supports automatic registration.
*
* `providerConfig` may contain unresolved `{{ENV_VAR}}` references (e.g. an
* API key field backed by an environment variable) — these are resolved here
* before the provider call so deploy-triggered registration (this function is
* also called from the async deployment outbox, not just the interactive
* webhook-save route) behaves the same as a manual save. The persisted
* `providerConfig` returned to the caller stays unresolved; only the
* provider-managed fields from `result.providerConfigUpdates` get merged in.
*
* The returned provider-managed fields are merged back into `providerConfig`
* by the caller.
*/
Expand All @@ -106,8 +115,16 @@ export async function createExternalWebhookSubscription(
return { updatedProviderConfig: providerConfig, externalSubscriptionCreated: false }
}

const workspaceId = typeof workflow.workspaceId === 'string' ? workflow.workspaceId : undefined

const resolvedProviderConfig = await resolveWebhookProviderConfig(
providerConfig,
userId,
Comment thread
waleedlatif1 marked this conversation as resolved.
workspaceId
)

const result = await handler.createSubscription({
webhook: webhookData,
webhook: { ...webhookData, providerConfig: resolvedProviderConfig },
workflow,
userId,
requestId,
Expand Down
Loading