diff --git a/server/routes/download-tasks.integration.test.ts b/server/routes/download-tasks.integration.test.ts index 8367196f..32ef5af0 100644 --- a/server/routes/download-tasks.integration.test.ts +++ b/server/routes/download-tasks.integration.test.ts @@ -423,6 +423,48 @@ describe('Download tasks API integration', () => { }) }) + it('returns storage failure details when multipart upload completion fails', async () => { + vi.mocked(S3Service.prototype.completeMultipartUpload).mockRejectedValueOnce(new Error('InvalidPart: part missing')) + const { app, db } = await createTestApp() + await insertStorage(db) + const headers = { + ...(await authedHeaders(app, 'multipart-complete-user@example.com')), + 'Content-Type': 'application/json', + } + + const createObjectRes = await app.request('/api/objects', { + method: 'POST', + headers, + body: JSON.stringify({ + name: 'fixture.bin', + type: 'application/octet-stream', + size: 6 * 1024 * 1024 * 1024, + parent: '', + }), + }) + expect(createObjectRes.status).toBe(201) + const object = (await createObjectRes.json()) as { id: string } + + const sessionRes = await app.request(`/api/objects/${object.id}/uploads`, { + method: 'POST', + headers, + body: JSON.stringify({ partSize: 64 * 1024 * 1024 }), + }) + expect(sessionRes.status).toBe(201) + const session = (await sessionRes.json()) as { id: string } + + const completeRes = await app.request(`/api/objects/${object.id}/uploads/${session.id}`, { + method: 'PATCH', + headers, + body: JSON.stringify({ action: 'complete', parts: [{ partNumber: 1, etag: '"etag-1"' }] }), + }) + + expect(completeRes.status).toBe(502) + await expect(completeRes.json()).resolves.toEqual({ + error: 'Storage multipart upload complete failed: InvalidPart: part missing', + }) + }) + it('submits user task actions through downloader polling state', async () => { const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) await insertStorage(db) diff --git a/server/services/object-upload-sessions.ts b/server/services/object-upload-sessions.ts index 04051ee4..9d352f0b 100644 --- a/server/services/object-upload-sessions.ts +++ b/server/services/object-upload-sessions.ts @@ -114,13 +114,27 @@ export async function patchObjectUploadSession( if (row.status !== 'active') throw new ObjectUploadSessionError('invalid_state') const now = new Date() if (params.input.action === 'complete') { - await s3.completeMultipartUpload(params.storage, row.storageKey, row.uploadId, params.input.parts) + try { + await s3.completeMultipartUpload(params.storage, row.storageKey, row.uploadId, params.input.parts) + } catch (error) { + throw new ObjectUploadSessionError( + 'storage_failure', + `Storage multipart upload complete failed: ${(error as Error).message}`, + ) + } await db .update(objectUploadSessions) .set({ status: 'completed', updatedAt: now }) .where(eq(objectUploadSessions.id, row.id)) } else { - await s3.abortMultipartUpload(params.storage, row.storageKey, row.uploadId) + try { + await s3.abortMultipartUpload(params.storage, row.storageKey, row.uploadId) + } catch (error) { + throw new ObjectUploadSessionError( + 'storage_failure', + `Storage multipart upload abort failed: ${(error as Error).message}`, + ) + } await db .update(objectUploadSessions) .set({ status: 'aborted', updatedAt: now }) diff --git a/server/services/s3.test.ts b/server/services/s3.test.ts index ffb2a15e..78b90478 100644 --- a/server/services/s3.test.ts +++ b/server/services/s3.test.ts @@ -289,6 +289,21 @@ describe('S3Service', () => { expect(mockSend).not.toHaveBeenCalled() }) + it('rejects embedded S3 complete errors returned with HTTP 200', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce( + new Response('InvalidPartpart does not exist'), + ), + ) + + await expect( + service.completeMultipartUpload(storage, 'video.mp4', 'upload-1', [{ partNumber: 1, etag: '"etag-1"' }]), + ).rejects.toThrow('InvalidPart') + }) + it('aborts multipart uploads through a presigned DELETE', async () => { const fetchMock = vi.fn().mockResolvedValueOnce(new Response(null, { status: 204 })) vi.stubGlobal('fetch', fetchMock) diff --git a/server/services/s3.ts b/server/services/s3.ts index f1160f0c..026a1aa7 100644 --- a/server/services/s3.ts +++ b/server/services/s3.ts @@ -116,8 +116,11 @@ export class S3Service { headers: { 'Content-Type': 'application/xml' }, body: multipartCompleteXml(sortedParts), }) + const body = await response.text() if (!response.ok) { - const body = await response.text() + throw new Error(`S3 multipart upload complete failed: ${response.status}: ${body.trim()}`) + } + if (xmlTag(body, 'Error') !== null || xmlTag(body, 'Code') !== null) { throw new Error(`S3 multipart upload complete failed: ${response.status}: ${body.trim()}`) } }