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 = 'true';
+ 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');
+ });
+});