Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
374f2ba
feat(folders): generalize folders and pinning across workflows, files…
waleedlatif1 Jul 11, 2026
cdd5045
fix(folders): map reorder internal errors to 500 instead of 400
waleedlatif1 Jul 11, 2026
9df6dff
fix(folders): close workflow-folder restore lock race, align restore …
waleedlatif1 Jul 11, 2026
f2c305f
fix(folders): row-lock restore reads, map conflict errorCode to 409
waleedlatif1 Jul 11, 2026
056e33c
fix(folders): close parent-lock write race, allow combined unlock+move
waleedlatif1 Jul 11, 2026
b4a55a2
fix(folders): lock the full ancestor closure in one ordered statement
waleedlatif1 Jul 11, 2026
411570a
fix(folders): close parent-delete race in restore, fix file-folder er…
waleedlatif1 Jul 11, 2026
8ac1ab6
fix(folders): fix source-lock bypass in unlock skip, add in-tx rechec…
waleedlatif1 Jul 11, 2026
0976caf
fix(folders): recheck cycle-freedom inside the reorder transaction
waleedlatif1 Jul 11, 2026
7685991
fix(folders): add in-tx lock recheck to workflow-folder update
waleedlatif1 Jul 11, 2026
f57d28d
fix(folders): proactive sweep for missing in-tx lock rechecks + paren…
waleedlatif1 Jul 11, 2026
9f28f57
fix(folders): apply the parent lock/delete-race fix to create paths too
waleedlatif1 Jul 11, 2026
93c6995
fix(folders): fail safe when the reorder closure goes stale under lock
waleedlatif1 Jul 11, 2026
0711c61
fix(folders): large-scale audit fixes across locking engine and folde…
waleedlatif1 Jul 11, 2026
06b7898
fix(folders): cleanup pass + fix stale cycle-check race in workflow f…
waleedlatif1 Jul 11, 2026
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
164 changes: 164 additions & 0 deletions apps/sim/app/api/folders/[id]/duplicate/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/**
* Tests for the folder-duplicate route's target-parent ancestor walk.
*
* @vitest-environment node
*/
import { FolderLockedError } from '@sim/platform-authz/workflow'
import { describe, expect, it, vi } from 'vitest'
import { assertTargetParentFolderMutable } from '@/app/api/folders/[id]/duplicate/route'

describe('assertTargetParentFolderMutable', () => {
const workspaceId = 'workspace-123'
const sourceFolderId = 'source-folder'

/**
* `assertTargetParentFolderMutable` issues one `select().from().where().limit()`
* per ancestor hop, in walk order (target parent, then its parent, ...).
* `eq(folderTable.id, currentFolderId)` isn't inspectable without mocking
* drizzle-orm, so this mock returns each `chain` entry in call order instead.
*/
function buildTx(chain: Array<Record<string, unknown> | undefined>) {
let call = 0
const limit = vi.fn().mockImplementation(() => {
const row = chain[call]
call += 1
return row ? [row] : []
})
return {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
for: vi.fn().mockReturnValue({ limit }),
limit,
}),
}),
}),
} as unknown as Parameters<typeof assertTargetParentFolderMutable>[0]
}

it('allows a target parent whose full ancestor chain is active', async () => {
const tx = buildTx([
{
id: 'parent',
parentId: 'grandparent',
locked: false,
workspaceId,
resourceType: 'workflow',
deletedAt: null,
},
{
id: 'grandparent',
parentId: null,
locked: false,
workspaceId,
resourceType: 'workflow',
deletedAt: null,
},
])

await expect(
assertTargetParentFolderMutable(tx, 'parent', workspaceId, sourceFolderId)
).resolves.toBeUndefined()
})

it('rejects when the immediate target parent is soft-deleted', async () => {
const tx = buildTx([
{
id: 'parent',
parentId: null,
locked: false,
workspaceId,
resourceType: 'workflow',
deletedAt: new Date(),
},
])

await expect(
assertTargetParentFolderMutable(tx, 'parent', workspaceId, sourceFolderId)
).rejects.toThrow('Target parent folder not found')
})

it('rejects when an ANCESTOR beyond the immediate parent is soft-deleted (regression: previously only checked the first hop)', async () => {
const tx = buildTx([
{
id: 'parent',
parentId: 'grandparent',
locked: false,
workspaceId,
resourceType: 'workflow',
deletedAt: null,
},
{
id: 'grandparent',
parentId: null,
locked: false,
workspaceId,
resourceType: 'workflow',
deletedAt: new Date(),
},
])

await expect(
assertTargetParentFolderMutable(tx, 'parent', workspaceId, sourceFolderId)
).rejects.toThrow('Target parent folder not found')
})

it('rejects when the target parent belongs to a different workspace', async () => {
const tx = buildTx([
{
id: 'parent',
parentId: null,
locked: false,
workspaceId: 'other-workspace',
resourceType: 'workflow',
deletedAt: null,
},
])

await expect(
assertTargetParentFolderMutable(tx, 'parent', workspaceId, sourceFolderId)
).rejects.toThrow('Target parent folder not found')
})

it('rejects duplicating into the source folder itself', async () => {
const tx = buildTx([
{
id: sourceFolderId,
parentId: null,
locked: false,
workspaceId,
resourceType: 'workflow',
deletedAt: null,
},
])

await expect(
assertTargetParentFolderMutable(tx, sourceFolderId, workspaceId, sourceFolderId)
).rejects.toThrow('Cannot duplicate folder into itself or one of its descendants')
})

it('rejects when the target parent is locked', async () => {
const tx = buildTx([
{
id: 'parent',
parentId: null,
locked: true,
workspaceId,
resourceType: 'workflow',
deletedAt: null,
},
])

await expect(
assertTargetParentFolderMutable(tx, 'parent', workspaceId, sourceFolderId)
).rejects.toThrow(FolderLockedError)
})

it('is a no-op for a null parentId (duplicating to root)', async () => {
const tx = buildTx([])

await expect(
assertTargetParentFolderMutable(tx, null, workspaceId, sourceFolderId)
).resolves.toBeUndefined()
})
})
Loading
Loading