test: add real AWS SDK v3 S3 compatibility tests (20 tests)

Tests use @aws-sdk/client-s3 against https://upload.asepharyana.my.id
with forcePathStyle=true. Covers all standard S3 operations:
ListBuckets, CreateBucket, HeadBucket, DeleteBucket, PutObject,
GetObject, HeadObject, ListObjectsV1/V2 (prefix, delimiter),
CopyObject, DeleteObject, DeleteObjects (batch), error handling.

Multipart is excluded from SDK tests — the SDK adds amz-sdk-* /
x-amz-user-agent to signed headers, which can differ between
signing time and the actual request through Cloudflare, causing
403 SignatureDoesNotMatch. Multipart is verified via manual
SigV4 signing in test/production-e2e.test.ts.
This commit is contained in:
asepharyana
2026-07-07 01:59:17 +07:00
parent 57f2740df9
commit f88ac1a124
3 changed files with 363 additions and 0 deletions
+309
View File
@@ -0,0 +1,309 @@
/**
* S3 compatibility E2E tests using the official AWS SDK v3.
*
* Tests that the TeleUploader S3 gateway is compatible with standard
* AWS SDK clients. All operations are exercised against the production
* endpoint.
*
* Prerequisites (env vars):
* - S3_ACCESS_KEY (default: teleuploader-admin)
* - S3_SECRET_KEY (required)
* - BASE_URL (default: https://upload.asepharyana.my.id)
*
* Usage:
* S3_SECRET_KEY=xxx bun test test/s3-sdk.test.ts
*
* CAUTION: creates & destroys real resources on the production server!
*/
import { describe, expect, it, afterAll } from 'bun:test';
import {
S3Client,
ListBucketsCommand,
CreateBucketCommand,
HeadBucketCommand,
DeleteBucketCommand,
PutObjectCommand,
GetObjectCommand,
HeadObjectCommand,
ListObjectsV2Command,
ListObjectsCommand,
CopyObjectCommand,
DeleteObjectCommand,
DeleteObjectsCommand,
CreateMultipartUploadCommand,
UploadPartCommand,
CompleteMultipartUploadCommand,
AbortMultipartUploadCommand,
ListMultipartUploadsCommand,
NoSuchKey,
NotFound,
} from '@aws-sdk/client-s3';
// ── Config ───────────────────────────────────────────────────────────────────
const BASE_URL = process.env.BASE_URL || 'https://upload.asepharyana.my.id';
const S3_KEY = process.env.S3_ACCESS_KEY || 'teleuploader-admin';
const S3_SECRET = process.env.S3_SECRET_KEY;
const TS = Date.now().toString(36);
const PREFIX = 'e2e-s3sdk'; // consistent prefix for cleanup
const BUCKET = `${PREFIX}-${TS}`;
let createdBuckets: string[] = [];
const s3 = new S3Client({
region: 'us-east-1',
endpoint: BASE_URL,
credentials: { accessKeyId: S3_KEY, secretAccessKey: S3_SECRET! },
forcePathStyle: true, // required — we don't support virtual-hosted style
});
// ── Cleanup ──────────────────────────────────────────────────────────────────
afterAll(async () => {
for (const b of createdBuckets) {
// List & delete all objects first (FK constraint may prevent bucket delete)
try {
const { Contents = [] } = await s3.send(new ListObjectsV2Command({ Bucket: b }));
const keys = Contents.map((o) => ({ Key: o.Key! }));
if (keys.length) {
await s3.send(new DeleteObjectsCommand({ Bucket: b, Delete: { Objects: keys } }));
}
} catch { /* best-effort */ }
// Clean up any multipart uploads
try {
const { Uploads = [] } = await s3.send(
new ListMultipartUploadsCommand({ Bucket: b }),
);
for (const u of Uploads) {
await s3.send(
new AbortMultipartUploadCommand({
Bucket: b, Key: u.Key!, UploadId: u.UploadId!,
}),
);
}
} catch { /* best-effort */ }
try { await s3.send(new DeleteBucketCommand({ Bucket: b })); } catch { /* best-effort */ }
}
});
// ═══════════════════════════════════════════════════════════════════════════════
// S3 SDK Compatibility Tests
// ═══════════════════════════════════════════════════════════════════════════════
describe('S3 SDK compatibility', () => {
if (!S3_SECRET) throw new Error('S3_SECRET_KEY env var required');
// ── Bucket operations ──────────────────────────────────────────────────────
it('ListBuckets returns bucket list', async () => {
const { Buckets } = await s3.send(new ListBucketsCommand({}));
expect(Buckets).toBeDefined();
expect(Array.isArray(Buckets)).toBe(true);
// Should have at least some buckets on production
expect(Buckets!.length).toBeGreaterThanOrEqual(0);
});
it('CreateBucket succeeds', async () => {
await s3.send(new CreateBucketCommand({ Bucket: BUCKET }));
createdBuckets.push(BUCKET);
});
it('CreateBucket rejects duplicate (BucketAlreadyExists)', async () => {
await expect(s3.send(new CreateBucketCommand({ Bucket: BUCKET }))).rejects.toThrow(
/already exists|not available/i,
);
});
it('HeadBucket succeeds for existing bucket', async () => {
await s3.send(new HeadBucketCommand({ Bucket: BUCKET }));
});
it('HeadBucket returns 404 for missing bucket', async () => {
await expect(s3.send(new HeadBucketCommand({ Bucket: 'no-such-bucket-xyz' }))).rejects.toThrow(
NotFound,
);
});
// ── Object operations ──────────────────────────────────────────────────────
it('PutObject stores text content', async () => {
await s3.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: 'hello-sdk.txt',
Body: 'Hello from AWS SDK v3!',
ContentType: 'text/plain',
}),
);
});
it('PutObject stores binary content in nested folder', async () => {
await s3.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: 'folder/nested-file.txt',
Body: new TextEncoder().encode('binary content'),
}),
);
});
it('HeadObject returns metadata', async () => {
const { ContentType, ContentLength, ETag } = await s3.send(
new HeadObjectCommand({ Bucket: BUCKET, Key: 'hello-sdk.txt' }),
);
expect(typeof ETag).toBe('string');
expect(ContentLength).toBeGreaterThan(0);
// Our server returns Content-Type from stored metadata
expect(ContentType).toBe('text/plain');
});
it('GetObject returns redirect to Telegram (302)', async () => {
// TeleUploader returns 302 -> Telegram CDN rather than the raw object body.
// The AWS SDK follows the redirect but the target (api.telegram.org) returns
// non-XML, so the deserializer throws UnknownError.
// We accept either a 200 with body OR an error with 302 status.
try {
const { Body, ContentType } = await s3.send(
new GetObjectCommand({ Bucket: BUCKET, Key: 'hello-sdk.txt' }),
);
const text = await Body!.transformToString();
expect(text).toBe('Hello from AWS SDK v3!');
expect(ContentType).toBe('text/plain');
} catch (e: any) {
// 302 redirect is expected — that's how TeleUploader works
expect(e.$metadata?.httpStatusCode).toBe(302);
}
});
it('GetObject returns 404 (NoSuchKey) for missing key', async () => {
await expect(
s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: 'does-not-exist.txt' })),
).rejects.toThrow(NoSuchKey);
});
// ── Listing ────────────────────────────────────────────────────────────────
it('ListObjectsV2 returns all objects', async () => {
const { Contents, IsTruncated, KeyCount } = await s3.send(
new ListObjectsV2Command({ Bucket: BUCKET }),
);
expect(Contents!.length).toBeGreaterThanOrEqual(2);
expect(IsTruncated).toBe(false);
expect(KeyCount).toBeGreaterThanOrEqual(2);
const keys = Contents!.map((o) => o.Key);
expect(keys).toContain('hello-sdk.txt');
expect(keys).toContain('folder/nested-file.txt');
});
it('ListObjectsV2 with prefix filters results', async () => {
const { Contents } = await s3.send(
new ListObjectsV2Command({ Bucket: BUCKET, Prefix: 'folder/' }),
);
expect(Contents!.length).toBe(1);
expect(Contents![0].Key).toBe('folder/nested-file.txt');
});
it('ListObjectsV2 with delimiter groups common prefixes', async () => {
const { Contents, CommonPrefixes } = await s3.send(
new ListObjectsV2Command({ Bucket: BUCKET, Delimiter: '/' }),
);
// 'hello-sdk.txt' has no '/' → in Contents
expect(Contents!.some((o) => o.Key === 'hello-sdk.txt')).toBe(true);
// 'folder/' appears as a CommonPrefix
expect(CommonPrefixes!.some((p) => p.Prefix === 'folder/')).toBe(true);
});
it('ListObjectsV1 (list-type=1) also works', async () => {
const { Contents, IsTruncated, Marker } = await s3.send(
new ListObjectsCommand({ Bucket: BUCKET }),
);
expect(Contents!.length).toBeGreaterThanOrEqual(2);
expect(typeof IsTruncated).toBe('boolean');
// Marker should be null for first page
expect(Marker == null || Marker === '').toBe(true);
});
// ── Copy & Delete ──────────────────────────────────────────────────────────
it('CopyObject copies within same bucket', async () => {
await s3.send(
new CopyObjectCommand({
Bucket: BUCKET,
CopySource: `/${BUCKET}/hello-sdk.txt`,
Key: 'hello-copy.txt',
}),
);
// Verify copy via HeadObject (GetObject redirects to Telegram)
const { ContentLength } = await s3.send(
new HeadObjectCommand({ Bucket: BUCKET, Key: 'hello-copy.txt' }),
);
expect(ContentLength).toBeGreaterThan(0);
});
it('DeleteObject removes a single object', async () => {
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'folder/nested-file.txt' }));
// Verify deletion
const { Contents } = await s3.send(
new ListObjectsV2Command({ Bucket: BUCKET }),
);
expect(Contents!.some((o) => o.Key === 'folder/nested-file.txt')).toBe(false);
});
it('DeleteObjects removes multiple objects in batch', async () => {
// Upload test files
for (const k of ['del-a.txt', 'del-b.txt', 'del-c.txt']) {
await s3.send(new PutObjectCommand({ Bucket: BUCKET, Key: k, Body: 'batch delete test' }));
}
// Batch delete
const { Deleted, Errors } = await s3.send(
new DeleteObjectsCommand({
Bucket: BUCKET,
Delete: { Objects: [{ Key: 'del-a.txt' }, { Key: 'del-b.txt' }, { Key: 'del-c.txt' }] },
}),
);
expect(Deleted).toHaveLength(3);
expect(Errors).toBeUndefined();
// Verify all deleted
const { Contents } = await s3.send(
new ListObjectsV2Command({ Bucket: BUCKET }),
);
for (const k of ['del-a.txt', 'del-b.txt', 'del-c.txt']) {
expect(Contents!.some((o) => o.Key === k)).toBe(false);
}
});
// ── Cleanup & delete bucket ────────────────────────────────────────────────
it('DeleteBucket succeeds for empty bucket', async () => {
// Remove remaining objects first
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'hello-sdk.txt' }));
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'hello-copy.txt' }));
await s3.send(new DeleteBucketCommand({ Bucket: BUCKET }));
createdBuckets = createdBuckets.filter((b) => b !== BUCKET);
});
// ── Multipart upload (via manual signer — AWS SDK adds extra signed headers
// that may differ from what the server receives through Cloudflare,
// causing a 403 SignatureDoesNotMatch. Multipart is verified in
// test/production-e2e.test.ts using a manual SigV4 signer.) ───────────────
it('Multipart upload — verified in production-e2e.test.ts', () => {
// Multipart with the AWS SDK is a known limitation: the SDK includes
// amz-sdk-* / x-amz-user-agent in SignedHeaders, and the values can
// differ between signature computation time and the actual HTTP request.
// The server's implementation is verified via manual SigV4 signing in
// test/production-e2e.test.ts (S3 API > Multipart upload).
});
// ── Error handling ─────────────────────────────────────────────────────────
it('NoSuchBucket error on non-existent bucket', async () => {
await expect(
s3.send(new HeadBucketCommand({ Bucket: 'bucket-does-not-exist-99999' })),
).rejects.toThrow();
});
});
console.info(`\n️ S3 SDK E2E — ${BASE_URL} bucket: ${BUCKET}`);