Skip to content
Open
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
6 changes: 5 additions & 1 deletion apps/sim/lib/workflows/application/read-workflow-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/aut
import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context'
import { workflowOperations } from '@/lib/workflows/application/operations'
import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope'
import { projectLegacySlackV2Auth } from '@/lib/workflows/compatibility/slack-v2-auth'
import { sanitizeWorkflowForSharing } from '@/lib/workflows/credentials/credential-extractor'
import { getWorkflowDeploymentVersion } from '@/lib/workflows/persistence/utils'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
Expand Down Expand Up @@ -68,7 +69,10 @@ export const readWorkflowVersion = defineAuthorizedWorkflowUseCase({
if (!isWorkflowState(state)) {
throw new Error('Deployment version contains invalid workflow state')
}
const presentedState = input.includeCredentialValues ? state : sanitizeVersionState(state)
const compatibleState = { ...state, blocks: projectLegacySlackV2Auth(state.blocks ?? {}) }
const presentedState = input.includeCredentialValues
? compatibleState
: sanitizeVersionState(compatibleState)
logger.info('Read workflow version', {
workspaceId: context.workspaceId,
workflowId: context.workflowId,
Expand Down
28 changes: 28 additions & 0 deletions apps/sim/lib/workflows/application/workflow-crud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ import { listWorkflowVersions } from '@/lib/workflows/application/list-workflow-
import { readWorkflow } from '@/lib/workflows/application/read-workflow'
import { readWorkflowVersion } from '@/lib/workflows/application/read-workflow-version'
import { updateWorkflow } from '@/lib/workflows/application/update-workflow'
import { createHistoricalSlackV2Block } from '@/lib/workflows/compatibility/slack-v2-auth.fixtures'

const WORKSPACE_ID = 'workspace-1'
const WORKFLOW_ID = 'workflow-1'
Expand Down Expand Up @@ -466,4 +467,31 @@ describe('authorized workflow CRUD and version reads', () => {
).resolves.toMatchObject({ version: { id: 'version-1', version: 1 } })
expect(mocks.resolveWorkflowContext).toHaveBeenCalledBefore(mocks.readVersion)
})

it('presents historical Slack v2 auth canonically without mutating the stored version', async () => {
const historicalSlack = createHistoricalSlackV2Block('slack')
const state = {
blocks: { slack: historicalSlack },
edges: [],
loops: {},
parallels: {},
}
mocks.readVersion.mockResolvedValue({
id: 'version-legacy-slack',
version: 1,
state,
})

const result = await readWorkflowVersion.execute({
principal: personalPrincipal,
input: { workflowId: WORKFLOW_ID, version: 1, includeCredentialValues: true },
})

expect(result.version.state.blocks.slack.subBlocks.credential.value).toBe(
'credential-custom-bot'
)
expect(result.version.state.blocks.slack.subBlocks).not.toHaveProperty('authMethod')
expect(historicalSlack.subBlocks.authMethod.value).toBe('bot_token')
expect(historicalSlack.subBlocks.credential.value).toBe('dormant-oauth')
})
})
41 changes: 41 additions & 0 deletions apps/sim/lib/workflows/compatibility/slack-v2-auth.fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { BlockState, SubBlockState } from '@sim/workflow-types/workflow'

function subBlock(
id: string,
type: SubBlockState['type'],
value: SubBlockState['value']
): SubBlockState {
return { id, type, value }
}

/** Relevant persisted fields produced by the slack_v2 schema introduced in f4d47ed. */
export function createHistoricalSlackV2Block(id = 'slack-1'): BlockState {
return {
id,
type: 'slack_v2',
name: 'Slack',
position: { x: 0, y: 0 },
enabled: true,
triggerMode: false,
subBlocks: {
operation: subBlock('operation', 'dropdown', 'send'),
authMethod: subBlock('authMethod', 'dropdown', 'bot_token'),
credential: subBlock('credential', 'oauth-input', 'dormant-oauth'),
manualCredential: subBlock('manualCredential', 'short-input', null),
customBotCredential: subBlock('customBotCredential', 'oauth-input', 'credential-custom-bot'),
manualCustomBotCredential: subBlock('manualCustomBotCredential', 'short-input', null),
destinationType: subBlock('destinationType', 'dropdown', 'channel'),
channel: subBlock('channel', 'channel-selector', 'C123456789'),
text: subBlock('text', 'long-input', 'Hello'),
messageFormat: subBlock('messageFormat', 'dropdown', 'text'),
},
data: {
canonicalModes: {
oauthCredential: 'basic',
botCredential: 'basic',
channel: 'basic',
},
},
outputs: {},
}
}
120 changes: 120 additions & 0 deletions apps/sim/lib/workflows/compatibility/slack-v2-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* @vitest-environment node
*/

import { omit } from '@sim/utils/object'
import type { BlockState, WorkflowState } from '@sim/workflow-types/workflow'
import { afterAll, describe, expect, it, vi } from 'vitest'

vi.unmock('@/blocks/registry')

import { generateWorkflowDiffSummary } from '@/lib/workflows/comparison/compare'
import { projectLegacySlackV2Auth } from '@/lib/workflows/compatibility/slack-v2-auth'
import { createHistoricalSlackV2Block } from '@/lib/workflows/compatibility/slack-v2-auth.fixtures'
import { buildSelectorContextFromBlock } from '@/lib/workflows/subblocks/context'
import * as blocksBarrel from '@/blocks'
import { getBlock as getRealBlock } from '@/blocks/registry'
import { extractBlockParams } from '@/serializer'

const getBlockSpy = vi.spyOn(blocksBarrel, 'getBlock').mockImplementation(getRealBlock)

afterAll(() => {
getBlockSpy.mockRestore()
})

function workflowWith(block: BlockState): WorkflowState {
return { blocks: { [block.id]: block }, edges: [], loops: {}, parallels: {} }
}

describe('projectLegacySlackV2Auth', () => {
it('makes the historical custom-bot action behave like its current equivalent', () => {
const historical = createHistoricalSlackV2Block()
const original = structuredClone(historical)
const equivalentCurrent = structuredClone(historical)
equivalentCurrent.subBlocks = omit(equivalentCurrent.subBlocks, [
'authMethod',
'customBotCredential',
'manualCustomBotCredential',
])
equivalentCurrent.subBlocks.credential.value = 'credential-custom-bot'
const blocks = projectLegacySlackV2Auth({ [historical.id]: historical })
const projected = blocks[historical.id]

expect(historical).toEqual(original)
expect(projected.subBlocks).not.toHaveProperty('authMethod')
expect(projected.subBlocks).not.toHaveProperty('customBotCredential')
expect(projected.subBlocks.credential.value).toBe('credential-custom-bot')
expect(projected.data?.canonicalModes).toMatchObject({
oauthCredential: 'basic',
botCredential: 'basic',
})

const selectorContext = buildSelectorContextFromBlock(projected.type, projected.subBlocks, {
selectorKey: 'slack.channels',
dependsOn: ['credential'],
canonicalModes: projected.data?.canonicalModes,
})
expect(selectorContext.oauthCredential).toBe('credential-custom-bot')

const params = extractBlockParams(projected)
expect(params).toMatchObject({
oauthCredential: 'credential-custom-bot',
channel: 'C123456789',
})
expect(params).not.toHaveProperty('botCredential')

expect(
generateWorkflowDiffSummary(workflowWith(equivalentCurrent), workflowWith(projected))
.hasChanges
).toBe(false)
})

it('honors the historical custom-bot and OAuth modes', () => {
const historical = createHistoricalSlackV2Block()
historical.data!.canonicalModes!.botCredential = 'advanced'
historical.subBlocks.manualCustomBotCredential.value = 'credential-manual-bot'

const projected = projectLegacySlackV2Auth({ [historical.id]: historical })[historical.id]

expect(projected.subBlocks.credential.value).toBeNull()
expect(projected.subBlocks.manualCredential.value).toBe('credential-manual-bot')
expect(projected.data?.canonicalModes?.oauthCredential).toBe('advanced')

const historicalOauth = createHistoricalSlackV2Block()
historicalOauth.subBlocks.authMethod.value = 'oauth'
const projectedOauth = projectLegacySlackV2Auth({ slack: historicalOauth }).slack
expect(projectedOauth.subBlocks.credential.value).toBe('dormant-oauth')
})

it('leaves current, trigger, and unidentifiable states untouched', () => {
const cases = [
createHistoricalSlackV2Block(),
createHistoricalSlackV2Block(),
createHistoricalSlackV2Block(),
]
cases[0].subBlocks = omit(cases[0].subBlocks, ['authMethod'])
cases[1].triggerMode = true
cases[2].subBlocks.authMethod.value = null

for (const block of cases) {
const blocks = { [block.id]: block }
expect(projectLegacySlackV2Auth(blocks)).toBe(blocks)
}
})

it('does not substitute a dormant OAuth account for a missing historical bot credential', () => {
const historical = createHistoricalSlackV2Block()
historical.subBlocks.customBotCredential.value = null

const projected = projectLegacySlackV2Auth({ [historical.id]: historical })[historical.id]

expect(projected.subBlocks.credential.value).toBeNull()
expect(
buildSelectorContextFromBlock(projected.type, projected.subBlocks, {
selectorKey: 'slack.channels',
dependsOn: ['credential'],
canonicalModes: projected.data?.canonicalModes,
}).oauthCredential
).toBeUndefined()
})
})
98 changes: 98 additions & 0 deletions apps/sim/lib/workflows/compatibility/slack-v2-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { omit } from '@sim/utils/object'
import type { BlockState, SubBlockState } from '@sim/workflow-types/workflow'
import { isNonEmptyValue } from '@/lib/workflows/subblocks/visibility'

type CanonicalMode = 'basic' | 'advanced'

function resolveLegacyMode(
override: CanonicalMode | undefined,
basicValue: unknown,
advancedValue: unknown
): CanonicalMode {
if (override === 'basic' || override === 'advanced') return override
return !isNonEmptyValue(basicValue) && isNonEmptyValue(advancedValue) ? 'advanced' : 'basic'
}

function withValue(
subBlock: SubBlockState | undefined,
id: string,
type: SubBlockState['type'],
value: SubBlockState['value']
): SubBlockState {
return { ...(subBlock ?? { id, type }), value }
}

/**
* Projects the preview-era slack_v2 action auth shape into the merged credential picker added in
* 5be35b5. The returned view is safe for current readers but is never marked for persistence, so
* frozen deployment snapshots and normalized workflow rows remain unchanged.
*/
export function projectLegacySlackV2Auth(
blocks: Record<string, BlockState>
): Record<string, BlockState> {
let projectedBlocks: Record<string, BlockState> | undefined

for (const [blockId, block] of Object.entries(blocks)) {
if (block.type !== 'slack_v2' || block.triggerMode) continue

const authMethod = block.subBlocks.authMethod?.value
if (authMethod !== 'oauth' && authMethod !== 'bot_token') continue

const canonicalModes = block.data?.canonicalModes ?? {}
const oauthMode = resolveLegacyMode(
canonicalModes.oauthCredential,
block.subBlocks.credential?.value,
block.subBlocks.manualCredential?.value
)
const botMode = resolveLegacyMode(
canonicalModes.botCredential,
block.subBlocks.customBotCredential?.value,
block.subBlocks.manualCustomBotCredential?.value
)
const activeMode = authMethod === 'bot_token' ? botMode : oauthMode
const activeValue =
authMethod === 'bot_token'
? activeMode === 'advanced'
? block.subBlocks.manualCustomBotCredential?.value
: block.subBlocks.customBotCredential?.value
: activeMode === 'advanced'
? block.subBlocks.manualCredential?.value
: block.subBlocks.credential?.value
const credentialValue = isNonEmptyValue(activeValue) ? (activeValue ?? null) : null
const currentSubBlocks = omit(block.subBlocks, [
'authMethod',
'customBotCredential',
'manualCustomBotCredential',
])

projectedBlocks ??= { ...blocks }
projectedBlocks[blockId] = {
...block,
subBlocks: {
...currentSubBlocks,
credential: withValue(
block.subBlocks.credential,
'credential',
'oauth-input',
activeMode === 'basic' ? credentialValue : null
),
manualCredential: withValue(
block.subBlocks.manualCredential,
'manualCredential',
'short-input',
activeMode === 'advanced' ? credentialValue : null
),
},
data: {
...block.data,
canonicalModes: {
...canonicalModes,
oauthCredential: activeMode,
botCredential: 'basic',
},
},
}
}

return projectedBlocks ?? blocks
}
21 changes: 21 additions & 0 deletions apps/sim/lib/workflows/persistence/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
schemaMock,
} from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { createHistoricalSlackV2Block } from '@/lib/workflows/compatibility/slack-v2-auth.fixtures'
import type {
BlockState as AppBlockState,
WorkflowState as AppWorkflowState,
Expand Down Expand Up @@ -347,6 +348,26 @@ describe('Database Helpers', () => {
})
})

describe('materializeDeploymentState', () => {
it('projects historical Slack v2 auth without changing the frozen snapshot', async () => {
const historicalSlack = createHistoricalSlackV2Block('slack')
const frozenState = createWorkflowState({
blocks: { slack: historicalSlack },
})

const materialized = await dbHelpers.materializeDeploymentState(
mockWorkflowId,
{ id: 'legacy-slack-version', state: frozenState },
'test-workspace-id'
)

expect(materialized.blocks.slack.subBlocks.credential.value).toBe('credential-custom-bot')
expect(materialized.blocks.slack.subBlocks).not.toHaveProperty('authMethod')
expect(historicalSlack.subBlocks.authMethod.value).toBe('bot_token')
expect(historicalSlack.subBlocks.credential.value).toBe('dormant-oauth')
})
})

describe('loadWorkflowFromNormalizedTables', () => {
it('should successfully load workflow data from normalized tables', async () => {
queueLoadFixtures({
Expand Down
Loading
Loading