From 57f2740df967ba7e36faa525b3d74aef4c0e0817 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Tue, 7 Jul 2026 00:49:45 +0700 Subject: [PATCH] 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 --- Dockerfile | 1 + docker-compose.yml | 3 + src/db/buckets.ts | 28 ++- src/db/files-ext.ts | 16 +- src/db/multipart.ts | 15 +- src/index.ts | 39 +++- test/production-e2e.test.ts | 381 ++++++++++++++++++++++++++++++++++++ 7 files changed, 452 insertions(+), 31 deletions(-) create mode 100644 test/production-e2e.test.ts diff --git a/Dockerfile b/Dockerfile index 9771c40..485cfb9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 ./ diff --git a/docker-compose.yml b/docker-compose.yml index 9959e01..1364ed8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/src/db/buckets.ts b/src/db/buckets.ts index 1b2ad55..54c501d 100644 --- a/src/db/buckets.ts +++ b/src/db/buckets.ts @@ -8,16 +8,14 @@ export interface Bucket { updatedAt: Date; } -interface QueryResult { - rows: Record[]; - rowCount: number; -} +type QueryRow = Record; +type QueryResult = QueryRow[]; export const createBucket = async (name: string): Promise => { 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 => 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 => { 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 => { }; export const deleteBucket = async (name: string): Promise => { + // 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 => { 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; }; diff --git a/src/db/files-ext.ts b/src/db/files-ext.ts index 7fe6686..9fa9412 100644 --- a/src/db/files-ext.ts +++ b/src/db/files-ext.ts @@ -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[]; - }; + const rawResult = (await db.execute(query)) as unknown as Record[]; if (delimiter === '/') { const prefixSet = new Set(); 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 => { 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[] }; - return result.rows.length > 0; + )) as unknown as Record[]; + return result.length > 0; }; export const softDeleteFilesBatch = async (bucketId: string, keys: string[]): Promise => { @@ -123,8 +121,8 @@ export const softDeleteFilesBatch = async (bucketId: string, keys: string[]): Pr export const countBucketObjects = async (bucketId: string): Promise => { 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[] }; - return Number(result.rows[0]?.count || 0); + )) as unknown as Record[]; + return Number(result[0]?.count || 0); }; export const findOrphanFilesByBucket = async (bucketId: string): Promise => { diff --git a/src/db/multipart.ts b/src/db/multipart.ts index 938acff..a538ca0 100644 --- a/src/db/multipart.ts +++ b/src/db/multipart.ts @@ -23,11 +23,6 @@ export interface MultipartPart { createdAt: Date; } -interface QueryResult { - rows: Record[]; - 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 => { 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[]; + 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 ({ + )) as unknown as Record[]; + return result.map((r) => ({ id: r.id as number, uploadId: r.upload_id as string, partNumber: r.part_number as number, diff --git a/src/index.ts b/src/index.ts index 799aa83..6ded652 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 }); diff --git a/test/production-e2e.test.ts b/test/production-e2e.test.ts new file mode 100644 index 0000000..bc196c3 --- /dev/null +++ b/test/production-e2e.test.ts @@ -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 = {}, +): Record { + 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; headers?: Record } = {}, +): Promise { + 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 = 'batch-1.txtbatch-2.txt'; + 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');