Skip to content

Commit 9f9befd

Browse files
authored
fix(folders): run reorder lock checks on the reorder transaction (#9055)
The folder reorder route checked folder locks inside its transaction but through the global pool, checking out a second connection while the transaction held one. assertFolderMutable now takes an optional executor and the route passes its transaction handle, so the ancestry walk reads on the held connection and under the folder-tree advisory lock.
1 parent 3c3bbaa commit 9f9befd

3 files changed

Lines changed: 130 additions & 6 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* Folder reorder against real PostgreSQL with the transaction tripwire in `throw` mode: the lock
3+
* checks run inside the reorder transaction, so every query they issue must ride that
4+
* transaction's connection rather than check a second one out of the global pool.
5+
*/
6+
import { db } from '@sim/db'
7+
import { folder, permissions, user, workspace } from '@sim/db/schema'
8+
import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
9+
import { authMock, authMockFns, createMockRequest } from '@sim/testing'
10+
import { generateId } from '@sim/utils/id'
11+
import { eq, inArray } from 'drizzle-orm'
12+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
13+
14+
vi.mock('@/lib/auth', () => authMock)
15+
16+
import { PUT } from '@/app/api/folders/reorder/route'
17+
18+
const userId = generateId()
19+
const workspaceId = generateId()
20+
const lockedRootId = generateId()
21+
const lockedChildId = generateId()
22+
const openRootId = generateId()
23+
const openChildId = generateId()
24+
const destinationId = generateId()
25+
26+
function reorder(updates: Array<{ id: string; sortOrder: number; parentId?: string | null }>) {
27+
return PUT(
28+
createMockRequest('PUT', { workspaceId, resourceType: 'workflow', updates }),
29+
undefined
30+
)
31+
}
32+
33+
describe('PUT /api/folders/reorder in PostgreSQL', () => {
34+
beforeAll(async () => {
35+
const now = new Date()
36+
await db.insert(user).values({
37+
id: userId,
38+
name: 'Folder reorder fixture',
39+
email: `${userId}@fixture.test`,
40+
emailVerified: true,
41+
createdAt: now,
42+
updatedAt: now,
43+
})
44+
await insertWorkspaceFixture(db, {
45+
id: workspaceId,
46+
name: 'Folder reorder fixture',
47+
ownerId: userId,
48+
billedAccountUserId: userId,
49+
})
50+
await db.insert(permissions).values({
51+
id: generateId(),
52+
userId,
53+
entityType: 'workspace',
54+
entityId: workspaceId,
55+
permissionType: 'admin',
56+
})
57+
const base = { resourceType: 'workflow' as const, userId, workspaceId }
58+
await db.insert(folder).values([
59+
{ ...base, id: lockedRootId, name: 'Locked root', locked: true },
60+
{ ...base, id: openRootId, name: 'Open root' },
61+
{ ...base, id: destinationId, name: 'Destination' },
62+
])
63+
await db.insert(folder).values([
64+
{ ...base, id: lockedChildId, name: 'Locked child', parentId: lockedRootId },
65+
{ ...base, id: openChildId, name: 'Open child', parentId: openRootId },
66+
])
67+
authMockFns.mockGetSession.mockResolvedValue({ user: { id: userId } })
68+
})
69+
70+
afterAll(async () => {
71+
await deleteWorkspaceFixture(db, eq(workspace.id, workspaceId))
72+
await db.delete(user).where(eq(user.id, userId))
73+
})
74+
75+
it('reparents and reorders unlocked folders', async () => {
76+
const response = await reorder([
77+
{ id: openChildId, sortOrder: 3, parentId: destinationId },
78+
{ id: openRootId, sortOrder: 1 },
79+
])
80+
81+
expect(response.status).toBe(200)
82+
expect(await response.json()).toEqual({ success: true, updated: 2 })
83+
const rows = await db
84+
.select({ id: folder.id, parentId: folder.parentId, sortOrder: folder.sortOrder })
85+
.from(folder)
86+
.where(inArray(folder.id, [openChildId, openRootId]))
87+
expect(rows).toEqual(
88+
expect.arrayContaining([
89+
{ id: openChildId, parentId: destinationId, sortOrder: 3 },
90+
{ id: openRootId, parentId: null, sortOrder: 1 },
91+
])
92+
)
93+
})
94+
95+
it('refuses to move a folder that inherits a lock from its ancestor', async () => {
96+
const response = await reorder([{ id: lockedChildId, sortOrder: 0, parentId: destinationId }])
97+
98+
expect(response.status).toBe(423)
99+
expect(await response.json()).toEqual({ error: 'Folder is locked by an ancestor folder' })
100+
const [row] = await db
101+
.select({ parentId: folder.parentId })
102+
.from(folder)
103+
.where(eq(folder.id, lockedChildId))
104+
expect(row.parentId).toBe(lockedRootId)
105+
})
106+
107+
it('refuses to move a folder into a locked destination', async () => {
108+
const response = await reorder([{ id: destinationId, sortOrder: 0, parentId: lockedRootId }])
109+
110+
expect(response.status).toBe(423)
111+
expect(await response.json()).toEqual({ error: 'Folder is locked' })
112+
})
113+
})

‎apps/sim/app/api/folders/reorder/route.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,9 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
143143

144144
if (folderResourceSupportsLocking(resourceType)) {
145145
for (const update of validUpdates) {
146-
await assertFolderMutable(update.id)
146+
await assertFolderMutable(update.id, tx)
147147
if (update.parentId !== undefined) {
148-
await assertFolderMutable(update.parentId)
148+
await assertFolderMutable(update.parentId, tx)
149149
}
150150
}
151151
}

‎packages/platform-authz/src/workflow.ts‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,10 @@ interface LockStatus {
7676
lockedFolderId: string | null
7777
}
7878

79-
async function getFolderLockStatus(folderId: string | null): Promise<LockStatus> {
79+
async function getFolderLockStatus(
80+
folderId: string | null,
81+
executor: Pick<typeof db, 'select'> = db
82+
): Promise<LockStatus> {
8083
if (!folderId) {
8184
return {
8285
locked: false,
@@ -93,7 +96,7 @@ async function getFolderLockStatus(folderId: string | null): Promise<LockStatus>
9396

9497
while (currentFolderId && !visited.has(currentFolderId)) {
9598
visited.add(currentFolderId)
96-
const [folder] = await db
99+
const [folder] = await executor
97100
.select({
98101
id: folderTable.id,
99102
parentId: folderTable.parentId,
@@ -177,8 +180,16 @@ export async function assertWorkflowMutable(workflowId: string): Promise<void> {
177180
}
178181
}
179182

180-
export async function assertFolderMutable(folderId: string | null): Promise<void> {
181-
const status = await getFolderLockStatus(folderId)
183+
/**
184+
* Throws {@link FolderLockedError} when the folder or an ancestor is locked. A caller inside a
185+
* transaction passes its handle as `executor`, so the ancestry walk reads on the connection the
186+
* transaction already holds instead of checking a second one out of the pool.
187+
*/
188+
export async function assertFolderMutable(
189+
folderId: string | null,
190+
executor: Pick<typeof db, 'select'> = db
191+
): Promise<void> {
192+
const status = await getFolderLockStatus(folderId, executor)
182193
if (status.locked) {
183194
throw new FolderLockedError(
184195
status.inheritedLocked ? 'Folder is locked by an ancestor folder' : 'Folder is locked'

0 commit comments

Comments
 (0)