Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
925e7a2
fix(uploads): direct-to-S3 workspace files + shared transport
waleedlatif1 May 2, 2026
0128897
chore(testing): centralize posthog and storage-service mocks
waleedlatif1 May 2, 2026
8afc02a
fix(uploads): address PR review — abort propagation, orphan cleanup, …
waleedlatif1 May 2, 2026
b9f4533
fix(uploads): add Zod contracts for workspace presigned/register routes
waleedlatif1 May 2, 2026
7ab4465
fix(uploads): correct BlobServiceClient type name in headBlobObject
waleedlatif1 May 2, 2026
91c0772
fix(uploads): address PR review — typo, complete-failure cleanup, dou…
waleedlatif1 May 2, 2026
681e1d7
fix(uploads): preserve fallback size and reuse existing display name …
waleedlatif1 May 2, 2026
0147ffc
fix(uploads): surface server error message and bypass quota for local…
waleedlatif1 May 2, 2026
13fcf40
fix(uploads): align register response schema with UserFile; skip pres…
waleedlatif1 May 2, 2026
d0c6269
fix(uploads): idempotent register skips duplicate audit/posthog; add …
waleedlatif1 May 2, 2026
1301394
chore: merge staging into mp4-upload-fail
waleedlatif1 May 2, 2026
08bdd26
fix(uploads): include 50MiB boundary in batch presigned fetch
waleedlatif1 May 2, 2026
7bda8c3
fix(uploads): trust HEAD size to prevent quota inflation
waleedlatif1 May 2, 2026
e77f8d5
fix(uploads): audit verified file size, not client-supplied
waleedlatif1 May 2, 2026
92f841f
fix(uploads): handle register retries and name-collision races
waleedlatif1 May 2, 2026
5abdb7c
fix(uploads): retry transient DirectUploadErrors at outer KB level
waleedlatif1 May 2, 2026
c448556
fix(uploads): only retry transient 5xx, not deterministic 4xx
waleedlatif1 May 2, 2026
2aa4f2f
refactor(uploads): collapse getFileContentType into resolveFileType
waleedlatif1 May 2, 2026
888f286
chore(uploads): trim verbose comments
waleedlatif1 May 2, 2026
7101c7e
fix(uploads): regenerate fileId per insert retry; require cloud stora…
waleedlatif1 May 2, 2026
11907c6
fix(uploads): cap formdata fallback at 100MB; drop unused size param
waleedlatif1 May 2, 2026
a3fc764
fix(uploads): abort multipart on get-part-urls failure; retry registe…
waleedlatif1 May 2, 2026
768fc94
fix(uploads): drop vestigial size field from register contract
waleedlatif1 May 2, 2026
639fe18
fix(uploads): abort multipart on complete-fetch throw
waleedlatif1 May 2, 2026
f4e1f28
fix(uploads): set kb presignedEndpoint fallback; race-safe blob HEAD
waleedlatif1 May 3, 2026
42ec45f
fix(uploads): include ?type=knowledge-base on kb presigned fallback
waleedlatif1 May 3, 2026
a8ede19
fix(uploads): remove abort listener on xhr timeout
waleedlatif1 May 3, 2026
c7a6fc9
fix(uploads): add timeout/abort to kb api fallback upload
waleedlatif1 May 3, 2026
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
50 changes: 21 additions & 29 deletions apps/sim/app/api/files/delete/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,25 @@
/**
* @vitest-environment node
*/
import { authMockFns, hybridAuthMockFns } from '@sim/testing'
import {
authMockFns,
hybridAuthMockFns,
storageServiceMock,
storageServiceMockFns,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => {
const mockVerifyFileAccess = vi.fn()
const mockVerifyWorkspaceFileAccess = vi.fn()
const mockDeleteFile = vi.fn()
const mockHasCloudStorage = vi.fn()
const mockGetStorageProvider = vi.fn()
const mockIsUsingCloudStorage = vi.fn()
const mockUploadFile = vi.fn()
const mockDownloadFile = vi.fn()

return {
mockVerifyFileAccess,
mockVerifyWorkspaceFileAccess,
mockDeleteFile,
mockHasCloudStorage,
mockGetStorageProvider,
mockIsUsingCloudStorage,
mockUploadFile,
mockDownloadFile,
}
})

Expand Down Expand Up @@ -68,23 +65,18 @@ vi.mock('@/lib/uploads', () => ({
getStorageProvider: mocks.mockGetStorageProvider,
isUsingCloudStorage: mocks.mockIsUsingCloudStorage,
StorageService: {
uploadFile: mocks.mockUploadFile,
downloadFile: mocks.mockDownloadFile,
deleteFile: mocks.mockDeleteFile,
hasCloudStorage: mocks.mockHasCloudStorage,
uploadFile: storageServiceMockFns.mockUploadFile,
downloadFile: storageServiceMockFns.mockDownloadFile,
deleteFile: storageServiceMockFns.mockDeleteFile,
hasCloudStorage: storageServiceMockFns.mockHasCloudStorage,
},
uploadFile: mocks.mockUploadFile,
downloadFile: mocks.mockDownloadFile,
deleteFile: mocks.mockDeleteFile,
hasCloudStorage: mocks.mockHasCloudStorage,
uploadFile: storageServiceMockFns.mockUploadFile,
downloadFile: storageServiceMockFns.mockDownloadFile,
deleteFile: storageServiceMockFns.mockDeleteFile,
hasCloudStorage: storageServiceMockFns.mockHasCloudStorage,
}))

vi.mock('@/lib/uploads/core/storage-service', () => ({
uploadFile: mocks.mockUploadFile,
downloadFile: mocks.mockDownloadFile,
deleteFile: mocks.mockDeleteFile,
hasCloudStorage: mocks.mockHasCloudStorage,
}))
vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)

vi.mock('@/lib/uploads/server/metadata', () => ({
deleteFileMetadata: vi.fn().mockResolvedValue(undefined),
Expand Down Expand Up @@ -117,14 +109,14 @@ describe('File Delete API Route', () => {
})
mocks.mockVerifyFileAccess.mockResolvedValue(true)
mocks.mockVerifyWorkspaceFileAccess.mockResolvedValue(true)
mocks.mockDeleteFile.mockResolvedValue(undefined)
mocks.mockHasCloudStorage.mockReturnValue(true)
storageServiceMockFns.mockDeleteFile.mockResolvedValue(undefined)
storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true)
mocks.mockGetStorageProvider.mockReturnValue('s3')
mocks.mockIsUsingCloudStorage.mockReturnValue(true)
})

it('should handle local file deletion successfully', async () => {
mocks.mockHasCloudStorage.mockReturnValue(false)
storageServiceMockFns.mockHasCloudStorage.mockReturnValue(false)
mocks.mockGetStorageProvider.mockReturnValue('local')
mocks.mockIsUsingCloudStorage.mockReturnValue(false)

Expand All @@ -142,7 +134,7 @@ describe('File Delete API Route', () => {
})

it('should handle file not found gracefully', async () => {
mocks.mockHasCloudStorage.mockReturnValue(false)
storageServiceMockFns.mockHasCloudStorage.mockReturnValue(false)
mocks.mockGetStorageProvider.mockReturnValue('local')
mocks.mockIsUsingCloudStorage.mockReturnValue(false)

Expand Down Expand Up @@ -170,7 +162,7 @@ describe('File Delete API Route', () => {
expect(data).toHaveProperty('success', true)
expect(data).toHaveProperty('message', 'File deleted successfully')

expect(mocks.mockDeleteFile).toHaveBeenCalledWith({
expect(storageServiceMockFns.mockDeleteFile).toHaveBeenCalledWith({
key: 'workspace/test-workspace-id/1234567890-test-file.txt',
context: 'workspace',
})
Expand All @@ -190,7 +182,7 @@ describe('File Delete API Route', () => {
expect(data).toHaveProperty('success', true)
expect(data).toHaveProperty('message', 'File deleted successfully')

expect(mocks.mockDeleteFile).toHaveBeenCalledWith({
expect(storageServiceMockFns.mockDeleteFile).toHaveBeenCalledWith({
key: 'workspace/test-workspace-id/1234567890-test-document.pdf',
context: 'workspace',
})
Expand Down
202 changes: 202 additions & 0 deletions apps/sim/app/api/files/multipart/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
/**
* @vitest-environment node
*/
import { authMockFns, permissionsMock, permissionsMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const {
mockIsUsingCloudStorage,
mockGetStorageProvider,
mockGetStorageConfig,
mockCompleteS3MultipartUpload,
mockCompleteBlobMultipartUpload,
mockDeriveBlobBlockId,
mockVerifyUploadToken,
mockSignUploadToken,
} = vi.hoisted(() => ({
mockIsUsingCloudStorage: vi.fn(),
mockGetStorageProvider: vi.fn(),
mockGetStorageConfig: vi.fn(),
mockCompleteS3MultipartUpload: vi.fn(),
mockCompleteBlobMultipartUpload: vi.fn(),
mockDeriveBlobBlockId: vi.fn(),
mockVerifyUploadToken: vi.fn(),
mockSignUploadToken: vi.fn(),
}))

vi.mock('@/lib/uploads', () => ({
isUsingCloudStorage: mockIsUsingCloudStorage,
getStorageProvider: mockGetStorageProvider,
getStorageConfig: mockGetStorageConfig,
}))

vi.mock('@/lib/uploads/core/upload-token', () => ({
signUploadToken: mockSignUploadToken,
verifyUploadToken: mockVerifyUploadToken,
}))

vi.mock('@/lib/uploads/providers/s3/client', () => ({
completeS3MultipartUpload: mockCompleteS3MultipartUpload,
initiateS3MultipartUpload: vi.fn(),
getS3MultipartPartUrls: vi.fn(),
abortS3MultipartUpload: vi.fn(),
}))

vi.mock('@/lib/uploads/providers/blob/client', () => ({
completeMultipartUpload: mockCompleteBlobMultipartUpload,
deriveBlobBlockId: mockDeriveBlobBlockId,
initiateMultipartUpload: vi.fn(),
getMultipartPartUrls: vi.fn(),
abortMultipartUpload: vi.fn(),
}))

vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)

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

const tokenPayload = {
uploadId: 'upload-1',
key: 'workspace/ws-1/123-abc-file.bin',
userId: 'user-1',
workspaceId: 'ws-1',
context: 'workspace' as const,
}

const makeRequest = (action: string, body: unknown) =>
new NextRequest(`http://localhost/api/files/multipart?action=${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})

describe('POST /api/files/multipart action=complete', () => {
beforeEach(() => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
mockIsUsingCloudStorage.mockReturnValue(true)
mockGetStorageConfig.mockReturnValue({ bucket: 'b', region: 'r' })
mockVerifyUploadToken.mockReturnValue({ valid: true, payload: tokenPayload })
mockSignUploadToken.mockReturnValue('signed-token')
mockCompleteS3MultipartUpload.mockResolvedValue({
location: 'loc',
path: '/api/files/serve/...',
key: tokenPayload.key,
})
mockCompleteBlobMultipartUpload.mockResolvedValue({
location: 'loc',
path: '/api/files/serve/...',
key: tokenPayload.key,
})
mockDeriveBlobBlockId.mockImplementation(
(n: number) => `block-${n.toString().padStart(6, '0')}`
)
})

it('rejects parts without partNumber', async () => {
mockGetStorageProvider.mockReturnValue('s3')
const res = await POST(
makeRequest('complete', {
uploadToken: 'tok',
parts: [{ etag: 'abc' }],
})
)
expect(res.status).toBe(400)
expect(mockCompleteS3MultipartUpload).not.toHaveBeenCalled()
})

it('S3 path requires etag and forwards { ETag, PartNumber }', async () => {
mockGetStorageProvider.mockReturnValue('s3')

const missingEtag = await POST(
makeRequest('complete', {
uploadToken: 'tok',
parts: [{ partNumber: 1 }],
})
)
expect(missingEtag.status).toBe(500)

mockCompleteS3MultipartUpload.mockClear()

const ok = await POST(
makeRequest('complete', {
uploadToken: 'tok',
parts: [
{ partNumber: 1, etag: 'aaa' },
{ partNumber: 2, etag: 'bbb' },
],
})
)
expect(ok.status).toBe(200)
expect(mockCompleteS3MultipartUpload).toHaveBeenCalledWith(
tokenPayload.key,
tokenPayload.uploadId,
[
{ ETag: 'aaa', PartNumber: 1 },
{ ETag: 'bbb', PartNumber: 2 },
],
expect.any(Object)
)
})

it('Blob path derives blockId from partNumber and ignores etag', async () => {
mockGetStorageProvider.mockReturnValue('blob')
mockGetStorageConfig.mockReturnValue({
containerName: 'c',
accountName: 'a',
accountKey: 'k',
})

const res = await POST(
makeRequest('complete', {
uploadToken: 'tok',
parts: [{ partNumber: 1, etag: 'irrelevant' }, { partNumber: 2 }],
})
)

expect(res.status).toBe(200)
expect(mockDeriveBlobBlockId).toHaveBeenCalledWith(1)
expect(mockDeriveBlobBlockId).toHaveBeenCalledWith(2)
expect(mockCompleteBlobMultipartUpload).toHaveBeenCalledWith(
tokenPayload.key,
[
{ partNumber: 1, blockId: 'block-000001' },
{ partNumber: 2, blockId: 'block-000002' },
],
expect.objectContaining({ containerName: 'c' })
)
})

it('returns 403 when token is invalid', async () => {
mockGetStorageProvider.mockReturnValue('s3')
mockVerifyUploadToken.mockReturnValueOnce({ valid: false })
const res = await POST(
makeRequest('complete', {
uploadToken: 'bad',
parts: [{ partNumber: 1, etag: 'a' }],
})
)
expect(res.status).toBe(403)
})

it('batch complete normalizes per upload', async () => {
mockGetStorageProvider.mockReturnValue('s3')
const res = await POST(
makeRequest('complete', {
uploads: [
{
uploadToken: 'tok-a',
parts: [{ partNumber: 1, etag: 'aaa' }],
},
{
uploadToken: 'tok-b',
parts: [{ partNumber: 1, etag: 'bbb' }],
},
],
})
)
expect(res.status).toBe(200)
expect(mockCompleteS3MultipartUpload).toHaveBeenCalledTimes(2)
})
})
Loading
Loading