fix(triggers): env var resolution in provider configs (#4160)

* fix(triggers): env var resolution in provider configs

* throw on errored resolution
This commit is contained in:
Vikhyath Mondreti
2026-04-14 14:30:26 -07:00
committed by GitHub
parent 6b2e83bf58
commit 7529a75ac0
4 changed files with 209 additions and 1 deletions
@@ -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'
)
})
})
+27 -1
View File
@@ -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<T & { providerConfig: Record<string, unknown> }> {
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<string | undefined> {
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,
@@ -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)
})
})
+40
View File
@@ -20,3 +20,43 @@ export async function resolveEnvVarsInObject<T extends Record<string, unknown>>(
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<string, unknown> {
if (providerConfig && typeof providerConfig === 'object' && !Array.isArray(providerConfig)) {
return providerConfig as Record<string, unknown>
}
return {}
}
/**
* Resolves environment variable references inside a webhook provider config object.
*/
export async function resolveWebhookProviderConfig(
providerConfig: unknown,
userId: string,
workspaceId?: string
): Promise<Record<string, unknown>> {
return resolveEnvVarsInObject(normalizeWebhookProviderConfig(providerConfig), userId, workspaceId)
}
/**
* Clones a webhook-like record with its provider config resolved for runtime use.
*/
export async function resolveWebhookRecordProviderConfig<T extends { providerConfig?: unknown }>(
webhookRecord: T,
userId: string,
workspaceId?: string
): Promise<T & { providerConfig: Record<string, unknown> }> {
return {
...webhookRecord,
providerConfig: await resolveWebhookProviderConfig(
webhookRecord.providerConfig,
userId,
workspaceId
),
}
}