Skip to content

Commit 1945237

Browse files
committed
fix(workflows): validate deployment structure
1 parent 928bd91 commit 1945237

2 files changed

Lines changed: 171 additions & 42 deletions

File tree

apps/sim/lib/workflows/orchestration/deploy.test.ts

Lines changed: 126 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
66
const {
77
mockLimit,
88
mockUpdateSet,
9+
mockActivateWorkflowVersion,
10+
mockDeployWorkflow,
11+
mockValidateWorkflowSchedules,
12+
mockValidateTriggerWebhookConfigForDeploy,
13+
mockValidateWorkflowState,
914
mockSaveWorkflowToNormalizedTables,
1015
mockRecordAudit,
1116
mockCaptureServerEvent,
@@ -14,6 +19,11 @@ const {
1419
} = vi.hoisted(() => ({
1520
mockLimit: vi.fn(),
1621
mockUpdateSet: vi.fn(),
22+
mockActivateWorkflowVersion: vi.fn(),
23+
mockDeployWorkflow: vi.fn(),
24+
mockValidateWorkflowSchedules: vi.fn(),
25+
mockValidateTriggerWebhookConfigForDeploy: vi.fn(),
26+
mockValidateWorkflowState: vi.fn(),
1727
mockSaveWorkflowToNormalizedTables: vi.fn(),
1828
mockRecordAudit: vi.fn(),
1929
mockCaptureServerEvent: vi.fn(),
@@ -59,7 +69,11 @@ vi.mock('@sim/db', () => ({
5969
}))
6070

6171
vi.mock('@sim/audit', () => ({
62-
AuditAction: { WORKFLOW_DEPLOYMENT_REVERTED: 'WORKFLOW_DEPLOYMENT_REVERTED' },
72+
AuditAction: {
73+
WORKFLOW_DEPLOYED: 'WORKFLOW_DEPLOYED',
74+
WORKFLOW_DEPLOYMENT_ACTIVATED: 'WORKFLOW_DEPLOYMENT_ACTIVATED',
75+
WORKFLOW_DEPLOYMENT_REVERTED: 'WORKFLOW_DEPLOYMENT_REVERTED',
76+
},
6377
AuditResourceType: { WORKFLOW: 'WORKFLOW' },
6478
recordAudit: mockRecordAudit,
6579
}))
@@ -78,9 +92,9 @@ vi.mock('@/lib/posthog/server', () => ({
7892
}))
7993

8094
vi.mock('@/lib/workflows/persistence/utils', () => ({
81-
activateWorkflowVersion: vi.fn(),
95+
activateWorkflowVersion: mockActivateWorkflowVersion,
8296
activateWorkflowVersionById: vi.fn(),
83-
deployWorkflow: vi.fn(),
97+
deployWorkflow: mockDeployWorkflow,
8498
loadWorkflowDeploymentSnapshot: vi.fn(),
8599
saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables,
86100
undeployWorkflow: vi.fn(),
@@ -95,15 +109,122 @@ vi.mock('@/lib/webhooks/deploy', () => ({
95109
cleanupWebhooksForWorkflow: vi.fn(),
96110
restorePreviousVersionWebhooks: vi.fn(),
97111
saveTriggerWebhooksForDeploy: vi.fn(),
112+
validateTriggerWebhookConfigForDeploy: mockValidateTriggerWebhookConfigForDeploy,
98113
}))
99114

100115
vi.mock('@/lib/workflows/schedules', () => ({
101116
cleanupDeploymentVersion: vi.fn(),
102117
createSchedulesForDeploy: vi.fn(),
103-
validateWorkflowSchedules: vi.fn(),
118+
validateWorkflowSchedules: mockValidateWorkflowSchedules,
119+
}))
120+
121+
vi.mock('@/lib/workflows/sanitization/validation', () => ({
122+
validateWorkflowState: mockValidateWorkflowState,
104123
}))
105124

106-
import { performRevertToVersion } from '@/lib/workflows/orchestration/deploy'
125+
import {
126+
performActivateVersion,
127+
performFullDeploy,
128+
performRevertToVersion,
129+
} from '@/lib/workflows/orchestration/deploy'
130+
131+
const VALID_WORKFLOW_STATE = {
132+
blocks: {},
133+
edges: [],
134+
loops: {},
135+
parallels: {},
136+
}
137+
138+
describe('performFullDeploy', () => {
139+
beforeEach(() => {
140+
vi.clearAllMocks()
141+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
142+
mockLimit.mockResolvedValue([
143+
{
144+
id: 'workflow-1',
145+
name: 'Workflow',
146+
workspaceId: 'workspace-1',
147+
},
148+
])
149+
mockValidateWorkflowState.mockReturnValue({ valid: true, errors: [], warnings: [] })
150+
mockValidateWorkflowSchedules.mockReturnValue({ isValid: true })
151+
mockValidateTriggerWebhookConfigForDeploy.mockResolvedValue({ success: true })
152+
})
153+
154+
it('rejects structurally invalid workflows before schedule or webhook deployment checks', async () => {
155+
mockValidateWorkflowState.mockReturnValue({
156+
valid: false,
157+
errors: ["Edge references non-existent source block 'missing-source'"],
158+
warnings: [],
159+
})
160+
mockDeployWorkflow.mockImplementation(async ({ validateWorkflowState }) => {
161+
return validateWorkflowState({
162+
...VALID_WORKFLOW_STATE,
163+
edges: [{ id: 'edge-1', source: 'missing-source', target: 'missing-target' }],
164+
})
165+
})
166+
167+
const result = await performFullDeploy({
168+
workflowId: 'workflow-1',
169+
userId: 'user-1',
170+
workflowName: 'Workflow',
171+
})
172+
173+
expect(result).toEqual({
174+
success: false,
175+
error:
176+
"Invalid workflow structure: Edge references non-existent source block 'missing-source'",
177+
errorCode: 'validation',
178+
})
179+
expect(mockValidateWorkflowSchedules).not.toHaveBeenCalled()
180+
expect(mockValidateTriggerWebhookConfigForDeploy).not.toHaveBeenCalled()
181+
})
182+
})
183+
184+
describe('performActivateVersion', () => {
185+
beforeEach(() => {
186+
vi.clearAllMocks()
187+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
188+
mockValidateWorkflowState.mockReturnValue({ valid: true, errors: [], warnings: [] })
189+
mockValidateWorkflowSchedules.mockReturnValue({ isValid: true })
190+
mockValidateTriggerWebhookConfigForDeploy.mockResolvedValue({ success: true })
191+
})
192+
193+
it('rejects invalid deployment snapshots before activation', async () => {
194+
mockLimit.mockResolvedValue([
195+
{
196+
id: 'deployment-version-1',
197+
state: {
198+
...VALID_WORKFLOW_STATE,
199+
edges: [{ id: 'edge-1', source: 'missing-source', target: 'missing-target' }],
200+
},
201+
isActive: false,
202+
},
203+
])
204+
mockValidateWorkflowState.mockReturnValue({
205+
valid: false,
206+
errors: ["Edge references non-existent source block 'missing-source'"],
207+
warnings: [],
208+
})
209+
210+
const result = await performActivateVersion({
211+
workflowId: 'workflow-1',
212+
version: 2,
213+
userId: 'user-1',
214+
workflow: { id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' },
215+
})
216+
217+
expect(result).toEqual({
218+
success: false,
219+
error:
220+
"Invalid workflow structure: Edge references non-existent source block 'missing-source'",
221+
errorCode: 'validation',
222+
})
223+
expect(mockActivateWorkflowVersion).not.toHaveBeenCalled()
224+
expect(mockValidateWorkflowSchedules).not.toHaveBeenCalled()
225+
expect(mockValidateTriggerWebhookConfigForDeploy).not.toHaveBeenCalled()
226+
})
227+
})
107228

108229
describe('performRevertToVersion', () => {
109230
beforeEach(() => {

apps/sim/lib/workflows/orchestration/deploy.ts

Lines changed: 45 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ import {
2121
saveWorkflowToNormalizedTables,
2222
undeployWorkflow,
2323
} from '@/lib/workflows/persistence/utils'
24+
import { validateWorkflowState } from '@/lib/workflows/sanitization/validation'
2425
import { validateWorkflowSchedules } from '@/lib/workflows/schedules'
25-
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
26+
import type { WorkflowState } from '@/stores/workflows/workflow/types'
2627

2728
const logger = createLogger('DeployOrchestration')
2829

@@ -77,6 +78,46 @@ export interface PerformFullDeployResult {
7778
warnings?: string[]
7879
}
7980

81+
async function validateWorkflowForDeployment(workflowState: WorkflowState): Promise<
82+
| {
83+
success: true
84+
}
85+
| {
86+
success: false
87+
error: string
88+
errorCode: 'validation'
89+
}
90+
> {
91+
const structureValidation = validateWorkflowState(workflowState)
92+
if (!structureValidation.valid) {
93+
return {
94+
success: false,
95+
error: `Invalid workflow structure: ${structureValidation.errors.join('; ')}`,
96+
errorCode: 'validation',
97+
}
98+
}
99+
100+
const scheduleValidation = validateWorkflowSchedules(workflowState.blocks)
101+
if (!scheduleValidation.isValid) {
102+
return {
103+
success: false,
104+
error: `Invalid schedule configuration: ${scheduleValidation.error}`,
105+
errorCode: 'validation',
106+
}
107+
}
108+
109+
const triggerValidation = await validateTriggerWebhookConfigForDeploy(workflowState.blocks)
110+
if (!triggerValidation.success) {
111+
return {
112+
success: false,
113+
error: triggerValidation.error?.message || 'Invalid trigger configuration',
114+
errorCode: 'validation',
115+
}
116+
}
117+
118+
return { success: true }
119+
}
120+
80121
/**
81122
* Performs a full workflow deployment: creates a deployment version, queues
82123
* external side effects transactionally, processes that outbox event after
@@ -108,23 +149,7 @@ export async function performFullDeploy(
108149
deployedBy: actorId,
109150
workflowName: workflowName || workflowRecord.name || undefined,
110151
validateWorkflowState: async (workflowState) => {
111-
const scheduleValidation = validateWorkflowSchedules(workflowState.blocks)
112-
if (!scheduleValidation.isValid) {
113-
return {
114-
success: false,
115-
error: `Invalid schedule configuration: ${scheduleValidation.error}`,
116-
errorCode: 'validation',
117-
}
118-
}
119-
const triggerValidation = await validateTriggerWebhookConfigForDeploy(workflowState.blocks)
120-
if (!triggerValidation.success) {
121-
return {
122-
success: false,
123-
error: triggerValidation.error?.message || 'Invalid trigger configuration',
124-
errorCode: 'validation',
125-
}
126-
}
127-
return { success: true }
152+
return validateWorkflowForDeployment(workflowState)
128153
},
129154
onDeployTransaction: async (tx, result) => {
130155
outboxEventId = await enqueueWorkflowDeploymentSideEffects(tx, {
@@ -349,25 +374,8 @@ export async function performActivateVersion(
349374
return { success: false, error: 'Invalid deployed state structure', errorCode: 'validation' }
350375
}
351376

352-
const scheduleValidation = validateWorkflowSchedules(blocks as Record<string, BlockState>)
353-
if (!scheduleValidation.isValid) {
354-
return {
355-
success: false,
356-
error: `Invalid schedule configuration: ${scheduleValidation.error}`,
357-
errorCode: 'validation',
358-
}
359-
}
360-
361-
const triggerValidation = await validateTriggerWebhookConfigForDeploy(
362-
blocks as Record<string, BlockState>
363-
)
364-
if (!triggerValidation.success) {
365-
return {
366-
success: false,
367-
error: triggerValidation.error?.message || 'Invalid trigger configuration',
368-
errorCode: 'validation',
369-
}
370-
}
377+
const validation = await validateWorkflowForDeployment(versionRow.state as WorkflowState)
378+
if (!validation.success) return validation
371379

372380
let outboxEventId: string | undefined
373381
const result = await activateWorkflowVersion({

0 commit comments

Comments
 (0)