Skip to content

Commit 374f2ba

Browse files
committed
feat(folders): generalize folders and pinning across workflows, files, knowledge bases, and tables
Introduces a shared `folder`/`pinned_item` schema replacing the per-resource folder tables, and generalizes resource-level locking (item lock + folder cascade) across all four resource types with full parity to the existing workflow implementation. - Generic `folder` table (resourceType discriminator) + `pinned_item` table, shared API contracts/routes/hooks/UI across workflows, files, knowledge bases, and tables. - Generic resource-lock engine (`@sim/platform-authz/resource-lock`): item-level `locked` column + folder-cascade enforcement, wired into every structural mutation entry point (create, rename, move, delete, restore) for all four resource types, including destination-folder and TOCTOU checks a prior implementation missed. - Migration collapses cleanly with expand/contract discipline: FK adds deferred to a follow-up contract migration (blue/green deploy safety), concurrent index builds, NOT VALID + VALIDATE for new-column FKs, and a dedup-safe backfill for production's pre-existing duplicate folder names. - Extensive review-loop hardening: cycle-detection gaps, archived-row mutability gaps, silent-partial-success batch operations, and lock-check TOCTOU races closed across every restore/move/create entry point, with regression tests for each. # Conflicts: # packages/db/migrations/meta/0259_snapshot.json # packages/db/migrations/meta/_journal.json
1 parent 8525ba5 commit 374f2ba

158 files changed

Lines changed: 25184 additions & 1914 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/**
2+
* Tests for the folder-duplicate route's target-parent ancestor walk.
3+
*
4+
* @vitest-environment node
5+
*/
6+
import { FolderLockedError } from '@sim/platform-authz/workflow'
7+
import { describe, expect, it, vi } from 'vitest'
8+
import { assertTargetParentFolderMutable } from '@/app/api/folders/[id]/duplicate/route'
9+
10+
describe('assertTargetParentFolderMutable', () => {
11+
const workspaceId = 'workspace-123'
12+
const sourceFolderId = 'source-folder'
13+
14+
/**
15+
* `assertTargetParentFolderMutable` issues one `select().from().where().limit()`
16+
* per ancestor hop, in walk order (target parent, then its parent, ...).
17+
* `eq(folderTable.id, currentFolderId)` isn't inspectable without mocking
18+
* drizzle-orm, so this mock returns each `chain` entry in call order instead.
19+
*/
20+
function buildTx(chain: Array<Record<string, unknown> | undefined>) {
21+
let call = 0
22+
return {
23+
select: vi.fn().mockReturnValue({
24+
from: vi.fn().mockReturnValue({
25+
where: vi.fn().mockReturnValue({
26+
limit: vi.fn().mockImplementation(() => {
27+
const row = chain[call]
28+
call += 1
29+
return row ? [row] : []
30+
}),
31+
}),
32+
}),
33+
}),
34+
} as unknown as Parameters<typeof assertTargetParentFolderMutable>[0]
35+
}
36+
37+
it('allows a target parent whose full ancestor chain is active', async () => {
38+
const tx = buildTx([
39+
{
40+
id: 'parent',
41+
parentId: 'grandparent',
42+
locked: false,
43+
workspaceId,
44+
resourceType: 'workflow',
45+
deletedAt: null,
46+
},
47+
{
48+
id: 'grandparent',
49+
parentId: null,
50+
locked: false,
51+
workspaceId,
52+
resourceType: 'workflow',
53+
deletedAt: null,
54+
},
55+
])
56+
57+
await expect(
58+
assertTargetParentFolderMutable(tx, 'parent', workspaceId, sourceFolderId)
59+
).resolves.toBeUndefined()
60+
})
61+
62+
it('rejects when the immediate target parent is soft-deleted', async () => {
63+
const tx = buildTx([
64+
{
65+
id: 'parent',
66+
parentId: null,
67+
locked: false,
68+
workspaceId,
69+
resourceType: 'workflow',
70+
deletedAt: new Date(),
71+
},
72+
])
73+
74+
await expect(
75+
assertTargetParentFolderMutable(tx, 'parent', workspaceId, sourceFolderId)
76+
).rejects.toThrow('Target parent folder not found')
77+
})
78+
79+
it('rejects when an ANCESTOR beyond the immediate parent is soft-deleted (regression: previously only checked the first hop)', async () => {
80+
const tx = buildTx([
81+
{
82+
id: 'parent',
83+
parentId: 'grandparent',
84+
locked: false,
85+
workspaceId,
86+
resourceType: 'workflow',
87+
deletedAt: null,
88+
},
89+
{
90+
id: 'grandparent',
91+
parentId: null,
92+
locked: false,
93+
workspaceId,
94+
resourceType: 'workflow',
95+
deletedAt: new Date(),
96+
},
97+
])
98+
99+
await expect(
100+
assertTargetParentFolderMutable(tx, 'parent', workspaceId, sourceFolderId)
101+
).rejects.toThrow('Target parent folder not found')
102+
})
103+
104+
it('rejects when the target parent belongs to a different workspace', async () => {
105+
const tx = buildTx([
106+
{
107+
id: 'parent',
108+
parentId: null,
109+
locked: false,
110+
workspaceId: 'other-workspace',
111+
resourceType: 'workflow',
112+
deletedAt: null,
113+
},
114+
])
115+
116+
await expect(
117+
assertTargetParentFolderMutable(tx, 'parent', workspaceId, sourceFolderId)
118+
).rejects.toThrow('Target parent folder not found')
119+
})
120+
121+
it('rejects duplicating into the source folder itself', async () => {
122+
const tx = buildTx([
123+
{
124+
id: sourceFolderId,
125+
parentId: null,
126+
locked: false,
127+
workspaceId,
128+
resourceType: 'workflow',
129+
deletedAt: null,
130+
},
131+
])
132+
133+
await expect(
134+
assertTargetParentFolderMutable(tx, sourceFolderId, workspaceId, sourceFolderId)
135+
).rejects.toThrow('Cannot duplicate folder into itself or one of its descendants')
136+
})
137+
138+
it('rejects when the target parent is locked', async () => {
139+
const tx = buildTx([
140+
{
141+
id: 'parent',
142+
parentId: null,
143+
locked: true,
144+
workspaceId,
145+
resourceType: 'workflow',
146+
deletedAt: null,
147+
},
148+
])
149+
150+
await expect(
151+
assertTargetParentFolderMutable(tx, 'parent', workspaceId, sourceFolderId)
152+
).rejects.toThrow(FolderLockedError)
153+
})
154+
155+
it('is a no-op for a null parentId (duplicating to root)', async () => {
156+
const tx = buildTx([])
157+
158+
await expect(
159+
assertTargetParentFolderMutable(tx, null, workspaceId, sourceFolderId)
160+
).resolves.toBeUndefined()
161+
})
162+
})

0 commit comments

Comments
 (0)