Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions src/storage/protocols/tus/s3-store.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -26,7 +28,24 @@ function createStore(
})
}

function baseUpload(overrides: Partial<Upload> = {}): 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())

Expand Down Expand Up @@ -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()
})
})
23 changes: 23 additions & 0 deletions src/storage/protocols/tus/s3-store.ts
Original file line number Diff line number Diff line change
@@ -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<Upload> {
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
}
}
Loading