Files
TeleUploader/test/production-e2e.test.ts
T
asepharyana 66fb92e5af test(e2e): make 1GB suite opt-in (RUN_LARGE_E2E=1) + fail-fast source probe
The 1GB suite downloads a 1GB fixture from Hetzner and uploads it to the
live deployment. Bun's Response-body write hangs against this source, so
the suite burned the full hook timeout on every routine run. Now:
- skipped by default (opt-in via RUN_LARGE_E2E=1)
- pre-flight Range probe (8s AbortSignal) fails fast when source is down
- 10min hook timeout retained for when the suite is actually opted in
2026-08-01 21:02:10 +07:00

799 lines
32 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), S3 (SigV4 XML), and direct HTTP upload
* interfaces, including large (1 GB) file transfers.
*
* Requires env vars:
* - BASE_URL (default: https://upload.asepharyana.my.id)
* - S3_ACCESS_KEY (default: filedrop-admin)
* - S3_SECRET_KEY (required for S3 & large-file tests)
* - ADMIN_API_TOKEN (required for Web API tests)
*
* Usage:
* S3_SECRET_KEY=xxx ADMIN_API_TOKEN=xxx bun test test/production-e2e.test.ts
*
* Large-file tests (1 GB) are opt-in — they download a 1 GB fixture and
* upload it to the live deployment:
* RUN_LARGE_E2E=1 S3_SECRET_KEY=xxx ADMIN_API_TOKEN=xxx bun test test/production-e2e.test.ts
* (downloads https://ash-speed.hetzner.com/1GB.bin into /tmp/kilo/1GB.bin)
*/
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
import { existsSync } from 'node:fs';
// ── Config ───────────────────────────────────────────────────────────────────
const BASE_URL = process.env.BASE_URL || 'https://upload.asepharyana.my.id';
const S3_KEY = process.env.S3_ACCESS_KEY || 'filedrop-admin';
const S3_SECRET = process.env.S3_SECRET_KEY || '';
const AUTH_TOKEN = process.env.ADMIN_API_TOKEN || '';
const TS = Date.now().toString(36);
let createdBuckets: string[] = [];
// Large-file paths
const LARGE_FILE_PATH = '/tmp/kilo/1GB.bin';
const LARGE_FILE_URL = 'https://ash-speed.hetzner.com/1GB.bin';
// ── Long timeouts for large uploads ──────────────────────────────────────────
const LARGE_UPLOAD_TIMEOUT = 600_000; // 10 minutes
const LARGE_DOWNLOAD_TIMEOUT = 120_000; // 2 minutes
// ── 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,
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 authHeaders: Record<string, string> = AUTH_TOKEN
? { authorization: `Bearer ${AUTH_TOKEN}` }
: {};
const apiJson = (p: string, o: RequestInit = {}) =>
fetch(api(p), {
headers: { 'content-type': 'application/json', ...authHeaders },
...o,
});
/** Authenticated form-data upload helper (Web API v1). */
async function apiUploadFormData(path: string, fd: FormData): Promise<Response> {
return fetch(api(path), {
method: 'POST',
headers: { ...authHeaders },
body: fd,
});
}
// ── Ensure large test file exists ────────────────────────────────────────────
async function ensureLargeFile(): Promise<boolean> {
if (existsSync(LARGE_FILE_PATH)) {
const stat = await Bun.file(LARGE_FILE_PATH).stat();
if (stat.size >= 1_000_000_000) return true;
}
return false;
}
/**
* Probes the 1 GB fixture source with a tiny Range request so the suite
* fails fast (and skips) when the download host is unreachable — without
* it, every full e2e run burns the whole beforeAll timeout hanging on the
* download.
*/
const LARGE_SOURCE_OK = await (async (): Promise<boolean> => {
try {
const probe = await fetch(LARGE_FILE_URL, {
headers: { Range: 'bytes=0-0' },
signal: AbortSignal.timeout(8_000),
});
return probe.ok || probe.status === 206 || probe.status === 416;
} catch {
return false;
}
})();
// ── Shared cleanup ───────────────────────────────────────────────────────────
afterAll(async () => {
for (const name of createdBuckets) {
try {
// Use Web API with auth to delete the bucket (it deletes contents too)
await fetch(`${BASE_URL}/api/v1/buckets/${name}`, {
method: 'DELETE',
headers: { ...authHeaders },
});
} 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 (with auth)', async () => {
const fd = new FormData();
fd.append('file', new Blob(['hello']), 'hello.txt');
fd.append('key', 'hello.txt');
const r = await apiUploadFormData(`/buckets/e2e-web-${TS}/upload`, 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) — manual signed requests
// ═══════════════════════════════════════════════════════════════════════════════
describe('S3 API (production, SigV4)', () => {
const skipS3 = !S3_SECRET;
if (skipS3) {
it('S3 tests skipped — set S3_SECRET_KEY env var', () => {
console.info('️ S3_SKIP: S3_SECRET_KEY not set — skipping S3 tests');
});
} else {
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 () => {
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);
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',
});
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');
},
{ timeout: 30_000 },
);
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');
});
}
});
// ═══════════════════════════════════════════════════════════════════════════════
// 1 GB large-file upload tests
// ═══════════════════════════════════════════════════════════════════════════════
//
// The deployment sits behind Cloudflare (free plan) which imposes a ~100 MB
// request body limit. Single-request uploads larger than ~100 MB return 413.
//
// The S3 Multipart Upload API (used below) bypasses this by splitting the file
// into parts of ~40 MB each — well under Cloudflare's limit. This is also
// the recommended S3 pattern for large-object uploads.
//
// ═══════════════════════════════════════════════════════════════════════════════
describe('1 GB large-file upload', () => {
// Opt-in via RUN_LARGE_E2E=1 — the suite downloads a 1 GB fixture and
// uploads it to the live deployment (real Telegram storage), so it must
// never run implicitly in routine verification.
const skipLarge =
!S3_SECRET || !AUTH_TOKEN || !LARGE_SOURCE_OK || process.env.RUN_LARGE_E2E !== '1';
let largeBucket = '';
beforeAll(async () => {
if (skipLarge) return;
const ok = await ensureLargeFile();
if (!ok) {
console.info('️ 1GB.bin not found locally — downloading from Hetzner');
const dl = await fetch(LARGE_FILE_URL);
if (!dl.ok || !dl.body) throw new Error(`Failed to download ${LARGE_FILE_URL}: ${dl.status}`);
await Bun.write(LARGE_FILE_PATH, dl);
const stat = await Bun.file(LARGE_FILE_PATH).stat();
console.info(` Downloaded ${(stat.size / 1_000_000_000).toFixed(1)} GB`);
} else {
const stat = await Bun.file(LARGE_FILE_PATH).stat();
console.info(` Using existing file: ${(stat.size / 1_000_000_000).toFixed(1)} GB`);
}
// Create a dedicated bucket via S3 API
largeBucket = `e2e-large-${TS}`;
const r = await s3Request('PUT', `/${largeBucket}`);
expect(r.status).toBe(200);
createdBuckets.push(largeBucket);
}, 600_000);
if (skipLarge) {
it('1 GB tests skipped — set RUN_LARGE_E2E=1 + S3_SECRET_KEY + ADMIN_API_TOKEN', () => {
if (process.env.RUN_LARGE_E2E !== '1') {
console.info('️ LARGE_SKIP: set RUN_LARGE_E2E=1 to run the 1 GB suite');
} else if (!S3_SECRET || !AUTH_TOKEN) {
console.info('️ LARGE_SKIP: set S3_SECRET_KEY + ADMIN_API_TOKEN for 1 GB tests');
} else {
console.info(
`️ LARGE_SKIP: 1GB source ${LARGE_FILE_URL} unreachable (probe failed) — skipping 1 GB suite`,
);
}
});
return;
}
// ── S3: PutObject (1 GB via in-memory body, hashed) ─────────────────────────
//
// Alternative A: Single PUT request — reads the whole file into memory
// so we can compute SHA-256 and sign the body hash. Requires the test
// runner to have >1 GB RAM but verifies the simplest PUT path.
it(
'S3 PutObject — upload 1 GB file as a single PUT (in-memory)',
async () => {
const file = Bun.file(LARGE_FILE_PATH);
const fileBuffer = await file.bytes();
const r = await s3Request('PUT', `/${largeBucket}/1GB-single.bin`, {
body: fileBuffer as unknown as Uint8Array,
});
if (r.status === 413) {
const text = await r.text();
console.info(' S3 413 (Cloudflare >100 MB limit):', text.slice(0, 200));
// Not a failure — infrastructure limitation of the free Cloudflare plan
} else if (r.status !== 200) {
const text = await r.text();
console.info(` S3 PUT error (${r.status}):`, text.slice(0, 300));
}
// Accept 200 = success or 413 = Cloudflare blocked (>100 MB)
expect([200, 413]).toContain(r.status);
if (r.status === 200) {
const etag = r.headers.get('etag') || '';
expect(etag).toBeTruthy();
console.info(' S3 single PUT 1GB: ETag =', etag);
}
},
LARGE_UPLOAD_TIMEOUT,
);
it(
'S3 GetObject range — verify single-PUT object exists (if PUT succeeded)',
async () => {
const r = await s3Request('GET', `/${largeBucket}/1GB-single.bin`, {
headers: { Range: 'bytes=0-7' },
});
if (r.status === 404) {
console.info(' 1GB-single.bin not found (PUT was blocked by Cloudflare 413)');
expect(true).toBe(true); // Acceptable — the PUT was blocked
} else {
expect(r.status).toBe(206);
const chunk = await r.arrayBuffer();
expect(chunk.byteLength).toBe(8);
console.info(' S3 GetObject range(0-7): OK');
}
},
LARGE_DOWNLOAD_TIMEOUT,
);
// ── S3 Multipart Upload (all parts ≤ telegramChunkSizeBytes ~ 48 MB) ──────
//
// Alternative B: S3 Multipart Upload — the recommended way for large files.
// Each part fits within Cloudflare's 100 MB limit AND the server's
// telegramChunkSizeBytes limit (~48 MB / 50331648 bytes).
it(
'S3 Multipart Upload — upload 1 GB in ~40 MB parts',
async () => {
const key = '1GB-multipart.bin';
// 1. Initiate multipart upload
const init = await s3Request('POST', `/${largeBucket}/${key}`, {
query: { uploads: '' },
});
expect(init.status).toBe(200);
const initXml = await init.text();
const uploadId = initXml.match(/<UploadId>([^<]+)<\/UploadId>/)?.[1];
expect(uploadId).toBeTruthy();
console.info(` Multipart upload initiated: ${uploadId}`);
// 2. Upload parts (40 MB each — well under telegramChunkSizeBytes)
const PART_SIZE = 40 * 1024 * 1024; // 40 MB
const file = Bun.file(LARGE_FILE_PATH);
const fileSize = file.size;
const parts: { partNumber: number; etag: string }[] = [];
let offset = 0;
let partNumber = 1;
while (offset < fileSize) {
const chunkSize = Math.min(PART_SIZE, fileSize - offset);
const chunk = await file.slice(offset, offset + chunkSize).arrayBuffer();
const r = await s3Request('PUT', `/${largeBucket}/${key}`, {
query: { partNumber: String(partNumber), uploadId: uploadId! },
body: new Uint8Array(chunk),
});
expect(r.status).toBe(200);
const etag = r.headers.get('etag') || '';
expect(etag).toBeTruthy();
parts.push({ partNumber, etag });
console.info(` Part ${partNumber}: ${chunkSize} bytes uploaded`);
offset += chunkSize;
partNumber++;
}
expect(parts.length).toBeGreaterThan(1);
console.info(` Total parts: ${parts.length}`);
// 3. Complete multipart upload
const completeXml = `<CompleteMultipartUpload>${parts
.map((p) => `<Part><PartNumber>${p.partNumber}</PartNumber><ETag>${p.etag}</ETag></Part>`)
.join('')}</CompleteMultipartUpload>`;
const complete = await s3Request('POST', `/${largeBucket}/${key}`, {
query: { uploadId: uploadId! },
body: new TextEncoder().encode(completeXml),
});
expect(complete.status).toBe(200);
const completeBody = await complete.text();
expect(completeBody).toContain('CompleteMultipartUploadResult');
console.info(' Multipart upload completed (1 GB file stored in Telegram)');
console.info(' Note: GET of the assembled multipart object returns 500');
console.info(' This is a known limitation — the app needs chunked storage integration');
},
LARGE_UPLOAD_TIMEOUT,
);
// ── Verify multipart object via ListObjects (metadata only) ────────────────
//
// HEAD and GET have issues with multipart objects, so we verify via the
// S3 ListObjects API that the file record was created correctly.
it(
'S3 ListObjects — verify 1GB multipart object metadata',
async () => {
const r = await s3Request('GET', `/${largeBucket}`, { query: { 'list-type': '2' } });
expect(r.status).toBe(200);
const xml = await r.text();
// Verify the multipart file appears in listing with correct 1GB size
expect(xml).toContain('1GB-multipart.bin');
expect(xml).toContain('<Size>1073741824</Size>');
console.info(' ListObjects: 1GB-multipart.bin found with correct size');
},
LARGE_DOWNLOAD_TIMEOUT,
);
// ── POST /api/upload — direct HTTP multipart upload ────────────────────────
//
// Sends a stand-alone file via POST /api/upload (the non-bucketed endpoint).
// Cloudflare (free plan) limits request bodies to ~100 MB, so a 1 GB upload
// is expected to fail with 413. This test verifies that the app itself
// would accept the upload when the infrastructure allows it.
it(
'POST /api/upload — 1 GB (expected 413 behind Cloudflare free plan)',
async () => {
const fileBlob = Bun.file(LARGE_FILE_PATH);
const fd = new FormData();
fd.append('file', fileBlob, '1GB-test.bin');
const r = await fetch(`${BASE_URL}/api/upload`, {
method: 'POST',
body: fd,
});
if (r.status === 413 || r.status === 502) {
const text = await r.text();
console.info(
` POST ${r.status} (expected — Cloudflare/Infra limit):`,
text.slice(0, 100),
);
} else {
expect(r.status).toBe(200);
const body = (await r.json()) as {
public_id: string;
file_name: string;
size_bytes: number;
};
expect(body.public_id).toBeTruthy();
expect(body.file_name).toBe('1GB-test.bin');
expect(body.size_bytes).toBeGreaterThan(1_000_000_000);
console.info(' POST /api/upload 1GB: public_id =', body.public_id);
}
},
LARGE_UPLOAD_TIMEOUT,
);
// ── Cleanup: delete the 1GB single-PUT object ─────────────────────────────
it(
'Cleanup — delete 1GB S3 objects and bucket',
async () => {
// Delete single-PUT object (may or may not exist)
const del1 = await s3Request('DELETE', `/${largeBucket}/1GB-single.bin`);
console.info(` Delete 1GB-single.bin: ${del1.status}`);
// Delete multipart object
const del2 = await s3Request('DELETE', `/${largeBucket}/1GB-multipart.bin`);
console.info(` Delete 1GB-multipart.bin: ${del2.status}`);
// Delete the bucket
const delBucket = await s3Request('DELETE', `/${largeBucket}`);
console.info(` Delete bucket ${largeBucket}: ${delBucket.status}`);
},
LARGE_DOWNLOAD_TIMEOUT,
);
});
console.info(`\n️ Production E2E — ${BASE_URL}`);
if (!S3_SECRET) console.info('️ S3 tests skipped — set S3_SECRET_KEY');
if (!AUTH_TOKEN) console.info('️ Web API tests need ADMIN_API_TOKEN for upload');