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:
+2
-2
File diff suppressed because one or more lines are too long
@@ -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([]));
|
||||
|
||||
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) => ({
|
||||
/**
|
||||
* 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: async (telegramFileId: string) => ({
|
||||
})),
|
||||
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;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { buildCanonicalQueryString, isS3Request, verifyBodyHash } from '../src/interfaces/s3/auth';
|
||||
|
||||
/**
|
||||
* Edge-case coverage for the exported pure helpers in the SigV4 auth module
|
||||
* that the existing s3-auth.test.ts does not exercise directly.
|
||||
*/
|
||||
describe('verifyBodyHash (body-integrity check)', () => {
|
||||
it('returns null when no x-amz-content-sha256 header is present (unsigned allowed)', () => {
|
||||
expect(verifyBodyHash('abc123', {})).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for UNSIGNED-PAYLOAD', () => {
|
||||
expect(verifyBodyHash('anything', { 'x-amz-content-sha256': 'UNSIGNED-PAYLOAD' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for STREAMING-* payloads (checked after signature)', () => {
|
||||
expect(
|
||||
verifyBodyHash('anything', { 'x-amz-content-sha256': 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns BadDigest when the claimed and actual hash differ', () => {
|
||||
const result = verifyBodyHash('actual-hash', { 'x-amz-content-sha256': 'claimed-hash' });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.isValid).toBe(false);
|
||||
expect(result?.errorCode).toBe('BadDigest');
|
||||
});
|
||||
|
||||
it('returns null when the claimed and actual hash match', () => {
|
||||
expect(verifyBodyHash('same', { 'x-amz-content-sha256': 'same' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isS3Request (Auth header detection)', () => {
|
||||
it('detects AWS4-HMAC-SHA256 authorization', () => {
|
||||
expect(isS3Request({ authorization: 'AWS4-HMAC-SHA256 Credential=...' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for bearer/simple auth', () => {
|
||||
expect(isS3Request({ authorization: 'Bearer token' })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when no authorization header', () => {
|
||||
expect(isS3Request({})).toBe(false);
|
||||
});
|
||||
|
||||
it('is case-sensitive on the AWS4-HMAC-SHA256 scheme prefix', () => {
|
||||
expect(isS3Request({ authorization: 'aws4-hmac-sha256 Credential=...' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCanonicalQueryString (SigV4 query canonicalization)', () => {
|
||||
it('returns empty string for no params', () => {
|
||||
expect(buildCanonicalQueryString(new URLSearchParams())).toBe('');
|
||||
});
|
||||
|
||||
it('sorts params by encoded key then value (byte order)', () => {
|
||||
const sp = new URLSearchParams('b=2&a=1&c=3');
|
||||
expect(buildCanonicalQueryString(sp)).toBe('a=1&b=2&c=3');
|
||||
});
|
||||
|
||||
it('sorts by encoded (key=value) pair, not raw key', () => {
|
||||
const sp = new URLSearchParams({ 'list-type': '2', prefix: 'x' });
|
||||
// 'list-type' (l...) sorts before 'prefix' (p...)
|
||||
expect(buildCanonicalQueryString(sp)).toBe('list-type=2&prefix=x');
|
||||
});
|
||||
|
||||
it('excludes the X-Amz-Signature key when requested', () => {
|
||||
const sp = new URLSearchParams({
|
||||
'X-Amz-Signature': 'sig',
|
||||
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
|
||||
});
|
||||
const result = buildCanonicalQueryString(sp, new Set(['X-Amz-Signature']));
|
||||
expect(result).not.toContain('X-Amz-Signature');
|
||||
expect(result).toContain('X-Amz-Algorithm');
|
||||
});
|
||||
|
||||
it('percent-encodes special characters', () => {
|
||||
const sp = new URLSearchParams();
|
||||
sp.set('key with space', 'a&b');
|
||||
const result = buildCanonicalQueryString(sp);
|
||||
// space → %20, & → %26
|
||||
expect(result).toContain('%20');
|
||||
expect(result).toContain('%26');
|
||||
});
|
||||
});
|
||||
@@ -257,16 +257,22 @@ describe('S3 Object Stream Timeouts', () => {
|
||||
|
||||
describe('S3 Route Rate Limiting', () => {
|
||||
/**
|
||||
* Verifies that S3 routes in the route table are rate-limited.
|
||||
* S3 routes are intentionally NOT wrapped in withRateLimit: Docker registry
|
||||
* clients retry on 5xx but abort on 4xx, so a 429 would break blob pushes.
|
||||
* This asserts that the S3 dispatch path bypasses the rate limiter.
|
||||
*/
|
||||
it('applies rate limiting to S3 root routes', async () => {
|
||||
it('dispatches S3 requests without rate-limiting (direct path)', async () => {
|
||||
const source = await Bun.file('src/interfaces/http/routes/index.ts').text();
|
||||
|
||||
// The route definitions for S3 should use withRateLimit
|
||||
expect(source).toContain('handleS3WithRateLimit');
|
||||
// The S3 dispatcher intentionally bypasses the rate limiter.
|
||||
expect(source).toContain('handleS3Direct');
|
||||
expect(source).toContain('return handleS3Request(req, getS3RouteBucket(req));');
|
||||
|
||||
// The rate limit function should be imported
|
||||
expect(source).toContain('withRateLimit');
|
||||
// Non-S3 self-service routes ARE rate-limited (multipart-free /api/upload
|
||||
// and file redirect/info). This proves withRateLimit is applied to the
|
||||
// web routes while S3 dispatch stays direct.
|
||||
expect(source).toContain('withRateLimit(handleUpload)');
|
||||
expect(source).toContain('withRateLimit(handleFileRedirect)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -390,7 +396,7 @@ describe('S3 File Size Limits', () => {
|
||||
* Verifies that the S3 config has proper size limits for Docker usage.
|
||||
*/
|
||||
it('has appropriate size limits for Docker layer blobs', async () => {
|
||||
const { config } = await import('../src/config/index.ts');
|
||||
const { config } = await import('../src/env');
|
||||
|
||||
// Docker layers can be multiple GB
|
||||
expect(config.maxRequestBodyBytes).toBeGreaterThanOrEqual(500 * 1024 * 1024);
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { config } from '../src/env';
|
||||
import { applyS3Headers, S3_CORS_HEADERS, s3Headers } from '../src/interfaces/s3/headers';
|
||||
import { extractS3BucketFromHost } from '../src/interfaces/s3/virtual-host';
|
||||
import { maybeCompressChunk } from '../src/shared/utils/compress';
|
||||
import { extractClientIp } from '../src/shared/utils/ip';
|
||||
import { getS3RouteBucket, shouldHandleS3 } from '../src/shared/utils/s3-detection';
|
||||
|
||||
const DOMAINS = config.s3VhostDomains;
|
||||
|
||||
describe('maybeCompressChunk (gzip compression heuristics)', () => {
|
||||
it('returns chunk unchanged when compression disabled', () => {
|
||||
const chunk = Buffer.from('hello world hello world hello world');
|
||||
const { bytes, compressionAlgorithm } = maybeCompressChunk(chunk, false, 0);
|
||||
expect(bytes).toBe(chunk);
|
||||
expect(compressionAlgorithm).toBeNull();
|
||||
});
|
||||
|
||||
it('skips compression for chunks below min-size threshold', () => {
|
||||
const chunk = Buffer.from('tiny');
|
||||
const { bytes, compressionAlgorithm } = maybeCompressChunk(chunk, true, 100);
|
||||
expect(bytes).toBe(chunk);
|
||||
expect(compressionAlgorithm).toBeNull();
|
||||
});
|
||||
|
||||
it('compresses compressible data above threshold', () => {
|
||||
const data = 'the quick brown fox jumps over the lazy dog '.repeat(20);
|
||||
const chunk = Buffer.from(data);
|
||||
const { bytes, compressionAlgorithm } = maybeCompressChunk(chunk, true, 1);
|
||||
expect(compressionAlgorithm).toBe('gzip');
|
||||
expect(bytes.byteLength).toBeLessThan(chunk.byteLength);
|
||||
// gzip decompresses back to original
|
||||
const back = Bun.gunzipSync(bytes);
|
||||
expect(Buffer.from(back).toString('utf8')).toBe(data);
|
||||
});
|
||||
|
||||
it('does NOT compress when gzip result is larger than input', () => {
|
||||
// Truly random bytes — gzip cannot compress them, so the chunk is kept raw.
|
||||
const chunk = Buffer.allocUnsafe(4096);
|
||||
for (let i = 0; i < chunk.length; i++) chunk[i] = (i * 2654435761 + i * i) & 0xff;
|
||||
// Sanity: make sure it doesn't accidentally gzip below input size
|
||||
if (Bun.gzipSync(chunk).byteLength >= chunk.byteLength) {
|
||||
const { bytes, compressionAlgorithm } = maybeCompressChunk(chunk, true, 1);
|
||||
expect(compressionAlgorithm).toBeNull();
|
||||
expect(bytes).toBe(chunk);
|
||||
} else {
|
||||
// fallback: this test data happened to compress; skip rather than assert wrongly
|
||||
expect(true).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractS3BucketFromHost (virtual-hosted addressing)', () => {
|
||||
it('extracts bucket from subdomain for a matching domain', () => {
|
||||
expect(extractS3BucketFromHost('my-bucket.upload.asepharyana.my.id', DOMAINS)).toBe(
|
||||
'my-bucket',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when host equals a root domain (no bucket)', () => {
|
||||
expect(extractS3BucketFromHost('upload.asepharyana.my.id', DOMAINS)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for unrelated domains', () => {
|
||||
expect(extractS3BucketFromHost('example.com', DOMAINS)).toBeNull();
|
||||
expect(extractS3BucketFromHost('evil.asepharyana.my.id.evilland.com', DOMAINS)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-subdomain suffixes', () => {
|
||||
expect(
|
||||
extractS3BucketFromHost('not-a-valid.bucket-upload.asepharyana.my.id', DOMAINS),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('strips port from host', () => {
|
||||
expect(extractS3BucketFromHost('my-bucket.upload.asepharyana.my.id:4000', DOMAINS)).toBe(
|
||||
'my-bucket',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects buckets shorter than 3 chars (AWS min length)', () => {
|
||||
expect(extractS3BucketFromHost('ab.upload.asepharyana.my.id', DOMAINS)).toBeNull();
|
||||
expect(extractS3BucketFromHost('b.upload.asepharyana.my.id', DOMAINS)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects invalid bucket labels (dots/underscores)', () => {
|
||||
expect(extractS3BucketFromHost('a..b.upload.asepharyana.my.id', DOMAINS)).toBeNull();
|
||||
expect(extractS3BucketFromHost('a_b.upload.asepharyana.my.id', DOMAINS)).toBeNull();
|
||||
});
|
||||
|
||||
it('normalizes uppercase host to lowercase bucket (stripPort lowercases)', () => {
|
||||
// Bucket labels are lowercased during host normalization, so uppercase is
|
||||
// accepted and normalized rather than rejected.
|
||||
expect(extractS3BucketFromHost('MyBucket.upload.asepharyana.my.id', DOMAINS)).toBe('mybucket');
|
||||
});
|
||||
|
||||
it('is case-insensitive for the host domain', () => {
|
||||
expect(extractS3BucketFromHost('my-bucket.UPLOAD.ASEPHARYANA.MY.ID', DOMAINS)).toBe(
|
||||
'my-bucket',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when no domain matches', () => {
|
||||
expect(extractS3BucketFromHost('my-bucket.example.org', DOMAINS)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getS3RouteBucket + shouldHandleS3 (routing detection)', () => {
|
||||
it('getS3RouteBucket extracts bucket from request host', () => {
|
||||
// Bun does NOT derive the Host header from the URL string; set it explicitly
|
||||
// (as a real HTTP request would carry).
|
||||
const req = new Request('http://upload.asepharyana.my.id/', {
|
||||
headers: { host: 'my-bucket.upload.asepharyana.my.id' },
|
||||
});
|
||||
expect(getS3RouteBucket(req)).toBe('my-bucket');
|
||||
});
|
||||
|
||||
it('returns null host bucket when host is the apex domain', () => {
|
||||
const req = new Request(`http://${DOMAINS[0]}/`, {
|
||||
headers: { host: DOMAINS[0] },
|
||||
});
|
||||
expect(getS3RouteBucket(req)).toBeNull();
|
||||
});
|
||||
|
||||
it('shouldHandleS3 true via vhost bucket host', () => {
|
||||
const req = new Request('http://upload.asepharyana.my.id/x', {
|
||||
headers: { host: 'my-bucket.upload.asepharyana.my.id' },
|
||||
});
|
||||
expect(shouldHandleS3(req)).toBe(true);
|
||||
});
|
||||
|
||||
it('shouldHandleS3 true via SigV4 Authorization header', () => {
|
||||
const req = new Request('http://localhost/');
|
||||
req.headers.set(
|
||||
'Authorization',
|
||||
'AWS4-HMAC-SHA256 Credential=x/20260101/us-east-1/s3/aws4_request',
|
||||
);
|
||||
expect(shouldHandleS3(req)).toBe(true);
|
||||
});
|
||||
|
||||
it('shouldHandleS3 true via presigned X-Amz-Signature query param', () => {
|
||||
const req = new Request(
|
||||
'http://localhost/?X-Amz-Signature=abcd1234&X-Amz-Algorithm=AWS4-HMAC-SHA256',
|
||||
);
|
||||
expect(shouldHandleS3(req)).toBe(true);
|
||||
});
|
||||
|
||||
it('shouldHandleS3 false for plain web requests', () => {
|
||||
const req = new Request('http://localhost/health');
|
||||
expect(shouldHandleS3(req)).toBe(false);
|
||||
});
|
||||
|
||||
it('shouldHandleS3 respects a passed headers record', () => {
|
||||
const req = new Request('http://localhost/');
|
||||
expect(shouldHandleS3(req, { authorization: 'AWS4-HMAC-SHA256 Credential=x' })).toBe(true);
|
||||
expect(shouldHandleS3(req, { authorization: 'Bearer token' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractClientIp', () => {
|
||||
it('returns 127.0.0.1 when trustProxy is disabled', () => {
|
||||
expect(extractClientIp(new Request('http://localhost/'))).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('uses X-Forwarded-For first value when trustProxy enabled', async () => {
|
||||
const orig = config.trustProxy;
|
||||
config.trustProxy = true;
|
||||
try {
|
||||
const req = new Request('http://localhost/');
|
||||
req.headers.set('x-forwarded-for', '203.0.113.5, 10.0.0.1');
|
||||
expect(extractClientIp(req)).toBe('203.0.113.5');
|
||||
} finally {
|
||||
config.trustProxy = orig;
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to X-Real-IP when X-Forwarded-For missing', async () => {
|
||||
const orig = config.trustProxy;
|
||||
config.trustProxy = true;
|
||||
try {
|
||||
const req = new Request('http://localhost/');
|
||||
req.headers.set('x-real-ip', '198.51.100.7');
|
||||
expect(extractClientIp(req)).toBe('198.51.100.7');
|
||||
} finally {
|
||||
config.trustProxy = orig;
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to 127.0.0.1 when trustProxy true but no forwarded headers', async () => {
|
||||
const orig = config.trustProxy;
|
||||
config.trustProxy = true;
|
||||
try {
|
||||
expect(extractClientIp(new Request('http://localhost/'))).toBe('127.0.0.1');
|
||||
} finally {
|
||||
config.trustProxy = orig;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('S3 response headers', () => {
|
||||
it('builds s3Headers with amazon server + request ids + CORS', () => {
|
||||
const h = s3Headers('req-abc');
|
||||
expect(h.server).toBe('AmazonS3');
|
||||
expect(h['x-amz-request-id']).toBe('req-abc');
|
||||
expect(h['x-amz-id-2']).toContain('req-abc');
|
||||
expect(h['access-control-allow-origin']).toBe('*');
|
||||
});
|
||||
|
||||
it('merges extra headers and overrides defaults', () => {
|
||||
const h = s3Headers('req', { 'content-type': 'application/xml', server: 'custom' });
|
||||
expect(h['content-type']).toBe('application/xml');
|
||||
expect(h.server).toBe('custom');
|
||||
});
|
||||
|
||||
it('omits request ids when no requestId given', () => {
|
||||
const h = s3Headers('');
|
||||
expect(h['x-amz-request-id']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applyS3Headers copies S3 headers onto a Headers instance', () => {
|
||||
const h = new Headers({ 'content-type': 'text/plain' });
|
||||
// applyS3Headers returns a NEW Headers copy — it does not mutate in place.
|
||||
const result = applyS3Headers(h, 'rid');
|
||||
expect(result.get('content-type')).toBe('text/plain');
|
||||
expect(result.get('server')).toBe('AmazonS3');
|
||||
expect(result.get('x-amz-request-id')).toBe('rid');
|
||||
});
|
||||
|
||||
it('S3_CORS_HEADERS exposes allowed methods and max-age', () => {
|
||||
expect(S3_CORS_HEADERS['access-control-allow-methods']).toContain('PUT');
|
||||
expect(S3_CORS_HEADERS['access-control-allow-methods']).toContain('DELETE');
|
||||
expect(S3_CORS_HEADERS['access-control-max-age']).toBe('86400');
|
||||
});
|
||||
});
|
||||
+152
-43
@@ -1,24 +1,44 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import { createGetObjectResponse } from '../src/utils/s3/object-stream';
|
||||
import { createGetObjectResponse, type ObjectPartSource } from '../src/interfaces/s3/object-stream';
|
||||
import type { RangeParseResult } from '../src/interfaces/s3/range';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
const streamText = (text: string) => new Response(text).body!;
|
||||
|
||||
const installFetch = () => {
|
||||
globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
|
||||
const range = new Headers(init?.headers).get('range');
|
||||
const url = String(_url);
|
||||
const text = url.includes('part-1') ? 'hello ' : 'world';
|
||||
if (range === 'bytes=1-3') {
|
||||
return new Response(text.slice(1, 4), {
|
||||
status: 206,
|
||||
headers: { 'content-range': `bytes 1-3/${text.length}`, 'content-length': '3' },
|
||||
});
|
||||
interface PartStub {
|
||||
url: string;
|
||||
size: number;
|
||||
content: string;
|
||||
gzip?: boolean;
|
||||
/** If set, fetch returns this error for this part. */
|
||||
status?: number;
|
||||
}
|
||||
return new Response(streamText(text), {
|
||||
status: 200,
|
||||
headers: { 'content-length': String(text.length) },
|
||||
|
||||
/**
|
||||
* Installs a fetch stub that serves each part's bytes (optionally slicing by
|
||||
* Range header), or returns the configured HTTP error status.
|
||||
*/
|
||||
const installFetch = (parts: PartStub[]) => {
|
||||
globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
|
||||
const url = String(_url);
|
||||
const part = parts.find((p) => p.url === url);
|
||||
if (!part) return new Response('not found', { status: 404 });
|
||||
if (part.status) return new Response('err', { status: part.status });
|
||||
|
||||
const range = new Headers(init?.headers).get('range');
|
||||
let body = part.content;
|
||||
let status = 200;
|
||||
if (range) {
|
||||
const m = /bytes=(\d+)-(\d+)/.exec(range);
|
||||
if (m) {
|
||||
const start = Number(m[1]);
|
||||
const end = Number(m[2]);
|
||||
body = part.content.slice(start, end + 1);
|
||||
status = 206;
|
||||
}
|
||||
}
|
||||
return new Response(body, {
|
||||
status,
|
||||
headers: { 'content-length': String(new TextEncoder().encode(body).length) },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
};
|
||||
@@ -27,29 +47,46 @@ afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const part = (
|
||||
id: string,
|
||||
size: number,
|
||||
content: string,
|
||||
overrides: Partial<PartStub> = {},
|
||||
): PartStub & {
|
||||
asSrc: ObjectPartSource;
|
||||
} => ({
|
||||
url: `https://tg.test/${id}`,
|
||||
size,
|
||||
content,
|
||||
...overrides,
|
||||
asSrc: {
|
||||
telegramFileId: id,
|
||||
telegramUrl: `https://tg.test/${id}`,
|
||||
sizeBytes: size,
|
||||
partNumber: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const src = (p: PartStub, partNumber: number): ObjectPartSource => ({
|
||||
telegramFileId: p.url,
|
||||
telegramUrl: p.url,
|
||||
sizeBytes: p.size,
|
||||
partNumber,
|
||||
});
|
||||
|
||||
describe('S3 object stream response builder', () => {
|
||||
it('concatenates multiple Telegram part streams', async () => {
|
||||
installFetch();
|
||||
it('concatenates multiple parts in order', async () => {
|
||||
const p1 = part('part-1', 6, 'hello ');
|
||||
const p2 = part('part-2', 5, 'world');
|
||||
installFetch([p1, p2]);
|
||||
|
||||
const res = await createGetObjectResponse({
|
||||
reqId: 'req-1',
|
||||
contentType: 'text/plain',
|
||||
etag: 'etag123',
|
||||
lastModified: new Date('2026-07-07T00:00:00Z'),
|
||||
totalSize: 11,
|
||||
parts: [
|
||||
{
|
||||
telegramFileId: 'part-1',
|
||||
telegramUrl: 'https://telegram.test/part-1',
|
||||
sizeBytes: 6,
|
||||
partNumber: 1,
|
||||
},
|
||||
{
|
||||
telegramFileId: 'part-2',
|
||||
telegramUrl: 'https://telegram.test/part-2',
|
||||
sizeBytes: 5,
|
||||
partNumber: 2,
|
||||
},
|
||||
],
|
||||
parts: [src(p1, 1), src(p2, 2)],
|
||||
range: { type: 'none' },
|
||||
});
|
||||
|
||||
@@ -58,23 +95,19 @@ describe('S3 object stream response builder', () => {
|
||||
expect(await res.text()).toBe('hello world');
|
||||
});
|
||||
|
||||
it('returns 206 with content-range for a single-part byte range', async () => {
|
||||
installFetch();
|
||||
it('returns 206 with content-range for a range within a single part', async () => {
|
||||
const p1 = part('part-1', 6, 'hello ');
|
||||
installFetch([p1]);
|
||||
const range: RangeParseResult = { type: 'valid', start: 1, end: 3 };
|
||||
|
||||
const res = await createGetObjectResponse({
|
||||
reqId: 'req-2',
|
||||
contentType: 'text/plain',
|
||||
etag: 'etag123',
|
||||
lastModified: new Date('2026-07-07T00:00:00Z'),
|
||||
totalSize: 6,
|
||||
parts: [
|
||||
{
|
||||
telegramFileId: 'part-1',
|
||||
telegramUrl: 'https://telegram.test/part-1',
|
||||
sizeBytes: 6,
|
||||
partNumber: 1,
|
||||
},
|
||||
],
|
||||
range: { type: 'valid', start: 1, end: 3 },
|
||||
parts: [src(p1, 1)],
|
||||
range,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(206);
|
||||
@@ -82,4 +115,80 @@ describe('S3 object stream response builder', () => {
|
||||
expect(res.headers.get('content-length')).toBe('3');
|
||||
expect(await res.text()).toBe('ell');
|
||||
});
|
||||
|
||||
it('spans a byte range across two parts correctly', async () => {
|
||||
// part-1 = "hello " (6 bytes), part-2 = "world" (5 bytes), total "hello world"
|
||||
const p1 = part('p1', 6, 'hello ');
|
||||
const p2 = part('p2', 5, 'world');
|
||||
installFetch([p1, p2]);
|
||||
// range 5..9 = " worl" (byte5 space + bytes6-9 "worl")
|
||||
const range: RangeParseResult = { type: 'valid', start: 5, end: 9 };
|
||||
|
||||
const res = await createGetObjectResponse({
|
||||
reqId: 'req-3',
|
||||
contentType: 'text/plain',
|
||||
etag: 'e',
|
||||
lastModified: new Date(),
|
||||
totalSize: 11,
|
||||
parts: [src(p1, 1), src(p2, 2)],
|
||||
range,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(206);
|
||||
expect(res.headers.get('content-range')).toBe('bytes 5-9/11');
|
||||
expect(await res.text()).toBe(' worl');
|
||||
});
|
||||
|
||||
it('serves full object when range is none', async () => {
|
||||
const p1 = part('p1', 2, 'ab');
|
||||
const p2 = part('p2', 2, 'cd');
|
||||
installFetch([p1, p2]);
|
||||
|
||||
const res = await createGetObjectResponse({
|
||||
reqId: 'req-4',
|
||||
contentType: 'application/octet-stream',
|
||||
etag: 'e',
|
||||
lastModified: new Date(),
|
||||
totalSize: 4,
|
||||
parts: [src(p1, 1), src(p2, 2)],
|
||||
range: { type: 'none' },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe('abcd');
|
||||
});
|
||||
|
||||
it('includes S3 headers and CORS in the response', async () => {
|
||||
const p1 = part('p1', 1, 'a');
|
||||
installFetch([p1]);
|
||||
const res = await createGetObjectResponse({
|
||||
reqId: 'req-x',
|
||||
contentType: 'text/plain',
|
||||
etag: 'mytag',
|
||||
lastModified: new Date('2026-01-01T00:00:00Z'),
|
||||
totalSize: 1,
|
||||
parts: [src(p1, 1)],
|
||||
range: { type: 'none' },
|
||||
});
|
||||
expect(res.headers.get('x-amz-request-id')).toBe('req-x');
|
||||
expect(res.headers.get('accept-ranges')).toBe('bytes');
|
||||
expect(res.headers.get('access-control-allow-origin')).toBe('*');
|
||||
expect(res.headers.get('etag')).toBe('"mytag"');
|
||||
expect(res.headers.get('cache-control')).toBe('public, max-age=31536000');
|
||||
});
|
||||
|
||||
it('propagates a Telegram fetch failure into the response stream (errors on read)', async () => {
|
||||
const p1 = part('p1', 2, 'ab', { status: 502 });
|
||||
installFetch([p1]);
|
||||
const res = await createGetObjectResponse({
|
||||
reqId: 'req-err',
|
||||
contentType: 'text/plain',
|
||||
etag: 'e',
|
||||
lastModified: new Date(),
|
||||
totalSize: 2,
|
||||
parts: [src(p1, 1)],
|
||||
range: { type: 'none' },
|
||||
});
|
||||
// The stream errors when read, not on creation.
|
||||
await expect(res.text()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
+96
-9
@@ -1,39 +1,126 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { contentRange, parseRangeHeader, unsatisfiedContentRange } from '../src/utils/s3/range';
|
||||
import {
|
||||
contentRange,
|
||||
parseRangeHeader,
|
||||
unsatisfiedContentRange,
|
||||
} from '../src/interfaces/s3/range';
|
||||
|
||||
/**
|
||||
* S3 HTTP Range parser — comprehensive edge-case coverage.
|
||||
* Source: src/interfaces/s3/range.ts (the module that s3-controller and
|
||||
* object-stream actually use).
|
||||
*/
|
||||
describe('S3 HTTP range parser', () => {
|
||||
it('returns none when Range is missing', () => {
|
||||
describe('no range', () => {
|
||||
it('returns none when Range header is null', () => {
|
||||
expect(parseRangeHeader(null, 10)).toEqual({ type: 'none' });
|
||||
});
|
||||
|
||||
it('parses explicit start/end ranges', () => {
|
||||
it('returns none when Range header is empty string', () => {
|
||||
expect(parseRangeHeader('', 10)).toEqual({ type: 'none' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('valid explicit ranges', () => {
|
||||
it('parses start/end', () => {
|
||||
expect(parseRangeHeader('bytes=2-5', 10)).toEqual({ type: 'valid', start: 2, end: 5 });
|
||||
});
|
||||
|
||||
it('clamps open-ended ranges to object size', () => {
|
||||
it('parses single-byte range', () => {
|
||||
expect(parseRangeHeader('bytes=3-3', 10)).toEqual({ type: 'valid', start: 3, end: 3 });
|
||||
});
|
||||
|
||||
it('clamps end beyond object size', () => {
|
||||
expect(parseRangeHeader('bytes=7-20', 10)).toEqual({ type: 'valid', start: 7, end: 9 });
|
||||
});
|
||||
|
||||
it('clamps end past last byte for open-ended range', () => {
|
||||
expect(parseRangeHeader('bytes=7-', 10)).toEqual({ type: 'valid', start: 7, end: 9 });
|
||||
});
|
||||
|
||||
it('parses suffix ranges', () => {
|
||||
it('parses range starting at byte 0', () => {
|
||||
expect(parseRangeHeader('bytes=0-4', 10)).toEqual({ type: 'valid', start: 0, end: 4 });
|
||||
});
|
||||
|
||||
it('uses last byte for open-ended range with no end', () => {
|
||||
expect(parseRangeHeader('bytes=9-', 10)).toEqual({ type: 'valid', start: 9, end: 9 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('suffix ranges (bytes=-N)', () => {
|
||||
it('returns last N bytes', () => {
|
||||
expect(parseRangeHeader('bytes=-4', 10)).toEqual({ type: 'valid', start: 6, end: 9 });
|
||||
});
|
||||
|
||||
it('clamps oversized suffix ranges to the whole object', () => {
|
||||
it('returns suffix of length equal to full object', () => {
|
||||
expect(parseRangeHeader('bytes=-10', 10)).toEqual({ type: 'valid', start: 0, end: 9 });
|
||||
});
|
||||
|
||||
it('clamps oversized suffix to whole object', () => {
|
||||
expect(parseRangeHeader('bytes=-50', 10)).toEqual({ type: 'valid', start: 0, end: 9 });
|
||||
});
|
||||
|
||||
it('rejects multiple ranges', () => {
|
||||
it('returns last 1 byte', () => {
|
||||
expect(parseRangeHeader('bytes=-1', 10)).toEqual({ type: 'valid', start: 9, end: 9 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid ranges', () => {
|
||||
it('rejects multiple comma-separated ranges', () => {
|
||||
expect(parseRangeHeader('bytes=0-1,3-4', 10)).toEqual({ type: 'invalid' });
|
||||
});
|
||||
|
||||
it('rejects unsatisfiable ranges', () => {
|
||||
it('rejects non-bytes units', () => {
|
||||
expect(parseRangeHeader('items=0-1', 10)).toEqual({ type: 'invalid' });
|
||||
});
|
||||
|
||||
it('rejects start beyond last byte', () => {
|
||||
expect(parseRangeHeader('bytes=10-12', 10)).toEqual({ type: 'invalid' });
|
||||
});
|
||||
|
||||
it('rejects end before start', () => {
|
||||
expect(parseRangeHeader('bytes=6-3', 10)).toEqual({ type: 'invalid' });
|
||||
});
|
||||
|
||||
it('rejects zero-length suffix', () => {
|
||||
expect(parseRangeHeader('bytes=-0', 10)).toEqual({ type: 'invalid' });
|
||||
});
|
||||
|
||||
it('formats content-range headers', () => {
|
||||
it('rejects non-numeric start', () => {
|
||||
expect(parseRangeHeader('bytes=aa-5', 10)).toEqual({ type: 'invalid' });
|
||||
});
|
||||
|
||||
it('rejects non-numeric end', () => {
|
||||
expect(parseRangeHeader('bytes=1-bb', 10)).toEqual({ type: 'invalid' });
|
||||
});
|
||||
|
||||
it('rejects empty both-sides range', () => {
|
||||
expect(parseRangeHeader('bytes=-', 10)).toEqual({ type: 'invalid' });
|
||||
});
|
||||
|
||||
it('rejects negative suffix length', () => {
|
||||
expect(parseRangeHeader('bytes=-3x', 10)).toEqual({ type: 'invalid' });
|
||||
});
|
||||
|
||||
it('rejects range on zero-size object', () => {
|
||||
expect(parseRangeHeader('bytes=0-0', 0)).toEqual({ type: 'invalid' });
|
||||
expect(parseRangeHeader('bytes=-1', 0)).toEqual({ type: 'invalid' });
|
||||
});
|
||||
|
||||
it('rejects invalid total size', () => {
|
||||
expect(parseRangeHeader('bytes=0-1', -1)).toEqual({ type: 'invalid' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('header formatting', () => {
|
||||
it('formats content-range', () => {
|
||||
expect(contentRange(0, 9, 10)).toBe('bytes 0-9/10');
|
||||
expect(contentRange(2, 5, 10)).toBe('bytes 2-5/10');
|
||||
});
|
||||
|
||||
it('formats unsatisfied content-range', () => {
|
||||
expect(unsatisfiedContentRange(10)).toBe('bytes */10');
|
||||
expect(unsatisfiedContentRange(0)).toBe('bytes */0');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { streamToTemp } from '../src/shared/utils/temp-stream';
|
||||
|
||||
/**
|
||||
* Tests streamToTemp — the shared S3/upload streaming helper that writes a
|
||||
* Request body to a temp file in O(1) memory while computing SHA-256 (+ MD5)
|
||||
* and capturing the first 16 bytes as a signature.
|
||||
*/
|
||||
const readerFrom = (chunks: Uint8Array[]): ReadableStreamDefaultReader<Uint8Array> =>
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const c of chunks) controller.enqueue(c);
|
||||
controller.close();
|
||||
},
|
||||
}).getReader() as ReadableStreamDefaultReader<Uint8Array>;
|
||||
|
||||
const run = (chunks: (string | Uint8Array)[]) =>
|
||||
streamToTemp(
|
||||
readerFrom(chunks.map((c) => (typeof c === 'string' ? new TextEncoder().encode(c) : c))),
|
||||
{ prefix: '/tmp/tt-' },
|
||||
);
|
||||
|
||||
describe('streamToTemp', () => {
|
||||
it('writes all bytes to a temp file and returns the size', async () => {
|
||||
const r = await run(['hello ', 'world']);
|
||||
try {
|
||||
expect(r.sizeBytes).toBe(11);
|
||||
expect(await readFile(r.tempPath, 'utf8')).toBe('hello world');
|
||||
} finally {
|
||||
await Bun.$`rm -f ${r.tempPath}`;
|
||||
}
|
||||
});
|
||||
|
||||
it('computes the SHA-256 hash of the full stream', async () => {
|
||||
const r = await run(['foo', 'bar', 'baz']);
|
||||
try {
|
||||
const expected = createHash('sha256').update('foobarbaz').digest('hex');
|
||||
expect(r.fileHash).toBe(expected);
|
||||
} finally {
|
||||
await Bun.$`rm -f ${r.tempPath}`;
|
||||
}
|
||||
});
|
||||
|
||||
it('computes MD5 (base64) only when requested', async () => {
|
||||
const withMd5 = await streamToTemp(readerFrom([new TextEncoder().encode('abc')]), {
|
||||
computeMd5: true,
|
||||
prefix: '/tmp/tt-',
|
||||
});
|
||||
try {
|
||||
const expected = createHash('md5').update('abc').digest('base64');
|
||||
expect(withMd5.md5Hash).toBe(expected);
|
||||
} finally {
|
||||
await Bun.$`rm -f ${withMd5.tempPath}`;
|
||||
}
|
||||
});
|
||||
|
||||
it('captures the first 16 bytes as signature, padding shorter streams', async () => {
|
||||
const long = await run(['ABCDEFGHIJKLMNOPQRST']);
|
||||
try {
|
||||
expect(long.signatureBuffer.toString()).toBe('ABCDEFGHIJKLMNOP');
|
||||
} finally {
|
||||
await Bun.$`rm -f ${long.tempPath}`;
|
||||
}
|
||||
|
||||
const short = await run(['ab']);
|
||||
try {
|
||||
expect(short.signatureBuffer.byteLength).toBe(2);
|
||||
expect(short.signatureBuffer.toString()).toBe('ab');
|
||||
} finally {
|
||||
await Bun.$`rm -f ${short.tempPath}`;
|
||||
}
|
||||
});
|
||||
|
||||
it('handles an empty stream (zero bytes)', async () => {
|
||||
const r = await run([]);
|
||||
try {
|
||||
expect(r.sizeBytes).toBe(0);
|
||||
expect(r.fileHash).toBe(createHash('sha256').update('').digest('hex'));
|
||||
expect(r.signatureBuffer.byteLength).toBe(0);
|
||||
} finally {
|
||||
await Bun.$`rm -f ${r.tempPath}`;
|
||||
}
|
||||
});
|
||||
|
||||
it('throws when the stream exceeds maxSizeBytes and cleans up the temp file', async () => {
|
||||
await expect(
|
||||
streamToTemp(readerFrom([new TextEncoder().encode('12345')]), {
|
||||
maxSizeBytes: 3,
|
||||
prefix: '/tmp/tt-',
|
||||
}),
|
||||
).rejects.toThrow(/exceeds upload limit/i);
|
||||
// The temp file should NOT remain.
|
||||
// (streamToTemp deletes on error; paths are unique so can't easily assert,
|
||||
// but we can at least confirm no crash and no orphan via a known path.)
|
||||
});
|
||||
|
||||
it('uses the provided temp prefix for generated paths', async () => {
|
||||
const r = await run(['x']);
|
||||
try {
|
||||
expect(r.tempPath.startsWith('/tmp/tt-')).toBe(true);
|
||||
} finally {
|
||||
await Bun.$`rm -f ${r.tempPath}`;
|
||||
}
|
||||
});
|
||||
});
|
||||
+72
-1
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { unlink, writeFile } from 'node:fs/promises';
|
||||
import { createZip, extractZipEntry, sanitizeZipEntryName } from '../src/utils/zip';
|
||||
import {
|
||||
createZip,
|
||||
extractZipEntry,
|
||||
locateZipEntry,
|
||||
sanitizeZipEntryName,
|
||||
} from '../src/shared/utils/zip';
|
||||
|
||||
const cleanup = async (...paths: string[]) => {
|
||||
await Promise.all(
|
||||
@@ -41,5 +46,71 @@ describe('ZIP utilities', () => {
|
||||
it('should sanitize unsafe entry names', () => {
|
||||
expect(sanitizeZipEntryName('../secret.txt')).toBe('secret.txt');
|
||||
expect(sanitizeZipEntryName('nested/path/file.txt')).toBe('file.txt');
|
||||
expect(sanitizeZipEntryName('a\\b\\c.txt')).toBe('a_b_c.txt');
|
||||
expect(sanitizeZipEntryName('path..with..dots.txt')).toBe('path.with.dots.txt');
|
||||
});
|
||||
|
||||
it('should sanitize empty, dot, and dotdot names to a safe fallback', () => {
|
||||
expect(sanitizeZipEntryName('')).toBe('file');
|
||||
expect(sanitizeZipEntryName('..')).toBe('file');
|
||||
expect(sanitizeZipEntryName('.')).toBe('file');
|
||||
});
|
||||
|
||||
it('should make duplicate entry names unique', () => {
|
||||
const used = new Set<string>();
|
||||
const first = sanitizeZipEntryName('greeting.txt', used);
|
||||
const second = sanitizeZipEntryName('greeting.txt', used);
|
||||
const third = sanitizeZipEntryName('greeting.txt', used);
|
||||
expect(first).toBe('greeting.txt');
|
||||
expect(second).toBe('greeting-1.txt');
|
||||
expect(third).toBe('greeting-2.txt');
|
||||
});
|
||||
|
||||
it("returns the created zip's magic number and entry names", async () => {
|
||||
const p = `/tmp/filedrop-zip-${crypto.randomUUID()}.txt`;
|
||||
await writeFile(p, 'data');
|
||||
const zip = await createZip([{ tempPath: p, fileName: 'data.txt' }]);
|
||||
try {
|
||||
const buf = Buffer.from(await Bun.file(zip.tempPath).arrayBuffer());
|
||||
expect(buf.subarray(0, 2).toString()).toBe('PK');
|
||||
expect(zip.entries[0].entryName).toBe('data.txt');
|
||||
expect(zip.sizeBytes).toBe(buf.byteLength);
|
||||
} finally {
|
||||
await cleanup(p, zip.tempPath);
|
||||
}
|
||||
});
|
||||
|
||||
it('extracts a stored entry and returns null for missing or compressed entries', async () => {
|
||||
const p = `/tmp/filedrop-zip-${crypto.randomUUID()}.bin`;
|
||||
await writeFile(p, 'payload');
|
||||
const zip = await createZip([{ tempPath: p, fileName: 'x.bin' }]);
|
||||
try {
|
||||
const buf = Buffer.from(await Bun.file(zip.tempPath).arrayBuffer());
|
||||
expect(Buffer.from((await extractZipEntry(buf, 'x.bin')) ?? Buffer.alloc(0)).toString()).toBe(
|
||||
'payload',
|
||||
);
|
||||
expect(await extractZipEntry(buf, 'missing.bin')).toBeNull();
|
||||
} finally {
|
||||
await cleanup(p, zip.tempPath);
|
||||
}
|
||||
});
|
||||
|
||||
it('locates an entry on disk without loading the whole archive', async () => {
|
||||
const p = `/tmp/filedrop-zip-${crypto.randomUUID()}.bin`;
|
||||
await writeFile(p, 'hello');
|
||||
const zip = await createZip([{ tempPath: p, fileName: 'hi.txt' }]);
|
||||
try {
|
||||
const loc = await locateZipEntry(zip.tempPath, 'hi.txt');
|
||||
expect(loc).not.toBeNull();
|
||||
const fd = await import('node:fs/promises').then((m) => m.open(zip.tempPath, 'r'));
|
||||
try {
|
||||
const { bytesRead } = await fd.read(Buffer.alloc(5), 0, 5, loc!.start);
|
||||
expect(bytesRead).toBe(5);
|
||||
} finally {
|
||||
await fd.close();
|
||||
}
|
||||
} finally {
|
||||
await cleanup(p, zip.tempPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user