diff --git a/src/storage/protocols/tus/s3-store.test.ts b/src/storage/protocols/tus/s3-store.test.ts index e624868bb..733f47392 100644 --- a/src/storage/protocols/tus/s3-store.test.ts +++ b/src/storage/protocols/tus/s3-store.test.ts @@ -1,4 +1,6 @@ import { HttpResponse } from '@smithy/protocol-http' +import { S3Store as TusS3Store } from '@tus/s3-store' +import type { Upload } from '@tus/server' import { S3Store } from './s3-store' class TestS3Store extends S3Store { @@ -26,7 +28,24 @@ function createStore( }) } +function baseUpload(overrides: Partial = {}): Upload { + return { + id: 'tenant/bucket/empty.txt/version-1', + offset: 0, + size: 0, + sizeIsDeferred: false, + metadata: { contentType: 'text/plain' }, + creation_date: undefined, + storage: undefined, + ...overrides, + } as Upload +} + describe('S3Store', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + test('removes the no-op logger middleware from the internal TUS client', () => { const store = createStore(vi.fn()) @@ -67,4 +86,42 @@ describe('S3Store', () => { $metadata: { attempts: 1, totalRetryDelay: 0 }, }) }) + + test('finalizes an empty multipart upload when Upload-Length is 0', async () => { + const store = createStore(vi.fn()) + const upload = baseUpload({ size: 0 }) + const metadata = { + file: upload, + 'upload-id': 'mpu-123', + 'tus-version': '1.0.0', + } + + vi.spyOn(TusS3Store.prototype, 'create').mockResolvedValue(upload) + const getMetadata = vi.spyOn(store as any, 'getMetadata').mockResolvedValue(metadata) + const finish = vi.spyOn(store as any, 'finishMultipartUpload').mockResolvedValue('s3://loc') + const complete = vi.spyOn(store as any, 'completeMetadata').mockResolvedValue(undefined) + const clear = vi.spyOn(store as any, 'clearCache').mockResolvedValue(undefined) + + await expect(store.create(upload)).resolves.toBe(upload) + + expect(getMetadata).toHaveBeenCalledWith(upload.id) + expect(finish).toHaveBeenCalledWith(metadata, []) + expect(complete).toHaveBeenCalledWith(upload) + expect(clear).toHaveBeenCalledWith(upload.id) + }) + + test('does not finalize multipart upload for non-zero or deferred sizes', async () => { + const store = createStore(vi.fn()) + const finish = vi.spyOn(store as any, 'finishMultipartUpload') + + for (const upload of [ + baseUpload({ size: 1 }), + baseUpload({ size: 0, sizeIsDeferred: true }), + ]) { + vi.spyOn(TusS3Store.prototype, 'create').mockResolvedValueOnce(upload) + await store.create(upload) + } + + expect(finish).not.toHaveBeenCalled() + }) }) diff --git a/src/storage/protocols/tus/s3-store.ts b/src/storage/protocols/tus/s3-store.ts index fdcac9d1e..e14748c9c 100644 --- a/src/storage/protocols/tus/s3-store.ts +++ b/src/storage/protocols/tus/s3-store.ts @@ -1,8 +1,31 @@ import { type Options, S3Store as TusS3Store } from '@tus/s3-store' +import type { Upload } from '@tus/server' export class S3Store extends TusS3Store { constructor(options: Options) { super(options) this.client.middlewareStack.remove('loggerMiddleware') } + + /** + * TUS treats `Upload-Length: 0` as immediately final and calls `onUploadFinish` + * without `write()`. Upstream `S3Store.create` only starts a multipart upload, + * so `HeadObject` in our finish hook 404s and clients see a bare Not Found. + * + * Complete the empty multipart here (upstream `finishMultipartUpload` already + * uploads a zero-byte part when `parts` is empty) so the object exists before + * finish runs. + */ + async create(upload: Upload): Promise { + const created = await super.create(upload) + + if (upload.size === 0 && !upload.sizeIsDeferred) { + const metadata = await this.getMetadata(upload.id) + await this.finishMultipartUpload(metadata, []) + await this.completeMetadata(metadata.file) + await this.clearCache(upload.id) + } + + return created + } }