Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 189 additions & 0 deletions apps/sim/app/api/files/presigned/batch/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/**
* Tests for the batch presigned upload API route
*
* @vitest-environment node
*/

import { authMockFns, storageServiceMock, storageServiceMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const {
mockValidateFileType,
mockGetUserEntityPermissions,
mockRecordKnowledgeBaseFileOwnershipMany,
} = vi.hoisted(() => ({
mockValidateFileType: vi.fn().mockReturnValue(null),
mockGetUserEntityPermissions: vi.fn().mockResolvedValue('write'),
mockRecordKnowledgeBaseFileOwnershipMany: vi.fn().mockResolvedValue(undefined),
}))

vi.mock('@/lib/uploads/config', () => ({
getServeStoragePrefix: () => 's3',
}))

vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)

vi.mock('@/lib/uploads/utils/validation', () => ({
validateFileType: mockValidateFileType,
SUPPORTED_ARCHIVE_EXTENSIONS: ['zip'] as const,
}))

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

vi.mock('@/lib/uploads/server/metadata', () => ({
recordKnowledgeBaseFileOwnershipMany: mockRecordKnowledgeBaseFileOwnershipMany,
}))

import { POST } from '@/app/api/files/presigned/batch/route'

const KB_QUERY = 'type=knowledge-base&workspaceId=ws-1'

const buildRequest = (query: string, files?: unknown) =>
new NextRequest(`http://localhost:3000/api/files/presigned/batch?${query}`, {
method: 'POST',
body: JSON.stringify({
files: files ?? [{ fileName: 'doc.pdf', contentType: 'application/pdf', fileSize: 1024 }],
}),
})

describe('/api/files/presigned/batch', () => {
beforeEach(() => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
mockValidateFileType.mockReturnValue(null)
mockGetUserEntityPermissions.mockResolvedValue('write')
mockRecordKnowledgeBaseFileOwnershipMany.mockResolvedValue(undefined)
storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true)
storageServiceMockFns.mockGenerateBatchPresignedUploadUrls.mockImplementation(
async (files: Array<{ fileName: string }>, context: string) =>
files.map((file) => ({
url: `https://example.com/${context}/${file.fileName}`,
key: `${context}/${file.fileName}`,
}))
)
})

it('returns 401 when the caller has no session', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)

const response = await POST(buildRequest(KB_QUERY))

expect(response.status).toBe(401)
expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled()
})

it.each([
'workspace-logos',
'profile-pictures',
'execution',
'mothership',
'chat',
'copilot',
'workspace',
])('refuses to presign the %s context', async (type) => {
const response = await POST(buildRequest(`type=${type}&workspaceId=ws-1`))
const data = await response.json()

expect(response.status).toBe(400)
expect(data.error).toContain('Invalid type parameter')
expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled()
})

it('returns 400 when type is missing', async () => {
const response = await POST(buildRequest('workspaceId=ws-1'))

expect(response.status).toBe(400)
expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled()
})

it('returns 400 when workspaceId is missing', async () => {
const response = await POST(buildRequest('type=knowledge-base'))
const data = await response.json()

expect(response.status).toBe(400)
expect(data.error).toContain('workspaceId')
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled()
})

it.each([['read'], [null]])(
'returns 403 when the caller has %s access to the workspace',
async (permission) => {
mockGetUserEntityPermissions.mockResolvedValue(permission)

const response = await POST(buildRequest(KB_QUERY))

expect(response.status).toBe(403)
expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled()
}
)

it('authorizes the workspace before returning the local-storage fallback', async () => {
storageServiceMockFns.mockHasCloudStorage.mockReturnValue(false)
mockGetUserEntityPermissions.mockResolvedValue('read')

const response = await POST(buildRequest(KB_QUERY))

expect(response.status).toBe(403)
})

it('rejects unsupported file types before minting any URL', async () => {
mockValidateFileType.mockReturnValue({
code: 'UNSUPPORTED_FILE_TYPE',
message: 'Unsupported file type: html.',
supportedTypes: ['pdf'],
})

const response = await POST(
buildRequest(KB_QUERY, [{ fileName: 'poc.html', contentType: 'text/html', fileSize: 41 }])
)
const data = await response.json()

expect(response.status).toBe(400)
expect(data.code).toBe('UNSUPPORTED_FILE_TYPE')
expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled()
})

it('mints knowledge-base URLs and records workspace ownership for a permitted caller', async () => {
const response = await POST(buildRequest(KB_QUERY))
const data = await response.json()

expect(response.status).toBe(200)
expect(mockGetUserEntityPermissions).toHaveBeenCalledWith('user-1', 'workspace', 'ws-1')
expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).toHaveBeenCalledWith(
[{ fileName: 'doc.pdf', contentType: 'application/pdf', fileSize: 1024 }],
'knowledge-base',
'user-1',
3600
)
expect(data.files).toHaveLength(1)
expect(data.files[0].fileInfo.key).toBe('knowledge-base/doc.pdf')
expect(data.files[0].fileInfo.path).toContain('?context=knowledge-base')
expect(data.directUploadSupported).toBe(true)
expect(mockRecordKnowledgeBaseFileOwnershipMany).toHaveBeenCalledWith([
{
key: 'knowledge-base/doc.pdf',
userId: 'user-1',
workspaceId: 'ws-1',
originalName: 'doc.pdf',
contentType: 'application/pdf',
size: 1024,
},
])
})

it('returns the fallback response when cloud storage is not configured', async () => {
storageServiceMockFns.mockHasCloudStorage.mockReturnValue(false)

const response = await POST(buildRequest(KB_QUERY))
const data = await response.json()

expect(response.status).toBe(200)
expect(data.directUploadSupported).toBe(false)
expect(data.files[0].presignedUrl).toBe('')
expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled()
})
})
95 changes: 42 additions & 53 deletions apps/sim/app/api/files/presigned/batch/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import {
batchPresignedUploadBodyContract,
uploadTypeSchema,
batchPresignedUploadTypeSchema,
batchPresignedUploadTypes,
} from '@/lib/api/contracts/storage-transfer'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { StorageContext } from '@/lib/uploads/config'
import { getServeStoragePrefix } from '@/lib/uploads/config'
import {
generateBatchPresignedUploadUrls,
Expand All @@ -20,8 +20,13 @@ import { createErrorResponse } from '@/app/api/files/utils'

const logger = createLogger('BatchPresignedUploadAPI')

const VALID_UPLOAD_TYPES = ['knowledge-base', 'chat', 'copilot', 'profile-pictures'] as const

/**
* Mints presigned upload URLs for knowledge-base ingest, the only context this
* endpoint can authorize. Every request must name a workspace the caller has
* write access to; other storage contexts are rejected rather than presigned,
* because a presigned PUT is a write grant into a bucket served from a trusted
* origin.
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
Expand Down Expand Up @@ -52,59 +57,46 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ error: 'type query parameter is required' }, { status: 400 })
}

const uploadTypeResult = uploadTypeSchema.safeParse(uploadTypeParam)
const uploadTypeResult = batchPresignedUploadTypeSchema.safeParse(uploadTypeParam)
if (!uploadTypeResult.success) {
return NextResponse.json(
{ error: `Invalid type parameter. Must be one of: ${VALID_UPLOAD_TYPES.join(', ')}` },
{
error: `Invalid type parameter. Must be one of: ${batchPresignedUploadTypes.join(', ')}`,
},
{ status: 400 }
)
}

const uploadType = uploadTypeResult.data as StorageContext

const uploadType = uploadTypeResult.data
const sessionUserId = session.user.id

let knowledgeBaseWorkspaceId: string | null = null
if (uploadType === 'knowledge-base') {
for (const file of files) {
const fileValidationError = validateFileType(file.fileName, file.contentType)
if (fileValidationError) {
return NextResponse.json(
{
error: fileValidationError.message,
code: fileValidationError.code,
supportedTypes: fileValidationError.supportedTypes,
},
{ status: 400 }
)
}
}

knowledgeBaseWorkspaceId = request.nextUrl.searchParams.get('workspaceId')
if (!knowledgeBaseWorkspaceId?.trim()) {
for (const file of files) {
const fileValidationError = validateFileType(file.fileName, file.contentType)
if (fileValidationError) {
return NextResponse.json(
{ error: 'workspaceId query parameter is required for knowledge-base uploads' },
{
error: fileValidationError.message,
code: fileValidationError.code,
supportedTypes: fileValidationError.supportedTypes,
},
{ status: 400 }
)
}
}

const permission = await getUserEntityPermissions(
sessionUserId,
'workspace',
knowledgeBaseWorkspaceId
const workspaceId = request.nextUrl.searchParams.get('workspaceId')
if (!workspaceId?.trim()) {
return NextResponse.json(
{ error: 'workspaceId query parameter is required for knowledge-base uploads' },
{ status: 400 }
)
if (permission !== 'write' && permission !== 'admin') {
return NextResponse.json(
{ error: 'Write or Admin access required for knowledge-base uploads' },
{ status: 403 }
)
}
}

if (uploadType === 'copilot' && !sessionUserId?.trim()) {
const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId)
if (permission !== 'write' && permission !== 'admin') {
return NextResponse.json(
{ error: 'Authenticated user session is required for copilot uploads' },
{ status: 400 }
{ error: 'Write or Admin access required for knowledge-base uploads' },
{ status: 403 }
)
}

Expand Down Expand Up @@ -149,19 +141,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
`Generated ${files.length} presigned URLs in ${duration}ms (avg ${Math.round(duration / files.length)}ms per file)`
)

if (uploadType === 'knowledge-base' && knowledgeBaseWorkspaceId) {
const ownerWorkspaceId = knowledgeBaseWorkspaceId
await recordKnowledgeBaseFileOwnershipMany(
presignedUrls.map((urlResponse, index) => ({
key: urlResponse.key,
userId: sessionUserId,
workspaceId: ownerWorkspaceId,
originalName: files[index].fileName,
contentType: files[index].contentType,
size: files[index].fileSize,
}))
)
}
await recordKnowledgeBaseFileOwnershipMany(
presignedUrls.map((urlResponse, index) => ({
key: urlResponse.key,
userId: sessionUserId,
workspaceId,
originalName: files[index].fileName,
contentType: files[index].contentType,
size: files[index].fileSize,
}))
)

const storagePrefix = getServeStoragePrefix()

Expand Down
Loading
Loading