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
189 changes: 189 additions & 0 deletions apps/sim/app/api/files/presigned/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,14 @@ const {
mockIsUsingCloudStorage,
mockGetStorageProvider,
mockValidateFileType,
mockValidateAttachmentFileType,
mockGenerateCopilotUploadUrl,
mockIsImageFileType,
mockGetStorageProviderUploads,
mockIsUsingCloudStorageUploads,
mockGetUserEntityPermissions,
mockGenerateWorkspaceFileKey,
mockGenerateExecutionFileKey,
} = vi.hoisted(() => ({
mockVerifyFileAccess: vi.fn().mockResolvedValue(true),
mockVerifyWorkspaceFileAccess: vi.fn().mockResolvedValue(true),
Expand All @@ -30,13 +34,22 @@ const {
mockIsUsingCloudStorage: vi.fn(),
mockGetStorageProvider: vi.fn(),
mockValidateFileType: vi.fn().mockReturnValue(null),
mockValidateAttachmentFileType: vi.fn().mockReturnValue(null),
mockGenerateCopilotUploadUrl: vi.fn().mockResolvedValue({
url: 'https://example.com/presigned-url',
key: 'copilot/test-key.txt',
}),
mockIsImageFileType: vi.fn().mockReturnValue(true),
mockGetStorageProviderUploads: vi.fn(),
mockIsUsingCloudStorageUploads: vi.fn(),
mockGetUserEntityPermissions: vi.fn().mockResolvedValue('admin'),
mockGenerateWorkspaceFileKey: vi.fn(
(workspaceId: string, fileName: string) => `workspace/${workspaceId}/${fileName}`
),
mockGenerateExecutionFileKey: vi.fn(
(ctx: { workspaceId: string; workflowId: string; executionId: string }, fileName: string) =>
`execution/${ctx.workspaceId}/${ctx.workflowId}/${ctx.executionId}/${fileName}`
),
}))

vi.mock('@/app/api/files/authorization', () => ({
Expand All @@ -61,6 +74,19 @@ vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)

vi.mock('@/lib/uploads/utils/validation', () => ({
validateFileType: mockValidateFileType,
validateAttachmentFileType: mockValidateAttachmentFileType,
}))

vi.mock('@/lib/workspaces/permissions/utils', () => ({
getUserEntityPermissions: mockGetUserEntityPermissions,
}))

vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
generateWorkspaceFileKey: mockGenerateWorkspaceFileKey,
}))

vi.mock('@/lib/uploads/contexts/execution/utils', () => ({
generateExecutionFileKey: mockGenerateExecutionFileKey,
}))

vi.mock('@/lib/uploads/utils/file-utils', () => ({
Expand Down Expand Up @@ -139,6 +165,8 @@ function setupFileApiMocks(
)

mockValidateFileType.mockReturnValue(null)
mockValidateAttachmentFileType.mockReturnValue(null)
mockGetUserEntityPermissions.mockResolvedValue('admin')

mockGetStorageProviderUploads.mockReturnValue(
storageProvider === 'blob' ? 'Azure Blob' : storageProvider === 's3' ? 'S3' : 'Local'
Expand Down Expand Up @@ -518,6 +546,167 @@ describe('/api/files/presigned', () => {
})
})

describe('mothership uploads', () => {
it('uses validateAttachmentFileType (not validateFileType) — accepts images', async () => {
setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })

const request = new NextRequest(
'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1',
{
method: 'POST',
body: JSON.stringify({
fileName: 'screenshot.png',
contentType: 'image/png',
fileSize: 4096,
}),
}
)

const response = await POST(request)
expect(response.status).toBe(200)
expect(mockValidateAttachmentFileType).toHaveBeenCalledWith('screenshot.png')
expect(mockValidateFileType).not.toHaveBeenCalled()
})

it('rejects unsupported types when validator returns an error', async () => {
setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
mockValidateAttachmentFileType.mockReturnValue({
code: 'UNSUPPORTED_FILE_TYPE',
message: 'Unsupported file type: exe.',
supportedTypes: [],
})

const request = new NextRequest(
'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1',
{
method: 'POST',
body: JSON.stringify({
fileName: 'virus.exe',
contentType: 'application/octet-stream',
fileSize: 4096,
}),
}
)

const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.code).toBe('VALIDATION_ERROR')
expect(data.error).toContain('exe')
})

it('returns 403 when user lacks workspace write permission', async () => {
setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
mockGetUserEntityPermissions.mockResolvedValue('read')

const request = new NextRequest(
'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1',
{
method: 'POST',
body: JSON.stringify({
fileName: 'doc.pdf',
contentType: 'application/pdf',
fileSize: 4096,
}),
}
)

const response = await POST(request)
expect(response.status).toBe(403)
})
})

describe('execution uploads', () => {
it('uses validateAttachmentFileType — accepts video', async () => {
setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })

const request = new NextRequest(
'http://localhost:3000/api/files/presigned?type=execution&workspaceId=ws-1&workflowId=wf-1&executionId=exec-1',
{
method: 'POST',
body: JSON.stringify({
fileName: 'output.mp4',
contentType: 'video/mp4',
fileSize: 4096,
}),
}
)

const response = await POST(request)
expect(response.status).toBe(200)
expect(mockValidateAttachmentFileType).toHaveBeenCalledWith('output.mp4')
expect(mockValidateFileType).not.toHaveBeenCalled()
})

it('rejects when validator returns an error', async () => {
setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
mockValidateAttachmentFileType.mockReturnValue({
code: 'UNSUPPORTED_FILE_TYPE',
message: 'Unsupported file type: bin.',
supportedTypes: [],
})

const request = new NextRequest(
'http://localhost:3000/api/files/presigned?type=execution&workspaceId=ws-1&workflowId=wf-1&executionId=exec-1',
{
method: 'POST',
body: JSON.stringify({
fileName: 'blob.bin',
contentType: 'application/octet-stream',
fileSize: 4096,
}),
}
)

const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.code).toBe('VALIDATION_ERROR')
})

it('returns 400 when missing workflowId/executionId', async () => {
setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })

const request = new NextRequest(
'http://localhost:3000/api/files/presigned?type=execution&workspaceId=ws-1',
{
method: 'POST',
body: JSON.stringify({
fileName: 'output.mp4',
contentType: 'video/mp4',
fileSize: 4096,
}),
}
)

const response = await POST(request)
expect(response.status).toBe(400)
})
})

describe('knowledge-base uploads', () => {
it('uses validateFileType (docs-only), not validateAttachmentFileType', async () => {
setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })

const request = new NextRequest(
'http://localhost:3000/api/files/presigned?type=knowledge-base',
{
method: 'POST',
body: JSON.stringify({
fileName: 'doc.pdf',
contentType: 'application/pdf',
fileSize: 4096,
}),
}
)

const response = await POST(request)
expect(response.status).toBe(200)
expect(mockValidateFileType).toHaveBeenCalledWith('doc.pdf', 'application/pdf')
expect(mockValidateAttachmentFileType).not.toHaveBeenCalled()
})
})

describe('OPTIONS', () => {
it('should handle CORS preflight requests', async () => {
const response = await OPTIONS()
Expand Down
6 changes: 3 additions & 3 deletions apps/sim/app/api/files/presigned/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { generateExecutionFileKey } from '@/lib/uploads/contexts/execution/utils
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service'
import { isImageFileType } from '@/lib/uploads/utils/file-utils'
import { validateFileType } from '@/lib/uploads/utils/validation'
import { validateAttachmentFileType, validateFileType } from '@/lib/uploads/utils/validation'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { createErrorResponse } from '@/app/api/files/utils'

Expand Down Expand Up @@ -141,7 +141,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
)
}

const fileValidationError = validateFileType(fileName, contentType)
const fileValidationError = validateAttachmentFileType(fileName)
if (fileValidationError) {
throw new ValidationError(fileValidationError.message)
}
Comment thread
waleedlatif1 marked this conversation as resolved.
Expand Down Expand Up @@ -175,7 +175,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
)
}

const fileValidationError = validateFileType(fileName, contentType)
const fileValidationError = validateAttachmentFileType(fileName)
if (fileValidationError) {
throw new ValidationError(fileValidationError.message)
}
Expand Down
13 changes: 2 additions & 11 deletions apps/sim/app/api/files/upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,8 @@ import type { StorageContext } from '@/lib/uploads/config'
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { isImageFileType, resolveFileType } from '@/lib/uploads/utils/file-utils'
import {
SUPPORTED_AUDIO_EXTENSIONS,
SUPPORTED_CODE_EXTENSIONS,
SUPPORTED_DOCUMENT_EXTENSIONS,
SUPPORTED_ATTACHMENT_EXTENSIONS,
SUPPORTED_IMAGE_EXTENSIONS,
SUPPORTED_VIDEO_EXTENSIONS,
validateFileType,
} from '@/lib/uploads/utils/validation'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
Expand All @@ -29,13 +26,7 @@ import {
InvalidRequestError,
} from '@/app/api/files/utils'

const ALLOWED_EXTENSIONS = new Set<string>([
...SUPPORTED_DOCUMENT_EXTENSIONS,
...SUPPORTED_CODE_EXTENSIONS,
...SUPPORTED_IMAGE_EXTENSIONS,
...SUPPORTED_AUDIO_EXTENSIONS,
...SUPPORTED_VIDEO_EXTENSIONS,
])
const ALLOWED_EXTENSIONS = new Set<string>(SUPPORTED_ATTACHMENT_EXTENSIONS)

function validateFileExtension(filename: string): boolean {
const extension = filename.split('.').pop()?.toLowerCase()
Expand Down
73 changes: 73 additions & 0 deletions apps/sim/lib/uploads/utils/validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import {
SUPPORTED_ATTACHMENT_EXTENSIONS,
validateAttachmentFileType,
} from '@/lib/uploads/utils/validation'

describe('validateAttachmentFileType', () => {
it('accepts image files (png, jpg, gif, webp, svg)', () => {
expect(validateAttachmentFileType('screenshot.png')).toBeNull()
expect(validateAttachmentFileType('photo.jpg')).toBeNull()
expect(validateAttachmentFileType('photo.JPEG')).toBeNull()
expect(validateAttachmentFileType('animation.gif')).toBeNull()
expect(validateAttachmentFileType('image.webp')).toBeNull()
expect(validateAttachmentFileType('icon.svg')).toBeNull()
})

it('accepts video files (mp4, mov, webm)', () => {
expect(validateAttachmentFileType('clip.mp4')).toBeNull()
expect(validateAttachmentFileType('clip.mov')).toBeNull()
expect(validateAttachmentFileType('clip.webm')).toBeNull()
})

it('accepts audio files (mp3, wav, m4a)', () => {
expect(validateAttachmentFileType('voice.mp3')).toBeNull()
expect(validateAttachmentFileType('voice.wav')).toBeNull()
expect(validateAttachmentFileType('voice.m4a')).toBeNull()
})

it('accepts document files (pdf, docx, csv, md)', () => {
expect(validateAttachmentFileType('report.pdf')).toBeNull()
expect(validateAttachmentFileType('letter.docx')).toBeNull()
expect(validateAttachmentFileType('data.csv')).toBeNull()
expect(validateAttachmentFileType('notes.md')).toBeNull()
})

it('accepts code files (ts, py, sh, json)', () => {
expect(validateAttachmentFileType('app.ts')).toBeNull()
expect(validateAttachmentFileType('main.py')).toBeNull()
expect(validateAttachmentFileType('script.sh')).toBeNull()
expect(validateAttachmentFileType('config.json')).toBeNull()
})

it('rejects executables and unknown extensions', () => {
expect(validateAttachmentFileType('virus.exe')?.code).toBe('UNSUPPORTED_FILE_TYPE')
expect(validateAttachmentFileType('installer.msi')?.code).toBe('UNSUPPORTED_FILE_TYPE')
expect(validateAttachmentFileType('archive.dmg')?.code).toBe('UNSUPPORTED_FILE_TYPE')
expect(validateAttachmentFileType('binary.bin')?.code).toBe('UNSUPPORTED_FILE_TYPE')
})

it('rejects files with no extension', () => {
const result = validateAttachmentFileType('README')
expect(result?.code).toBe('UNSUPPORTED_FILE_TYPE')
expect(result?.message).toContain('README')
})

it('rejects files with non-alphanumeric extensions', () => {
expect(validateAttachmentFileType('odd.<>')?.code).toBe('UNSUPPORTED_FILE_TYPE')
expect(validateAttachmentFileType('foo. ')?.code).toBe('UNSUPPORTED_FILE_TYPE')
})

it('does not contain duplicate extensions (e.g. webm)', () => {
const seen = new Set<string>()
for (const ext of SUPPORTED_ATTACHMENT_EXTENSIONS) {
expect(seen.has(ext)).toBe(false)
seen.add(ext)
}
})

it('returns supportedTypes list in error', () => {
const result = validateAttachmentFileType('foo.exe')
expect(result?.supportedTypes).toEqual(expect.arrayContaining(['png', 'pdf', 'mp4', 'mp3']))
})
})
Loading
Loading