chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+795
View File
@@ -0,0 +1,795 @@
/**
* 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<typeof import('@/lib/uploads/shared/types')>()
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 = '<h1>Hello World</h1>'
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 =
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg>'
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')
})
})
})
+485
View File
@@ -0,0 +1,485 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { sanitizeFileName } from '@/executor/constants'
import '@/lib/uploads/core/setup.server'
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import {
uploadFilesFormFieldsSchema,
uploadFilesFormFilesSchema,
} from '@/lib/api/contracts/storage-transfer'
import { getValidationErrorMessage } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import {
assertKnownSizeWithinLimit,
isPayloadSizeLimitError,
MAX_MULTIPART_OVERHEAD_BYTES,
readFileToBufferWithLimit,
readFormDataWithLimit,
} from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import type { StorageContext } from '@/lib/uploads/config'
import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager'
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { MAX_WORKSPACE_FORMDATA_FILE_SIZE } from '@/lib/uploads/shared/types'
import { isImageFileType, resolveFileType } from '@/lib/uploads/utils/file-utils'
import {
SUPPORTED_ATTACHMENT_EXTENSIONS,
SUPPORTED_IMAGE_EXTENSIONS,
validateFileType,
} from '@/lib/uploads/utils/validation'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { createErrorResponse, InvalidRequestError } from '@/app/api/files/utils'
const ALLOWED_EXTENSIONS = new Set<string>(SUPPORTED_ATTACHMENT_EXTENSIONS)
function validateFileExtension(filename: string): boolean {
const extension = filename.split('.').pop()?.toLowerCase()
if (!extension) return false
return ALLOWED_EXTENSIONS.has(extension)
}
export const dynamic = 'force-dynamic'
const logger = createLogger('FilesUploadAPI')
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const formData = await readFormDataWithLimit(request, {
maxBytes: MAX_WORKSPACE_FORMDATA_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES,
label: 'multipart upload body',
})
const rawFiles = formData.getAll('file')
const filesResult = uploadFilesFormFilesSchema.safeParse(rawFiles)
if (!filesResult.success) {
throw new InvalidRequestError('No files provided')
}
const files = filesResult.data
const totalFileSize = files.reduce((total, file) => total + file.size, 0)
assertKnownSizeWithinLimit(totalFileSize, MAX_WORKSPACE_FORMDATA_FILE_SIZE, 'uploaded files')
const formFieldsResult = uploadFilesFormFieldsSchema.safeParse({
workflowId: formData.get('workflowId'),
executionId: formData.get('executionId'),
workspaceId: formData.get('workspaceId'),
context: formData.get('context'),
})
if (!formFieldsResult.success) {
throw new InvalidRequestError(
getValidationErrorMessage(formFieldsResult.error, 'Invalid upload form data')
)
}
const formFields = formFieldsResult.data
const { workflowId, executionId, workspaceId, context: contextParam } = formFields
// Context must be explicitly provided
if (!contextParam) {
throw new InvalidRequestError(
'Upload requires explicit context parameter (knowledge-base, workspace, execution, copilot, chat, profile-pictures, or workspace-logos)'
)
}
const context = contextParam as StorageContext
const storageService = await import('@/lib/uploads/core/storage-service')
const usingCloudStorage = storageService.hasCloudStorage()
logger.info(`Using storage mode: ${usingCloudStorage ? 'Cloud' : 'Local'} for file upload`)
// Execution context requires a workspace write/admin permission check. Resolve it once per
// request (not per file) since workspaceId is invariant across all files in the upload.
let executionUploadContext:
| { workspaceId: string; workflowId: string; executionId: string }
| undefined
if (context === 'execution') {
if (!workflowId || !executionId || !workspaceId) {
throw new InvalidRequestError(
'Execution context requires workflowId, executionId, and workspaceId parameters'
)
}
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (permission !== 'write' && permission !== 'admin') {
return NextResponse.json(
{ error: 'Write or Admin access required for execution uploads' },
{ status: 403 }
)
}
executionUploadContext = { workspaceId, workflowId, executionId }
}
// Mothership context requires the same workspace write/admin permission check, plus a
// storage quota check. Resolve both once per request (not per file) since workspaceId is
// invariant across all files in the upload and quota must account for the full batch size,
// not just one file.
let mothershipWorkspaceId: string | undefined
if (context === 'mothership') {
if (!workspaceId) {
throw new InvalidRequestError('Mothership context requires workspaceId parameter')
}
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (permission !== 'write' && permission !== 'admin') {
return NextResponse.json(
{ error: 'Write or Admin access required for mothership uploads' },
{ status: 403 }
)
}
const { checkStorageQuota } = await import('@/lib/billing/storage')
const quotaCheck = await checkStorageQuota(session.user.id, totalFileSize)
if (!quotaCheck.allowed) {
return NextResponse.json(
{ error: quotaCheck.error || 'Storage limit exceeded' },
{ status: 413 }
)
}
mothershipWorkspaceId = workspaceId
}
const uploadResults = []
for (const file of files) {
const originalName = file.name || 'untitled.md'
if (!validateFileExtension(originalName)) {
const extension = originalName.split('.').pop()?.toLowerCase() || 'unknown'
throw new InvalidRequestError(
`File type '${extension}' is not allowed. Allowed types: ${Array.from(ALLOWED_EXTENSIONS).join(', ')}`
)
}
const buffer = await readFileToBufferWithLimit(file, {
maxBytes: MAX_WORKSPACE_FORMDATA_FILE_SIZE,
label: 'uploaded file',
})
// Handle execution context
if (context === 'execution' && executionUploadContext) {
const { uploadExecutionFile } = await import('@/lib/uploads/contexts/execution')
const userFile = await uploadExecutionFile(
executionUploadContext,
buffer,
originalName,
file.type,
session.user.id
)
uploadResults.push(userFile)
continue
}
// Handle knowledge-base context
if (context === 'knowledge-base') {
// Validate file type for knowledge base
const validationError = validateFileType(originalName, file.type)
if (validationError) {
throw new InvalidRequestError(validationError.message)
}
if (!workspaceId) {
throw new InvalidRequestError('workspaceId is required for knowledge-base uploads')
}
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (permission !== 'write' && permission !== 'admin') {
return NextResponse.json(
{ error: 'Write or Admin access required for knowledge-base uploads' },
{ status: 403 }
)
}
logger.info(`Uploading knowledge-base file: ${originalName}`)
const storageKey = generateKnowledgeBaseFileKey(originalName)
const metadata: Record<string, string> = {
originalName: originalName,
uploadedAt: new Date().toISOString(),
purpose: 'knowledge-base',
userId: session.user.id,
workspaceId,
}
const fileInfo = await storageService.uploadFile({
file: buffer,
fileName: storageKey,
contentType: file.type,
context: 'knowledge-base',
preserveKey: true,
customKey: storageKey,
metadata,
})
const finalPath = usingCloudStorage
? `${fileInfo.path}?context=knowledge-base`
: fileInfo.path
const uploadResult = {
fileName: originalName,
presignedUrl: '', // Not used for server-side uploads
fileInfo: {
path: finalPath,
key: fileInfo.key,
name: originalName,
size: buffer.length,
type: file.type,
},
directUploadSupported: false,
}
logger.info(`Successfully uploaded knowledge-base file: ${fileInfo.key}`)
uploadResults.push(uploadResult)
continue
}
// Handle workspace context
if (context === 'workspace') {
if (!workspaceId) {
throw new InvalidRequestError('Workspace context requires workspaceId parameter')
}
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (permission !== 'admin' && permission !== 'write') {
return NextResponse.json(
{ error: 'Write or Admin access required for workspace uploads' },
{ status: 403 }
)
}
try {
const { uploadWorkspaceFile } = await import('@/lib/uploads/contexts/workspace')
const userFile = await uploadWorkspaceFile(
workspaceId,
session.user.id,
buffer,
originalName,
file.type || 'application/octet-stream'
)
uploadResults.push(userFile)
continue
} catch (workspaceError) {
const errorMessage = getErrorMessage(workspaceError, 'Upload failed')
const isDuplicate = errorMessage.includes('already exists')
const isStorageLimitError =
errorMessage.includes('Storage limit exceeded') ||
errorMessage.includes('storage limit')
logger.warn(`Workspace file upload failed: ${errorMessage}`)
let statusCode = 500
if (isDuplicate) statusCode = 409
else if (isStorageLimitError) statusCode = 413
return NextResponse.json(
{
success: false,
error: errorMessage,
isDuplicate,
},
{ status: statusCode }
)
}
}
// Handle mothership context (chat-scoped uploads to workspace S3)
if (context === 'mothership' && mothershipWorkspaceId) {
logger.info(`Uploading mothership file: ${originalName}`)
const storageKey = generateWorkspaceFileKey(mothershipWorkspaceId, originalName)
const metadata: Record<string, string> = {
originalName: originalName,
uploadedAt: new Date().toISOString(),
purpose: 'mothership',
userId: session.user.id,
workspaceId: mothershipWorkspaceId,
}
const fileInfo = await storageService.uploadFile({
file: buffer,
fileName: storageKey,
contentType: file.type || 'application/octet-stream',
context: 'mothership',
preserveKey: true,
customKey: storageKey,
metadata,
})
const finalPath = usingCloudStorage ? `${fileInfo.path}?context=mothership` : fileInfo.path
uploadResults.push({
fileName: originalName,
presignedUrl: '',
fileInfo: {
path: finalPath,
key: fileInfo.key,
name: originalName,
size: buffer.length,
type: file.type || 'application/octet-stream',
},
directUploadSupported: false,
})
logger.info(`Successfully uploaded mothership file: ${fileInfo.key}`)
continue
}
if (
context === 'copilot' ||
context === 'chat' ||
context === 'profile-pictures' ||
context === 'workspace-logos'
) {
if (context !== 'copilot') {
const mimeType = file.type
const isGenericMime = !mimeType || mimeType === 'application/octet-stream'
const extension = originalName.split('.').pop()?.toLowerCase() ?? ''
const extensionIsImage = (SUPPORTED_IMAGE_EXTENSIONS as readonly string[]).includes(
extension
)
const isImage = isGenericMime ? extensionIsImage : isImageFileType(mimeType)
if (!isImage) {
throw new InvalidRequestError(`Only image files are allowed for ${context} uploads`)
}
}
if (context === 'workspace-logos') {
if (!workspaceId) {
throw new InvalidRequestError('workspace-logos context requires workspaceId parameter')
}
const permission = await getUserEntityPermissions(
session.user.id,
'workspace',
workspaceId
)
if (permission !== 'admin') {
return NextResponse.json(
{ error: 'Admin access required for workspace logo uploads' },
{ status: 403 }
)
}
}
if (context === 'chat' && workspaceId) {
const permission = await getUserEntityPermissions(
session.user.id,
'workspace',
workspaceId
)
if (permission === null) {
return NextResponse.json(
{ error: 'Insufficient permissions for workspace' },
{ status: 403 }
)
}
}
logger.info(`Uploading ${context} file: ${originalName}`)
const resolvedContentType = resolveFileType({ type: file.type, name: originalName })
const timestamp = Date.now()
const safeFileName = sanitizeFileName(originalName)
const storageKey = `${context}/${timestamp}-${safeFileName}`
const metadata: Record<string, string> = {
originalName: originalName,
uploadedAt: new Date().toISOString(),
purpose: context,
userId: session.user.id,
}
if (workspaceId && context === 'chat') {
metadata.workspaceId = workspaceId
}
const fileInfo = await storageService.uploadFile({
file: buffer,
fileName: storageKey,
contentType: resolvedContentType,
context,
preserveKey: true,
customKey: storageKey,
metadata,
})
const finalPath = usingCloudStorage ? `${fileInfo.path}?context=${context}` : fileInfo.path
const uploadResult = {
fileName: originalName,
presignedUrl: '', // Not used for server-side uploads
fileInfo: {
path: finalPath,
key: fileInfo.key,
name: originalName,
size: buffer.length,
type: resolvedContentType,
},
directUploadSupported: false,
}
logger.info(`Successfully uploaded ${context} file: ${fileInfo.key}`)
if (context === 'workspace-logos' && workspaceId) {
recordAudit({
workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.FILE_UPLOADED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: workspaceId,
description: `Uploaded workspace logo "${originalName}"`,
metadata: {
fileName: originalName,
fileKey: fileInfo.key,
fileSize: buffer.length,
fileType: resolvedContentType,
},
request,
})
captureServerEvent(session.user.id, 'workspace_logo_uploaded', {
workspace_id: workspaceId,
file_name: originalName,
file_size: buffer.length,
})
}
uploadResults.push(uploadResult)
continue
}
// Unknown context
throw new InvalidRequestError(
`Unsupported context: ${context}. Use knowledge-base, workspace, execution, copilot, chat, profile-pictures, or workspace-logos`
)
}
if (uploadResults.length === 1) {
return NextResponse.json(uploadResults[0])
}
return NextResponse.json({ files: uploadResults })
} catch (error) {
logger.error('Error in file upload:', error)
if (isPayloadSizeLimitError(error)) {
return NextResponse.json(
{
error: 'PayloadSizeLimitError',
message: `File exceeds the server upload limit of ${Math.round(error.maxBytes / (1024 * 1024))}MB. Use direct upload for larger workspace files.`,
},
{ status: 413 }
)
}
return createErrorResponse(error instanceof Error ? error : new Error('File upload failed'))
}
})