Files
TeleUploader/test/s3-sdk.test.ts
T
asepharyana 06f93e30f6 feat: proxy GetObject from Telegram CDN for real S3 client compatibility
- Add proxyS3Get config (PROXY_S3_GET env, default true) to env.ts
- Proxy handleGetObject and handleGetMultipartObject: fetch from Telegram
  CDN and return 200 with streaming body instead of 302 redirect
- Real S3 clients (AWS SDK v3) expect 200+body on GetObject, not redirect
- Legacy 302 redirect path preserved when proxyS3Get=false
- Updated production-e2e: GetObject asserts 200 with body content
- Updated s3-sdk.test.ts: GetObject asserts 200 with body (removed try/catch)
- Cleaned up unused multipart imports in s3-sdk.test.ts

All 49 tests pass (29 production-e2e + 20 s3-sdk).
2026-07-07 02:54:51 +07:00

300 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
ListMultipartUploadsCommand,
AbortMultipartUploadCommand,
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 stored content (proxied from Telegram)', async () => {
const { Body, ContentType, ContentLength, ETag } = 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');
expect(ContentLength).toBeGreaterThan(0);
expect(ETag).toBeTruthy();
});
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}`);