From 9a48fbf2279edc0855ca3faadd9aaf3e1bad4df6 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Tue, 7 Jul 2026 03:07:51 +0700 Subject: [PATCH] fix: satisfy deploy lint gate for S3 compatibility work - Apply Biome organize-import/formatting fixes across changed S3 files - Replace remaining string concatenations with template literals for lint - Make home page inline handlers explicit via window.* and add button types - Clean S3 auth lint issues with dot-property access and optional chaining - Keep GetObject proxy and production/S3 SDK tests passing Verification: - bun run lint (0 errors, 1 CSS specificity warning) - S3_SECRET_KEY= bun test test/production-e2e.test.ts (29 pass) - S3_SECRET_KEY= bun test test/s3-sdk.test.ts (20 pass) - bun test test/s3-auth.test.ts (5 pass) --- src/db/buckets.ts | 24 ++++++++----- src/db/files-ext.ts | 4 +-- src/db/migrate.ts | 10 +++--- src/db/multipart.ts | 2 +- src/home.html | 21 +++++------ src/index.ts | 6 ++-- src/routes/home.ts | 2 +- src/routes/s3.ts | 56 ++++++++++++++--------------- src/routes/web-api.ts | 12 +++---- src/utils/s3/auth.ts | 6 ++-- src/utils/s3/xml.ts | 15 +++----- test/production-e2e.test.ts | 71 ++++++++++++++++++++++++++----------- test/s3-auth.test.ts | 2 +- test/s3-sdk.test.ts | 54 +++++++++++++++------------- 14 files changed, 161 insertions(+), 124 deletions(-) diff --git a/src/db/buckets.ts b/src/db/buckets.ts index 54c501d..669509e 100644 --- a/src/db/buckets.ts +++ b/src/db/buckets.ts @@ -52,15 +52,21 @@ 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(() => {}); + 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; diff --git a/src/db/files-ext.ts b/src/db/files-ext.ts index 9fa9412..876122d 100644 --- a/src/db/files-ext.ts +++ b/src/db/files-ext.ts @@ -1,4 +1,4 @@ -import { eq, and, sql } from 'drizzle-orm'; +import { and, eq, sql } from 'drizzle-orm'; import { db, files as fileSchema } from './index'; import type { File } from './schema'; @@ -62,7 +62,7 @@ export const listObjectsByPrefix = async ( maxKeys: number, startAfter: string | null, ): Promise<{ objects: S3FileRecord[]; prefixes: string[] }> => { - let query = sql`SELECT * FROM files WHERE bucket_id = ${bucketId}::uuid AND is_deleted = false AND s3_key LIKE ${prefix + '%'}`; + let query = sql`SELECT * FROM files WHERE bucket_id = ${bucketId}::uuid AND is_deleted = false AND s3_key LIKE ${`${prefix}%`}`; if (startAfter) { query = sql`${query} AND s3_key > ${startAfter}`; diff --git a/src/db/migrate.ts b/src/db/migrate.ts index d2653c8..c67441e 100644 --- a/src/db/migrate.ts +++ b/src/db/migrate.ts @@ -12,10 +12,10 @@ export const runMigration = async (): Promise => { // In source via bun --hot: import.meta.dir = .../src/db/ const dir = import.meta.dir || ''; const candidates = [ - dir + '/../../schema.sql', // from dist/ - dir + '/../schema.sql', // from src/ (bun --hot src/index.ts) - dir + '/../schema.sql', // from src/db/ (bun --hot src/db/migrate.ts) - dir + '/schema.sql', // from src/ (bun run db:migrate) + `${dir}/../../schema.sql`, // from dist/ + `${dir}/../schema.sql`, // from src/ (bun --hot src/index.ts) + `${dir}/../schema.sql`, // from src/db/ (bun --hot src/db/migrate.ts) + `${dir}/schema.sql`, // from src/ (bun run db:migrate) ]; let schemaSql: string | null = null; @@ -29,7 +29,7 @@ export const runMigration = async (): Promise => { } if (!schemaSql) { - logger.error('Migration failed: schema.sql not found (tried ' + candidates.join(', ') + ')'); + logger.error(`Migration failed: schema.sql not found (tried ${candidates.join(', ')})`); process.exitCode = 1; return; } diff --git a/src/db/multipart.ts b/src/db/multipart.ts index a538ca0..188b15d 100644 --- a/src/db/multipart.ts +++ b/src/db/multipart.ts @@ -1,6 +1,6 @@ import { sql } from 'drizzle-orm'; -import { db } from './index'; import { nanoid } from 'nanoid'; +import { db } from './index'; export interface MultipartUpload { uploadId: string; diff --git a/src/home.html b/src/home.html index 7a0ca1a..d658cd2 100644 --- a/src/home.html +++ b/src/home.html @@ -118,14 +118,14 @@
- - - + +
@@ -156,7 +156,7 @@ const data = await apiJson('/api/v1/buckets'); allBuckets = data.buckets || []; const sel = document.getElementById('bucketSelect'); - sel.innerHTML = '' + allBuckets.map(b => ``).join(''); + sel.innerHTML = `${allBuckets.map(b => ``).join('')}`; if (currentBucket) sel.value = currentBucket; }; const switchBucket = async (name) => { @@ -172,9 +172,9 @@ if (!currentPrefix) { bc.style.display = 'none'; return; } bc.style.display = 'block'; const parts = currentPrefix.split('/').filter(Boolean); - bc.innerHTML = `${currentBucket}`; + bc.innerHTML = `${currentBucket}`; let accumulated = ''; - for (const part of parts) { accumulated += part + '/'; bc.innerHTML += `/${part}`; } + for (const part of parts) { accumulated += `${part}/`; bc.innerHTML += `/${part}`; } }; const navigateTo = (prefix) => { currentPrefix = prefix; loadObjects(); }; const loadObjects = async () => { @@ -194,7 +194,7 @@ let html = ''; for (const prefix of currentPrefixes) { const displayName = prefix.replace(currentPrefix, ''); - html += `
🗂${displayName.endsWith('/') ? displayName : displayName + '/'}—
`; + html += `
🗂${displayName.endsWith('/') ? displayName : `${displayName}/`}—
`; } for (const obj of currentObjects) { const displayName = obj.key.replace(currentPrefix, ''); @@ -222,7 +222,7 @@ await new Promise((resolve,reject)=>{ const fd=new FormData(); fd.append('file',file); fd.append('key',currentPrefix+file.name); const xhr=new XMLHttpRequest(); - xhr.upload.onprogress=(e)=>{if(e.lengthComputable){const p=Math.round((e.loaded/e.total)*100);fill.style.width=p+'%';pp.textContent=p+'%';}}; + xhr.upload.onprogress=(e)=>{if(e.lengthComputable){const p=Math.round((e.loaded/e.total)*100);fill.style.width=`${p}%`;pp.textContent=`${p}%`;}}; xhr.onload=()=>{if(xhr.status>=200&&xhr.status<300)resolve();else reject(new Error(xhr.statusText));}; xhr.onerror=()=>reject(new Error('Upload failed')); xhr.open('POST',`/api/v1/buckets/${encodeURIComponent(currentBucket)}/upload`); xhr.send(fd); @@ -239,7 +239,8 @@ const closeModal=(e)=>{if(e&&e.target!==e.currentTarget)return;document.getElementById('modalOverlay').style.display='none';}; const showCreateBucketModal=()=>{showModal(`

Create Bucket

Lowercase, 3-63 chars, no underscores

`);setTimeout(()=>document.getElementById('bucketNameInput')?.focus(),100);}; const createBucket=async()=>{const n=document.getElementById('bucketNameInput').value.trim();if(!n)return;try{await apiJson('/api/v1/buckets',{method:'POST',body:JSON.stringify({name:n})});closeModal();await loadBuckets();document.getElementById('bucketSelect').value=n;await switchBucket(n);}catch(e){alert(`Failed: ${e.message}`);}}; - const showCredentialsModal=()=>{showModal(`

S3 Credentials

Use these in any S3 client (aws-cli, rclone, s3cmd, etc.)

`);}; + const showCredentialsModal=()=>{showModal(`

S3 Credentials

Use these in any S3 client (aws-cli, rclone, s3cmd, etc.)

`);}; + Object.assign(window, { switchBucket, navigateTo, debouncedSearch, downloadObject, copyLink, deleteObject, closeModal, showCreateBucketModal, createBucket, showCredentialsModal }); loadBuckets(); diff --git a/src/index.ts b/src/index.ts index 6ded652..5d98b39 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,16 +3,16 @@ import { startBot } from './bot'; import { config } from './env'; import { handleFileInfo, handleFileRedirect } from './routes/files'; import { handleHealth } from './routes/health'; +import { handleHome } from './routes/home'; +import { handleS3Request } from './routes/s3'; import { handleSwaggerHtml, handleSwaggerJson } from './routes/swagger'; import { handleUpload } from './routes/upload'; -import { handleHome } from './routes/home'; import { handleWebApiV1 } from './routes/web-api'; -import { handleS3Request } from './routes/s3'; -import { isS3Request } from './utils/s3/auth'; import { fileInfoCache } from './utils/cache'; import logger from './utils/logger'; import { metricsCollector } from './utils/metrics'; import { cleanupRateLimitCache, withRateLimit } from './utils/rateLimit'; +import { isS3Request } from './utils/s3/auth'; // ─── Auto-run migration at startup ────────────────────────────────────────── try { diff --git a/src/routes/home.ts b/src/routes/home.ts index c35f0d3..24dd034 100644 --- a/src/routes/home.ts +++ b/src/routes/home.ts @@ -1,5 +1,5 @@ export const handleHome = async (): Promise => { - const html = await Bun.file(import.meta.dir + '/../home.html').text(); + const html = await Bun.file(`${import.meta.dir}/../home.html`).text(); return new Response(html, { status: 200, headers: { diff --git a/src/routes/s3.ts b/src/routes/s3.ts index 6a20ebe..aea45b0 100644 --- a/src/routes/s3.ts +++ b/src/routes/s3.ts @@ -1,39 +1,39 @@ -import { verifySignature, verifyPresignedUrl } from '../utils/s3/auth'; -import { - listBucketsXml, - s3ErrorResponse, - listBucketResultXml, - listBucketV2ResultXml, - initiateMultipartUploadXml, - listPartsXml, - completeMultipartUploadXml, - deleteResultXml, - copyObjectResultXml, - parseDeleteObjectsBody, - parseCompleteMultipartBody, -} from '../utils/s3/xml'; -import { createBucket, findBucketByName, listBuckets, deleteBucket } from '../db/buckets'; -import { - createMultipartUpload, - findMultipartUpload, - completeMultipartUpload, - abortMultipartUpload, - insertMultipartPart, - listMultipartParts, -} from '../db/multipart'; +import { createReadStream } from 'node:fs'; +import { nanoid } from 'nanoid'; +import { createBucket, deleteBucket, findBucketByName, listBuckets } from '../db/buckets'; import { + countBucketObjects, findFileByBucketAndKey, listObjectsByPrefix, softDeleteFile, - countBucketObjects, } from '../db/files-ext'; +import { + abortMultipartUpload, + completeMultipartUpload, + createMultipartUpload, + findMultipartUpload, + insertMultipartPart, + listMultipartParts, +} from '../db/multipart'; import type { File } from '../db/schema'; import { config } from '../env'; -import { forwardToStorage, getFileInfo } from '../utils/telegram'; -import { computeHash, ensureExtension, getErrorMessage, cleanupTempFile } from '../utils/file'; -import { nanoid } from 'nanoid'; -import { createReadStream } from 'node:fs'; +import { cleanupTempFile, computeHash, ensureExtension, getErrorMessage } from '../utils/file'; import logger from '../utils/logger'; +import { verifyPresignedUrl, verifySignature } from '../utils/s3/auth'; +import { + completeMultipartUploadXml, + copyObjectResultXml, + deleteResultXml, + initiateMultipartUploadXml, + listBucketResultXml, + listBucketsXml, + listBucketV2ResultXml, + listPartsXml, + parseCompleteMultipartBody, + parseDeleteObjectsBody, + s3ErrorResponse, +} from '../utils/s3/xml'; +import { forwardToStorage, getFileInfo } from '../utils/telegram'; const REGION = config.s3DefaultRegion || 'us-east-1'; const REQUEST_ID = () => nanoid(16); diff --git a/src/routes/web-api.ts b/src/routes/web-api.ts index c06dc79..eb7e7a3 100644 --- a/src/routes/web-api.ts +++ b/src/routes/web-api.ts @@ -1,16 +1,16 @@ -import { createBucket, findBucketByName, listBuckets, deleteBucket } from '../db/buckets'; +import { createReadStream } from 'node:fs'; +import { nanoid } from 'nanoid'; +import { createBucket, deleteBucket, findBucketByName, listBuckets } from '../db/buckets'; import { + countBucketObjects, findFileByBucketAndKey, listObjectsByPrefix, softDeleteFile, - countBucketObjects, } from '../db/files-ext'; -import { createReadStream } from 'node:fs'; import { config } from '../env'; -import { forwardToStorage, getFileInfo } from '../utils/telegram'; -import { computeHash, ensureExtension, getErrorMessage, cleanupTempFile } from '../utils/file'; -import { nanoid } from 'nanoid'; +import { cleanupTempFile, computeHash, ensureExtension, getErrorMessage } from '../utils/file'; import logger from '../utils/logger'; +import { forwardToStorage, getFileInfo } from '../utils/telegram'; type RouteParams = { bucket?: string; key?: string }; diff --git a/src/utils/s3/auth.ts b/src/utils/s3/auth.ts index 57befb8..56a8532 100644 --- a/src/utils/s3/auth.ts +++ b/src/utils/s3/auth.ts @@ -131,8 +131,8 @@ export const verifySignature = async ( s3SecretKey: string, region: string, ): Promise => { - const authHeader = headers['authorization']; - if (!authHeader || !authHeader.startsWith('AWS4-HMAC-SHA256')) { + const authHeader = headers.authorization; + if (!authHeader?.startsWith('AWS4-HMAC-SHA256')) { return { isValid: false, credential: null, errorCode: 'AccessDenied' }; } @@ -276,6 +276,6 @@ export const verifyPresignedUrl = async ( }; export const isS3Request = (headers: Record): boolean => { - const auth = headers['authorization'] || ''; + const auth = headers.authorization || ''; return auth.startsWith('AWS4-HMAC-SHA256'); }; diff --git a/src/utils/s3/xml.ts b/src/utils/s3/xml.ts index 37c300d..2874317 100644 --- a/src/utils/s3/xml.ts +++ b/src/utils/s3/xml.ts @@ -12,7 +12,7 @@ const isoDate = (d: Date): string => d.toISOString().replace(/\.\d{3}Z$/, 'Z'); export const listBucketsXml = ( buckets: { name: string; createdAt: Date }[], - requestId: string, + _requestId: string, ): string => ` @@ -39,7 +39,7 @@ export const listBucketResultXml = ( prefix: string, delimiter: string | null, nextMarker: string | null, - requestId: string, + _requestId: string, ): string => ` ${escapeXml(bucketName)} @@ -80,7 +80,7 @@ export const listBucketV2ResultXml = ( continuationToken: string | null, nextContinuationToken: string | null, keyCount: number, - requestId: string, + _requestId: string, ): string => ` ${escapeXml(bucketName)} @@ -131,7 +131,7 @@ export const listPartsXml = ( parts: { partNumber: number; etag: string; sizeBytes: number; createdAt: Date }[], maxParts: number, isTruncated: boolean, - requestId: string, + _requestId: string, ): string => ` ${escapeXml(bucketName)} @@ -233,12 +233,7 @@ export const s3ErrorResponse = ( // ─────── DeleteObjects XML parser ─────── export const parseDeleteObjectsBody = (body: string): { keys: string[]; quiet: boolean } => { - const keys: string[] = []; - const keyRegex = /([^<]+)<\/Key>/g; - let match; - while ((match = keyRegex.exec(body)) !== null) { - keys.push(match[1]); - } + const keys = Array.from(body.matchAll(/([^<]+)<\/Key>/g), (match) => match[1]); const quiet = body.includes('true') || body.includes('true '); return { keys, quiet }; }; diff --git a/test/production-e2e.test.ts b/test/production-e2e.test.ts index 19f519b..d27740c 100644 --- a/test/production-e2e.test.ts +++ b/test/production-e2e.test.ts @@ -11,7 +11,7 @@ * S3_SECRET_KEY=xxx bun test test/production-e2e.test.ts */ -import { describe, expect, it, afterAll } from 'bun:test'; +import { afterAll, describe, expect, it } from 'bun:test'; // ── Config ─────────────────────────────────────────────────────────────────── const BASE_URL = process.env.BASE_URL || 'https://upload.asepharyana.my.id'; @@ -26,7 +26,9 @@ let createdBuckets: string[] = []; 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(''); + return Array.from(h.digest()) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); } function hmacSha256(key: Uint8Array, msg: string): Uint8Array { @@ -37,13 +39,16 @@ function hmacSha256(key: Uint8Array, msg: string): Uint8Array { 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'); + 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(''); + return Array.from(a) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); } /** Build S3 SigV4 authorization headers for a raw HTTP request. */ @@ -51,7 +56,7 @@ function s3Headers( method: string, host: string, path: string, - qs: string, // canonical query string (sorted, URI-encoded) + qs: string, // canonical query string (sorted, URI-encoded) payloadHash: string, extraHeaders: Record = {}, ): Record { @@ -79,7 +84,11 @@ function s3Headers( async function s3Request( method: string, path: string, - opts: { body?: Uint8Array; query?: Record; headers?: Record } = {}, + opts: { + body?: Uint8Array; + query?: Record; + headers?: Record; + } = {}, ): Promise { const url = new URL(path, BASE_URL); if (opts.query) { @@ -87,7 +96,14 @@ async function s3Request( } 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); + 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' }, @@ -103,8 +119,11 @@ const apiJson = (p: string, o: RequestInit = {}) => // ── Shared cleanup ─────────────────────────────────────────────────────────── afterAll(async () => { for (const name of createdBuckets) { - try { await fetch(`${BASE_URL}/api/v1/buckets/${name}`, { method: 'DELETE' }); } - catch { /* best-effort */ } + try { + await fetch(`${BASE_URL}/api/v1/buckets/${name}`, { method: 'DELETE' }); + } catch { + /* best-effort */ + } } }); @@ -116,7 +135,7 @@ 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[] }; + const b = (await r.json()) as { buckets: unknown[] }; expect(Array.isArray(b.buckets)).toBe(true); }); @@ -135,7 +154,10 @@ describe('Web API v1 (production)', () => { }); it('POST /api/v1/buckets — rejects invalid name (400)', async () => { - const r = await apiJson('/buckets', { method: 'POST', body: JSON.stringify({ name: 'INVALID!' }) }); + const r = await apiJson('/buckets', { + method: 'POST', + body: JSON.stringify({ name: 'INVALID!' }), + }); expect(r.status).toBe(400); }); @@ -154,7 +176,7 @@ describe('Web API v1 (production)', () => { 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[] }; + const b = (await r.json()) as { objects: unknown[] }; expect(Array.isArray(b.objects)).toBe(true); }); @@ -162,9 +184,12 @@ describe('Web API v1 (production)', () => { 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 }); + 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 }; + const b = (await r.json()) as { key: string; etag: string }; expect(b.key).toBe('hello.txt'); expect(b.etag).toBeTruthy(); }); @@ -172,7 +197,7 @@ describe('Web API v1 (production)', () => { 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 }[] }; + const b = (await r.json()) as { objects: { key: string }[] }; expect(b.objects.some((o) => o.key === 'hello.txt')).toBe(true); }); @@ -270,7 +295,9 @@ describe('S3 API (production, SigV4)', () => { }); it('ListObjectsV2 — continuation', async () => { - const r = await s3Request('GET', `/${bucketName}`, { query: { 'list-type': '2', 'max-keys': '1' } }); + 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'); @@ -285,7 +312,8 @@ describe('S3 API (production, SigV4)', () => { 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 deleteBody = + 'batch-1.txtbatch-2.txt'; const r = await s3Request('POST', `/${bucketName}`, { query: { delete: '' }, body: new TextEncoder().encode(deleteBody), @@ -326,7 +354,9 @@ describe('S3 API (production, SigV4)', () => { }); // 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 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); @@ -373,7 +403,8 @@ describe('S3 API (production, SigV4)', () => { 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', + 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', }, diff --git a/test/s3-auth.test.ts b/test/s3-auth.test.ts index 99890aa..af0968a 100644 --- a/test/s3-auth.test.ts +++ b/test/s3-auth.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeAll } from 'bun:test'; +import { beforeAll, describe, expect, it } from 'bun:test'; describe('S3 Auth (SigV4)', () => { let verifySignature: typeof import('../src/utils/s3/auth').verifySignature; diff --git a/test/s3-sdk.test.ts b/test/s3-sdk.test.ts index 70b07d2..73c7532 100644 --- a/test/s3-sdk.test.ts +++ b/test/s3-sdk.test.ts @@ -16,25 +16,25 @@ * CAUTION: creates & destroys real resources on the production server! */ -import { describe, expect, it, afterAll } from 'bun:test'; +import { afterAll, describe, expect, it } from 'bun:test'; import { - S3Client, - ListBucketsCommand, - CreateBucketCommand, - HeadBucketCommand, - DeleteBucketCommand, - PutObjectCommand, - GetObjectCommand, - HeadObjectCommand, - ListObjectsV2Command, - ListObjectsCommand, + AbortMultipartUploadCommand, CopyObjectCommand, + CreateBucketCommand, + DeleteBucketCommand, DeleteObjectCommand, DeleteObjectsCommand, + GetObjectCommand, + HeadBucketCommand, + HeadObjectCommand, + ListBucketsCommand, ListMultipartUploadsCommand, - AbortMultipartUploadCommand, + ListObjectsCommand, + ListObjectsV2Command, NoSuchKey, NotFound, + PutObjectCommand, + S3Client, } from '@aws-sdk/client-s3'; // ── Config ─────────────────────────────────────────────────────────────────── @@ -66,21 +66,29 @@ afterAll(async () => { if (keys.length) { await s3.send(new DeleteObjectsCommand({ Bucket: b, Delete: { Objects: keys } })); } - } catch { /* best-effort */ } + } catch { + /* best-effort */ + } // Clean up any multipart uploads try { - const { Uploads = [] } = await s3.send( - new ListMultipartUploadsCommand({ Bucket: b }), - ); + const { Uploads = [] } = await s3.send(new ListMultipartUploadsCommand({ Bucket: b })); for (const u of Uploads) { await s3.send( new AbortMultipartUploadCommand({ - Bucket: b, Key: u.Key!, UploadId: u.UploadId!, + Bucket: b, + Key: u.Key!, + UploadId: u.UploadId!, }), ); } - } catch { /* best-effort */ } - try { await s3.send(new DeleteBucketCommand({ Bucket: b })); } catch { /* best-effort */ } + } catch { + /* best-effort */ + } + try { + await s3.send(new DeleteBucketCommand({ Bucket: b })); + } catch { + /* best-effort */ + } } }); @@ -234,9 +242,7 @@ describe('S3 SDK compatibility', () => { it('DeleteObject removes a single object', async () => { await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'folder/nested-file.txt' })); // Verify deletion - const { Contents } = await s3.send( - new ListObjectsV2Command({ Bucket: BUCKET }), - ); + const { Contents } = await s3.send(new ListObjectsV2Command({ Bucket: BUCKET })); expect(Contents!.some((o) => o.Key === 'folder/nested-file.txt')).toBe(false); }); @@ -255,9 +261,7 @@ describe('S3 SDK compatibility', () => { expect(Deleted).toHaveLength(3); expect(Errors).toBeUndefined(); // Verify all deleted - const { Contents } = await s3.send( - new ListObjectsV2Command({ Bucket: BUCKET }), - ); + const { Contents } = await s3.send(new ListObjectsV2Command({ Bucket: BUCKET })); for (const k of ['del-a.txt', 'del-b.txt', 'del-c.txt']) { expect(Contents!.some((o) => o.Key === k)).toBe(false); }