test: add S3 auth, operations, and web API tests

This commit is contained in:
asepharyana
2026-07-06 21:15:26 +07:00
parent 5e38f80267
commit e175a27512
4 changed files with 235 additions and 1 deletions
+54
View File
@@ -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');
});
});
+91
View File
@@ -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('<?xml');
expect(result).toContain('<ListAllMyBucketsResult');
expect(result).toContain('<Name>test-bucket</Name>');
expect(result).toContain('<CreationDate>2026-01-01T00:00:00Z</CreationDate>');
});
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('<ListBucketResult');
expect(result).toContain('<Key>folder/a&amp;b.txt</Key>');
expect(result).toContain('<Size>100</Size>');
expect(result).toContain('<Prefix>photos/</Prefix>');
});
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('<ListBucketResultV2');
expect(result).toContain('<KeyCount>1</KeyCount>');
expect(result).toContain('<Key>a.txt</Key>');
});
it('builds multipart and copy XML responses', async () => {
const xml = await import('../src/utils/s3/xml');
expect(xml.initiateMultipartUploadXml('bucket', 'key', 'upload-123')).toContain('<UploadId>upload-123</UploadId>');
expect(xml.completeMultipartUploadXml('bucket', 'key', 'etag-abc', 'http://localhost/bucket/key')).toContain('<CompleteMultipartUploadResult');
expect(xml.copyObjectResultXml('etag-abc', new Date('2026-01-01T00:00:00Z'))).toContain('<CopyObjectResult');
});
it('builds error XML and error Response', async () => {
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('<Code>NoSuchBucket</Code>');
expect(result).toContain('<RequestId>req-1</RequestId>');
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 = '<Delete><Object><Key>file1.txt</Key></Object><Object><Key>file2.txt</Key></Object><Quiet>true</Quiet></Delete>';
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 = '<CompleteMultipartUpload><Part><PartNumber>1</PartNumber><ETag>"abc"</ETag></Part><Part><PartNumber>2</PartNumber><ETag>"def"</ETag></Part></CompleteMultipartUpload>';
const parts = xml.parseCompleteMultipartBody(body);
expect(parts).toEqual([
{ partNumber: 1, etag: 'abc' },
{ partNumber: 2, etag: 'def' },
]);
});
});
+85
View File
@@ -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');
});
});