@@ -17,8 +17,10 @@ const {
1717 mockPerformDeleteWorkflow,
1818 mockAssertWorkflowMutable,
1919 mockAssertFolderMutable,
20+ mockAssertFolderInWorkspace,
2021 WorkflowLockedErrorMock,
2122 FolderLockedErrorMock,
23+ FolderNotFoundErrorMock,
2224} = vi . hoisted ( ( ) => ( {
2325 mockCheckRateLimit : vi . fn ( ) ,
2426 mockResolveWorkspaceAccess : vi . fn ( ) ,
@@ -27,12 +29,16 @@ const {
2729 mockPerformDeleteWorkflow : vi . fn ( ) ,
2830 mockAssertWorkflowMutable : vi . fn ( ) ,
2931 mockAssertFolderMutable : vi . fn ( ) ,
32+ mockAssertFolderInWorkspace : vi . fn ( ) ,
3033 WorkflowLockedErrorMock : class WorkflowLockedError extends Error {
3134 status = 423
3235 } ,
3336 FolderLockedErrorMock : class FolderLockedError extends Error {
3437 status = 423
3538 } ,
39+ FolderNotFoundErrorMock : class FolderNotFoundError extends Error {
40+ status = 400
41+ } ,
3642} ) )
3743
3844vi . mock ( '@/app/api/v1/middleware' , ( ) => ( {
@@ -49,8 +55,10 @@ vi.mock('@sim/platform-authz/workflow', () => ({
4955 getActiveWorkflowRecord : mockGetActiveWorkflowRecord ,
5056 assertWorkflowMutable : mockAssertWorkflowMutable ,
5157 assertFolderMutable : mockAssertFolderMutable ,
58+ assertFolderInWorkspace : mockAssertFolderInWorkspace ,
5259 WorkflowLockedError : WorkflowLockedErrorMock ,
5360 FolderLockedError : FolderLockedErrorMock ,
61+ FolderNotFoundError : FolderNotFoundErrorMock ,
5462} ) )
5563
5664vi . mock ( '@/lib/workflows/input-format' , ( ) => ( {
@@ -139,6 +147,7 @@ describe('PATCH /api/v2/workflows/[id]', () => {
139147 mockGetActiveWorkflowRecord . mockResolvedValue ( WORKFLOW_RECORD )
140148 mockAssertWorkflowMutable . mockResolvedValue ( undefined )
141149 mockAssertFolderMutable . mockResolvedValue ( undefined )
150+ mockAssertFolderInWorkspace . mockResolvedValue ( undefined )
142151 mockPerformUpdateWorkflow . mockResolvedValue ( { success : true , workflow : UPDATED } )
143152 } )
144153
@@ -196,6 +205,40 @@ describe('PATCH /api/v2/workflows/[id]', () => {
196205 expect ( mockPerformUpdateWorkflow ) . not . toHaveBeenCalled ( )
197206 } )
198207
208+ it ( '400s a folder outside the workspace without ever reading its lock state' , async ( ) => {
209+ mockAssertFolderInWorkspace . mockRejectedValue (
210+ new FolderNotFoundErrorMock ( 'Target folder not found' )
211+ )
212+ const res = await callPatch ( { folderId : 'fld-other-workspace' } )
213+
214+ expect ( res . status ) . toBe ( 400 )
215+ expect ( ( await res . json ( ) ) . error . code ) . toBe ( 'BAD_REQUEST' )
216+ // Containment runs first, so a locked foreign folder cannot be told apart
217+ // from a nonexistent one by its status code.
218+ expect ( mockAssertFolderMutable ) . not . toHaveBeenCalled ( )
219+ expect ( mockPerformUpdateWorkflow ) . not . toHaveBeenCalled ( )
220+ } )
221+
222+ it ( 'checks folder containment against the workflow workspace before mutability' , async ( ) => {
223+ const order : string [ ] = [ ]
224+ mockAssertFolderInWorkspace . mockImplementation ( async ( ) => {
225+ order . push ( 'containment' )
226+ } )
227+ mockAssertFolderMutable . mockImplementation ( async ( ) => {
228+ order . push ( 'mutability' )
229+ } )
230+
231+ await callPatch ( { folderId : 'fld-1' } )
232+
233+ expect ( order ) . toEqual ( [ 'containment' , 'mutability' ] )
234+ expect ( mockAssertFolderInWorkspace ) . toHaveBeenCalledWith ( 'fld-1' , 'workspace-1' )
235+ } )
236+
237+ it ( 'skips the containment check on a rename that does not move the workflow' , async ( ) => {
238+ await callPatch ( { name : 'Support Agent v2' } )
239+ expect ( mockAssertFolderInWorkspace ) . not . toHaveBeenCalled ( )
240+ } )
241+
199242 it ( '409s when the target name is taken in the destination folder' , async ( ) => {
200243 mockPerformUpdateWorkflow . mockResolvedValue ( {
201244 success : false ,
0 commit comments