278 lines
8.5 KiB
TypeScript
278 lines
8.5 KiB
TypeScript
/**
|
|
* S3 integration tests.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import {
|
|
S3Client,
|
|
CreateBucketCommand,
|
|
PutObjectCommand,
|
|
GetObjectCommand,
|
|
DeleteObjectCommand,
|
|
ListObjectsV2Command,
|
|
DeleteBucketCommand,
|
|
HeadObjectCommand,
|
|
HeadBucketCommand,
|
|
ListBucketsCommand,
|
|
CopyObjectCommand,
|
|
GetBucketLocationCommand,
|
|
CreateMultipartUploadCommand,
|
|
UploadPartCopyCommand,
|
|
CompleteMultipartUploadCommand,
|
|
AbortMultipartUploadCommand,
|
|
} from '@aws-sdk/client-s3';
|
|
import { makeClient, uniqueName, CLIENT_CONFIG } from './setup';
|
|
|
|
describe('S3', () => {
|
|
let s3: S3Client;
|
|
let euS3: S3Client;
|
|
let bucketName: string;
|
|
let euBucketName: string;
|
|
let signedRegionBucket: string;
|
|
|
|
beforeAll(() => {
|
|
s3 = makeClient(S3Client, { forcePathStyle: true });
|
|
euS3 = new S3Client({ ...CLIENT_CONFIG, region: 'eu-central-1', forcePathStyle: true });
|
|
bucketName = `test-bucket-${uniqueName()}`;
|
|
euBucketName = `test-bucket-eu-${uniqueName()}`;
|
|
signedRegionBucket = `test-bucket-signed-${uniqueName()}`;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
// Cleanup buckets
|
|
for (const bucket of [bucketName, euBucketName, signedRegionBucket]) {
|
|
try {
|
|
const list = await s3.send(new ListObjectsV2Command({ Bucket: bucket }));
|
|
for (const obj of list.Contents || []) {
|
|
await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: obj.Key }));
|
|
}
|
|
await s3.send(new DeleteBucketCommand({ Bucket: bucket }));
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
});
|
|
|
|
it('should create bucket', async () => {
|
|
const response = await s3.send(new CreateBucketCommand({ Bucket: bucketName }));
|
|
expect(response.Location).toBe(`/${bucketName}`);
|
|
});
|
|
|
|
it('should create bucket with LocationConstraint', async () => {
|
|
const response = await s3.send(
|
|
new CreateBucketCommand({
|
|
Bucket: euBucketName,
|
|
CreateBucketConfiguration: { LocationConstraint: 'eu-central-1' },
|
|
})
|
|
);
|
|
expect(response.Location).toBe(`/${euBucketName}`);
|
|
});
|
|
|
|
it('should get bucket location', async () => {
|
|
const response = await s3.send(new GetBucketLocationCommand({ Bucket: euBucketName }));
|
|
expect(response.LocationConstraint).toBe('eu-central-1');
|
|
});
|
|
|
|
it('should create bucket using signing region when body empty', async () => {
|
|
await euS3.send(new CreateBucketCommand({ Bucket: signedRegionBucket }));
|
|
const head = await euS3.send(new HeadBucketCommand({ Bucket: signedRegionBucket }));
|
|
expect(head.BucketRegion).toBe('eu-central-1');
|
|
const loc = await euS3.send(new GetBucketLocationCommand({ Bucket: signedRegionBucket }));
|
|
expect(loc.LocationConstraint).toBe('eu-central-1');
|
|
});
|
|
|
|
it('should reject explicit us-east-1 LocationConstraint', async () => {
|
|
await expect(
|
|
s3.send(
|
|
new CreateBucketCommand({
|
|
Bucket: `invalid-loc-${uniqueName()}`,
|
|
CreateBucketConfiguration: { LocationConstraint: 'us-east-1' },
|
|
})
|
|
)
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it('should list buckets', async () => {
|
|
const response = await s3.send(new ListBucketsCommand({}));
|
|
expect(response.Buckets?.some((b) => b.Name === bucketName)).toBe(true);
|
|
});
|
|
|
|
it('should put object', async () => {
|
|
await s3.send(
|
|
new PutObjectCommand({ Bucket: bucketName, Key: 'test.txt', Body: 'hello from test' })
|
|
);
|
|
});
|
|
|
|
it('should head object', async () => {
|
|
const response = await s3.send(
|
|
new HeadObjectCommand({ Bucket: bucketName, Key: 'test.txt' })
|
|
);
|
|
expect(response.ContentLength).toBeGreaterThan(0);
|
|
expect(response.LastModified).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it('should get object', async () => {
|
|
const response = await s3.send(
|
|
new GetObjectCommand({ Bucket: bucketName, Key: 'test.txt' })
|
|
);
|
|
const body = await response.Body?.transformToString();
|
|
expect(body).toBe('hello from test');
|
|
});
|
|
|
|
it('should copy object', async () => {
|
|
await s3.send(
|
|
new CopyObjectCommand({
|
|
CopySource: `${bucketName}/test.txt`,
|
|
Bucket: bucketName,
|
|
Key: 'test-copy.txt',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should copy object with non-ASCII key', async () => {
|
|
const nonAsciiKey = 'src/テスト画像.png';
|
|
const nonAsciiDst = 'dst/テスト画像.png';
|
|
|
|
await s3.send(
|
|
new PutObjectCommand({
|
|
Bucket: bucketName,
|
|
Key: nonAsciiKey,
|
|
Body: Buffer.from('non-ascii content'),
|
|
})
|
|
);
|
|
await s3.send(
|
|
new CopyObjectCommand({
|
|
CopySource: `${bucketName}/${encodeURIComponent(nonAsciiKey)}`,
|
|
Bucket: bucketName,
|
|
Key: nonAsciiDst,
|
|
})
|
|
);
|
|
const response = await s3.send(
|
|
new GetObjectCommand({ Bucket: bucketName, Key: nonAsciiDst })
|
|
);
|
|
const body = await response.Body?.transformToString();
|
|
expect(body).toBe('non-ascii content');
|
|
|
|
// Cleanup
|
|
await s3.send(new DeleteObjectCommand({ Bucket: bucketName, Key: nonAsciiKey }));
|
|
await s3.send(new DeleteObjectCommand({ Bucket: bucketName, Key: nonAsciiDst }));
|
|
});
|
|
|
|
it('should multipart copy object with non-ASCII key', async () => {
|
|
const srcKey = 'src/テスト画像.bin';
|
|
const dstKey = 'dst/テスト画像.bin';
|
|
const body = Buffer.alloc(6 * 1024 * 1024, 'a');
|
|
let uploadId: string | undefined;
|
|
|
|
try {
|
|
await s3.send(
|
|
new PutObjectCommand({
|
|
Bucket: bucketName,
|
|
Key: srcKey,
|
|
Body: body,
|
|
})
|
|
);
|
|
|
|
const create = await s3.send(
|
|
new CreateMultipartUploadCommand({
|
|
Bucket: bucketName,
|
|
Key: dstKey,
|
|
})
|
|
);
|
|
uploadId = create.UploadId;
|
|
expect(uploadId).toBeTruthy();
|
|
|
|
const part = await s3.send(
|
|
new UploadPartCopyCommand({
|
|
Bucket: bucketName,
|
|
Key: dstKey,
|
|
UploadId: uploadId,
|
|
PartNumber: 1,
|
|
CopySource: `${bucketName}/${encodeURIComponent(srcKey)}`,
|
|
})
|
|
);
|
|
expect(part.CopyPartResult?.ETag).toBeTruthy();
|
|
|
|
await s3.send(
|
|
new CompleteMultipartUploadCommand({
|
|
Bucket: bucketName,
|
|
Key: dstKey,
|
|
UploadId: uploadId,
|
|
MultipartUpload: {
|
|
Parts: [
|
|
{
|
|
ETag: part.CopyPartResult?.ETag,
|
|
PartNumber: 1,
|
|
},
|
|
],
|
|
},
|
|
})
|
|
);
|
|
uploadId = undefined;
|
|
|
|
const response = await s3.send(
|
|
new GetObjectCommand({ Bucket: bucketName, Key: dstKey })
|
|
);
|
|
const copied = await response.Body?.transformToByteArray();
|
|
expect(copied && Buffer.from(copied)).toEqual(body);
|
|
} finally {
|
|
if (uploadId) {
|
|
await s3.send(
|
|
new AbortMultipartUploadCommand({
|
|
Bucket: bucketName,
|
|
Key: dstKey,
|
|
UploadId: uploadId,
|
|
})
|
|
).catch(() => {});
|
|
}
|
|
await s3.send(new DeleteObjectCommand({ Bucket: bucketName, Key: srcKey })).catch(() => {});
|
|
await s3.send(new DeleteObjectCommand({ Bucket: bucketName, Key: dstKey })).catch(() => {});
|
|
}
|
|
});
|
|
|
|
it('should list objects', async () => {
|
|
const response = await s3.send(new ListObjectsV2Command({ Bucket: bucketName }));
|
|
expect(response.KeyCount).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
it('should upload large object (25 MB)', async () => {
|
|
await s3.send(
|
|
new PutObjectCommand({
|
|
Bucket: bucketName,
|
|
Key: 'large-object-25mb.bin',
|
|
Body: Buffer.alloc(25 * 1024 * 1024),
|
|
ContentType: 'application/octet-stream',
|
|
})
|
|
);
|
|
|
|
const response = await s3.send(
|
|
new HeadObjectCommand({ Bucket: bucketName, Key: 'large-object-25mb.bin' })
|
|
);
|
|
expect(response.ContentLength).toBe(25 * 1024 * 1024);
|
|
|
|
await s3.send(
|
|
new DeleteObjectCommand({ Bucket: bucketName, Key: 'large-object-25mb.bin' })
|
|
);
|
|
});
|
|
|
|
it('should delete object', async () => {
|
|
await s3.send(new DeleteObjectCommand({ Bucket: bucketName, Key: 'test.txt' }));
|
|
await s3.send(new DeleteObjectCommand({ Bucket: bucketName, Key: 'test-copy.txt' }));
|
|
});
|
|
|
|
it('should fail for missing object', async () => {
|
|
await expect(
|
|
s3.send(new GetObjectCommand({ Bucket: bucketName, Key: 'missing.txt' }))
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it('should delete bucket', async () => {
|
|
await s3.send(new DeleteBucketCommand({ Bucket: bucketName }));
|
|
await s3.send(new DeleteBucketCommand({ Bucket: euBucketName }));
|
|
await euS3.send(new DeleteBucketCommand({ Bucket: signedRegionBucket }));
|
|
bucketName = '';
|
|
euBucketName = '';
|
|
signedRegionBucket = '';
|
|
});
|
|
});
|