From e175a275129be6ee26ceec51ab5b43a13b2f59f5 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Mon, 6 Jul 2026 21:15:26 +0700 Subject: [PATCH] test: add S3 auth, operations, and web API tests --- package.json | 6 ++- test/s3-auth.test.ts | 54 ++++++++++++++++++++++ test/s3-operations.test.ts | 91 ++++++++++++++++++++++++++++++++++++++ test/web-api.test.ts | 85 +++++++++++++++++++++++++++++++++++ 4 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 test/s3-auth.test.ts create mode 100644 test/s3-operations.test.ts create mode 100644 test/web-api.test.ts diff --git a/package.json b/package.json index abb2844..d4f8c0b 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,11 @@ "build": "bun build src/index.ts --target=bun --outfile=dist/index.js && bun build src/db/migrate.ts --target=bun --outfile=dist/migrate.js", "start": "NODE_ENV=production bun dist/index.js", "db:migrate": "bun dist/migrate.js", - "test": "bun test test/rateLimit.test.ts && bun test test/file.test.ts && bun test test/telegram.test.ts && bun test test/upload.test.ts && bun test test/files.test.ts && bun test test/health.test.ts && bun test test/bot.test.ts && bun test test/bootstrap.test.ts && bun test test/swagger.test.ts", + "test": "bun test test/rateLimit.test.ts && bun test test/file.test.ts && bun test test/telegram.test.ts && bun test test/upload.test.ts && bun test test/files.test.ts && bun test test/health.test.ts && bun test test/bot.test.ts && bun test test/bootstrap.test.ts && bun test test/swagger.test.ts && bun test test/s3-auth.test.ts && bun test test/s3-operations.test.ts && bun test test/web-api.test.ts", + "test:s3-auth": "bun test test/s3-auth.test.ts", + "test:s3-ops": "bun test test/s3-operations.test.ts", + "test:web-api": "bun test test/web-api.test.ts", + "test:s3": "bun test test/s3-auth.test.ts && bun test test/s3-operations.test.ts && bun test test/web-api.test.ts", "lint": "bunx biome check src test", "format": "bunx biome format --write src test" }, diff --git a/test/s3-auth.test.ts b/test/s3-auth.test.ts new file mode 100644 index 0000000..872b763 --- /dev/null +++ b/test/s3-auth.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, beforeAll } from 'bun:test'; + +describe('S3 Auth (SigV4)', () => { + let verifySignature: typeof import('../src/utils/s3/auth').verifySignature; + let verifyPresignedUrl: typeof import('../src/utils/s3/auth').verifyPresignedUrl; + let isS3Request: typeof import('../src/utils/s3/auth').isS3Request; + + beforeAll(async () => { + const auth = await import('../src/utils/s3/auth'); + verifySignature = auth.verifySignature; + verifyPresignedUrl = auth.verifyPresignedUrl; + isS3Request = auth.isS3Request; + }); + + it('detects S3 requests by Authorization header', () => { + expect(isS3Request({ authorization: 'AWS4-HMAC-SHA256 Credential=...' })).toBe(true); + expect(isS3Request({ authorization: 'Bearer token123' })).toBe(false); + expect(isS3Request({})).toBe(false); + }); + + it('rejects missing Authorization header', async () => { + const result = await verifySignature('GET', 'http://localhost/', {}, null, 'key', 'secret', 'us-east-1'); + expect(result.isValid).toBe(false); + expect(result.errorCode).toBe('AccessDenied'); + }); + + it('rejects wrong access key before signature calculation succeeds', async () => { + const headers = { + authorization: 'AWS4-HMAC-SHA256 Credential=wrongkey/20260706/us-east-1/s3/aws4_request, SignedHeaders=host, Signature=abc123', + 'x-amz-date': '20260706T120000Z', + host: 'localhost', + }; + const result = await verifySignature('GET', 'http://localhost/', headers, null, 'correctkey', 'secret', 'us-east-1'); + expect(result.isValid).toBe(false); + expect(result.errorCode).toBe('SignatureDoesNotMatch'); + }); + + it('rejects region mismatch in Authorization credential scope', async () => { + const headers = { + authorization: 'AWS4-HMAC-SHA256 Credential=testkey/20260706/eu-west-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=abc123', + 'x-amz-date': '20260706T120000Z', + host: 'localhost', + }; + const result = await verifySignature('GET', 'http://localhost/', headers, null, 'testkey', 'secret', 'us-east-1'); + expect(result.isValid).toBe(false); + expect(result.errorCode).toBe('SignatureDoesNotMatch'); + }); + + it('rejects malformed presigned URLs', async () => { + const result = await verifyPresignedUrl('http://localhost/bucket/key', 'GET', 'key', 'secret', 'us-east-1'); + expect(result.isValid).toBe(false); + expect(result.errorCode).toBe('AccessDenied'); + }); +}); diff --git a/test/s3-operations.test.ts b/test/s3-operations.test.ts new file mode 100644 index 0000000..f96e3d4 --- /dev/null +++ b/test/s3-operations.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'bun:test'; + +describe('S3 XML Builders', () => { + it('builds ListBuckets XML', async () => { + const xml = await import('../src/utils/s3/xml'); + const result = xml.listBucketsXml( + [{ name: 'test-bucket', createdAt: new Date('2026-01-01T00:00:00Z') }], + 'req-1', + ); + expect(result).toContain('test-bucket'); + expect(result).toContain('2026-01-01T00:00:00Z'); + }); + + it('builds escaped ListBucketResult XML', async () => { + const xml = await import('../src/utils/s3/xml'); + const result = xml.listBucketResultXml( + 'my-bucket', + [{ key: 'folder/a&b.txt', sizeBytes: 100, etag: 'abc', lastModified: new Date('2026-01-01T00:00:00Z'), mimeType: 'text/plain' }], + ['photos/'], + false, + null, + 1000, + '', + '/', + null, + 'req-1', + ); + expect(result).toContain('folder/a&b.txt'); + expect(result).toContain('100'); + expect(result).toContain('photos/'); + }); + + it('builds ListBucketV2 XML', async () => { + const xml = await import('../src/utils/s3/xml'); + const result = xml.listBucketV2ResultXml( + 'my-bucket', + [{ key: 'a.txt', sizeBytes: 50, etag: 'def', lastModified: new Date('2026-01-01T00:00:00Z'), mimeType: 'text/plain' }], + [], + false, + 1000, + '', + null, + null, + null, + 1, + 'req-2', + ); + expect(result).toContain('1'); + expect(result).toContain('a.txt'); + }); + + it('builds multipart and copy XML responses', async () => { + const xml = await import('../src/utils/s3/xml'); + expect(xml.initiateMultipartUploadXml('bucket', 'key', 'upload-123')).toContain('upload-123'); + expect(xml.completeMultipartUploadXml('bucket', 'key', 'etag-abc', 'http://localhost/bucket/key')).toContain(' { + const xml = await import('../src/utils/s3/xml'); + const result = xml.s3ErrorXml('NoSuchBucket', 'The specified bucket does not exist', '/bucket', 'req-1'); + expect(result).toContain('NoSuchBucket'); + expect(result).toContain('req-1'); + + const res = xml.s3ErrorResponse('NoSuchBucket', 'Missing', '/bucket', 404, 'req-2'); + expect(res.status).toBe(404); + expect(res.headers.get('x-amz-request-id')).toBe('req-2'); + }); + + it('parses DeleteObjects body', async () => { + const xml = await import('../src/utils/s3/xml'); + const body = 'file1.txtfile2.txttrue'; + const { keys, quiet } = xml.parseDeleteObjectsBody(body); + expect(keys).toEqual(['file1.txt', 'file2.txt']); + expect(quiet).toBe(true); + }); + + it('parses CompleteMultipartUpload body', async () => { + const xml = await import('../src/utils/s3/xml'); + const body = '1"abc"2"def"'; + const parts = xml.parseCompleteMultipartBody(body); + expect(parts).toEqual([ + { partNumber: 1, etag: 'abc' }, + { partNumber: 2, etag: 'def' }, + ]); + }); +}); diff --git a/test/web-api.test.ts b/test/web-api.test.ts new file mode 100644 index 0000000..e86092a --- /dev/null +++ b/test/web-api.test.ts @@ -0,0 +1,85 @@ +import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test'; + +const mockBuckets = [ + { id: 'uuid-1', name: 'test-bucket', createdAt: new Date('2026-01-01'), updatedAt: new Date('2026-01-01') }, +]; + +mock.module('../src/db/buckets', () => ({ + listBuckets: () => Promise.resolve(mockBuckets), + findBucketByName: (name: string) => Promise.resolve(mockBuckets.find((b) => b.name === name) || null), + createBucket: (name: string) => Promise.resolve({ id: 'new-uuid', name, createdAt: new Date(), updatedAt: new Date() }), + deleteBucket: () => Promise.resolve(true), + bucketExists: () => Promise.resolve(false), +})); + +mock.module('../src/db/files-ext', () => ({ + findFileByBucketAndKey: () => Promise.resolve(null), + listObjectsByPrefix: () => Promise.resolve({ objects: [], prefixes: [] }), + softDeleteFile: () => Promise.resolve(true), + softDeleteFilesBatch: () => Promise.resolve(0), + countBucketObjects: () => Promise.resolve(0), + findOrphanFilesByBucket: () => Promise.resolve([]), +})); + +mock.module('../src/utils/telegram', () => ({ + forwardToStorage: () => + Promise.resolve({ + telegramFileId: 'mock-tg-id', + telegramFileUniqueId: 'mock-tg-unique', + storageMessageId: 12345, + }), + getFileInfo: () => + Promise.resolve({ + file_size: 100, + mime_type: 'text/plain', + file_path: 'documents/file.txt', + bot_token: '123456:ABC-DEF', + }), +})); + +describe('Web API v1', () => { + let handleWebApiV1: typeof import('../src/routes/web-api').handleWebApiV1; + + beforeAll(async () => { + process.env.BOT_TOKEN = '123456:ABC-DEF'; + process.env.STORAGE_CHANNEL_ID = '-1001234567890'; + process.env.BASE_URL = 'http://localhost:3000'; + process.env.DATABASE_URL = 'postgresql://localhost/test'; + const webApi = await import('../src/routes/web-api'); + handleWebApiV1 = webApi.handleWebApiV1; + }); + + afterAll(() => { + mock.restore(); + }); + + it('should list buckets via GET /api/v1/buckets', async () => { + const req = new Request('http://localhost:3000/api/v1/buckets'); + const res = await handleWebApiV1(req); + expect(res.status).toBe(200); + const data = (await res.json()) as { buckets: { name: string }[] }; + expect(data).toHaveProperty('buckets'); + expect(Array.isArray(data.buckets)).toBe(true); + expect(data.buckets[0].name).toBe('test-bucket'); + }); + + it('should return 404 for unknown API path', async () => { + const req = new Request('http://localhost:3000/api/v1/unknown'); + const res = await handleWebApiV1(req); + expect(res.status).toBe(404); + const data = (await res.json()) as { error: string }; + expect(data).toHaveProperty('error'); + }); + + it('should return 400 for invalid bucket name on create', async () => { + const req = new Request('http://localhost:3000/api/v1/buckets', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'INVALID_NAME!' }), + }); + const res = await handleWebApiV1(req); + expect(res.status).toBe(400); + const data = (await res.json()) as { error: string }; + expect(data.error).toContain('Invalid bucket name'); + }); +});