Skip to content

Commit 808473b

Browse files
fix(slack): harden custom bot rollout
1 parent cd574c4 commit 808473b

4 files changed

Lines changed: 469 additions & 147 deletions

File tree

apps/sim/lib/webhooks/deploy.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,10 +310,11 @@ describe('resolveWebhookConfigForBlock — slack_oauth routing', () => {
310310
expect(mockFetchSlackTeamId).not.toHaveBeenCalled()
311311
})
312312

313-
it('resolves identity when a migrated bot is deployed for reaction events', async () => {
313+
it('resolves missing bot identity for reaction events even when team identity is stored', async () => {
314314
mockGetSlackBotCredential.mockResolvedValue({
315315
workspaceId: 'ws-1',
316316
botToken: 'xoxb-migrated',
317+
teamId: 'T123',
317318
signingSecret: 'secret',
318319
})
319320
mockFetchSlackTeamId.mockResolvedValue({ teamId: 'T123', userId: 'UBOT' })

apps/sim/lib/webhooks/deploy.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -447,9 +447,8 @@ export async function resolveWebhookConfigForBlock(input: {
447447
if (botCredential.botUserId) {
448448
providerConfig.bot_user_id = botCredential.botUserId
449449
} else if (
450-
!botCredential.teamId &&
451-
(providerConfig.eventType === 'reaction_added' ||
452-
providerConfig.eventType === 'reaction_removed')
450+
providerConfig.eventType === 'reaction_added' ||
451+
providerConfig.eventType === 'reaction_removed'
453452
) {
454453
try {
455454
const { userId: botUserId } = await fetchSlackTeamId(botCredential.botToken)

packages/db/scripts/migrate-slack-custom-bots.test.ts

Lines changed: 165 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,81 @@ import {
1010
type EnvironmentLookup,
1111
extractSlackBotSources,
1212
groupSlackSourcesByWorkflowCredentials,
13+
isTransientDatabaseError,
1314
planLegacySlackTriggerLink,
1415
resolveSlackSourceSecrets,
16+
retryTransientDatabaseRead,
1517
type SlackBotSource,
1618
type SlackMigrationBlock,
1719
} from './migrate-slack-custom-bots'
1820

21+
describe('database read retries', () => {
22+
it('recognizes wrapped connection errors without retrying data errors', () => {
23+
const connectionError = new Error('Failed query', {
24+
cause: Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }),
25+
})
26+
const dataError = Object.assign(new Error('unique violation'), { code: '23505' })
27+
28+
expect(isTransientDatabaseError(connectionError)).toBe(true)
29+
expect(
30+
isTransientDatabaseError(Object.assign(new Error('connection failure'), { code: '08006' }))
31+
).toBe(true)
32+
expect(isTransientDatabaseError(dataError)).toBe(false)
33+
})
34+
35+
it('retries a transient read and returns the successful result', async () => {
36+
let attempts = 0
37+
const result = await retryTransientDatabaseRead(
38+
async () => {
39+
attempts++
40+
if (attempts < 3) {
41+
throw Object.assign(new Error('connection closed'), { code: 'CONNECTION_CLOSED' })
42+
}
43+
return 'ok'
44+
},
45+
{ operation: 'test read' },
46+
{ maxAttempts: 3, backoff: { baseMs: 1, maxMs: 1 } }
47+
)
48+
49+
expect(result).toBe('ok')
50+
expect(attempts).toBe(3)
51+
})
52+
53+
it('fails immediately for a non-transient read error', async () => {
54+
let attempts = 0
55+
const error = Object.assign(new Error('invalid data'), { code: '23505' })
56+
57+
await expect(
58+
retryTransientDatabaseRead(
59+
async () => {
60+
attempts++
61+
throw error
62+
},
63+
{ operation: 'test read' },
64+
{ maxAttempts: 5, backoff: { baseMs: 1, maxMs: 1 } }
65+
)
66+
).rejects.toBe(error)
67+
expect(attempts).toBe(1)
68+
})
69+
70+
it('stops after the configured number of transient attempts', async () => {
71+
let attempts = 0
72+
const error = Object.assign(new Error('connection closed'), { code: 'CONNECTION_CLOSED' })
73+
74+
await expect(
75+
retryTransientDatabaseRead(
76+
async () => {
77+
attempts++
78+
throw error
79+
},
80+
{ operation: 'test read' },
81+
{ maxAttempts: 3, backoff: { baseMs: 1, maxMs: 1 } }
82+
)
83+
).rejects.toBe(error)
84+
expect(attempts).toBe(3)
85+
})
86+
})
87+
1988
function storedSubBlocks(values: Record<string, unknown>): Record<string, { value: unknown }> {
2089
return Object.fromEntries(Object.entries(values).map(([id, value]) => [id, { value }]))
2190
}
@@ -168,6 +237,58 @@ describe('extractSlackBotSources', () => {
168237
])
169238
})
170239

240+
it('ignores Slack tools without params while extracting valid sibling tools', () => {
241+
const result = extractSlackBotSources(
242+
migrationBlock({
243+
blockType: 'agent',
244+
subBlocks: storedSubBlocks({
245+
tools: [
246+
{ type: 'slack', title: 'Incomplete Slack tool' },
247+
{
248+
type: 'slack',
249+
title: 'Send to incidents',
250+
params: { authMethod: 'bot_token', botToken: 'xoxb-tool' },
251+
},
252+
],
253+
}),
254+
})
255+
)
256+
257+
expect(result).toEqual([
258+
expect.objectContaining({
259+
sourceId: 'workflow-1:block-1:tools:1',
260+
toolTitle: 'Send to incidents',
261+
rawBotToken: 'xoxb-tool',
262+
}),
263+
])
264+
})
265+
266+
it('ignores non-object tool entries while preserving valid sibling indexes', () => {
267+
const result = extractSlackBotSources(
268+
migrationBlock({
269+
blockType: 'agent',
270+
subBlocks: storedSubBlocks({
271+
tools: [
272+
'legacy-invalid-tool',
273+
{
274+
type: 'slack',
275+
title: 'Send to incidents',
276+
params: { authMethod: 'bot_token', botToken: 'xoxb-tool' },
277+
},
278+
],
279+
}),
280+
})
281+
)
282+
283+
expect(result).toEqual([
284+
expect.objectContaining({
285+
sourceId: 'workflow-1:block-1:tools:1',
286+
toolTitle: 'Send to incidents',
287+
rawBotToken: 'xoxb-tool',
288+
}),
289+
])
290+
})
291+
171292
it('fails fast on malformed tool-input storage', () => {
172293
expect(() =>
173294
extractSlackBotSources(
@@ -374,6 +495,20 @@ describe('planLegacySlackTriggerLink', () => {
374495
})
375496
})
376497

498+
it('marks historical Slack webhooks that predate the trigger id', () => {
499+
expect(
500+
planLegacySlackTriggerLink(triggerSource, existingCredential, [
501+
{
502+
id: 'webhook-1',
503+
workflowId: 'workflow-1',
504+
blockId: 'block-1',
505+
routingKey: null,
506+
providerConfig: { signingSecret: 'secret' },
507+
},
508+
])
509+
).toEqual({ updateTriggerBlock: true, webhookIdsToUpdate: ['webhook-1'] })
510+
})
511+
377512
it('is idempotent after the block and webhook are linked', () => {
378513
expect(
379514
planLegacySlackTriggerLink(
@@ -406,6 +541,20 @@ describe('planLegacySlackTriggerLink', () => {
406541
)
407542
).toThrow(/different Slack bot credential/)
408543
})
544+
545+
it('fails fast instead of relabeling a different Slack trigger', () => {
546+
expect(() =>
547+
planLegacySlackTriggerLink(triggerSource, existingCredential, [
548+
{
549+
id: 'webhook-1',
550+
workflowId: 'workflow-1',
551+
blockId: 'block-1',
552+
routingKey: null,
553+
providerConfig: { triggerId: 'slack_oauth' },
554+
},
555+
])
556+
).toThrow(/does not use trigger slack_webhook/)
557+
})
409558
})
410559

411560
describe('resolveSlackSourceSecrets', () => {
@@ -451,14 +600,25 @@ describe('resolveSlackSourceSecrets', () => {
451600
})
452601
})
453602

454-
it('still fails fast when a personal variable cannot be promoted safely', () => {
455-
expect(() =>
603+
it('skips a personal variable that cannot be promoted safely', () => {
604+
expect(
456605
resolveSlackSourceSecrets(
457-
source({ workflowUserId: 'user-2', rawBotToken: '{{SLACK_BOT_TOKEN}}' }),
606+
source({
607+
sourceId: 'workflow-1:block-1:trigger',
608+
kind: 'trigger',
609+
workflowUserId: 'user-2',
610+
rawSigningSecret: '{{SLACK_CASINO_SECRET}}',
611+
}),
458612
environmentLookup({
459-
personalVariablesByUserId: new Map([['user-2', { SLACK_BOT_TOKEN: 'encrypted-value' }]]),
613+
personalVariablesByUserId: new Map([
614+
['user-2', { SLACK_CASINO_SECRET: 'encrypted-value' }],
615+
]),
460616
})
461617
)
462-
).toThrow(/non-owner personal environment variable/)
618+
).toEqual({
619+
status: 'unresolved',
620+
reason:
621+
'signingSecret uses non-owner personal environment variable SLACK_CASINO_SECRET; refusing to promote it to a workspace credential',
622+
})
463623
})
464624
})

0 commit comments

Comments
 (0)