Files
TeleUploader/test/production-e2e.test.ts
T

473 lines
18 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.
/**
* Production E2E tests — runs against the live deployment.
*
* Tests both the Web API (JSON v1) and S3 (SigV4 XML) interfaces.
* Requires env vars:
* - BASE_URL (default: https://upload.asepharyana.my.id)
* - S3_ACCESS_KEY (default: teleuploader-admin)
* - S3_SECRET_KEY (required for S3 tests)
*
* Usage:
* S3_SECRET_KEY=xxx bun test test/production-e2e.test.ts
*/
import { afterAll, describe, expect, it } from 'bun:test';
// ── 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);
let createdBuckets: string[] = [];
// ── SigV4 helpers — works in Bun with CryptoHasher ───────────────────────────
function sha256hex(data: string | Uint8Array): string {
const h = new Bun.CryptoHasher('sha256');
h.update(data);
return Array.from(h.digest())
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
function hmacSha256(key: Uint8Array, msg: string): Uint8Array {
const h = new Bun.CryptoHasher('sha256', key);
h.update(msg);
return h.digest();
}
function getSigningKey(secret: string, ds: string, region: string): Uint8Array {
const enc = (s: string) => new TextEncoder().encode(s);
let k = hmacSha256(enc(`AWS4${secret}`), ds);
k = hmacSha256(k, region);
k = hmacSha256(k, 's3');
return hmacSha256(k, 'aws4_request');
}
function hex(a: Uint8Array): string {
return Array.from(a)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/** Build S3 SigV4 authorization headers for a raw HTTP request. */
function s3Headers(
method: string,
host: string,
path: string,
qs: string, // canonical query string (sorted, URI-encoded)
payloadHash: string,
extraHeaders: Record<string, string> = {},
): Record<string, string> {
const now = new Date();
const pad2 = (n: number) => String(n).padStart(2, '0');
const amzDate = `${now.getUTCFullYear()}${pad2(now.getUTCMonth() + 1)}${pad2(now.getUTCDate())}T${pad2(now.getUTCHours())}${pad2(now.getUTCMinutes())}${pad2(now.getUTCSeconds())}Z`;
const dateStamp = amzDate.slice(0, 8);
const signedHeaders = 'host;x-amz-content-sha256;x-amz-date';
const hdr = `host:${host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n`;
const canonical = `${method}\n${path}\n${qs}\n${hdr}\n${signedHeaders}\n${payloadHash}`;
const hcr = sha256hex(canonical);
const cs = `${dateStamp}/us-east-1/s3/aws4_request`;
const sts = `AWS4-HMAC-SHA256\n${amzDate}\n${cs}\n${hcr}`;
const sk = getSigningKey(S3_SECRET, dateStamp, 'us-east-1');
const sig = hex(hmacSha256(sk, sts));
return {
Authorization: `AWS4-HMAC-SHA256 Credential=${S3_KEY}/${dateStamp}/us-east-1/s3/aws4_request, SignedHeaders=${signedHeaders}, Signature=${sig}`,
'x-amz-date': amzDate,
'x-amz-content-sha256': payloadHash,
...extraHeaders,
};
}
/** Issue a SigV4-signed S3 request. */
async function s3Request(
method: string,
path: string,
opts: {
body?: Uint8Array;
query?: Record<string, string>;
headers?: Record<string, string>;
} = {},
): Promise<Response> {
const url = new URL(path, BASE_URL);
if (opts.query) {
for (const [k, v] of Object.entries(opts.query)) url.searchParams.set(k, v);
}
const rawBody = opts.body ?? new Uint8Array(0);
const payloadHash = sha256hex(rawBody);
const headers = s3Headers(
method,
url.host,
url.pathname,
url.searchParams.toString(),
payloadHash,
opts.headers,
);
return fetch(url.toString(), {
method,
headers: { ...headers, 'Content-Type': 'application/octet-stream' },
body: rawBody.length > 0 ? rawBody : undefined,
});
}
// ── Web API helper ───────────────────────────────────────────────────────────
const api = (p: string) => `${BASE_URL}/api/v1${p}`;
const apiJson = (p: string, o: RequestInit = {}) =>
fetch(api(p), { headers: { 'content-type': 'application/json' }, ...o });
// ── Shared cleanup ───────────────────────────────────────────────────────────
afterAll(async () => {
for (const name of createdBuckets) {
try {
await fetch(`${BASE_URL}/api/v1/buckets/${name}`, { method: 'DELETE' });
} catch {
/* best-effort */
}
}
});
// ═══════════════════════════════════════════════════════════════════════════════
// Web API v1 (JSON)
// ═══════════════════════════════════════════════════════════════════════════════
describe('Web API v1 (production)', () => {
it('GET /api/v1/buckets — returns bucket list', async () => {
const r = await apiJson('/buckets');
expect(r.status).toBe(200);
const b = (await r.json()) as { buckets: unknown[] };
expect(Array.isArray(b.buckets)).toBe(true);
});
it('POST /api/v1/buckets — creates bucket', async () => {
const name = `e2e-web-${TS}`;
const r = await apiJson('/buckets', { method: 'POST', body: JSON.stringify({ name }) });
expect(r.status).toBe(201);
expect(((await r.json()) as { name: string }).name).toBe(name);
createdBuckets.push(name);
});
it('POST /api/v1/buckets — rejects duplicate (409)', async () => {
const name = `e2e-web-${TS}`;
const r = await apiJson('/buckets', { method: 'POST', body: JSON.stringify({ name }) });
expect(r.status).toBe(409);
});
it('POST /api/v1/buckets — rejects invalid name (400)', async () => {
const r = await apiJson('/buckets', {
method: 'POST',
body: JSON.stringify({ name: 'INVALID!' }),
});
expect(r.status).toBe(400);
});
it('DELETE /api/v1/buckets/:name — deletes empty bucket', async () => {
const name = `e2e-web-del-${TS}`;
await apiJson('/buckets', { method: 'POST', body: JSON.stringify({ name }) });
const r = await apiJson(`/buckets/${name}`, { method: 'DELETE' });
expect(r.status).toBe(200);
});
it('DELETE /api/v1/buckets/:name — 404 for missing bucket', async () => {
const r = await apiJson(`/buckets/missing-${TS}`, { method: 'DELETE' });
expect(r.status).toBe(404);
});
it('GET /api/v1/buckets/:name/objects — lists objects', async () => {
const r = await apiJson(`/buckets/e2e-web-${TS}/objects`);
expect(r.status).toBe(200);
const b = (await r.json()) as { objects: unknown[] };
expect(Array.isArray(b.objects)).toBe(true);
});
it('POST /api/v1/buckets/:name/upload — uploads a file', async () => {
const fd = new FormData();
fd.append('file', new Blob(['hello']), 'hello.txt');
fd.append('key', 'hello.txt');
const r = await fetch(`${BASE_URL}/api/v1/buckets/e2e-web-${TS}/upload`, {
method: 'POST',
body: fd,
});
expect(r.status).toBe(201);
const b = (await r.json()) as { key: string; etag: string };
expect(b.key).toBe('hello.txt');
expect(b.etag).toBeTruthy();
});
it('GET /api/v1/buckets/:name/objects — file now present', async () => {
const r = await apiJson(`/buckets/e2e-web-${TS}/objects`);
expect(r.status).toBe(200);
const b = (await r.json()) as { objects: { key: string }[] };
expect(b.objects.some((o) => o.key === 'hello.txt')).toBe(true);
});
it('DELETE /api/v1/buckets/:name/:key — deletes object', async () => {
const r = await apiJson(`/buckets/e2e-web-${TS}/hello.txt`, { method: 'DELETE' });
expect(r.status).toBe(200);
});
it('GET /api/v1/unknown — 404 for unknown path', async () => {
const r = await apiJson('/unknown');
expect(r.status).toBe(404);
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// S3 API (SigV4)
// ═══════════════════════════════════════════════════════════════════════════════
describe('S3 API (production, SigV4)', () => {
if (!S3_SECRET) throw new Error('S3_SECRET_KEY env var required');
const bucketName = `e2e-s3-${TS}`;
it('ListBuckets (GET /)', async () => {
const r = await s3Request('GET', '/');
expect(r.status).toBe(200);
const xml = await r.text();
expect(xml).toContain('ListAllMyBucketsResult');
});
it('CreateBucket (PUT /{bucket})', async () => {
const r = await s3Request('PUT', `/${bucketName}`);
expect(r.status).toBe(200);
createdBuckets.push(bucketName);
});
it('HeadBucket (HEAD /{bucket})', async () => {
const r = await s3Request('HEAD', `/${bucketName}`);
expect(r.status).toBe(200);
});
it('PutObject (PUT /{bucket}/{key})', async () => {
const r = await s3Request('PUT', `/${bucketName}/test-file.txt`, {
body: new TextEncoder().encode('hello s3'),
});
expect(r.status).toBe(200);
expect(r.headers.get('etag')).toBeTruthy();
});
it('PutObject — nested folder key', async () => {
const r = await s3Request('PUT', `/${bucketName}/folder/nested.txt`, {
body: new TextEncoder().encode('nested'),
});
expect(r.status).toBe(200);
});
it('HeadObject (HEAD /{bucket}/{key})', async () => {
const r = await s3Request('HEAD', `/${bucketName}/test-file.txt`);
expect(r.status).toBe(200);
expect(r.headers.get('etag')).toBeTruthy();
expect(Number(r.headers.get('content-length'))).toBeGreaterThan(0);
});
it('GetObject (GET /{bucket}/{key}) — proxies content from Telegram', async () => {
const r = await s3Request('GET', `/${bucketName}/test-file.txt`);
expect(r.status).toBe(200);
const text = await r.text();
expect(text).toContain('hello s3');
expect(r.headers.get('content-type')).toMatch(/text|octet/);
});
it('ListObjectsV1 (GET /{bucket})', async () => {
const r = await s3Request('GET', `/${bucketName}`);
expect(r.status).toBe(200);
const xml = await r.text();
expect(xml).toContain('ListBucketResult');
expect(xml).toContain('test-file.txt');
expect(xml).toContain('folder/nested.txt');
});
it('ListObjectsV1 — prefix filter', async () => {
const r = await s3Request('GET', `/${bucketName}`, { query: { prefix: 'folder/' } });
expect(r.status).toBe(200);
const xml = await r.text();
expect(xml).toContain('folder/nested.txt');
expect(xml).not.toContain('test-file.txt');
});
it('ListObjectsV2 (GET /{bucket}?list-type=2)', async () => {
const r = await s3Request('GET', `/${bucketName}`, { query: { 'list-type': '2' } });
expect(r.status).toBe(200);
const xml = await r.text();
expect(xml).toContain('ListBucketResultV2');
expect(xml).toContain('KeyCount');
});
it('ListObjectsV2 — continuation', async () => {
const r = await s3Request('GET', `/${bucketName}`, {
query: { 'list-type': '2', 'max-keys': '1' },
});
expect(r.status).toBe(200);
const xml = await r.text();
expect(xml).toContain('IsTruncated');
});
it('DeleteObject (DELETE /{bucket}/{key})', async () => {
const r = await s3Request('DELETE', `/${bucketName}/folder/nested.txt`);
expect(r.status).toBe(204);
});
it('DeleteObjects (POST /{bucket}?delete) — batch', async () => {
const content = new TextEncoder().encode('del');
await s3Request('PUT', `/${bucketName}/batch-1.txt`, { body: content });
await s3Request('PUT', `/${bucketName}/batch-2.txt`, { body: content });
const deleteBody =
'<Delete><Object><Key>batch-1.txt</Key></Object><Object><Key>batch-2.txt</Key></Object></Delete>';
const r = await s3Request('POST', `/${bucketName}`, {
query: { delete: '' },
body: new TextEncoder().encode(deleteBody),
});
expect(r.status).toBe(200);
const xml = await r.text();
expect(xml).toContain('DeleteResult');
});
it('CopyObject (PUT /{dest} with x-amz-copy-source)', async () => {
const r = await s3Request('PUT', `/${bucketName}/copy-dest.txt`, {
headers: { 'x-amz-copy-source': `/${bucketName}/test-file.txt` },
});
expect(r.status).toBe(200);
const xml = await r.text();
expect(xml).toContain('CopyObjectResult');
});
it('Presigned URL — GET with X-Amz-Signature', async () => {
// Use s3Request to compute a presigned URL signature — verify the object
await s3Request('PUT', `/${bucketName}/presigned-test.txt`, {
body: new TextEncoder().encode('presigned content'),
});
const host = new URL(BASE_URL).host;
const now = new Date();
const pad2 = (n: number) => String(n).padStart(2, '0');
const amzDate = `${now.getUTCFullYear()}${pad2(now.getUTCMonth() + 1)}${pad2(now.getUTCDate())}T${pad2(now.getUTCHours())}${pad2(now.getUTCMinutes())}${pad2(now.getUTCSeconds())}Z`;
const dateStamp = amzDate.slice(0, 8);
// Build canonical query string (must match server's buildCanonicalQueryString)
const sp = new URLSearchParams({
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
'X-Amz-Credential': `${S3_KEY}/${dateStamp}/us-east-1/s3/aws4_request`,
'X-Amz-Date': amzDate,
'X-Amz-Expires': '3600',
'X-Amz-SignedHeaders': 'host',
});
// Sort keys to match server's alphabetical sort
const sorted = [...sp.entries()].sort(([a], [b]) => a.localeCompare(b));
const canonicalQs = sorted
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join('&');
const canonical = `GET\n/${bucketName}/presigned-test.txt\n${canonicalQs}\nhost:${host}\n\nhost\nUNSIGNED-PAYLOAD`;
const hcr = sha256hex(canonical);
const cs = `${dateStamp}/us-east-1/s3/aws4_request`;
const sts = `AWS4-HMAC-SHA256\n${amzDate}\n${cs}\n${hcr}`;
const sk = getSigningKey(S3_SECRET, dateStamp, 'us-east-1');
const sig = hex(hmacSha256(sk, sts));
sp.set('X-Amz-Signature', sig);
const presignedUrl = `${BASE_URL}/${bucketName}/presigned-test.txt?${sp.toString()}`;
const r = await fetch(presignedUrl);
expect(r.status).toBe(200);
const text = await r.text();
expect(text).toContain('presigned content');
});
it('GetObject Range — returns partial single-part content', async () => {
const r = await s3Request('GET', `/${bucketName}/test-file.txt`, {
headers: { range: 'bytes=0-4' },
});
expect(r.status).toBe(206);
expect(r.headers.get('content-range')).toBe('bytes 0-4/8');
expect(await r.text()).toBe('hello');
});
it('GetObject Range — invalid range returns 416 XML', async () => {
const r = await s3Request('GET', `/${bucketName}/test-file.txt`, {
headers: { range: 'bytes=999-1000' },
});
expect(r.status).toBe(416);
expect(r.headers.get('content-range')).toBe('bytes */8');
const xml = await r.text();
expect(xml).toContain('InvalidRange');
});
it('Multipart GetObject — returns complete concatenated body', async () => {
const create = await s3Request('POST', `/${bucketName}/multipart-full.txt`, {
query: { uploads: '' },
});
expect(create.status).toBe(200);
const createXml = await create.text();
const uploadId = createXml.match(/<UploadId>([^<]+)<\/UploadId>/)?.[1];
expect(uploadId).toBeTruthy();
const part1 = new TextEncoder().encode('hello ');
const part2 = new TextEncoder().encode('multipart');
const p1 = await s3Request('PUT', `/${bucketName}/multipart-full.txt`, {
query: { partNumber: '1', uploadId: uploadId! },
body: part1,
});
const p2 = await s3Request('PUT', `/${bucketName}/multipart-full.txt`, {
query: { partNumber: '2', uploadId: uploadId! },
body: part2,
});
expect(p1.status).toBe(200);
expect(p2.status).toBe(200);
const completeBody = `<CompleteMultipartUpload><Part><PartNumber>1</PartNumber><ETag>${p1.headers.get('etag')}</ETag></Part><Part><PartNumber>2</PartNumber><ETag>${p2.headers.get('etag')}</ETag></Part></CompleteMultipartUpload>`;
const complete = await s3Request('POST', `/${bucketName}/multipart-full.txt`, {
query: { uploadId: uploadId! },
body: new TextEncoder().encode(completeBody),
});
expect(complete.status).toBe(200);
const full = await s3Request('GET', `/${bucketName}/multipart-full.txt`);
expect(full.status).toBe(200);
expect(await full.text()).toBe('hello multipart');
const partial = await s3Request('GET', `/${bucketName}/multipart-full.txt`, {
headers: { range: 'bytes=3-9' },
});
expect(partial.status).toBe(206);
expect(partial.headers.get('content-range')).toBe('bytes 3-9/15');
expect(await partial.text()).toBe('lo mult');
});
it('Delete bucket — must be empty first', async () => {
// Clean up remaining objects
await s3Request('DELETE', `/${bucketName}/test-file.txt`);
await s3Request('DELETE', `/${bucketName}/copy-dest.txt`);
await s3Request('DELETE', `/${bucketName}/presigned-test.txt`);
await s3Request('DELETE', `/${bucketName}/multipart-full.txt`);
const r = await s3Request('DELETE', `/${bucketName}`);
expect(r.status).toBe(204);
createdBuckets = createdBuckets.filter((b) => b !== bucketName);
});
it('S3 error — NoSuchBucket returns 404 XML', async () => {
const r = await s3Request('GET', '/bucket-nonexistent-xyz');
expect(r.status).toBe(404);
const xml = await r.text();
expect(xml).toContain('NoSuchBucket');
});
it('S3 error — bad signature returns 403', async () => {
const r = await fetch(`${BASE_URL}/`, {
headers: {
Authorization:
'AWS4-HMAC-SHA256 Credential=fake/20260701/us-east-1/s3/aws4_request, SignedHeaders=host, Signature=00',
'x-amz-date': '20260701T000000Z',
'x-amz-content-sha256': 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
},
});
expect(r.status).toBe(403);
const xml = await r.text();
expect(xml).toContain('Error');
});
});
console.info(`\n️ Production E2E — ${BASE_URL}`);
if (!S3_SECRET) console.info('️ S3 tests will fail — set S3_SECRET_KEY');