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
176 changes: 172 additions & 4 deletions apps/sim/lib/webhooks/deploy.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/**
* @vitest-environment node
*/
import { credential } from '@sim/db/schema'
import { resetDbChainMock } from '@sim/testing'
import { account, credential } from '@sim/db/schema'
import { queueTableRows, resetDbChainMock } from '@sim/testing'
import { eq } from 'drizzle-orm'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterAll, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import type { SubBlockConfig } from '@/blocks/types'
import type { BlockState } from '@/stores/workflows/workflow/types'

Expand All @@ -25,7 +25,33 @@ vi.mock('@/lib/webhooks/pending-verification', () => ({
PendingWebhookVerificationTracker: vi.fn(),
}))

import { buildProviderConfig, resolveTriggerCredentialId } from '@/lib/webhooks/deploy'
const {
mockGetSlackBotCredential,
mockResolveOAuthAccountId,
mockRefreshAccessTokenIfNeeded,
mockFetchSlackTeamId,
} = vi.hoisted(() => ({
mockGetSlackBotCredential: vi.fn(),
mockResolveOAuthAccountId: vi.fn(),
mockRefreshAccessTokenIfNeeded: vi.fn(),
mockFetchSlackTeamId: vi.fn(),
}))
vi.mock('@/app/api/auth/oauth/utils', () => ({
getSlackBotCredential: mockGetSlackBotCredential,
resolveOAuthAccountId: mockResolveOAuthAccountId,
refreshAccessTokenIfNeeded: mockRefreshAccessTokenIfNeeded,
}))
vi.mock('@/lib/webhooks/providers/slack', () => ({
fetchSlackTeamId: mockFetchSlackTeamId,
}))

import {
buildProviderConfig,
resolveTriggerCredentialId,
resolveWebhookConfigForBlock,
} from '@/lib/webhooks/deploy'
import { getBlock } from '@/blocks'
import { getTrigger } from '@/triggers'

afterAll(resetDbChainMock)

Expand Down Expand Up @@ -191,3 +217,145 @@ describe('resolveTriggerCredentialId', () => {
expect(eq).toHaveBeenCalledWith(credential.accountId, 'credential-1')
})
})

describe('resolveWebhookConfigForBlock — slack_oauth routing', () => {
const slackTriggerDef = {
provider: 'slack_app',
name: 'Slack',
subBlocks: [
{ id: 'eventType', mode: 'trigger', required: true },
{
id: 'customBotCredential',
mode: 'trigger',
canonicalParamId: 'botCredential',
serviceId: 'slack',
required: true,
},
{
id: 'manualBotCredential',
mode: 'trigger-advanced',
canonicalParamId: 'botCredential',
required: true,
},
],
}

function resolveSlack(
values: Record<string, unknown>,
workflow: Record<string, unknown> = { workspaceId: 'ws-1' }
) {
;(getBlock as unknown as Mock).mockReturnValue({ category: 'triggers' })
;(getTrigger as unknown as Mock).mockReturnValue(slackTriggerDef)
return resolveWebhookConfigForBlock({
block: makeBlock('slack_oauth', values),
workflow,
userId: 'deployer-1',
requestId: 'req-1',
})
}

it('routes a custom bot credential by credential id on the slack provider', async () => {
mockGetSlackBotCredential.mockResolvedValue({ workspaceId: 'ws-1', botUserId: 'BUSER' })

const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_bot_1' })

expect(result?.success).toBe(true)
if (!result?.success) throw new Error('expected success')
expect(result.config.provider).toBe('slack')
expect(result.config.routingKey).toBe('cred_bot_1')
expect(result.config.triggerPath).toBeNull()
expect(result.config.providerConfig.bot_user_id).toBe('BUSER')
})

it('rejects a custom bot credential from another workspace', async () => {
mockGetSlackBotCredential.mockResolvedValue({ workspaceId: 'other-ws', botUserId: 'BUSER' })

const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_bot_1' })

expect(result?.success).toBe(false)
if (result?.success) throw new Error('expected failure')
expect(result?.error?.status).toBe(400)
expect(result?.error?.message).toContain('not available in this workspace')
})

it('rejects a deleted or secretless custom bot credential as an invalid bot', async () => {
mockGetSlackBotCredential.mockResolvedValue(null)
mockResolveOAuthAccountId.mockResolvedValue({ credentialType: 'service_account' })

const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_bot_x' })

expect(result?.success).toBe(false)
if (result?.success) throw new Error('expected failure')
expect(result?.error?.status).toBe(400)
expect(result?.error?.message).toContain('bot credential is missing or invalid')
expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled()
})

it('rejects an OAuth credential not resolvable in the workflow workspace', async () => {
mockGetSlackBotCredential.mockResolvedValue(null)
mockResolveOAuthAccountId.mockResolvedValue({ accountId: 'acct-1' })
// No credential row queued → resolveTriggerCredentialId returns null.

const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_foreign' })

expect(result?.success).toBe(false)
if (result?.success) throw new Error('expected failure')
expect(result?.error?.status).toBe(400)
expect(result?.error?.message).toContain('not available in this workspace')
expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled()
})

it('rejects a non-simSubscribed event on the native Sim app (OAuth account)', async () => {
mockGetSlackBotCredential.mockResolvedValue(null)
mockResolveOAuthAccountId.mockResolvedValue({ accountId: 'acct-1' })
queueTableRows(credential, [{ id: 'cred_oauth_1' }])

const result = await resolveSlack({
eventType: 'file_shared',
customBotCredential: 'cred_oauth_1',
})

expect(result?.success).toBe(false)
if (result?.success) throw new Error('expected failure')
expect(result?.error?.status).toBe(400)
expect(result?.error?.message).toContain('not available on the Sim Slack app')
expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled()
})

it('routes an OAuth account by team_id on the slack_app provider', async () => {
mockGetSlackBotCredential.mockResolvedValue(null)
mockResolveOAuthAccountId.mockResolvedValue({ accountId: 'acct-1' })
queueTableRows(credential, [{ id: 'cred_oauth_1' }])
queueTableRows(account, [{ userId: 'owner-1' }])
mockRefreshAccessTokenIfNeeded.mockResolvedValue('xoxb-token')
mockFetchSlackTeamId.mockResolvedValue({ teamId: 'T123', userId: 'UBOT' })

const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_oauth_1' })

expect(result?.success).toBe(true)
if (!result?.success) throw new Error('expected success')
expect(result.config.provider).toBe('slack_app')
expect(result.config.routingKey).toBe('T123')
expect(result.config.triggerPath).toBeNull()
expect(result.config.providerConfig.bot_user_id).toBe('UBOT')
// Runtime token resolution + disconnect cleanup key slack_app rows on this.
expect(result.config.providerConfig.credentialId).toBe('cred_oauth_1')
// Owner's token, not the deploying actor's.
expect(mockRefreshAccessTokenIfNeeded).toHaveBeenCalledWith('cred_oauth_1', 'owner-1', 'req-1')
})

it('fails when the connected Slack account token cannot be resolved', async () => {
mockGetSlackBotCredential.mockResolvedValue(null)
mockResolveOAuthAccountId.mockResolvedValue({ accountId: '' })
queueTableRows(credential, [{ id: 'cred_oauth_1' }])
mockRefreshAccessTokenIfNeeded.mockResolvedValue(null)

const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_oauth_1' })

expect(result?.success).toBe(false)
if (result?.success) throw new Error('expected failure')
expect(result?.error?.status).toBe(400)
expect(result?.error?.message).toContain('Could not access the connected Slack account')
expect(mockFetchSlackTeamId).not.toHaveBeenCalled()
})
})
Loading
Loading