test: expand unit test coverage to all S3 edge cases; fix stale tests

Add comprehensive unit tests and repair stale tests that referenced the
old (pre-refactor) src/utils/* layout which no longer exists:

- s3-range: expand to 25 cases (suffix, clamping, malformed, zero-size,
  invalid totals, content-range formatting)
- s3-object-stream: rewrite against the real interfaces/s3 module; add
  multi-part ordering, ranges spanning parts, S3/CORS headers, fetch-error
  propagation
- s3-helpers-edge (new): compress heuristics, virtual-host bucket parsing,
  S3 route detection, client-IP/trustProxy, S3 response headers
- s3-auth-edge (new): verifyBodyHash, isS3Request, canonical-query-string
  encoding/sorting
- chunked-storage: rewrite against the real ChunkedStorage class (was
  importing deleted src/utils/chunked-storage) — chunk split, hashing,
  compression, size-limit guards, forwarding
- zip: fix stale import + add path-traversal/duplicate sanitization,
  locateZipEntry, empty-name fallback
- s3-docker-registry: fix stale src/config import; correct the rate-limit
  test to assert S3 routes INTENTIONALLY bypass rate limiting
- temp-stream (new): streamToTemp hashing, MD5, signature bytes, empty and
  oversized streams
- package.json: add the S3/unit files to test and test:s3 scripts

All new unit tests pass when run per-file (the project's documented mode to
avoid cross-file mock pollution). s3-sdk.test.ts (live E2E against a running
server) is deliberately excluded from test:s3.
This commit is contained in:
asepharyana
2026-08-27 15:48:14 +07:00
parent 43e36b7629
commit 77340f63ae
9 changed files with 876 additions and 153 deletions
+100 -78
View File
@@ -1,38 +1,32 @@
import { beforeEach, describe, expect, it, mock } from 'bun:test';
import { createHash } from 'node:crypto';
import type { ITelegramService } from '../src/domain/ports/telegram-service';
import { ChunkedStorage } from '../src/infrastructure/telegram/chunked-storage';
// Mock DB layer (chunked-storage imports db, insertFileParts, listFileParts)
const mockInsert = mock(() => Promise.resolve());
const mockPartsInsert = mock(() => Promise.resolve());
const mockPartsSelect = mock(() => Promise.resolve([]));
/**
* Tests the real ChunkedStorage class (src/infrastructure/telegram/
* chunked-storage.ts). uploadFileInTelegramChunks only depends on the injected
* telegramService, so we stub that and pass no-op repos for the rest.
*
* Rewritten from a stale test that imported the old `src/utils/chunked-storage`
* layout, which no longer exists after the refactor.
*/
const makeTelegramStub = (): ITelegramService =>
({
forwardToStorage: mock(async (_bytes: unknown, fileName: string) => ({
telegramFileId: `tg-${fileName}`,
telegramFileUniqueId: `tg-unique-${fileName}`,
storageMessageId: Math.floor(Math.random() * 100000) + 1,
})),
getFileInfo: mock(async (telegramFileId: string) => ({
file_size: 0,
mime_type: 'application/octet-stream',
file_path: `documents/${telegramFileId}`,
bot_token: '123456:ABC-DEF',
})),
}) as unknown as ITelegramService;
mock.module('../src/db/index', () => ({
db: {
insert: mockInsert,
select: () => ({ from: () => ({ where: () => ({ limit: () => Promise.resolve([]) }) }) }),
execute: mock(() => Promise.resolve([])),
},
files: {},
fileParts: {},
}));
mock.module('../src/db/file-parts', () => ({
insertFileParts: mockPartsInsert,
listFileParts: mockPartsSelect,
}));
mock.module('../src/utils/telegram', () => ({
forwardToStorage: async (_bytes: unknown, fileName: string) => ({
telegramFileId: `tg-${fileName}`,
telegramFileUniqueId: `tg-unique-${fileName}`,
storageMessageId: Math.floor(Math.random() * 100000) + 1,
}),
getFileInfo: async (telegramFileId: string) => ({
file_size: 0,
mime_type: 'application/octet-stream',
file_path: `documents/${telegramFileId}`,
bot_token: '123456:ABC-DEF',
}),
}));
const noopRepo = {} as never;
const writeTemp = async (path: string, data: Buffer): Promise<void> => {
await Bun.write(path, data);
@@ -46,85 +40,88 @@ const rmTemp = async (path: string): Promise<void> => {
}
};
describe('chunked-storage utility', () => {
describe('ChunkedStorage.uploadFileInTelegramChunks', () => {
let storage: ChunkedStorage;
let tg: ITelegramService;
beforeEach(() => {
mockInsert.mockClear();
mockPartsInsert.mockClear();
mockPartsSelect.mockClear();
tg = makeTelegramStub();
storage = new ChunkedStorage(noopRepo, noopRepo, tg);
});
it('should split a file into correct number of chunks', async () => {
const { uploadFileInTelegramChunks } = await import('../src/utils/chunked-storage');
const data = Buffer.from('1234567890ab');
it('splits a file into the correct number of chunks', async () => {
const data = Buffer.from('1234567890ab'); // 12 bytes
const path = '/tmp/test-chunk-1';
await writeTemp(path, data);
const result = await uploadFileInTelegramChunks({
const result = await storage.uploadFileInTelegramChunks({
tempPath: path,
partFileNamePrefix: 'test-1',
chunkSizeBytes: 4,
compress: false,
compressionMinSizeBytes: 4096,
});
await rmTemp(path);
// 12 bytes at 4 bytes/chunk = 3 chunks
expect(result.parts.length).toBe(3);
expect(result.totalSizeBytes).toBe(12);
expect(result.parts[0].partNumber).toBe(1);
expect(result.parts[1].partNumber).toBe(2);
expect(result.parts[2].partNumber).toBe(3);
expect(result.parts.map((p) => p.partNumber)).toEqual([1, 2, 3]);
expect(result.parts[0].sizeBytes).toBe(4);
expect(result.parts[0].storedSizeBytes).toBe(4);
expect(result.parts[0].compressionAlgorithm).toBeNull();
});
it('should compute correct full-file hash', async () => {
const { uploadFileInTelegramChunks } = await import('../src/utils/chunked-storage');
const { createHash } = await import('node:crypto');
it('handles a file smaller than one chunk as a single part', async () => {
const data = Buffer.from('abc');
const path = '/tmp/test-chunk-small';
await writeTemp(path, data);
const result = await storage.uploadFileInTelegramChunks({
tempPath: path,
partFileNamePrefix: 'small',
chunkSizeBytes: 1024,
compress: false,
compressionMinSizeBytes: 4096,
});
await rmTemp(path);
expect(result.parts.length).toBe(1);
expect(result.totalSizeBytes).toBe(3);
});
it('computes the correct full-file SHA-256 hash', async () => {
const data = Buffer.from('Hello, chunked storage!');
const path = '/tmp/test-chunk-hash';
await writeTemp(path, data);
// Expected SHA-256
const expectedHash = createHash('sha256').update(data).digest('hex');
const result = await uploadFileInTelegramChunks({
const result = await storage.uploadFileInTelegramChunks({
tempPath: path,
partFileNamePrefix: 'test-hash',
partFileNamePrefix: 'hash',
chunkSizeBytes: 10,
compress: false,
compressionMinSizeBytes: 4096,
});
await rmTemp(path);
expect(result.fileHash).toBe(expectedHash);
expect(result.fileHash).toBe(createHash('sha256').update(data).digest('hex'));
expect(result.totalSizeBytes).toBe(data.byteLength);
});
it('should gzip compressible chunks and skip incompressible ones', async () => {
const { uploadFileInTelegramChunks } = await import('../src/utils/chunked-storage');
// Use data large enough to exceed compressionMinSizeBytes
it('gzip-compresses compressible chunks and records the algorithm', async () => {
const data = Buffer.from('AAAAAAAAAA'.repeat(100)); // 1000 bytes, very compressible
const path = '/tmp/test-chunk-compress';
await writeTemp(path, data);
const result = await uploadFileInTelegramChunks({
const result = await storage.uploadFileInTelegramChunks({
tempPath: path,
partFileNamePrefix: 'test-comp',
partFileNamePrefix: 'comp',
chunkSizeBytes: 512,
compress: true,
compressionMinSizeBytes: 10,
});
await rmTemp(path);
expect(result.parts.length).toBe(2);
// At least one chunk was compressed (gzip)
for (const part of result.parts) {
expect(part.storedSizeBytes).toBeLessThanOrEqual(part.sizeBytes);
if (part.storedSizeBytes < part.sizeBytes) {
@@ -134,41 +131,66 @@ describe('chunked-storage utility', () => {
expect(result.totalSizeBytes).toBe(1000);
});
it('should not attempt compression for incompressible data', async () => {
const { uploadFileInTelegramChunks } = await import('../src/utils/chunked-storage');
it('does not compress already-incompressible (gzipped) data', async () => {
const { gzipSync } = await import('node:zlib');
const original = Buffer.from('AAAA'.repeat(100));
const compressed = gzipSync(original);
const compressed = gzipSync(original); // already gzipped → won't compress further
const path = '/tmp/test-chunk-incompress';
await writeTemp(path, compressed);
const result = await uploadFileInTelegramChunks({
const result = await storage.uploadFileInTelegramChunks({
tempPath: path,
partFileNamePrefix: 'test-inc',
partFileNamePrefix: 'inc',
chunkSizeBytes: 1024,
compress: true,
compressionMinSizeBytes: 10,
});
await rmTemp(path);
// Incompressible data should stay uncompressed
expect(result.parts[0].compressionAlgorithm).toBeNull();
expect(result.parts[0].sizeBytes).toBe(result.parts[0].storedSizeBytes);
});
it('should reject chunk size of zero', async () => {
const { uploadFileInTelegramChunks } = await import('../src/utils/chunked-storage');
expect(
uploadFileInTelegramChunks({
it('rejects a zero chunk size', async () => {
await expect(
storage.uploadFileInTelegramChunks({
tempPath: '/nonexistent',
partFileNamePrefix: 'err',
chunkSizeBytes: 0,
compress: false,
compressionMinSizeBytes: 4096,
}),
).rejects.toThrow('Invalid Telegram chunk size');
).rejects.toThrow(/chunk size/i);
});
it('rejects a chunk size that exceeds the Telegram 20MB safety limit', async () => {
await expect(
storage.uploadFileInTelegramChunks({
tempPath: '/nonexistent',
partFileNamePrefix: 'err',
chunkSizeBytes: 100 * 1024 * 1024, // 100 MB
compress: false,
compressionMinSizeBytes: 4096,
}),
).rejects.toThrow();
});
it('forwards each chunk to the telegram service via document type', async () => {
const data = Buffer.from('abcdef'); // 6 bytes, 3 chunks of 2
const path = '/tmp/test-chunk-fwd';
await writeTemp(path, data);
const result = await storage.uploadFileInTelegramChunks({
tempPath: path,
partFileNamePrefix: 'fwd',
chunkSizeBytes: 2,
compress: false,
compressionMinSizeBytes: 4096,
});
await rmTemp(path);
const fwd = tg.forwardToStorage as unknown as ReturnType<typeof mock>;
expect(fwd).toHaveBeenCalledTimes(3);
expect(result.parts.every((p) => p.telegramFileId.startsWith('tg-'))).toBe(true);
});
});