Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion apps/sim/app/api/folders/[id]/duplicate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ export const POST = withRouteHandler(
tx,
targetWorkspaceId,
targetParentId,
name
name,
'workflow'
)

await tx.insert(folderTable).values({
Expand Down
20 changes: 14 additions & 6 deletions apps/sim/app/api/folders/[id]/restore/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import { restoreFolderContract } from '@/lib/api/contracts'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { restoreFolder } from '@/lib/folders/lifecycle'
import { folderMutationStatus } from '@/lib/folders/status'
import { captureServerEvent } from '@/lib/posthog/server'
import { performRestoreFolder } from '@/lib/workflows/orchestration/folder-lifecycle'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'

const logger = createLogger('RestoreFolderAPI')
Expand All @@ -23,29 +24,36 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route
const parsed = await parseRequest(restoreFolderContract, request, context)
if (!parsed.success) return parsed.response
const { id: folderId } = parsed.data.params
const { workspaceId } = parsed.data.body
const { workspaceId, resourceType } = parsed.data.body

const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (permission !== 'admin' && permission !== 'write') {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}

const result = await performRestoreFolder({
const result = await restoreFolder({
resourceType,
folderId,
workspaceId,
userId: session.user.id,
})

if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
return NextResponse.json(
{ error: result.error },
{ status: folderMutationStatus(result.errorCode) }
)
}

logger.info(`Restored folder ${folderId}`, { restoredItems: result.restoredItems })
logger.info(`Restored folder ${folderId}`, {
resourceType,
restoredItems: result.restoredItems,
})

captureServerEvent(
session.user.id,
'folder_restored',
{ folder_id: folderId, workspace_id: workspaceId },
{ folder_id: folderId, workspace_id: workspaceId, resource_type: resourceType },
{ groups: { workspace: workspaceId } }
)

Expand Down
83 changes: 58 additions & 25 deletions apps/sim/app/api/folders/[id]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,14 @@ import {
authMockFns,
createMockRequest,
dbChainMockFns,
foldersLifecycleMock,
foldersLifecycleMockFns,
type MockUser,
permissionsMock,
permissionsMockFns,
queueTableRows,
resetDbChainMock,
schemaMock,
workflowsOrchestrationMock,
workflowsOrchestrationMockFns,
workflowsUtilsMock,
workflowsUtilsMockFns,
} from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'

Expand All @@ -36,8 +34,11 @@ const { mockLogger } = vi.hoisted(() => {
}
})

const mockPerformDeleteFolder = workflowsOrchestrationMockFns.mockPerformDeleteFolder
const mockPerformUpdateFolder = workflowsOrchestrationMockFns.mockPerformUpdateFolder
const mockDeleteFolder = foldersLifecycleMockFns.mockDeleteFolder
const mockUpdateFolder = foldersLifecycleMockFns.mockUpdateFolder

/** Parent ids the mocked engine treats as closing a cycle for the folder under test. */
const cyclicParentIds = new Set<string>()

const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions

Expand All @@ -48,8 +49,7 @@ vi.mock('@sim/logger', () => ({
getRequestContext: () => undefined,
}))
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock)
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
vi.mock('@/lib/folders/lifecycle', () => foldersLifecycleMock)

import { DELETE, PUT } from '@/app/api/folders/[id]/route'

Expand Down Expand Up @@ -101,25 +101,19 @@ describe('Individual Folder API Route', () => {
resetDbChainMock()

mockGetUserEntityPermissions.mockResolvedValue('admin')
mockPerformDeleteFolder.mockResolvedValue({
mockDeleteFolder.mockResolvedValue({
success: true,
deletedItems: { folders: 1, workflows: 0 },
})
mockPerformUpdateFolder.mockImplementation(async (params) => {
mockUpdateFolder.mockImplementation(async (params) => {
if (params.parentId && params.parentId === params.folderId) {
return {
success: false,
error: 'Folder cannot be its own parent',
errorCode: 'validation',
}
}
if (
params.parentId &&
(await workflowsUtilsMockFns.mockCheckForCircularReference(
params.folderId,
params.parentId
))
) {
if (params.parentId && cyclicParentIds.has(params.parentId)) {
return {
success: false,
error: 'Cannot create circular folder reference',
Expand All @@ -140,7 +134,7 @@ describe('Individual Folder API Route', () => {
},
}
})
workflowsUtilsMockFns.mockCheckForCircularReference.mockResolvedValue(false)
cyclicParentIds.clear()
})

describe('PUT /api/folders/[id]', () => {
Expand Down Expand Up @@ -250,6 +244,26 @@ describe('Individual Folder API Route', () => {
expect(data).toHaveProperty('folder')
})

it('rejects a locked write on a resource type that has no lock semantics', async () => {
mockAuthenticatedUser()
queueFolderLookup()

const req = createMockRequest(
'PUT',
{ locked: true },
{},
'http://localhost:3000/api/folders/folder-1?resourceType=knowledge_base'
)
const params = Promise.resolve({ id: 'folder-1' })

const response = await PUT(req, { params })

expect(response.status).toBe(400)
const data = await response.json()
expect(data.error).toBe('Folder locking is only supported for workflow folders')
expect(mockUpdateFolder).not.toHaveBeenCalled()
})

it('should return 400 when trying to set folder as its own parent', async () => {
mockAuthenticatedUser()

Expand Down Expand Up @@ -368,7 +382,7 @@ describe('Individual Folder API Route', () => {
workspaceId: 'workspace-123',
})

workflowsUtilsMockFns.mockCheckForCircularReference.mockResolvedValue(true)
cyclicParentIds.add('folder-1')

const req = createMockRequest('PUT', {
name: 'Updated Folder 3',
Expand All @@ -382,9 +396,8 @@ describe('Individual Folder API Route', () => {

const data = await response.json()
expect(data).toHaveProperty('error', 'Cannot create circular folder reference')
expect(workflowsUtilsMockFns.mockCheckForCircularReference).toHaveBeenCalledWith(
'folder-3',
'folder-1'
expect(mockUpdateFolder).toHaveBeenCalledWith(
expect.objectContaining({ folderId: 'folder-3', parentId: 'folder-1' })
)
})
})
Expand All @@ -405,14 +418,34 @@ describe('Individual Folder API Route', () => {
const data = await response.json()
expect(data).toHaveProperty('success', true)
expect(data).toHaveProperty('deletedItems')
expect(mockPerformDeleteFolder).toHaveBeenCalledWith({
expect(mockDeleteFolder).toHaveBeenCalledWith({
resourceType: 'workflow',
folderId: 'folder-1',
workspaceId: 'workspace-123',
userId: TEST_USER.id,
folderName: 'Test Folder',
})
})

it('surfaces a delete-locked resource as 423, not a generic 500', async () => {
mockAuthenticatedUser()
queueFolderLookup()
mockDeleteFolder.mockResolvedValueOnce({
success: false,
error: 'Cannot delete folder: table Ledger is delete-locked',
errorCode: 'locked',
})

const req = createMockRequest('DELETE')
const params = Promise.resolve({ id: 'folder-1' })

const response = await DELETE(req, { params })

expect(response.status).toBe(423)
const data = await response.json()
expect(data.error).toBe('Cannot delete folder: table Ledger is delete-locked')
})

it('should return 401 for unauthenticated delete requests', async () => {
mockUnauthenticated()

Expand Down Expand Up @@ -458,7 +491,7 @@ describe('Individual Folder API Route', () => {

const data = await response.json()
expect(data).toHaveProperty('success', true)
expect(mockPerformDeleteFolder).toHaveBeenCalled()
expect(mockDeleteFolder).toHaveBeenCalled()
})

it('should allow folder deletion for admin permissions', async () => {
Expand All @@ -476,7 +509,7 @@ describe('Individual Folder API Route', () => {

const data = await response.json()
expect(data).toHaveProperty('success', true)
expect(mockPerformDeleteFolder).toHaveBeenCalled()
expect(mockDeleteFolder).toHaveBeenCalled()
})

it('should handle database errors during deletion', async () => {
Expand Down
Loading
Loading