Skip to content

Commit e1a0fa1

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(slack): preserve historical v2 credential compatibility
1 parent 9d8d595 commit e1a0fa1

7 files changed

Lines changed: 319 additions & 4 deletions

File tree

apps/sim/lib/workflows/application/read-workflow-version.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/aut
55
import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context'
66
import { workflowOperations } from '@/lib/workflows/application/operations'
77
import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope'
8+
import { projectLegacySlackV2Auth } from '@/lib/workflows/compatibility/slack-v2-auth'
89
import { sanitizeWorkflowForSharing } from '@/lib/workflows/credentials/credential-extractor'
910
import { getWorkflowDeploymentVersion } from '@/lib/workflows/persistence/utils'
1011
import type { WorkflowState } from '@/stores/workflows/workflow/types'
@@ -68,7 +69,10 @@ export const readWorkflowVersion = defineAuthorizedWorkflowUseCase({
6869
if (!isWorkflowState(state)) {
6970
throw new Error('Deployment version contains invalid workflow state')
7071
}
71-
const presentedState = input.includeCredentialValues ? state : sanitizeVersionState(state)
72+
const compatibleState = { ...state, blocks: projectLegacySlackV2Auth(state.blocks ?? {}) }
73+
const presentedState = input.includeCredentialValues
74+
? compatibleState
75+
: sanitizeVersionState(compatibleState)
7276
logger.info('Read workflow version', {
7377
workspaceId: context.workspaceId,
7478
workflowId: context.workflowId,

apps/sim/lib/workflows/application/workflow-crud.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ import { listWorkflowVersions } from '@/lib/workflows/application/list-workflow-
106106
import { readWorkflow } from '@/lib/workflows/application/read-workflow'
107107
import { readWorkflowVersion } from '@/lib/workflows/application/read-workflow-version'
108108
import { updateWorkflow } from '@/lib/workflows/application/update-workflow'
109+
import { createHistoricalSlackV2Block } from '@/lib/workflows/compatibility/slack-v2-auth.fixtures'
109110

110111
const WORKSPACE_ID = 'workspace-1'
111112
const WORKFLOW_ID = 'workflow-1'
@@ -466,4 +467,31 @@ describe('authorized workflow CRUD and version reads', () => {
466467
).resolves.toMatchObject({ version: { id: 'version-1', version: 1 } })
467468
expect(mocks.resolveWorkflowContext).toHaveBeenCalledBefore(mocks.readVersion)
468469
})
470+
471+
it('presents historical Slack v2 auth canonically without mutating the stored version', async () => {
472+
const historicalSlack = createHistoricalSlackV2Block('slack')
473+
const state = {
474+
blocks: { slack: historicalSlack },
475+
edges: [],
476+
loops: {},
477+
parallels: {},
478+
}
479+
mocks.readVersion.mockResolvedValue({
480+
id: 'version-legacy-slack',
481+
version: 1,
482+
state,
483+
})
484+
485+
const result = await readWorkflowVersion.execute({
486+
principal: personalPrincipal,
487+
input: { workflowId: WORKFLOW_ID, version: 1, includeCredentialValues: true },
488+
})
489+
490+
expect(result.version.state.blocks.slack.subBlocks.credential.value).toBe(
491+
'credential-custom-bot'
492+
)
493+
expect(result.version.state.blocks.slack.subBlocks).not.toHaveProperty('authMethod')
494+
expect(historicalSlack.subBlocks.authMethod.value).toBe('bot_token')
495+
expect(historicalSlack.subBlocks.credential.value).toBe('dormant-oauth')
496+
})
469497
})
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { BlockState, SubBlockState } from '@sim/workflow-types/workflow'
2+
3+
function subBlock(
4+
id: string,
5+
type: SubBlockState['type'],
6+
value: SubBlockState['value']
7+
): SubBlockState {
8+
return { id, type, value }
9+
}
10+
11+
/** Relevant persisted fields produced by the slack_v2 schema introduced in f4d47ed. */
12+
export function createHistoricalSlackV2Block(id = 'slack-1'): BlockState {
13+
return {
14+
id,
15+
type: 'slack_v2',
16+
name: 'Slack',
17+
position: { x: 0, y: 0 },
18+
enabled: true,
19+
triggerMode: false,
20+
subBlocks: {
21+
operation: subBlock('operation', 'dropdown', 'send'),
22+
authMethod: subBlock('authMethod', 'dropdown', 'bot_token'),
23+
credential: subBlock('credential', 'oauth-input', 'dormant-oauth'),
24+
manualCredential: subBlock('manualCredential', 'short-input', null),
25+
customBotCredential: subBlock('customBotCredential', 'oauth-input', 'credential-custom-bot'),
26+
manualCustomBotCredential: subBlock('manualCustomBotCredential', 'short-input', null),
27+
destinationType: subBlock('destinationType', 'dropdown', 'channel'),
28+
channel: subBlock('channel', 'channel-selector', 'C123456789'),
29+
text: subBlock('text', 'long-input', 'Hello'),
30+
messageFormat: subBlock('messageFormat', 'dropdown', 'text'),
31+
},
32+
data: {
33+
canonicalModes: {
34+
oauthCredential: 'basic',
35+
botCredential: 'basic',
36+
channel: 'basic',
37+
},
38+
},
39+
outputs: {},
40+
}
41+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { omit } from '@sim/utils/object'
6+
import type { BlockState, WorkflowState } from '@sim/workflow-types/workflow'
7+
import { afterAll, describe, expect, it, vi } from 'vitest'
8+
9+
vi.unmock('@/blocks/registry')
10+
11+
import { generateWorkflowDiffSummary } from '@/lib/workflows/comparison/compare'
12+
import { projectLegacySlackV2Auth } from '@/lib/workflows/compatibility/slack-v2-auth'
13+
import { createHistoricalSlackV2Block } from '@/lib/workflows/compatibility/slack-v2-auth.fixtures'
14+
import { buildSelectorContextFromBlock } from '@/lib/workflows/subblocks/context'
15+
import * as blocksBarrel from '@/blocks'
16+
import { getBlock as getRealBlock } from '@/blocks/registry'
17+
import { extractBlockParams } from '@/serializer'
18+
19+
const getBlockSpy = vi.spyOn(blocksBarrel, 'getBlock').mockImplementation(getRealBlock)
20+
21+
afterAll(() => {
22+
getBlockSpy.mockRestore()
23+
})
24+
25+
function workflowWith(block: BlockState): WorkflowState {
26+
return { blocks: { [block.id]: block }, edges: [], loops: {}, parallels: {} }
27+
}
28+
29+
describe('projectLegacySlackV2Auth', () => {
30+
it('makes the historical custom-bot action behave like its current equivalent', () => {
31+
const historical = createHistoricalSlackV2Block()
32+
const original = structuredClone(historical)
33+
const equivalentCurrent = structuredClone(historical)
34+
equivalentCurrent.subBlocks = omit(equivalentCurrent.subBlocks, [
35+
'authMethod',
36+
'customBotCredential',
37+
'manualCustomBotCredential',
38+
])
39+
equivalentCurrent.subBlocks.credential.value = 'credential-custom-bot'
40+
const blocks = projectLegacySlackV2Auth({ [historical.id]: historical })
41+
const projected = blocks[historical.id]
42+
43+
expect(historical).toEqual(original)
44+
expect(projected.subBlocks).not.toHaveProperty('authMethod')
45+
expect(projected.subBlocks).not.toHaveProperty('customBotCredential')
46+
expect(projected.subBlocks.credential.value).toBe('credential-custom-bot')
47+
expect(projected.data?.canonicalModes).toMatchObject({
48+
oauthCredential: 'basic',
49+
botCredential: 'basic',
50+
})
51+
52+
const selectorContext = buildSelectorContextFromBlock(projected.type, projected.subBlocks, {
53+
selectorKey: 'slack.channels',
54+
dependsOn: ['credential'],
55+
canonicalModes: projected.data?.canonicalModes,
56+
})
57+
expect(selectorContext.oauthCredential).toBe('credential-custom-bot')
58+
59+
const params = extractBlockParams(projected)
60+
expect(params).toMatchObject({
61+
oauthCredential: 'credential-custom-bot',
62+
channel: 'C123456789',
63+
})
64+
expect(params).not.toHaveProperty('botCredential')
65+
66+
expect(
67+
generateWorkflowDiffSummary(workflowWith(equivalentCurrent), workflowWith(projected))
68+
.hasChanges
69+
).toBe(false)
70+
})
71+
72+
it('honors the historical custom-bot and OAuth modes', () => {
73+
const historical = createHistoricalSlackV2Block()
74+
historical.data!.canonicalModes!.botCredential = 'advanced'
75+
historical.subBlocks.manualCustomBotCredential.value = 'credential-manual-bot'
76+
77+
const projected = projectLegacySlackV2Auth({ [historical.id]: historical })[historical.id]
78+
79+
expect(projected.subBlocks.credential.value).toBeNull()
80+
expect(projected.subBlocks.manualCredential.value).toBe('credential-manual-bot')
81+
expect(projected.data?.canonicalModes?.oauthCredential).toBe('advanced')
82+
83+
const historicalOauth = createHistoricalSlackV2Block()
84+
historicalOauth.subBlocks.authMethod.value = 'oauth'
85+
const projectedOauth = projectLegacySlackV2Auth({ slack: historicalOauth }).slack
86+
expect(projectedOauth.subBlocks.credential.value).toBe('dormant-oauth')
87+
})
88+
89+
it('leaves current, trigger, and unidentifiable states untouched', () => {
90+
const cases = [
91+
createHistoricalSlackV2Block(),
92+
createHistoricalSlackV2Block(),
93+
createHistoricalSlackV2Block(),
94+
]
95+
cases[0].subBlocks = omit(cases[0].subBlocks, ['authMethod'])
96+
cases[1].triggerMode = true
97+
cases[2].subBlocks.authMethod.value = null
98+
99+
for (const block of cases) {
100+
const blocks = { [block.id]: block }
101+
expect(projectLegacySlackV2Auth(blocks)).toBe(blocks)
102+
}
103+
})
104+
105+
it('does not substitute a dormant OAuth account for a missing historical bot credential', () => {
106+
const historical = createHistoricalSlackV2Block()
107+
historical.subBlocks.customBotCredential.value = null
108+
109+
const projected = projectLegacySlackV2Auth({ [historical.id]: historical })[historical.id]
110+
111+
expect(projected.subBlocks.credential.value).toBeNull()
112+
expect(
113+
buildSelectorContextFromBlock(projected.type, projected.subBlocks, {
114+
selectorKey: 'slack.channels',
115+
dependsOn: ['credential'],
116+
canonicalModes: projected.data?.canonicalModes,
117+
}).oauthCredential
118+
).toBeUndefined()
119+
})
120+
})
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { omit } from '@sim/utils/object'
2+
import type { BlockState, SubBlockState } from '@sim/workflow-types/workflow'
3+
import { isNonEmptyValue } from '@/lib/workflows/subblocks/visibility'
4+
5+
type CanonicalMode = 'basic' | 'advanced'
6+
7+
function resolveLegacyMode(
8+
override: CanonicalMode | undefined,
9+
basicValue: unknown,
10+
advancedValue: unknown
11+
): CanonicalMode {
12+
if (override === 'basic' || override === 'advanced') return override
13+
return !isNonEmptyValue(basicValue) && isNonEmptyValue(advancedValue) ? 'advanced' : 'basic'
14+
}
15+
16+
function withValue(
17+
subBlock: SubBlockState | undefined,
18+
id: string,
19+
type: SubBlockState['type'],
20+
value: SubBlockState['value']
21+
): SubBlockState {
22+
return { ...(subBlock ?? { id, type }), value }
23+
}
24+
25+
/**
26+
* Projects the preview-era slack_v2 action auth shape into the merged credential picker added in
27+
* 5be35b5. The returned view is safe for current readers but is never marked for persistence, so
28+
* frozen deployment snapshots and normalized workflow rows remain unchanged.
29+
*/
30+
export function projectLegacySlackV2Auth(
31+
blocks: Record<string, BlockState>
32+
): Record<string, BlockState> {
33+
let projectedBlocks: Record<string, BlockState> | undefined
34+
35+
for (const [blockId, block] of Object.entries(blocks)) {
36+
if (block.type !== 'slack_v2' || block.triggerMode) continue
37+
38+
const authMethod = block.subBlocks.authMethod?.value
39+
if (authMethod !== 'oauth' && authMethod !== 'bot_token') continue
40+
41+
const canonicalModes = block.data?.canonicalModes ?? {}
42+
const oauthMode = resolveLegacyMode(
43+
canonicalModes.oauthCredential,
44+
block.subBlocks.credential?.value,
45+
block.subBlocks.manualCredential?.value
46+
)
47+
const botMode = resolveLegacyMode(
48+
canonicalModes.botCredential,
49+
block.subBlocks.customBotCredential?.value,
50+
block.subBlocks.manualCustomBotCredential?.value
51+
)
52+
const activeMode = authMethod === 'bot_token' ? botMode : oauthMode
53+
const activeValue =
54+
authMethod === 'bot_token'
55+
? activeMode === 'advanced'
56+
? block.subBlocks.manualCustomBotCredential?.value
57+
: block.subBlocks.customBotCredential?.value
58+
: activeMode === 'advanced'
59+
? block.subBlocks.manualCredential?.value
60+
: block.subBlocks.credential?.value
61+
const credentialValue = isNonEmptyValue(activeValue) ? (activeValue ?? null) : null
62+
const currentSubBlocks = omit(block.subBlocks, [
63+
'authMethod',
64+
'customBotCredential',
65+
'manualCustomBotCredential',
66+
])
67+
68+
projectedBlocks ??= { ...blocks }
69+
projectedBlocks[blockId] = {
70+
...block,
71+
subBlocks: {
72+
...currentSubBlocks,
73+
credential: withValue(
74+
block.subBlocks.credential,
75+
'credential',
76+
'oauth-input',
77+
activeMode === 'basic' ? credentialValue : null
78+
),
79+
manualCredential: withValue(
80+
block.subBlocks.manualCredential,
81+
'manualCredential',
82+
'short-input',
83+
activeMode === 'advanced' ? credentialValue : null
84+
),
85+
},
86+
data: {
87+
...block.data,
88+
canonicalModes: {
89+
...canonicalModes,
90+
oauthCredential: activeMode,
91+
botCredential: 'basic',
92+
},
93+
},
94+
}
95+
}
96+
97+
return projectedBlocks ?? blocks
98+
}

apps/sim/lib/workflows/persistence/utils.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
schemaMock,
2424
} from '@sim/testing'
2525
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
26+
import { createHistoricalSlackV2Block } from '@/lib/workflows/compatibility/slack-v2-auth.fixtures'
2627
import type {
2728
BlockState as AppBlockState,
2829
WorkflowState as AppWorkflowState,
@@ -347,6 +348,26 @@ describe('Database Helpers', () => {
347348
})
348349
})
349350

351+
describe('materializeDeploymentState', () => {
352+
it('projects historical Slack v2 auth without changing the frozen snapshot', async () => {
353+
const historicalSlack = createHistoricalSlackV2Block('slack')
354+
const frozenState = createWorkflowState({
355+
blocks: { slack: historicalSlack },
356+
})
357+
358+
const materialized = await dbHelpers.materializeDeploymentState(
359+
mockWorkflowId,
360+
{ id: 'legacy-slack-version', state: frozenState },
361+
'test-workspace-id'
362+
)
363+
364+
expect(materialized.blocks.slack.subBlocks.credential.value).toBe('credential-custom-bot')
365+
expect(materialized.blocks.slack.subBlocks).not.toHaveProperty('authMethod')
366+
expect(historicalSlack.subBlocks.authMethod.value).toBe('bot_token')
367+
expect(historicalSlack.subBlocks.credential.value).toBe('dormant-oauth')
368+
})
369+
})
370+
350371
describe('loadWorkflowFromNormalizedTables', () => {
351372
it('should successfully load workflow data from normalized tables', async () => {
352373
queueLoadFixtures({

0 commit comments

Comments
 (0)