diff --git a/packages/@uppy/aws-s3/tests/index.test.ts b/packages/@uppy/aws-s3/tests/index.test.ts index d3c9490af..35201311e 100644 --- a/packages/@uppy/aws-s3/tests/index.test.ts +++ b/packages/@uppy/aws-s3/tests/index.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import 'whatwg-fetch' import Core, { type Meta, type UppyFile } from '@uppy/core' @@ -7,6 +7,103 @@ import AwsS3, { type AwsBody, type AwsS3Options } from '../src/index.js' const KB = 1024 const MB = KB * KB +// --------------------------------------------------------------------------- +// Helpers for multipart upload tests +// --------------------------------------------------------------------------- + +/** Minimal XML responses that S3 returns for multipart operations */ +const s3Responses = { + createMultipart: (uploadId: string, key: string) => + ` + + ${uploadId} + ${key} + `, + + uploadPart: (etag: string) => '', + + listParts: (parts: { partNumber: number; etag: string }[]) => + ` + + ${parts.map((p) => `${p.partNumber}${p.etag}`).join('')} + `, + + completeMultipart: (location: string, key: string) => + ` + + ${location} + ${key} + test-bucket + `, + + abortMultipart: () => '', +} + +/** + * Creates mock signRequest + fetch for multipart upload tests. + * signRequest encodes the operation in the URL so fetchMock can route correctly. + */ +function createMultipartMocks(opts: { uploadId?: string; key?: string } = {}) { + const uploadId = opts.uploadId ?? 'test-upload-id' + const key = opts.key ?? 'test-key' + + // signRequest encodes operation details in the URL for fetchMock routing + const signRequest = vi.fn().mockImplementation(async (req: any) => { + const params = new URLSearchParams() + if (req.uploadId) params.set('uploadId', req.uploadId) + if (req.partNumber) params.set('partNumber', String(req.partNumber)) + params.set('method', req.method) + return { + url: `https://test-bucket.s3.us-east-1.amazonaws.com/${req.key || key}?${params}`, + } + }) + + const operations: string[] = [] + + const fetchMock = vi.fn().mockImplementation(async (url: string | Request, init?: any) => { + const urlStr = typeof url === 'string' ? url : url.url + const method = init?.method || 'GET' + const params = new URL(urlStr).searchParams + const hasUploadId = params.has('uploadId') + + if (method === 'POST' && !hasUploadId) { + operations.push('createMultipart') + return new Response(s3Responses.createMultipart(uploadId, key), { + status: 200, + headers: { 'Content-Type': 'application/xml' }, + }) + } + if (method === 'PUT') { + operations.push('uploadPart') + return new Response('', { + status: 200, + headers: { ETag: '"etag-1"' }, + }) + } + if (method === 'POST' && hasUploadId) { + operations.push('completeMultipart') + return new Response( + s3Responses.completeMultipart(`https://test-bucket.s3.amazonaws.com/${key}`, key), + { status: 200, headers: { 'Content-Type': 'application/xml' } }, + ) + } + if (method === 'GET' && hasUploadId) { + operations.push('listParts') + return new Response(s3Responses.listParts([]), { + status: 200, + headers: { 'Content-Type': 'application/xml' }, + }) + } + if (method === 'DELETE') { + operations.push('abortMultipart') + return new Response('', { status: 204 }) + } + return new Response('Not Found', { status: 404 }) + }) + + return { signRequest, fetchMock, operations, uploadId, key } +} + describe('AwsS3', () => { it('Registers AwsS3 upload plugin', () => { const core = new Core().use(AwsS3, { @@ -244,4 +341,153 @@ describe('AwsS3', () => { expect(result?.successful).toHaveLength(0) }) }) + + describe('Golden Retriever resume state (s3Multipart)', () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + it('persists s3Multipart on file state after creating multipart upload', async () => { + const { signRequest, fetchMock, uploadId } = createMultipartMocks() + // After createMultipart succeeds, hang on uploadPart so we can inspect state + fetchMock.mockImplementation(async (url: string | Request, init?: any) => { + const urlStr = typeof url === 'string' ? url : url.url + const method = init?.method || 'GET' + const hasUploadId = new URL(urlStr).searchParams.has('uploadId') + + if (method === 'POST' && !hasUploadId) { + return new Response( + s3Responses.createMultipart(uploadId, 'test-key'), + { status: 200, headers: { 'Content-Type': 'application/xml' } }, + ) + } + // Hang on everything else — we only need createMultipart to complete + return new Promise(() => {}) + }) + globalThis.fetch = fetchMock + + const core = new Core().use(AwsS3, { + bucket: 'test-bucket', + region: 'us-east-1', + signRequest, + shouldUseMultipart: true, + }) + + const fileId = core.addFile({ + source: 'test', + name: 'big.dat', + type: 'application/octet-stream', + data: new File([new Uint8Array(6 * MB)], 'big.dat'), + }) + + const uploadPromise = core.upload() + + // Wait for createMultipart to complete and state to be persisted + await new Promise((resolve) => setTimeout(resolve, 100)) + + const file = core.getFile(fileId) + expect(file.s3Multipart).toBeDefined() + expect(file.s3Multipart?.uploadId).toBe(uploadId) + + // Clean up + core.cancelAll() + await uploadPromise + }) + + it('clears s3Multipart when upload is aborted via cancelAll', async () => { + const { signRequest, fetchMock } = createMultipartMocks() + fetchMock.mockImplementation(async (url: string | Request, init?: any) => { + const urlStr = typeof url === 'string' ? url : url.url + const method = init?.method || 'GET' + const hasUploadId = new URL(urlStr).searchParams.has('uploadId') + + if (method === 'POST' && !hasUploadId) { + return new Response( + s3Responses.createMultipart('cancel-test-id', 'cancel-key'), + { status: 200, headers: { 'Content-Type': 'application/xml' } }, + ) + } + if (method === 'DELETE') { + return new Response('', { status: 204 }) + } + // Hang on everything else + return new Promise(() => {}) + }) + globalThis.fetch = fetchMock + + const core = new Core().use(AwsS3, { + bucket: 'test-bucket', + region: 'us-east-1', + signRequest, + shouldUseMultipart: true, + }) + + const fileId = core.addFile({ + source: 'test', + name: 'big.dat', + type: 'application/octet-stream', + data: new File([new Uint8Array(6 * MB)], 'big.dat'), + }) + + const uploadPromise = core.upload() + + // Wait for createMultipart, then cancel + await new Promise((resolve) => setTimeout(resolve, 50)) + core.cancelAll() + + await uploadPromise + + const file = core.getFile(fileId) + // s3Multipart should be cleared so retries don't use a dead uploadId + expect(file?.s3Multipart).toBeUndefined() + }) + + it('uses persisted s3Multipart key for resume (listParts, not createMultipart)', async () => { + const persistedKey = 'persisted-object-key' + const persistedUploadId = 'persisted-upload-id' + const { signRequest, fetchMock, operations } = createMultipartMocks({ + uploadId: persistedUploadId, + key: persistedKey, + }) + globalThis.fetch = fetchMock + + const core = new Core().use(AwsS3, { + bucket: 'test-bucket', + region: 'us-east-1', + signRequest, + shouldUseMultipart: false, // Would normally be simple upload + }) + + const fileId = core.addFile({ + source: 'test', + name: 'big.dat', + type: 'application/octet-stream', + data: new File([new Uint8Array(6 * MB)], 'big.dat'), + }) + + // Simulate Golden Retriever restoring s3Multipart state + core.setFileState(fileId, { + s3Multipart: { uploadId: persistedUploadId, key: persistedKey }, + }) + + const uploadPromise = core.upload() + + // Wait for the resume flow to call listParts (via fetch), then cancel + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Should have resumed (listParts) instead of creating a new multipart upload + expect(operations).toContain('listParts') + expect(operations).not.toContain('createMultipart') + + // The signRequest calls should use the persisted key, not a generated one + const signedKeys = signRequest.mock.calls.map((call: any) => call[0].key) + expect(signedKeys.every((k: string) => k === persistedKey)).toBe(true) + + // Clean up + core.cancelAll() + await uploadPromise + }) + }) })