Skip to content

Commit 1042272

Browse files
committed
fix(workflows): validate field values before full-state writes
1 parent 70e8a89 commit 1042272

2 files changed

Lines changed: 98 additions & 1 deletion

File tree

apps/sim/lib/workflows/application/replace-workflow-state.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ vi.mock('@/lib/workflows/deployment-status', () => ({
5656
import { OrchestrationError } from '@/lib/core/orchestration/types'
5757
import { replaceWorkflowState } from '@/lib/workflows/application/replace-workflow-state'
5858
import { REFERENCES_UNCHECKED_NOTE } from '@/lib/workflows/editing/lint-report'
59+
import { validateInputsForBlock } from '@/lib/workflows/editing/validation'
60+
import { ExaBlock } from '@/blocks/blocks/exa'
61+
import { getBlock } from '@/blocks/registry'
62+
63+
const defaultGetBlock = vi.mocked(getBlock).getMockImplementation()
5964

6065
const BLOCK = {
6166
id: 'block-1',
@@ -87,6 +92,9 @@ const input = { workflowId: 'workflow-1', blocks: { 'block-1': BLOCK }, edges: [
8792
describe('replaceWorkflowState', () => {
8893
beforeEach(() => {
8994
vi.clearAllMocks()
95+
vi.mocked(getBlock).mockImplementation((type) =>
96+
type === 'exa' ? ExaBlock : defaultGetBlock?.(type)
97+
)
9098
mocks.resolveContext.mockResolvedValue(context)
9199
mocks.resolvePermission.mockResolvedValue('write')
92100
workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined)
@@ -434,6 +442,66 @@ describe('replaceWorkflowState', () => {
434442
})
435443
})
436444

445+
describe('registry input validation', () => {
446+
for (const dryRun of [true, false]) {
447+
it.each(['type', 'category', 'text', 'highlights', 'summary'])(
448+
`rejects the same dynamic %s value as operations apply (dryRun=${dryRun})`,
449+
async (field) => {
450+
const value = `<start.${field}>`
451+
const { errors } = validateInputsForBlock('exa', { [field]: value }, BLOCK.id)
452+
expect(errors).toHaveLength(1)
453+
await expect(
454+
replaceWorkflowState.execute({
455+
principal: sessionPrincipal,
456+
input: {
457+
...input,
458+
dryRun,
459+
blocks: {
460+
[BLOCK.id]: {
461+
...BLOCK,
462+
type: 'exa',
463+
advancedMode: true,
464+
subBlocks: { [field]: { id: field, type: 'short-input', value } },
465+
},
466+
},
467+
},
468+
})
469+
).rejects.toThrow(errors[0].error)
470+
expect(mocks.replace).not.toHaveBeenCalled()
471+
expect(mocks.notify).not.toHaveBeenCalled()
472+
expect(mocks.recordAudit).not.toHaveBeenCalled()
473+
}
474+
)
475+
}
476+
477+
it('accepts literal choices alongside text references without enabling advanced mode', async () => {
478+
const block = {
479+
...BLOCK,
480+
type: 'exa',
481+
advancedMode: false,
482+
subBlocks: {
483+
operation: { id: 'operation', type: 'dropdown' as const, value: 'exa_search' },
484+
type: { id: 'type', type: 'dropdown' as const, value: 'auto' },
485+
query: { id: 'query', type: 'long-input' as const, value: '<start.query>' },
486+
text: { id: 'text', type: 'switch' as const, value: true },
487+
highlights: { id: 'highlights', type: 'switch' as const, value: false },
488+
summary: { id: 'summary', type: 'switch' as const, value: null },
489+
},
490+
}
491+
await expect(
492+
replaceWorkflowState.execute({
493+
principal: sessionPrincipal,
494+
input: { ...input, blocks: { [BLOCK.id]: block } },
495+
})
496+
).resolves.toMatchObject({ dryRun: false })
497+
expect(mocks.replace).toHaveBeenCalledWith(
498+
expect.objectContaining({
499+
state: { blocks: { [BLOCK.id]: block }, edges: [], variables: undefined },
500+
})
501+
)
502+
})
503+
})
504+
437505
/**
438506
* A replace stores blocks and their tool wiring wholesale, and the policies
439507
* deciding which of those a member may add take a human subject. A workspace

apps/sim/lib/workflows/application/replace-workflow-state.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,15 @@ import { normalizeWorkflowVariables } from '@/lib/workflows/application/workflow
2020
import { checkNeedsRedeployment } from '@/lib/workflows/deployment-status'
2121
import type { WorkflowLintReport } from '@/lib/workflows/editing/lint'
2222
import { buildWorkflowLintReport } from '@/lib/workflows/editing/lint-report'
23+
import { validateValueForSubBlockType } from '@/lib/workflows/editing/validation'
2324
import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state'
2425
import {
2526
assertWorkflowGraphIdsUnclaimed,
2627
collectWorkflowGraphIds,
2728
replaceWorkflowNormalizedState,
2829
} from '@/lib/workflows/persistence/replace-normalized-state'
2930
import { validateWorkflowState } from '@/lib/workflows/sanitization/validation'
31+
import { getBlock } from '@/blocks/registry'
3032

3133
const logger = createLogger('ReplaceWorkflowState')
3234

@@ -123,8 +125,35 @@ export const replaceWorkflowState = defineAuthorizedWorkflowUseCase({
123125
}
124126
const sanitized = validation.sanitizedState ?? candidate
125127

128+
/** Use registry control types, never the caller's subblock type, just as operation edits do. */
129+
const blocks = structuredClone(sanitized.blocks) as Record<string, BlockState>
130+
for (const [blockId, block] of Object.entries(blocks)) {
131+
const config = getBlock(block.type)
132+
if (!config) continue
133+
const fields = new Map(config.subBlocks.map((field) => [field.id, field]))
134+
for (const [fieldId, stored] of Object.entries(block.subBlocks ?? {})) {
135+
const field = fields.get(fieldId)
136+
if (!field) continue
137+
const result = validateValueForSubBlockType(
138+
field,
139+
stored.value,
140+
field.id,
141+
block.type,
142+
blockId
143+
)
144+
if (!result.valid) {
145+
throw new OrchestrationError(
146+
'validation',
147+
`Block ${block.name || blockId}: ${result.error?.error ?? `Invalid field ${field.id}`}`
148+
)
149+
}
150+
stored.value = result.value
151+
stored.type = field.type
152+
}
153+
}
154+
126155
const graph = {
127-
blocks: sanitized.blocks as Record<string, BlockState>,
156+
blocks,
128157
edges: sanitized.edges as WorkflowState['edges'],
129158
}
130159

0 commit comments

Comments
 (0)