mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(webhooks): resolve env var references before deploy-triggered subscription creation (#5619)
* fix(webhooks): resolve env var references before deploy-triggered subscription creation
Provider config fields like an API key can reference an environment
variable via {{VAR_NAME}}. The interactive trigger-save route already
resolved these before calling a provider's createSubscription, but the
async deployment-outbox path (workflow deploy -> saveTriggerWebhooksForDeploy
-> createExternalWebhookSubscription) did not, so the literal unresolved
{{VAR_NAME}} string was sent to the provider as the credential and
rejected. Resolve env vars in createExternalWebhookSubscription itself so
both callers behave the same; the persisted providerConfig keeps storing
the unresolved template, only the outbound call gets the resolved value.
* fix(webhooks): guard against a non-string workspaceId when resolving env vars
workflow.workspaceId as string | undefined was an unchecked cast on a
Record<string, unknown> — if a caller ever passed a workflow-like object
where workspaceId isn't actually a string, workspace-scoped {{VAR}}
references would silently stay unresolved and the provider would receive
the literal template as the credential, reproducing the exact class of bug
this change exists to fix. Replaced with a runtime typeof check that falls
back to undefined (personal-env-only resolution) instead of forwarding an
unvalidated value.
This commit is contained in:
@@ -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}}')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user