diff --git a/apps/sim/background/webhook-execution.test.ts b/apps/sim/background/webhook-execution.test.ts new file mode 100644 index 0000000000..620c073ac0 --- /dev/null +++ b/apps/sim/background/webhook-execution.test.ts @@ -0,0 +1,68 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockResolveWebhookRecordProviderConfig } = vi.hoisted(() => ({ + mockResolveWebhookRecordProviderConfig: vi.fn(), +})) + +vi.mock('@/lib/webhooks/env-resolver', () => ({ + resolveWebhookRecordProviderConfig: mockResolveWebhookRecordProviderConfig, +})) + +import { resolveWebhookExecutionProviderConfig } from './webhook-execution' + +describe('resolveWebhookExecutionProviderConfig', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns the resolved webhook record when provider config resolution succeeds', async () => { + const webhookRecord = { + id: 'webhook-1', + providerConfig: { + botToken: '{{SLACK_BOT_TOKEN}}', + }, + } + const resolvedWebhookRecord = { + ...webhookRecord, + providerConfig: { + botToken: 'xoxb-resolved', + }, + } + + mockResolveWebhookRecordProviderConfig.mockResolvedValue(resolvedWebhookRecord) + + await expect( + resolveWebhookExecutionProviderConfig(webhookRecord, 'slack', 'user-1', 'workspace-1') + ).resolves.toEqual(resolvedWebhookRecord) + + expect(mockResolveWebhookRecordProviderConfig).toHaveBeenCalledWith( + webhookRecord, + 'user-1', + 'workspace-1' + ) + }) + + it('throws a contextual error when provider config resolution fails', async () => { + mockResolveWebhookRecordProviderConfig.mockRejectedValue(new Error('env lookup failed')) + + await expect( + resolveWebhookExecutionProviderConfig( + { + id: 'webhook-1', + providerConfig: { + botToken: '{{SLACK_BOT_TOKEN}}', + }, + }, + 'slack', + 'user-1', + 'workspace-1' + ) + ).rejects.toThrow( + 'Failed to resolve webhook provider config for slack webhook webhook-1: env lookup failed' + ) + }) +}) diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index d78a140138..843de82201 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -11,6 +11,7 @@ import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { WebhookAttachmentProcessor } from '@/lib/webhooks/attachment-processor' +import { resolveWebhookRecordProviderConfig } from '@/lib/webhooks/env-resolver' import { getProviderHandler } from '@/lib/webhooks/providers' import { executeWorkflowCore, @@ -168,6 +169,24 @@ export async function executeWebhookJob(payload: WebhookExecutionPayload) { ) } +export async function resolveWebhookExecutionProviderConfig< + T extends { id: string; providerConfig?: unknown }, +>( + webhookRecord: T, + provider: string, + userId: string, + workspaceId?: string +): Promise }> { + try { + return await resolveWebhookRecordProviderConfig(webhookRecord, userId, workspaceId) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + throw new Error( + `Failed to resolve webhook provider config for ${provider} webhook ${webhookRecord.id}: ${errorMessage}` + ) + } +} + async function resolveCredentialAccountUserId(credentialId: string): Promise { const resolved = await resolveOAuthAccountId(credentialId) if (!resolved) { @@ -300,9 +319,16 @@ async function executeWebhookJobInternal( throw new Error(`Webhook record not found: ${payload.webhookId}`) } + const resolvedWebhookRecord = await resolveWebhookExecutionProviderConfig( + webhookRecord, + payload.provider, + workflowRecord.userId, + workspaceId + ) + if (handler.formatInput) { const result = await handler.formatInput({ - webhook: webhookRecord, + webhook: resolvedWebhookRecord, workflow: { id: payload.workflowId, userId: payload.userId }, body: payload.body, headers: payload.headers, diff --git a/apps/sim/lib/webhooks/env-resolver.test.ts b/apps/sim/lib/webhooks/env-resolver.test.ts new file mode 100644 index 0000000000..6da44a32fb --- /dev/null +++ b/apps/sim/lib/webhooks/env-resolver.test.ts @@ -0,0 +1,74 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetEffectiveDecryptedEnv } = vi.hoisted(() => ({ + mockGetEffectiveDecryptedEnv: vi.fn(), +})) + +vi.mock('@/lib/environment/utils', () => ({ + getEffectiveDecryptedEnv: mockGetEffectiveDecryptedEnv, +})) + +import { + resolveWebhookProviderConfig, + resolveWebhookRecordProviderConfig, +} from '@/lib/webhooks/env-resolver' + +describe('webhook env resolver', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetEffectiveDecryptedEnv.mockResolvedValue({ + SLACK_BOT_TOKEN: 'xoxb-resolved', + SLACK_HOST: 'files.slack.com', + }) + }) + + it('resolves environment variables inside webhook provider config', async () => { + const result = await resolveWebhookProviderConfig( + { + botToken: '{{SLACK_BOT_TOKEN}}', + includeFiles: true, + nested: { + url: 'https://{{SLACK_HOST}}/api/files.info', + }, + }, + 'user-1', + 'workspace-1' + ) + + expect(result).toEqual({ + botToken: 'xoxb-resolved', + includeFiles: true, + nested: { + url: 'https://files.slack.com/api/files.info', + }, + }) + expect(mockGetEffectiveDecryptedEnv).toHaveBeenCalledWith('user-1', 'workspace-1') + }) + + it('returns a cloned webhook record with resolved provider config', async () => { + const webhookRecord = { + id: 'webhook-1', + provider: 'slack', + providerConfig: { + botToken: '{{SLACK_BOT_TOKEN}}', + includeFiles: true, + }, + } + + const result = await resolveWebhookRecordProviderConfig(webhookRecord, 'user-1', 'workspace-1') + + expect(result).toEqual({ + ...webhookRecord, + providerConfig: { + botToken: 'xoxb-resolved', + includeFiles: true, + }, + }) + expect(result).not.toBe(webhookRecord) + expect(result.providerConfig).not.toBe(webhookRecord.providerConfig) + }) +}) diff --git a/apps/sim/lib/webhooks/env-resolver.ts b/apps/sim/lib/webhooks/env-resolver.ts index 23b83b6349..1537879e7d 100644 --- a/apps/sim/lib/webhooks/env-resolver.ts +++ b/apps/sim/lib/webhooks/env-resolver.ts @@ -20,3 +20,43 @@ export async function resolveEnvVarsInObject>( const envVars = await getEffectiveDecryptedEnv(userId, workspaceId) return resolveEnvVarReferences(config, envVars, { deep: true }) as T } + +/** + * Normalizes webhook provider config into a plain object for runtime resolution. + */ +export function normalizeWebhookProviderConfig(providerConfig: unknown): Record { + if (providerConfig && typeof providerConfig === 'object' && !Array.isArray(providerConfig)) { + return providerConfig as Record + } + + return {} +} + +/** + * Resolves environment variable references inside a webhook provider config object. + */ +export async function resolveWebhookProviderConfig( + providerConfig: unknown, + userId: string, + workspaceId?: string +): Promise> { + return resolveEnvVarsInObject(normalizeWebhookProviderConfig(providerConfig), userId, workspaceId) +} + +/** + * Clones a webhook-like record with its provider config resolved for runtime use. + */ +export async function resolveWebhookRecordProviderConfig( + webhookRecord: T, + userId: string, + workspaceId?: string +): Promise }> { + return { + ...webhookRecord, + providerConfig: await resolveWebhookProviderConfig( + webhookRecord.providerConfig, + userId, + workspaceId + ), + } +}