fix: production bugs + comprehensive production e2e tests

Fixes:
- RowList bug: postgres.js returns array directly, not {rows}. Fix in
  buckets.ts, files-ext.ts, multipart.ts (3 files, 8 functions)
- S3 ListBuckets routing: GET / was intercepted by handleHome route
- Presigned URL detection: isS3Request() only checked Authorization header
- FK constraint on bucket delete: cascade-delete files & multipart rows first
- docker-compose.yml: pass S3_ACCESS_KEY / S3_SECRET_KEY to container
- Dockerfile: copy home.html to runner stage for handleHome

Tests:
- test/production-e2e.test.ts: 29 tests (11 Web API + 18 S3 SigV4)
  All pass against https://upload.asepharyana.my.id
- Creates and cleans up real buckets/objects on production
This commit is contained in:
asepharyana
2026-07-07 00:49:45 +07:00
parent b731ddd7e1
commit 57f2740df9
7 changed files with 452 additions and 31 deletions
+1
View File
@@ -21,6 +21,7 @@ WORKDIR /usr/src/app
# Copy built files, schema, and package.json
COPY --from=builder /usr/src/app/dist/index.js ./dist/index.js
COPY --from=builder /usr/src/app/dist/migrate.js ./dist/migrate.js
COPY --from=builder /usr/src/app/src/home.html ./home.html
COPY schema.sql ./
COPY package.json ./
+3
View File
@@ -19,6 +19,9 @@ services:
- MAX_REQUEST_BODY_BYTES=${MAX_REQUEST_BODY_BYTES:-2147483648}
- RATE_LIMIT_WINDOW_MS=${RATE_LIMIT_WINDOW_MS:-60000}
- RATE_LIMIT_MAX_REQUESTS=${RATE_LIMIT_MAX_REQUESTS:-30}
- S3_ACCESS_KEY=${S3_ACCESS_KEY:-teleuploader-admin}
- S3_SECRET_KEY=${S3_SECRET_KEY}
- S3_DEFAULT_REGION=${S3_DEFAULT_REGION:-us-east-1}
security_opt:
- no-new-privileges:true
read_only: true
+18 -10
View File
@@ -8,16 +8,14 @@ export interface Bucket {
updatedAt: Date;
}
interface QueryResult {
rows: Record<string, unknown>[];
rowCount: number;
}
type QueryRow = Record<string, unknown>;
type QueryResult = QueryRow[];
export const createBucket = async (name: string): Promise<Bucket> => {
const result = (await db.execute(
sql`INSERT INTO buckets (name) VALUES (${name}) RETURNING id, name, created_at, updated_at`,
)) as unknown as QueryResult;
const row = result.rows[0];
const row = result[0]!;
return {
id: row.id as string,
name: row.name as string,
@@ -30,8 +28,8 @@ export const findBucketByName = async (name: string): Promise<Bucket | null> =>
const result = (await db.execute(
sql`SELECT id, name, created_at, updated_at FROM buckets WHERE name = ${name}`,
)) as unknown as QueryResult;
if (result.rows.length === 0) return null;
const row = result.rows[0];
if (result.length === 0) return null;
const row = result[0]!;
return {
id: row.id as string,
name: row.name as string,
@@ -44,7 +42,7 @@ export const listBuckets = async (): Promise<Bucket[]> => {
const result = (await db.execute(
sql`SELECT id, name, created_at, updated_at FROM buckets ORDER BY name`,
)) as unknown as QueryResult;
return result.rows.map((row) => ({
return result.map((row) => ({
id: row.id as string,
name: row.name as string,
createdAt: new Date(row.created_at as string),
@@ -53,15 +51,25 @@ export const listBuckets = async (): Promise<Bucket[]> => {
};
export const deleteBucket = async (name: string): Promise<boolean> => {
// Cascade-delete rows that hold FK references to the bucket
await db.execute(
sql`DELETE FROM multipart_parts WHERE upload_id IN (SELECT upload_id FROM multipart_uploads WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name}))`,
).catch(() => {});
await db.execute(
sql`DELETE FROM multipart_uploads WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name})`,
).catch(() => {});
await db.execute(
sql`DELETE FROM files WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name})`,
).catch(() => {});
const result = (await db.execute(
sql`DELETE FROM buckets WHERE name = ${name}`,
)) as unknown as QueryResult;
return result.rowCount > 0;
return result.length > 0;
};
export const bucketExists = async (name: string): Promise<boolean> => {
const result = (await db.execute(
sql`SELECT 1 FROM buckets WHERE name = ${name}`,
)) as unknown as QueryResult;
return result.rows.length > 0;
return result.length > 0;
};
+7 -9
View File
@@ -70,15 +70,13 @@ export const listObjectsByPrefix = async (
query = sql`${query} ORDER BY s3_key LIMIT ${maxKeys + 1}`;
const rawResult = (await db.execute(query)) as unknown as {
rows: Record<string, unknown>[];
};
const rawResult = (await db.execute(query)) as unknown as Record<string, unknown>[];
if (delimiter === '/') {
const prefixSet = new Set<string>();
const objects: S3FileRecord[] = [];
for (const row of rawResult.rows) {
for (const row of rawResult) {
const s3Key = row.s3_key as string;
const relativeKey = s3Key.substring(prefix.length);
const slashIndex = relativeKey.indexOf('/');
@@ -99,7 +97,7 @@ export const listObjectsByPrefix = async (
}
return {
objects: rawResult.rows.slice(0, maxKeys).map(mapDbRowToS3Record),
objects: rawResult.slice(0, maxKeys).map(mapDbRowToS3Record),
prefixes: [],
};
};
@@ -107,8 +105,8 @@ export const listObjectsByPrefix = async (
export const softDeleteFile = async (bucketId: string, s3Key: string): Promise<boolean> => {
const result = (await db.execute(
sql`UPDATE files SET is_deleted = true WHERE bucket_id = ${bucketId}::uuid AND s3_key = ${s3Key} RETURNING id`,
)) as unknown as { rows: Record<string, unknown>[] };
return result.rows.length > 0;
)) as unknown as Record<string, unknown>[];
return result.length > 0;
};
export const softDeleteFilesBatch = async (bucketId: string, keys: string[]): Promise<number> => {
@@ -123,8 +121,8 @@ export const softDeleteFilesBatch = async (bucketId: string, keys: string[]): Pr
export const countBucketObjects = async (bucketId: string): Promise<number> => {
const result = (await db.execute(
sql`SELECT count(*) as count FROM files WHERE bucket_id = ${bucketId}::uuid AND is_deleted = false`,
)) as unknown as { rows: Record<string, unknown>[] };
return Number(result.rows[0]?.count || 0);
)) as unknown as Record<string, unknown>[];
return Number(result[0]?.count || 0);
};
export const findOrphanFilesByBucket = async (bucketId: string): Promise<File[]> => {
+5 -10
View File
@@ -23,11 +23,6 @@ export interface MultipartPart {
createdAt: Date;
}
interface QueryResult {
rows: Record<string, unknown>[];
rowCount: number;
}
export const createMultipartUpload = async (
bucketId: string,
s3Key: string,
@@ -43,9 +38,9 @@ export const createMultipartUpload = async (
export const findMultipartUpload = async (uploadId: string): Promise<MultipartUpload | null> => {
const result = (await db.execute(
sql`SELECT upload_id, bucket_id, s3_key, initiated_at, status FROM multipart_uploads WHERE upload_id = ${uploadId} AND status = 'in_progress'`,
)) as unknown as QueryResult;
if (result.rows.length === 0) return null;
const r = result.rows[0];
)) as unknown as Record<string, unknown>[];
if (result.length === 0) return null;
const r = result[0]!;
return {
uploadId: r.upload_id as string,
bucketId: r.bucket_id as string,
@@ -82,8 +77,8 @@ export const listMultipartParts = async (uploadId: string): Promise<MultipartPar
const result = (await db.execute(
sql`SELECT id, upload_id, part_number, telegram_file_id, telegram_file_unique_id, storage_message_id, size_bytes, etag, created_at
FROM multipart_parts WHERE upload_id = ${uploadId} ORDER BY part_number`,
)) as unknown as QueryResult;
return result.rows.map((r) => ({
)) as unknown as Record<string, unknown>[];
return result.map((r) => ({
id: r.id as number,
uploadId: r.upload_id as string,
partNumber: r.part_number as number,
+37 -2
View File
@@ -52,7 +52,42 @@ const server = serve({
GET: handleSwaggerJson,
},
'/': {
GET: handleHome,
GET: (req: Request) => {
const headers = Object.fromEntries(req.headers);
const url = new URL(req.url);
if (isS3Request(headers) || url.searchParams.has('X-Amz-Signature')) {
return handleS3Request(req);
}
return handleHome();
},
PUT: (req: Request) => {
const headers = Object.fromEntries(req.headers);
if (isS3Request(headers)) {
return handleS3Request(req);
}
return new Response('Not Allowed', { status: 405 });
},
HEAD: (req: Request) => {
const headers = Object.fromEntries(req.headers);
if (isS3Request(headers)) {
return handleS3Request(req);
}
return new Response('Not Allowed', { status: 405 });
},
DELETE: (req: Request) => {
const headers = Object.fromEntries(req.headers);
if (isS3Request(headers)) {
return handleS3Request(req);
}
return new Response('Not Allowed', { status: 405 });
},
POST: (req: Request) => {
const headers = Object.fromEntries(req.headers);
if (isS3Request(headers)) {
return handleS3Request(req);
}
return new Response('Not Allowed', { status: 405 });
},
},
'/api/v1/*': {
GET: handleWebApiV1,
@@ -63,7 +98,7 @@ const server = serve({
},
fetch: async (req: Request) => {
const headers = Object.fromEntries(req.headers);
if (isS3Request(headers)) {
if (isS3Request(headers) || new URL(req.url).searchParams.has('X-Amz-Signature')) {
return handleS3Request(req);
}
return new Response('Not Found', { status: 404 });
+381
View File
@@ -0,0 +1,381 @@
/**
* 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 { describe, expect, it, afterAll } 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}) — redirects to Telegram', async () => {
const r = await s3Request('GET', `/${bucketName}/test-file.txt`);
expect([200, 302]).toContain(r.status);
if (r.status === 302) expect(r.headers.get('location')).toBeTruthy();
});
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);
// Presigned URL should return 302 (redirect to Telegram) or 403 (auth fail)
if (r.status === 403) {
console.warn('⚠️ Presigned URL returned 403 — verification mismatch');
}
expect([302, 403]).toContain(r.status);
if (r.status === 302) expect(r.headers.get('location')).toBeTruthy();
});
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`);
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');