Skip to content

Commit 013f039

Browse files
committed
Merge branch 'staging' into fix/memories
2 parents e0c04c2 + 71ebe81 commit 013f039

50 files changed

Lines changed: 3484 additions & 1642 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/files/delete/route.test.ts

Lines changed: 21 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,25 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { authMockFns, hybridAuthMockFns } from '@sim/testing'
4+
import {
5+
authMockFns,
6+
hybridAuthMockFns,
7+
storageServiceMock,
8+
storageServiceMockFns,
9+
} from '@sim/testing'
510
import { beforeEach, describe, expect, it, vi } from 'vitest'
611

712
const mocks = vi.hoisted(() => {
813
const mockVerifyFileAccess = vi.fn()
914
const mockVerifyWorkspaceFileAccess = vi.fn()
10-
const mockDeleteFile = vi.fn()
11-
const mockHasCloudStorage = vi.fn()
1215
const mockGetStorageProvider = vi.fn()
1316
const mockIsUsingCloudStorage = vi.fn()
14-
const mockUploadFile = vi.fn()
15-
const mockDownloadFile = vi.fn()
1617

1718
return {
1819
mockVerifyFileAccess,
1920
mockVerifyWorkspaceFileAccess,
20-
mockDeleteFile,
21-
mockHasCloudStorage,
2221
mockGetStorageProvider,
2322
mockIsUsingCloudStorage,
24-
mockUploadFile,
25-
mockDownloadFile,
2623
}
2724
})
2825

@@ -68,23 +65,18 @@ vi.mock('@/lib/uploads', () => ({
6865
getStorageProvider: mocks.mockGetStorageProvider,
6966
isUsingCloudStorage: mocks.mockIsUsingCloudStorage,
7067
StorageService: {
71-
uploadFile: mocks.mockUploadFile,
72-
downloadFile: mocks.mockDownloadFile,
73-
deleteFile: mocks.mockDeleteFile,
74-
hasCloudStorage: mocks.mockHasCloudStorage,
68+
uploadFile: storageServiceMockFns.mockUploadFile,
69+
downloadFile: storageServiceMockFns.mockDownloadFile,
70+
deleteFile: storageServiceMockFns.mockDeleteFile,
71+
hasCloudStorage: storageServiceMockFns.mockHasCloudStorage,
7572
},
76-
uploadFile: mocks.mockUploadFile,
77-
downloadFile: mocks.mockDownloadFile,
78-
deleteFile: mocks.mockDeleteFile,
79-
hasCloudStorage: mocks.mockHasCloudStorage,
73+
uploadFile: storageServiceMockFns.mockUploadFile,
74+
downloadFile: storageServiceMockFns.mockDownloadFile,
75+
deleteFile: storageServiceMockFns.mockDeleteFile,
76+
hasCloudStorage: storageServiceMockFns.mockHasCloudStorage,
8077
}))
8178

82-
vi.mock('@/lib/uploads/core/storage-service', () => ({
83-
uploadFile: mocks.mockUploadFile,
84-
downloadFile: mocks.mockDownloadFile,
85-
deleteFile: mocks.mockDeleteFile,
86-
hasCloudStorage: mocks.mockHasCloudStorage,
87-
}))
79+
vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)
8880

8981
vi.mock('@/lib/uploads/server/metadata', () => ({
9082
deleteFileMetadata: vi.fn().mockResolvedValue(undefined),
@@ -117,14 +109,14 @@ describe('File Delete API Route', () => {
117109
})
118110
mocks.mockVerifyFileAccess.mockResolvedValue(true)
119111
mocks.mockVerifyWorkspaceFileAccess.mockResolvedValue(true)
120-
mocks.mockDeleteFile.mockResolvedValue(undefined)
121-
mocks.mockHasCloudStorage.mockReturnValue(true)
112+
storageServiceMockFns.mockDeleteFile.mockResolvedValue(undefined)
113+
storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true)
122114
mocks.mockGetStorageProvider.mockReturnValue('s3')
123115
mocks.mockIsUsingCloudStorage.mockReturnValue(true)
124116
})
125117

126118
it('should handle local file deletion successfully', async () => {
127-
mocks.mockHasCloudStorage.mockReturnValue(false)
119+
storageServiceMockFns.mockHasCloudStorage.mockReturnValue(false)
128120
mocks.mockGetStorageProvider.mockReturnValue('local')
129121
mocks.mockIsUsingCloudStorage.mockReturnValue(false)
130122

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

144136
it('should handle file not found gracefully', async () => {
145-
mocks.mockHasCloudStorage.mockReturnValue(false)
137+
storageServiceMockFns.mockHasCloudStorage.mockReturnValue(false)
146138
mocks.mockGetStorageProvider.mockReturnValue('local')
147139
mocks.mockIsUsingCloudStorage.mockReturnValue(false)
148140

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

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

193-
expect(mocks.mockDeleteFile).toHaveBeenCalledWith({
185+
expect(storageServiceMockFns.mockDeleteFile).toHaveBeenCalledWith({
194186
key: 'workspace/test-workspace-id/1234567890-test-document.pdf',
195187
context: 'workspace',
196188
})
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { authMockFns, permissionsMock, permissionsMockFns } from '@sim/testing'
5+
import { NextRequest } from 'next/server'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const {
9+
mockIsUsingCloudStorage,
10+
mockGetStorageProvider,
11+
mockGetStorageConfig,
12+
mockCompleteS3MultipartUpload,
13+
mockCompleteBlobMultipartUpload,
14+
mockDeriveBlobBlockId,
15+
mockVerifyUploadToken,
16+
mockSignUploadToken,
17+
} = vi.hoisted(() => ({
18+
mockIsUsingCloudStorage: vi.fn(),
19+
mockGetStorageProvider: vi.fn(),
20+
mockGetStorageConfig: vi.fn(),
21+
mockCompleteS3MultipartUpload: vi.fn(),
22+
mockCompleteBlobMultipartUpload: vi.fn(),
23+
mockDeriveBlobBlockId: vi.fn(),
24+
mockVerifyUploadToken: vi.fn(),
25+
mockSignUploadToken: vi.fn(),
26+
}))
27+
28+
vi.mock('@/lib/uploads', () => ({
29+
isUsingCloudStorage: mockIsUsingCloudStorage,
30+
getStorageProvider: mockGetStorageProvider,
31+
getStorageConfig: mockGetStorageConfig,
32+
}))
33+
34+
vi.mock('@/lib/uploads/core/upload-token', () => ({
35+
signUploadToken: mockSignUploadToken,
36+
verifyUploadToken: mockVerifyUploadToken,
37+
}))
38+
39+
vi.mock('@/lib/uploads/providers/s3/client', () => ({
40+
completeS3MultipartUpload: mockCompleteS3MultipartUpload,
41+
initiateS3MultipartUpload: vi.fn(),
42+
getS3MultipartPartUrls: vi.fn(),
43+
abortS3MultipartUpload: vi.fn(),
44+
}))
45+
46+
vi.mock('@/lib/uploads/providers/blob/client', () => ({
47+
completeMultipartUpload: mockCompleteBlobMultipartUpload,
48+
deriveBlobBlockId: mockDeriveBlobBlockId,
49+
initiateMultipartUpload: vi.fn(),
50+
getMultipartPartUrls: vi.fn(),
51+
abortMultipartUpload: vi.fn(),
52+
}))
53+
54+
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
55+
56+
import { POST } from '@/app/api/files/multipart/route'
57+
58+
const tokenPayload = {
59+
uploadId: 'upload-1',
60+
key: 'workspace/ws-1/123-abc-file.bin',
61+
userId: 'user-1',
62+
workspaceId: 'ws-1',
63+
context: 'workspace' as const,
64+
}
65+
66+
const makeRequest = (action: string, body: unknown) =>
67+
new NextRequest(`http://localhost/api/files/multipart?action=${action}`, {
68+
method: 'POST',
69+
headers: { 'Content-Type': 'application/json' },
70+
body: JSON.stringify(body),
71+
})
72+
73+
describe('POST /api/files/multipart action=complete', () => {
74+
beforeEach(() => {
75+
vi.clearAllMocks()
76+
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
77+
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
78+
mockIsUsingCloudStorage.mockReturnValue(true)
79+
mockGetStorageConfig.mockReturnValue({ bucket: 'b', region: 'r' })
80+
mockVerifyUploadToken.mockReturnValue({ valid: true, payload: tokenPayload })
81+
mockSignUploadToken.mockReturnValue('signed-token')
82+
mockCompleteS3MultipartUpload.mockResolvedValue({
83+
location: 'loc',
84+
path: '/api/files/serve/...',
85+
key: tokenPayload.key,
86+
})
87+
mockCompleteBlobMultipartUpload.mockResolvedValue({
88+
location: 'loc',
89+
path: '/api/files/serve/...',
90+
key: tokenPayload.key,
91+
})
92+
mockDeriveBlobBlockId.mockImplementation(
93+
(n: number) => `block-${n.toString().padStart(6, '0')}`
94+
)
95+
})
96+
97+
it('rejects parts without partNumber', async () => {
98+
mockGetStorageProvider.mockReturnValue('s3')
99+
const res = await POST(
100+
makeRequest('complete', {
101+
uploadToken: 'tok',
102+
parts: [{ etag: 'abc' }],
103+
})
104+
)
105+
expect(res.status).toBe(400)
106+
expect(mockCompleteS3MultipartUpload).not.toHaveBeenCalled()
107+
})
108+
109+
it('S3 path requires etag and forwards { ETag, PartNumber }', async () => {
110+
mockGetStorageProvider.mockReturnValue('s3')
111+
112+
const missingEtag = await POST(
113+
makeRequest('complete', {
114+
uploadToken: 'tok',
115+
parts: [{ partNumber: 1 }],
116+
})
117+
)
118+
expect(missingEtag.status).toBe(500)
119+
120+
mockCompleteS3MultipartUpload.mockClear()
121+
122+
const ok = await POST(
123+
makeRequest('complete', {
124+
uploadToken: 'tok',
125+
parts: [
126+
{ partNumber: 1, etag: 'aaa' },
127+
{ partNumber: 2, etag: 'bbb' },
128+
],
129+
})
130+
)
131+
expect(ok.status).toBe(200)
132+
expect(mockCompleteS3MultipartUpload).toHaveBeenCalledWith(
133+
tokenPayload.key,
134+
tokenPayload.uploadId,
135+
[
136+
{ ETag: 'aaa', PartNumber: 1 },
137+
{ ETag: 'bbb', PartNumber: 2 },
138+
],
139+
expect.any(Object)
140+
)
141+
})
142+
143+
it('Blob path derives blockId from partNumber and ignores etag', async () => {
144+
mockGetStorageProvider.mockReturnValue('blob')
145+
mockGetStorageConfig.mockReturnValue({
146+
containerName: 'c',
147+
accountName: 'a',
148+
accountKey: 'k',
149+
})
150+
151+
const res = await POST(
152+
makeRequest('complete', {
153+
uploadToken: 'tok',
154+
parts: [{ partNumber: 1, etag: 'irrelevant' }, { partNumber: 2 }],
155+
})
156+
)
157+
158+
expect(res.status).toBe(200)
159+
expect(mockDeriveBlobBlockId).toHaveBeenCalledWith(1)
160+
expect(mockDeriveBlobBlockId).toHaveBeenCalledWith(2)
161+
expect(mockCompleteBlobMultipartUpload).toHaveBeenCalledWith(
162+
tokenPayload.key,
163+
[
164+
{ partNumber: 1, blockId: 'block-000001' },
165+
{ partNumber: 2, blockId: 'block-000002' },
166+
],
167+
expect.objectContaining({ containerName: 'c' })
168+
)
169+
})
170+
171+
it('returns 403 when token is invalid', async () => {
172+
mockGetStorageProvider.mockReturnValue('s3')
173+
mockVerifyUploadToken.mockReturnValueOnce({ valid: false })
174+
const res = await POST(
175+
makeRequest('complete', {
176+
uploadToken: 'bad',
177+
parts: [{ partNumber: 1, etag: 'a' }],
178+
})
179+
)
180+
expect(res.status).toBe(403)
181+
})
182+
183+
it('batch complete normalizes per upload', async () => {
184+
mockGetStorageProvider.mockReturnValue('s3')
185+
const res = await POST(
186+
makeRequest('complete', {
187+
uploads: [
188+
{
189+
uploadToken: 'tok-a',
190+
parts: [{ partNumber: 1, etag: 'aaa' }],
191+
},
192+
{
193+
uploadToken: 'tok-b',
194+
parts: [{ partNumber: 1, etag: 'bbb' }],
195+
},
196+
],
197+
})
198+
)
199+
expect(res.status).toBe(200)
200+
expect(mockCompleteS3MultipartUpload).toHaveBeenCalledTimes(2)
201+
})
202+
})

0 commit comments

Comments
 (0)