uppy/packages/@uppy/aws-s3/tests/s3-client/minio.test.ts
Prakash 2d429a8aef
@uppy/aws-s3: remote uploads and golden retriever (#6186)
- add support for remote uploads
- add support for restore using golden-retriever
- update examples
- #6181 still persists

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Mikael Finstad <finstaden@gmail.com>
2026-06-17 13:54:48 +05:30

456 lines
13 KiB
TypeScript

import { AssumeRoleCommand, STSClient } from '@aws-sdk/client-sts'
import { beforeAll, describe, expect, it } from 'vitest'
import { S3mini } from '../../src/s3-client/S3mini.js'
import { createSigV4Signer } from '../../src/s3-client/signer.js'
import { randomBytes } from '../test-utils/browser-crypto.js'
import { accessKeyId, getConfig, secretAccessKey } from './config.js'
// @ts-expect-error todo
const config = getConfig(import.meta.env)
const suiteName = 'MinIO S3 client tests'
if (config) {
const { endpoint, region } = config
const presigner = createSigV4Signer({
accessKeyId,
secretAccessKey,
region,
endpoint,
})
const s3client = new S3mini({
endpoint,
region,
signRequest: presigner,
})
const EIGHT_MB = 8 * 1024 * 1024
const key_bin = 'test-multipart.bin'
const large_buffer = randomBytes(EIGHT_MB * 3.2)
const content = 'some content'
const key = 'first-test-object.txt'
const key_list_parts = 'test-list-parts.bin'
const key_abort_multipart = 'test-abort-multipart.bin'
const FILE_KEYS = [key, key_bin, key_list_parts, key_abort_multipart]
beforeAll(async () => {
for (const key of FILE_KEYS) {
try {
await s3client.deleteObject(key)
} catch {
// intentionally ignore the errors as the objects don't exists. still feels hacky tbh
}
}
})
/** Create STS client using @aws-sdk/client-sts */
function createSTSClient({ endpoint, accessKeyId, secretAccessKey, region }) {
return new STSClient({
region,
endpoint,
credentials: { accessKeyId, secretAccessKey },
})
}
/** Get temporary credentials via AssumeRole */
async function assumeRole(
stsClient: STSClient,
{ durationSeconds = 900 } = {},
) {
const response = await stsClient.send(
new AssumeRoleCommand({
RoleArn: 'aws:iam::000000000000:role/test-role', // MinIO doesn't validate ARN
RoleSessionName: 'uppy-test',
DurationSeconds: durationSeconds,
}),
)
const creds = response.Credentials!
return {
AccessKeyId: creds.AccessKeyId,
SecretAccessKey: creds.SecretAccessKey,
SessionToken: creds.SessionToken,
Expiration: creds.Expiration?.toISOString() || creds.Expiration,
}
}
// ===== signRequest tests =====
describe(suiteName, () => {
it('simple putObject upload', async () => {
const key = 'presigned-test-file.txt'
const fileContents = new TextEncoder().encode(
'Hello from pre-signed URL test.',
)
const result = await s3client.putObject(key, fileContents, 'text/plain')
expect(result.location).toBeDefined()
expect(result.location).toContain(key)
// location should be a clean URL without query string (no signing params)
expect(result.location).not.toContain('X-Amz-Signature')
expect(result.location).not.toContain('?')
})
it('multipart upload with signRequest', async () => {
const key = `presigned-multipart-${Date.now()}.bin`
const partSize = 5 * 1024 * 1024 // 5MB
const part = randomBytes(partSize)
const { uploadId } = await s3client.createMultipartUpload(
key,
'application/octet-stream',
)
expect(uploadId).toBeDefined()
const partNumber = 1
const { etag } = await s3client.uploadPart(
key,
uploadId,
part,
partNumber,
)
expect(etag).toBeDefined()
const result = await s3client.completeMultipartUpload(key, uploadId, [
{ etag, partNumber },
])
expect(result.etag).toBeDefined()
expect(result.location).toBeDefined()
expect(result.location).toContain(key)
expect(result.key).toBe(key)
await s3client.deleteObject(key)
})
// ===== getCredentials tests (STS) =====
describe('getCredentials (STS)', () => {
const stsEndpoint = new URL(endpoint).origin
it('should upload using getCredentials callback', async () => {
const testKey = `sts-getcreds-${Date.now()}.txt`
const s3 = new S3mini({
endpoint,
region,
getCredentials: async () => {
const stsClient = createSTSClient({
endpoint: stsEndpoint,
accessKeyId,
secretAccessKey,
region,
})
const creds = await assumeRole(stsClient)
return {
credentials: {
accessKeyId: creds.AccessKeyId!,
secretAccessKey: creds.SecretAccessKey!,
sessionToken: creds.SessionToken!,
expiration: creds.Expiration as string,
},
bucket: new URL(endpoint).pathname.slice(1),
region,
}
},
})
try {
const result = await s3.putObject(testKey, 'Hello STS!', 'text/plain')
expect(result.location).toBeDefined()
expect(result.location).toContain(testKey)
expect(result.location).not.toContain('X-Amz-Signature')
expect(result.location).not.toContain('?')
} finally {
await s3.deleteObject(testKey)
}
})
it('should cache credentials across multiple requests', async () => {
let fetchCount = 0
const s3 = new S3mini({
endpoint,
region,
getCredentials: async () => {
fetchCount++
const stsClient = createSTSClient({
endpoint: stsEndpoint,
accessKeyId,
secretAccessKey,
region,
})
const creds = await assumeRole(stsClient)
return {
credentials: {
accessKeyId: creds.AccessKeyId!,
secretAccessKey: creds.SecretAccessKey!,
sessionToken: creds.SessionToken!,
expiration: creds.Expiration as string,
},
bucket: new URL(endpoint).pathname.slice(1),
region,
}
},
})
await s3.putObject(
`sts-cache-1-${Date.now()}.txt`,
'File 1',
'text/plain',
)
await s3.putObject(
`sts-cache-2-${Date.now()}.txt`,
'File 2',
'text/plain',
)
expect(fetchCount).toBe(1)
})
it('should perform multipart upload with getCredentials', async () => {
const s3 = new S3mini({
endpoint,
region,
getCredentials: async () => {
const stsClient = createSTSClient({
endpoint: stsEndpoint,
accessKeyId,
secretAccessKey,
region,
})
const creds = await assumeRole(stsClient)
return {
credentials: {
accessKeyId: creds.AccessKeyId!,
secretAccessKey: creds.SecretAccessKey!,
sessionToken: creds.SessionToken!,
expiration: creds.Expiration as string,
},
bucket: new URL(endpoint).pathname.slice(1),
region,
}
},
})
const key = `sts-multipart-${Date.now()}.bin`
const partSize = 5 * 1024 * 1024 // 5MB
const part = randomBytes(partSize)
const { uploadId } = await s3.createMultipartUpload(
key,
'application/octet-stream',
)
expect(uploadId).toBeDefined()
const partNumber = 1
const { etag } = await s3.uploadPart(key, uploadId, part, partNumber)
expect(etag).toBeDefined()
const result = await s3.completeMultipartUpload(key, uploadId, [
{ etag, partNumber },
])
expect(result.etag).toBeDefined()
expect(result.location).toBeDefined()
expect(result.location).toContain(key)
expect(result.key).toBe(key)
await s3.deleteObject(key)
})
})
it('instantiates s3client', () => {
expect(s3client).toBeInstanceOf(S3mini) // ← updated expectation
})
// we don't need an explicit eTag method as we already get eTag in the putOject response
it('putObject uploads successfully and returns ETag', async () => {
const response = await s3client.putObject(key, content, 'text/plain')
expect(response.etag).toBeDefined()
await s3client.deleteObject(key)
})
it('putObject handles binary data', async () => {
const binaryData = new Uint8Array(6).fill(0xff)
const response = await s3client.putObject(
key,
binaryData,
'application/octet-stream',
)
expect(response).toBeDefined()
expect(response.etag).toBeDefined()
// cleanup
await s3client.deleteObject(key)
})
// test createMultipartUpload
it('createMultipartUpload returns a valid uploadId', async () => {
const { uploadId } = await s3client.createMultipartUpload(
key_bin,
'application/octet-stream',
)
expect(uploadId).toBeDefined()
expect(typeof uploadId).toBe('string')
expect(uploadId.length).toBeGreaterThan(0)
// cleanup
await s3client.abortMultipartUpload(key, uploadId)
})
// test uploadPart
it('uploadPart returns partNumber and Etag', async () => {
const partData = randomBytes(EIGHT_MB)
const { uploadId } = await s3client.createMultipartUpload(
key_bin,
'application/octet-stream',
)
// upload part
const partResult = await s3client.uploadPart(
key_bin,
uploadId,
partData,
1,
)
expect(partResult).toBeDefined()
expect(partResult.etag).toBeDefined()
expect(typeof partResult.etag).toBe('string')
expect(partResult.etag.length).toBe(32)
// cleanup
await s3client.abortMultipartUpload(key, uploadId)
})
// end to end multipart flow
it('completeMultipartUpload assembles parts correctly', async () => {
// key - key_bin
const partSize = EIGHT_MB
const totalParts = Math.ceil(large_buffer.byteLength / partSize)
const { uploadId } = await s3client.createMultipartUpload(
key_bin,
'application/octet-stream',
)
expect(uploadId).toBeDefined()
// upload all parts
const uploadPromises: Promise<{ etag: string }>[] = []
for (let i = 0; i < totalParts; i++) {
const partBuffer = large_buffer.subarray(
i * partSize,
(i + 1) * partSize,
)
uploadPromises.push(
s3client.uploadPart(key_bin, uploadId, partBuffer, i + 1),
)
}
const uploadResponses = await Promise.all(uploadPromises)
// verify all parts uploaded succesfully
expect(uploadResponses.length).toBe(totalParts)
uploadResponses.forEach((response) => {
expect(response.etag).toBeDefined()
})
// create multipart upload
const parts = uploadResponses.map((response, index) => ({
partNumber: index + 1,
etag: response.etag,
}))
const completeResponse = await s3client.completeMultipartUpload(
key_bin,
uploadId,
parts,
)
expect(completeResponse).toBeDefined()
expect(typeof completeResponse).toBe('object')
expect(completeResponse.etag).toBeDefined()
expect(typeof completeResponse.etag).toBe('string')
expect(completeResponse.etag!.length).toBe(32 + 2)
// cleanup
await s3client.deleteObject(key_bin)
})
it('abortMultipartUpload cancels upload successfully', async () => {
// start upload
const { uploadId } = await s3client.createMultipartUpload(
key_abort_multipart,
'application/octet-stream',
)
expect(uploadId).toBeDefined()
const partData = randomBytes(EIGHT_MB)
await s3client.uploadPart(key_abort_multipart, uploadId, partData, 1)
// abort
await s3client.abortMultipartUpload(key_abort_multipart, uploadId)
})
it('listParts returns uploaded parts correctly', async () => {
const partSize = EIGHT_MB
const { uploadId } = await s3client.createMultipartUpload(
key_list_parts,
'application/octet-stream',
)
expect(uploadId).toBeDefined()
const part1Data = randomBytes(partSize)
const part2Data = randomBytes(partSize)
const part1Result = await s3client.uploadPart(
key_list_parts,
uploadId,
part1Data,
1,
)
const part2Result = await s3client.uploadPart(
key_list_parts,
uploadId,
part2Data,
2,
)
const parts = await s3client.listParts(uploadId, key_list_parts)
expect(parts).toBeInstanceOf(Array)
expect(parts.length).toBe(2)
// verify part 1
expect(parts[0].partNumber).toBe(1)
expect(parts[0].etag).toBe(part1Result.etag)
// verify part 2
expect(parts[1].partNumber).toBe(2)
expect(parts[1].etag).toBe(part2Result.etag)
// cleanup abort upload
await s3client.abortMultipartUpload(key, uploadId)
})
})
} else {
console.warn(
'Skipping MinIO S3 client tests: missing env variable VITE_MINIO_CONFIG',
)
describe.skip(suiteName, () => {
it('noop', () => {})
})
}