Skip to content

Commit 925e7a2

Browse files
committed
fix(uploads): direct-to-S3 workspace files + shared transport
1 parent 66bab93 commit 925e7a2

20 files changed

Lines changed: 2315 additions & 1081 deletions

File tree

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

0 commit comments

Comments
 (0)