chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user