1+ import { db } from '@sim/db'
12import { WorkflowLockedError } from '@sim/platform-authz/workflow'
2- import { workflowAuthzMockFns } from '@sim/testing'
3+ import { dbChainMockFns , resetDbChainMock , workflowAuthzMockFns } from '@sim/testing'
34import { beforeEach , describe , expect , it , vi } from 'vitest'
45
56const mocks = vi . hoisted ( ( ) => ( {
@@ -8,12 +9,17 @@ const mocks = vi.hoisted(() => ({
89 resolvePermission : vi . fn ( ) ,
910 notify : vi . fn ( ) ,
1011 replace : vi . fn ( ) ,
12+ replacementResult : vi . fn ( ) ,
13+ resolvedState : vi . fn ( ) ,
1114 prepare : vi . fn ( ) ,
1215 collectGraphIds : vi . fn ( ) ,
1316 assertIdsUnclaimed : vi . fn ( ) ,
1417 validate : vi . fn ( ) ,
1518 needsRedeployment : vi . fn ( ) ,
1619 loadNormalized : vi . fn ( ) ,
20+ saveNormalized : vi . fn ( ) ,
21+ extractCustomTools : vi . fn ( ) ,
22+ admitBlockTypes : vi . fn ( ) ,
1723} ) )
1824
1925vi . mock ( '@sim/audit' , ( ) => ( {
@@ -52,11 +58,21 @@ vi.mock('@/lib/workflows/deployment-status', () => ({
5258} ) )
5359vi . mock ( '@/lib/workflows/persistence/utils' , ( ) => ( {
5460 loadWorkflowFromNormalizedTables : mocks . loadNormalized ,
61+ saveWorkflowToNormalizedTables : mocks . saveNormalized ,
62+ } ) )
63+
64+ vi . mock ( '@/lib/workflows/persistence/block-access-guard' , ( ) => ( {
65+ assertNoWithheldBlockType : mocks . admitBlockTypes ,
66+ } ) )
67+
68+ vi . mock ( '@/lib/workflows/persistence/custom-tools-persistence' , ( ) => ( {
69+ extractAndPersistCustomTools : mocks . extractCustomTools ,
5570} ) )
5671
5772import { OrchestrationError } from '@/lib/core/orchestration/types'
5873import { replaceWorkflowState } from '@/lib/workflows/application/replace-workflow-state'
5974import { validateInputsForBlock } from '@/lib/workflows/editing/validation'
75+ import type { ReplaceWorkflowNormalizedStateInput } from '@/lib/workflows/persistence/replace-normalized-state'
6076import { AgentBlock } from '@/blocks/blocks/agent'
6177import { ExaBlock } from '@/blocks/blocks/exa'
6278import { getBlock } from '@/blocks/registry'
@@ -97,9 +113,14 @@ describe('replaceWorkflowState', () => {
97113 )
98114 mocks . resolveContext . mockResolvedValue ( context )
99115 mocks . resolvePermission . mockResolvedValue ( 'write' )
116+ mocks . admitBlockTypes . mockResolvedValue ( undefined )
100117 workflowAuthzMockFns . mockAssertWorkflowMutable . mockResolvedValue ( undefined )
101118 mocks . validate . mockReturnValue ( { valid : true , errors : [ ] , warnings : [ ] } )
102- mocks . replace . mockResolvedValue ( {
119+ mocks . replace . mockImplementation ( async ( { state } : ReplaceWorkflowNormalizedStateInput ) => {
120+ mocks . resolvedState ( typeof state === 'function' ? await state ( db ) : state )
121+ return mocks . replacementResult ( )
122+ } )
123+ mocks . replacementResult . mockReturnValue ( {
103124 warnings : [ ] ,
104125 state : { blocks : { 'block-1' : BLOCK } , edges : [ ] , loops : { } , parallels : { } } ,
105126 } )
@@ -152,13 +173,111 @@ describe('replaceWorkflowState', () => {
152173 ] )
153174 expect ( result . warnings . join ( ' ' ) ) . toContain ( 'removes 1 credential/table binding' )
154175 expect ( mocks . loadNormalized ) . toHaveBeenCalledTimes ( 1 )
155- expect ( mocks . loadNormalized ) . toHaveBeenCalledWith ( context . workflowId , undefined , {
156- persistMigrations : false ,
176+ expect ( mocks . loadNormalized ) . toHaveBeenCalledWith (
177+ context . workflowId ,
178+ dryRun ? undefined : db ,
179+ {
180+ persistMigrations : false ,
181+ }
182+ )
183+ expect ( mocks . replace ) . toHaveBeenCalledTimes ( dryRun ? 0 : 1 )
184+ }
185+ )
186+
187+ it ( 'compares removals against the baseline read after the replacement acquires its row lock' , async ( ) => {
188+ const { replaceWorkflowNormalizedState } = await vi . importActual <
189+ typeof import ( '@/lib/workflows/persistence/replace-normalized-state' )
190+ > ( '@/lib/workflows/persistence/replace-normalized-state' )
191+ resetDbChainMock ( )
192+ mocks . saveNormalized . mockResolvedValue ( { success : true } )
193+ mocks . extractCustomTools . mockResolvedValue ( { saved : 0 , errors : [ ] } )
194+ mocks . replace . mockImplementationOnce ( replaceWorkflowNormalizedState )
195+ vi . mocked ( getBlock ) . mockImplementation ( ( type ) =>
196+ type === 'bound-test'
197+ ? ( {
198+ type : 'bound-test' ,
199+ subBlocks : [ { id : 'credential' , type : 'oauth-input' } ] ,
200+ outputs : { } ,
201+ } as never )
202+ : defaultGetBlock ?.( type )
203+ )
204+ const baseline = ( credential : string ) => ( {
205+ blocks : {
206+ [ BLOCK . id ] : {
207+ ...BLOCK ,
208+ type : 'bound-test' ,
209+ subBlocks : {
210+ credential : { id : 'credential' , type : 'oauth-input' as const , value : credential } ,
211+ } ,
212+ } ,
213+ } ,
214+ edges : [ ] ,
215+ loops : { } ,
216+ parallels : { } ,
217+ } )
218+ let saved = baseline ( 'credential-before-concurrent-save' )
219+ mocks . loadNormalized . mockImplementation ( async ( ) => saved )
220+ let unlock ! : ( ) => void
221+ let started ! : ( ) => void
222+ const lockStarted = new Promise < void > ( ( resolve ) => {
223+ started = resolve
224+ } )
225+ const lockReleased = new Promise < void > ( ( resolve ) => {
226+ unlock = resolve
227+ } )
228+ dbChainMockFns . for . mockImplementationOnce ( async ( ) => {
229+ started ( )
230+ await lockReleased
231+ return [ { id : context . workflowId } ]
232+ } )
233+
234+ const replacing = replaceWorkflowState . execute ( { principal : sessionPrincipal , input } )
235+ await lockStarted
236+ saved = baseline ( 'credential-committed-while-waiting-for-lock' )
237+ unlock ( )
238+ const result = await replacing
239+
240+ expect ( result . removedBindings . map ( ( binding ) => binding . resourceId ) ) . toEqual ( [
241+ 'credential-committed-while-waiting-for-lock' ,
242+ ] )
243+ expect ( mocks . loadNormalized ) . toHaveBeenCalledWith ( context . workflowId , expect . anything ( ) , {
244+ persistMigrations : false ,
245+ } )
246+ expect ( mocks . saveNormalized ) . toHaveBeenCalledTimes ( 1 )
247+ expect ( mocks . admitBlockTypes ) . toHaveBeenCalledBefore ( dbChainMockFns . for )
248+ expect ( mocks . loadNormalized ) . toHaveBeenCalledBefore ( mocks . saveNormalized )
249+ } )
250+
251+ it . each ( [ true , false ] ) (
252+ 'allows an initially absent normalized graph (dryRun=%s)' ,
253+ async ( dryRun ) => {
254+ mocks . loadNormalized . mockResolvedValue ( null )
255+ const result = await replaceWorkflowState . execute ( {
256+ principal : sessionPrincipal ,
257+ input : { ...input , dryRun } ,
157258 } )
259+ expect ( result . removedBindings ) . toEqual ( [ ] )
260+ expect ( result . blocksCount ) . toBe ( 1 )
158261 expect ( mocks . replace ) . toHaveBeenCalledTimes ( dryRun ? 0 : 1 )
159262 }
160263 )
161264
265+ it ( 'refuses a withheld block before acquiring the replacement lock' , async ( ) => {
266+ mocks . admitBlockTypes . mockRejectedValueOnce (
267+ new OrchestrationError ( 'forbidden' , 'Block type is not allowed' )
268+ )
269+ await expect (
270+ replaceWorkflowState . execute ( { principal : sessionPrincipal , input } )
271+ ) . rejects . toMatchObject ( { code : 'forbidden' } )
272+ expect ( mocks . admitBlockTypes ) . toHaveBeenCalledWith (
273+ { workspaceId : context . workspaceId , subjectUserId : sessionPrincipal . userId } ,
274+ [ BLOCK ]
275+ )
276+ expect ( mocks . replace ) . not . toHaveBeenCalled ( )
277+ expect ( mocks . loadNormalized ) . not . toHaveBeenCalled ( )
278+ expect ( mocks . recordAudit ) . not . toHaveBeenCalled ( )
279+ } )
280+
162281 it ( 'does not read saved bindings before access is authorized' , async ( ) => {
163282 mocks . resolvePermission . mockResolvedValue ( 'read' )
164283 await expect (
@@ -205,7 +324,12 @@ describe('replaceWorkflowState', () => {
205324 workflowId : 'workflow-1' ,
206325 workspaceId : 'workspace-1' ,
207326 attributedUserId : 'user-1' ,
208- state : { blocks : { 'block-1' : BLOCK } , edges : [ ] , variables : undefined } ,
327+ state : expect . any ( Function ) ,
328+ } )
329+ expect ( mocks . resolvedState ) . toHaveBeenCalledWith ( {
330+ blocks : { 'block-1' : BLOCK } ,
331+ edges : [ ] ,
332+ variables : undefined ,
209333 } )
210334 } )
211335
@@ -229,15 +353,13 @@ describe('replaceWorkflowState', () => {
229353 } ,
230354 } )
231355
232- expect ( mocks . replace ) . toHaveBeenCalledWith (
356+ expect ( mocks . resolvedState ) . toHaveBeenCalledWith (
233357 expect . objectContaining ( {
234- state : expect . objectContaining ( {
235- variables : {
236- 'var-1' : { id : 'var-1' , name : 'retries' , type : 'number' , value : 42 } ,
237- 'var-2' : { id : 'var-2' , name : 'enabled' , type : 'boolean' , value : true } ,
238- 'var-3' : { id : 'var-3' , name : 'tags' , type : 'array' , value : [ 'a' , 'b' ] } ,
239- } ,
240- } ) ,
358+ variables : {
359+ 'var-1' : { id : 'var-1' , name : 'retries' , type : 'number' , value : 42 } ,
360+ 'var-2' : { id : 'var-2' , name : 'enabled' , type : 'boolean' , value : true } ,
361+ 'var-3' : { id : 'var-3' , name : 'tags' , type : 'array' , value : [ 'a' , 'b' ] } ,
362+ } ,
241363 } )
242364 )
243365 } )
@@ -425,14 +547,15 @@ describe('replaceWorkflowState', () => {
425547 }
426548 const replacement = { ...input , blocks : { [ BLOCK . id ] : block } }
427549
428- it . each ( [ false , true ] ) ( 'rejects new aliases before persistence (dryRun=%s)' , async ( dryRun ) => {
550+ it . each ( [ false , true ] ) ( 'rejects new aliases before writing (dryRun=%s)' , async ( dryRun ) => {
429551 await expect (
430552 replaceWorkflowState . execute ( {
431553 principal : copilotPrincipal ,
432554 input : { ...replacement , dryRun } ,
433555 } )
434556 ) . rejects . toThrow ( 'attachment names are read-only' )
435- expect ( mocks . replace ) . not . toHaveBeenCalled ( )
557+ expect ( mocks . replace ) . toHaveBeenCalledTimes ( dryRun ? 0 : 1 )
558+ expect ( mocks . resolvedState ) . not . toHaveBeenCalled ( )
436559 expect ( mocks . notify ) . not . toHaveBeenCalled ( )
437560 } )
438561
@@ -449,11 +572,9 @@ describe('replaceWorkflowState', () => {
449572 input : { ...replacement , blocks : { [ BLOCK . id ] : { ...block , name : 'Updated Agent' } } } ,
450573 } )
451574 ) . resolves . toMatchObject ( { dryRun : false } )
452- expect ( mocks . replace ) . toHaveBeenCalledWith (
575+ expect ( mocks . resolvedState ) . toHaveBeenCalledWith (
453576 expect . objectContaining ( {
454- state : expect . objectContaining ( {
455- blocks : { [ BLOCK . id ] : { ...block , name : 'Updated Agent' } } ,
456- } ) ,
577+ blocks : { [ BLOCK . id ] : { ...block , name : 'Updated Agent' } } ,
457578 } )
458579 )
459580 } )
0 commit comments