/** * Tests for file upload API route * * @vitest-environment node */ import { authMockFns, hybridAuthMockFns, permissionsMock, permissionsMockFns, storageServiceMock, storageServiceMockFns, } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => { const mockVerifyFileAccess = vi.fn() const mockVerifyWorkspaceFileAccess = vi.fn() const mockVerifyKBFileAccess = vi.fn() const mockVerifyCopilotFileAccess = vi.fn() const mockUploadWorkspaceFile = vi.fn() const mockGetStorageProvider = vi.fn() const mockIsUsingCloudStorage = vi.fn() const mockUploadFile = vi.fn() const mockUploadExecutionFile = vi.fn() const mockCheckStorageQuota = vi.fn() return { mockVerifyFileAccess, mockVerifyWorkspaceFileAccess, mockVerifyKBFileAccess, mockVerifyCopilotFileAccess, mockUploadWorkspaceFile, mockGetStorageProvider, mockIsUsingCloudStorage, mockUploadFile, mockUploadExecutionFile, mockCheckStorageQuota, } }) vi.mock('drizzle-orm', () => ({ and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), or: vi.fn((...conditions: unknown[]) => ({ type: 'or', conditions })), gte: vi.fn((field: unknown, value: unknown) => ({ type: 'gte', field, value })), lte: vi.fn((field: unknown, value: unknown) => ({ type: 'lte', field, value })), gt: vi.fn((field: unknown, value: unknown) => ({ type: 'gt', field, value })), lt: vi.fn((field: unknown, value: unknown) => ({ type: 'lt', field, value })), ne: vi.fn((field: unknown, value: unknown) => ({ type: 'ne', field, value })), asc: vi.fn((field: unknown) => ({ field, type: 'asc' })), desc: vi.fn((field: unknown) => ({ field, type: 'desc' })), isNull: vi.fn((field: unknown) => ({ field, type: 'isNull' })), isNotNull: vi.fn((field: unknown) => ({ field, type: 'isNotNull' })), inArray: vi.fn((field: unknown, values: unknown) => ({ field, values, type: 'inArray' })), notInArray: vi.fn((field: unknown, values: unknown) => ({ field, values, type: 'notInArray' })), like: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'like' })), ilike: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'ilike' })), count: vi.fn((field: unknown) => ({ field, type: 'count' })), sum: vi.fn((field: unknown) => ({ field, type: 'sum' })), avg: vi.fn((field: unknown) => ({ field, type: 'avg' })), min: vi.fn((field: unknown) => ({ field, type: 'min' })), max: vi.fn((field: unknown) => ({ field, type: 'max' })), sql: vi.fn((strings: unknown, ...values: unknown[]) => ({ type: 'sql', sql: strings, values })), })) vi.mock('@sim/utils/id', () => ({ generateId: vi.fn(() => 'test-uuid'), generateShortId: vi.fn(() => 'mock-short-id'), isValidUuid: vi.fn((v: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v) ), })) vi.mock('@/app/api/files/authorization', () => ({ verifyFileAccess: mocks.mockVerifyFileAccess, verifyWorkspaceFileAccess: mocks.mockVerifyWorkspaceFileAccess, verifyKBFileAccess: mocks.mockVerifyKBFileAccess, verifyCopilotFileAccess: mocks.mockVerifyCopilotFileAccess, })) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) vi.mock('@/lib/uploads/contexts/workspace', () => ({ uploadWorkspaceFile: mocks.mockUploadWorkspaceFile, })) vi.mock('@/lib/uploads/contexts/execution', () => ({ uploadExecutionFile: mocks.mockUploadExecutionFile, })) vi.mock('@/lib/uploads', () => ({ getStorageProvider: mocks.mockGetStorageProvider, isUsingCloudStorage: mocks.mockIsUsingCloudStorage, uploadFile: mocks.mockUploadFile, })) vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) vi.mock('@/lib/uploads/shared/types', async (importOriginal) => { const actual = await importOriginal() return { ...actual, MAX_WORKSPACE_FORMDATA_FILE_SIZE: 1024, } }) vi.mock('@/lib/uploads/setup.server', () => ({ UPLOAD_DIR_SERVER: '/tmp/test-uploads', })) vi.mock('@/lib/billing/storage', () => ({ checkStorageQuota: mocks.mockCheckStorageQuota, })) import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { POST } from '@/app/api/files/upload/route' /** * Configure mocks for authenticated file upload tests */ function setupFileApiMocks( options: { authenticated?: boolean storageProvider?: 's3' | 'blob' | 'local' cloudEnabled?: boolean } = {} ) { const { authenticated = true, storageProvider = 's3', cloudEnabled = true } = options vi.stubGlobal('crypto', { randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'), }) if (authenticated) { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'test-user-id' } }) } else { authMockFns.mockGetSession.mockResolvedValue(null) } hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValue({ success: authenticated, userId: authenticated ? 'test-user-id' : undefined, error: authenticated ? undefined : 'Unauthorized', }) mocks.mockVerifyFileAccess.mockResolvedValue(true) mocks.mockVerifyWorkspaceFileAccess.mockResolvedValue(true) mocks.mockVerifyKBFileAccess.mockResolvedValue(true) mocks.mockVerifyCopilotFileAccess.mockResolvedValue(true) permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin') mocks.mockUploadWorkspaceFile.mockResolvedValue({ id: 'test-file-id', name: 'test.txt', url: '/api/files/serve/workspace/test-workspace-id/test-file.txt', size: 100, type: 'text/plain', key: 'workspace/test-workspace-id/1234567890-test.txt', uploadedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), }) mocks.mockUploadExecutionFile.mockResolvedValue({ id: 'test-execution-file-id', name: 'test.txt', url: '/api/files/serve/execution/test-workspace-id/test-file.txt', size: 100, type: 'text/plain', key: 'execution/test-workspace-id/1234567890-test.txt', uploadedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), }) mocks.mockGetStorageProvider.mockReturnValue(storageProvider) mocks.mockIsUsingCloudStorage.mockReturnValue(cloudEnabled) mocks.mockUploadFile.mockResolvedValue({ path: '/api/files/serve/test-key.txt', key: 'test-key.txt', name: 'test.txt', size: 100, type: 'text/plain', }) storageServiceMockFns.mockHasCloudStorage.mockReturnValue(cloudEnabled) storageServiceMockFns.mockUploadFile.mockResolvedValue({ key: 'test-key', path: '/test/path', }) mocks.mockCheckStorageQuota.mockResolvedValue({ allowed: true, currentUsage: 0, limit: Number.MAX_SAFE_INTEGER, }) } describe('File Upload API Route', () => { const createMockFormData = (files: File[], context = 'workspace'): FormData => { const formData = new FormData() formData.append('context', context) formData.append('workspaceId', 'test-workspace-id') files.forEach((file) => { formData.append('file', file) }) return formData } const createMockFile = ( name = 'test.txt', type = 'text/plain', content = 'test content' ): File => { return new File([content], name, { type }) } const createUploadRequest = (formData: FormData): NextRequest => new NextRequest('http://localhost:3000/api/files/upload', { method: 'POST', headers: { 'content-length': '1024' }, body: formData, }) beforeEach(() => { vi.clearAllMocks() }) afterEach(() => { vi.clearAllMocks() }) it('should upload a file to local storage', async () => { setupFileApiMocks({ cloudEnabled: false, storageProvider: 'local', }) const mockFile = createMockFile() const formData = createMockFormData([mockFile]) const req = createUploadRequest(formData) const response = await POST(req) const data = await response.json() expect(response.status).toBe(200) expect(data).toHaveProperty('url') expect(data.url).toMatch(/\/api\/files\/serve\/.*\.txt$/) expect(data).toHaveProperty('name', 'test.txt') expect(data).toHaveProperty('size') expect(data).toHaveProperty('type', 'text/plain') expect(data).toHaveProperty('key') expect(uploadWorkspaceFile).toHaveBeenCalled() }) it('should accept chunked multipart uploads without a content-length header', async () => { setupFileApiMocks({ cloudEnabled: false, storageProvider: 'local', }) const formData = createMockFormData([createMockFile()]) const req = new NextRequest('http://localhost:3000/api/files/upload', { method: 'POST', body: formData, }) expect(req.headers.get('content-length')).toBeNull() const response = await POST(req) expect(response.status).toBe(200) expect(uploadWorkspaceFile).toHaveBeenCalled() }) it('should upload a file to S3 when in S3 mode', async () => { setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3', }) const mockFile = createMockFile() const formData = createMockFormData([mockFile]) const req = createUploadRequest(formData) const response = await POST(req) const data = await response.json() expect(response.status).toBe(200) expect(data).toHaveProperty('url') expect(data.url).toContain('/api/files/serve/') expect(data).toHaveProperty('name', 'test.txt') expect(data).toHaveProperty('size') expect(data).toHaveProperty('type', 'text/plain') expect(data).toHaveProperty('key') expect(uploadWorkspaceFile).toHaveBeenCalled() }) it('should handle multiple file uploads', async () => { setupFileApiMocks({ cloudEnabled: false, storageProvider: 'local', }) const mockFile1 = createMockFile('file1.txt', 'text/plain') const mockFile2 = createMockFile('file2.txt', 'text/plain') const formData = createMockFormData([mockFile1, mockFile2]) const req = createUploadRequest(formData) const response = await POST(req) const data = await response.json() expect(response.status).toBeGreaterThanOrEqual(200) expect(response.status).toBeLessThan(600) expect(data).toBeDefined() }) it('rejects oversized workspace uploads before materializing file contents', async () => { setupFileApiMocks({ cloudEnabled: false, storageProvider: 'local', }) const mockFile = createMockFile('large.txt', 'text/plain', 'x'.repeat(1025)) const arrayBufferSpy = vi.spyOn(mockFile, 'arrayBuffer') const formData = { getAll: (name: string) => (name === 'file' ? [mockFile] : []), get: (name: string) => { if (name === 'context') return 'workspace' if (name === 'workspaceId') return 'test-workspace-id' return null }, } as unknown as FormData const req = { formData: async () => formData, } as unknown as NextRequest const response = await POST(req) const data = await response.json() expect(response.status).toBe(413) expect(data.error).toBe('PayloadSizeLimitError') expect(data.message).toContain('File exceeds the server upload limit') expect(data.message).toContain('Use direct upload for larger workspace files') expect(arrayBufferSpy).not.toHaveBeenCalled() expect(uploadWorkspaceFile).not.toHaveBeenCalled() }) it('should handle missing files', async () => { setupFileApiMocks() const formData = new FormData() const req = createUploadRequest(formData) const response = await POST(req) const data = await response.json() expect(response.status).toBe(400) expect(data).toHaveProperty('error', 'InvalidRequestError') expect(data).toHaveProperty('message', 'No files provided') }) it('should handle S3 upload errors', async () => { setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3', }) mocks.mockUploadWorkspaceFile.mockRejectedValue(new Error('Storage limit exceeded')) const mockFile = createMockFile() const formData = createMockFormData([mockFile]) const req = createUploadRequest(formData) const response = await POST(req) const data = await response.json() expect(response.status).toBe(413) expect(data).toHaveProperty('error') expect(typeof data.error).toBe('string') }) }) describe('File Upload Security Tests', () => { beforeEach(() => { vi.clearAllMocks() authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'test-user-id' }, }) storageServiceMockFns.mockHasCloudStorage.mockReturnValue(false) storageServiceMockFns.mockUploadFile.mockResolvedValue({ key: 'test-key', path: '/test/path', }) mocks.mockIsUsingCloudStorage.mockReturnValue(false) }) afterEach(() => { vi.clearAllMocks() }) describe('File Extension Validation', () => { beforeEach(() => { setupFileApiMocks({ cloudEnabled: false, storageProvider: 'local', }) }) it('should accept allowed file types', async () => { const allowedTypes = [ 'pdf', 'doc', 'docx', 'txt', 'md', 'png', 'jpg', 'jpeg', 'gif', 'csv', 'xlsx', 'xls', ] for (const ext of allowedTypes) { const formData = new FormData() const file = new File(['test content'], `test.${ext}`, { type: 'application/octet-stream' }) formData.append('file', file) formData.append('context', 'workspace') formData.append('workspaceId', 'test-workspace-id') const req = new Request('http://localhost/api/files/upload', { method: 'POST', headers: { 'content-length': '1024' }, body: formData, }) const response = await POST(req as unknown as NextRequest) expect(response.status).toBe(200) } }) it('should accept HTML files (supported document type)', async () => { const formData = new FormData() const htmlContent = '

Hello World

' const file = new File([htmlContent], 'document.html', { type: 'text/html' }) formData.append('file', file) formData.append('context', 'workspace') formData.append('workspaceId', 'test-workspace-id') const req = new Request('http://localhost/api/files/upload', { method: 'POST', headers: { 'content-length': '1024' }, body: formData, }) const response = await POST(req as unknown as NextRequest) expect(response.status).toBe(200) }) it('should accept SVG files (supported image type)', async () => { const formData = new FormData() const svgContent = '' const file = new File([svgContent], 'image.svg', { type: 'image/svg+xml' }) formData.append('file', file) formData.append('context', 'workspace') formData.append('workspaceId', 'test-workspace-id') const req = new Request('http://localhost/api/files/upload', { method: 'POST', headers: { 'content-length': '1024' }, body: formData, }) const response = await POST(req as unknown as NextRequest) expect(response.status).toBe(200) }) it('should reject unsupported file types', async () => { const formData = new FormData() const content = 'binary data' const file = new File([content], 'archive.exe', { type: 'application/octet-stream' }) formData.append('file', file) formData.append('context', 'workspace') formData.append('workspaceId', 'test-workspace-id') const req = new Request('http://localhost/api/files/upload', { method: 'POST', headers: { 'content-length': '1024' }, body: formData, }) const response = await POST(req as unknown as NextRequest) expect(response.status).toBe(400) const data = await response.json() expect(data.message).toContain("File type 'exe' is not allowed") }) it('should reject files without extensions', async () => { const formData = new FormData() const file = new File(['test content'], 'noextension', { type: 'application/octet-stream' }) formData.append('file', file) formData.append('context', 'workspace') formData.append('workspaceId', 'test-workspace-id') const req = new Request('http://localhost/api/files/upload', { method: 'POST', headers: { 'content-length': '1024' }, body: formData, }) const response = await POST(req as unknown as NextRequest) expect(response.status).toBe(400) const data = await response.json() expect(data.message).toContain("File type 'noextension' is not allowed") }) it('should handle multiple files with mixed valid/invalid types', async () => { const formData = new FormData() const validFile = new File(['valid content'], 'valid.pdf', { type: 'application/pdf' }) formData.append('file', validFile) const invalidFile = new File(['binary content'], 'malicious.exe', { type: 'application/x-msdownload', }) formData.append('file', invalidFile) formData.append('context', 'workspace') formData.append('workspaceId', 'test-workspace-id') const req = new Request('http://localhost/api/files/upload', { method: 'POST', headers: { 'content-length': '1024' }, body: formData, }) const response = await POST(req as unknown as NextRequest) expect(response.status).toBe(400) const data = await response.json() expect(data.message).toContain("File type 'exe' is not allowed") }) }) describe('Execution Context Permission Gate', () => { const createExecutionFormData = ( file: File, workspaceId: string | null = 'test-workspace-id' ) => { const formData = new FormData() formData.append('file', file) formData.append('context', 'execution') formData.append('workflowId', 'test-workflow-id') formData.append('executionId', 'test-execution-id') if (workspaceId !== null) formData.append('workspaceId', workspaceId) return formData } const postExecutionUpload = async (workspaceId: string | null = 'test-workspace-id') => { const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' }) const formData = createExecutionFormData(file, workspaceId) const req = new Request('http://localhost/api/files/upload', { method: 'POST', headers: { 'content-length': '1024' }, body: formData, }) return POST(req as unknown as NextRequest) } beforeEach(() => { setupFileApiMocks({ cloudEnabled: false, storageProvider: 'local', }) }) it('rejects execution uploads without workspaceId', async () => { const response = await postExecutionUpload(null) expect(response.status).toBe(400) const data = await response.json() expect(data.message).toContain('workflowId, executionId, and workspaceId') expect(mocks.mockUploadExecutionFile).not.toHaveBeenCalled() }) it('rejects execution uploads for a read-only workspace member', async () => { permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read') const response = await postExecutionUpload() expect(response.status).toBe(403) const data = await response.json() expect(data.error).toBe('Write or Admin access required for execution uploads') expect(mocks.mockUploadExecutionFile).not.toHaveBeenCalled() }) it('rejects execution uploads for a member with no workspace permission', async () => { permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue(null) const response = await postExecutionUpload() expect(response.status).toBe(403) expect(mocks.mockUploadExecutionFile).not.toHaveBeenCalled() }) it('allows execution uploads for a write-permission workspace member', async () => { permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') const response = await postExecutionUpload() expect(response.status).toBe(200) expect(mocks.mockUploadExecutionFile).toHaveBeenCalledWith( { workspaceId: 'test-workspace-id', workflowId: 'test-workflow-id', executionId: 'test-execution-id', }, expect.anything(), 'test.pdf', 'application/pdf', 'test-user-id' ) }) it('allows execution uploads for an admin-permission workspace member', async () => { permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin') const response = await postExecutionUpload() expect(response.status).toBe(200) expect(mocks.mockUploadExecutionFile).toHaveBeenCalled() }) }) describe('Mothership Context Permission Gate', () => { const postMothershipUpload = async (workspaceId: string | null = 'test-workspace-id') => { const formData = new FormData() const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' }) formData.append('file', file) formData.append('context', 'mothership') if (workspaceId !== null) formData.append('workspaceId', workspaceId) const req = new Request('http://localhost/api/files/upload', { method: 'POST', headers: { 'content-length': '1024' }, body: formData, }) return POST(req as unknown as NextRequest) } beforeEach(() => { setupFileApiMocks({ cloudEnabled: false, storageProvider: 'local', }) }) it('rejects mothership uploads without workspaceId', async () => { const response = await postMothershipUpload(null) expect(response.status).toBe(400) const data = await response.json() expect(data.message).toContain('workspaceId') expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled() }) it('rejects mothership uploads for a workspace the caller does not belong to', async () => { permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue(null) const response = await postMothershipUpload() expect(response.status).toBe(403) const data = await response.json() expect(data.error).toBe('Write or Admin access required for mothership uploads') expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled() }) it('rejects mothership uploads for a read-only workspace member', async () => { permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read') const response = await postMothershipUpload() expect(response.status).toBe(403) expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled() }) it('rejects mothership uploads over the caller storage quota', async () => { permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') mocks.mockCheckStorageQuota.mockResolvedValue({ allowed: false, currentUsage: 100, limit: 100, error: 'Storage limit exceeded. Used: 0.00GB, Limit: 0GB', }) const response = await postMothershipUpload() expect(response.status).toBe(413) const data = await response.json() expect(data.error).toContain('Storage limit exceeded') expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled() }) it('allows mothership uploads for a write-permission workspace member', async () => { permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') const response = await postMothershipUpload() expect(response.status).toBe(200) expect(permissionsMockFns.mockGetUserEntityPermissions).toHaveBeenCalledWith( 'test-user-id', 'workspace', 'test-workspace-id' ) expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalled() }) it('allows mothership uploads for an admin-permission workspace member', async () => { permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin') const response = await postMothershipUpload() expect(response.status).toBe(200) expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalled() }) it('checks quota once against the combined size of a multi-file batch', async () => { permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') const formData = new FormData() const fileA = new File(['a'.repeat(10)], 'a.pdf', { type: 'application/pdf' }) const fileB = new File(['b'.repeat(20)], 'b.pdf', { type: 'application/pdf' }) formData.append('file', fileA) formData.append('file', fileB) formData.append('context', 'mothership') formData.append('workspaceId', 'test-workspace-id') const req = new Request('http://localhost/api/files/upload', { method: 'POST', headers: { 'content-length': '1024' }, body: formData, }) const response = await POST(req as unknown as NextRequest) expect(response.status).toBe(200) expect(mocks.mockCheckStorageQuota).toHaveBeenCalledTimes(1) expect(mocks.mockCheckStorageQuota).toHaveBeenCalledWith('test-user-id', 30) expect(permissionsMockFns.mockGetUserEntityPermissions).toHaveBeenCalledTimes(1) }) }) describe('Authentication Requirements', () => { it('should reject uploads without authentication', async () => { authMockFns.mockGetSession.mockResolvedValue(null) const formData = new FormData() const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' }) formData.append('file', file) const req = new Request('http://localhost/api/files/upload', { method: 'POST', headers: { 'content-length': '1024' }, body: formData, }) const response = await POST(req as unknown as NextRequest) expect(response.status).toBe(401) const data = await response.json() expect(data.error).toBe('Unauthorized') }) }) })