diff --git a/apps/sim/lib/webhooks/provider-subscriptions.test.ts b/apps/sim/lib/webhooks/provider-subscriptions.test.ts new file mode 100644 index 00000000000..beef3d4a62d --- /dev/null +++ b/apps/sim/lib/webhooks/provider-subscriptions.test.ts @@ -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}}') + }) +}) diff --git a/apps/sim/lib/webhooks/provider-subscriptions.ts b/apps/sim/lib/webhooks/provider-subscriptions.ts index ae8b160279e..afd9a926648 100644 --- a/apps/sim/lib/webhooks/provider-subscriptions.ts +++ b/apps/sim/lib/webhooks/provider-subscriptions.ts @@ -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') @@ -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. */ @@ -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, + workspaceId + ) + const result = await handler.createSubscription({ - webhook: webhookData, + webhook: { ...webhookData, providerConfig: resolvedProviderConfig }, workflow, userId, requestId,