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
+2 -2
View File
File diff suppressed because one or more lines are too long
+94 -72
View File
@@ -1,38 +1,32 @@
import { beforeEach, describe, expect, it, mock } from 'bun:test'; 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()); * Tests the real ChunkedStorage class (src/infrastructure/telegram/
const mockPartsInsert = mock(() => Promise.resolve()); * chunked-storage.ts). uploadFileInTelegramChunks only depends on the injected
const mockPartsSelect = mock(() => Promise.resolve([])); * telegramService, so we stub that and pass no-op repos for the rest.
*
mock.module('../src/db/index', () => ({ * Rewritten from a stale test that imported the old `src/utils/chunked-storage`
db: { * layout, which no longer exists after the refactor.
insert: mockInsert, */
select: () => ({ from: () => ({ where: () => ({ limit: () => Promise.resolve([]) }) }) }), const makeTelegramStub = (): ITelegramService =>
execute: mock(() => Promise.resolve([])), ({
}, forwardToStorage: mock(async (_bytes: unknown, fileName: string) => ({
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}`, telegramFileId: `tg-${fileName}`,
telegramFileUniqueId: `tg-unique-${fileName}`, telegramFileUniqueId: `tg-unique-${fileName}`,
storageMessageId: Math.floor(Math.random() * 100000) + 1, storageMessageId: Math.floor(Math.random() * 100000) + 1,
}), })),
getFileInfo: async (telegramFileId: string) => ({ getFileInfo: mock(async (telegramFileId: string) => ({
file_size: 0, file_size: 0,
mime_type: 'application/octet-stream', mime_type: 'application/octet-stream',
file_path: `documents/${telegramFileId}`, file_path: `documents/${telegramFileId}`,
bot_token: '123456:ABC-DEF', bot_token: '123456:ABC-DEF',
}), })),
})); }) as unknown as ITelegramService;
const noopRepo = {} as never;
const writeTemp = async (path: string, data: Buffer): Promise<void> => { const writeTemp = async (path: string, data: Buffer): Promise<void> => {
await Bun.write(path, data); 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(() => { beforeEach(() => {
mockInsert.mockClear(); tg = makeTelegramStub();
mockPartsInsert.mockClear(); storage = new ChunkedStorage(noopRepo, noopRepo, tg);
mockPartsSelect.mockClear();
}); });
it('should split a file into correct number of chunks', async () => { it('splits a file into the correct number of chunks', async () => {
const { uploadFileInTelegramChunks } = await import('../src/utils/chunked-storage'); const data = Buffer.from('1234567890ab'); // 12 bytes
const data = Buffer.from('1234567890ab');
const path = '/tmp/test-chunk-1'; const path = '/tmp/test-chunk-1';
await writeTemp(path, data); await writeTemp(path, data);
const result = await uploadFileInTelegramChunks({ const result = await storage.uploadFileInTelegramChunks({
tempPath: path, tempPath: path,
partFileNamePrefix: 'test-1', partFileNamePrefix: 'test-1',
chunkSizeBytes: 4, chunkSizeBytes: 4,
compress: false, compress: false,
compressionMinSizeBytes: 4096, compressionMinSizeBytes: 4096,
}); });
await rmTemp(path); await rmTemp(path);
// 12 bytes at 4 bytes/chunk = 3 chunks
expect(result.parts.length).toBe(3); expect(result.parts.length).toBe(3);
expect(result.totalSizeBytes).toBe(12); expect(result.totalSizeBytes).toBe(12);
expect(result.parts[0].partNumber).toBe(1); expect(result.parts.map((p) => p.partNumber)).toEqual([1, 2, 3]);
expect(result.parts[1].partNumber).toBe(2); expect(result.parts[0].sizeBytes).toBe(4);
expect(result.parts[2].partNumber).toBe(3);
expect(result.parts[0].storedSizeBytes).toBe(4); expect(result.parts[0].storedSizeBytes).toBe(4);
expect(result.parts[0].compressionAlgorithm).toBeNull(); expect(result.parts[0].compressionAlgorithm).toBeNull();
}); });
it('should compute correct full-file hash', async () => { it('handles a file smaller than one chunk as a single part', async () => {
const { uploadFileInTelegramChunks } = await import('../src/utils/chunked-storage'); const data = Buffer.from('abc');
const { createHash } = await import('node:crypto'); 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 data = Buffer.from('Hello, chunked storage!');
const path = '/tmp/test-chunk-hash'; const path = '/tmp/test-chunk-hash';
await writeTemp(path, data); await writeTemp(path, data);
// Expected SHA-256 const result = await storage.uploadFileInTelegramChunks({
const expectedHash = createHash('sha256').update(data).digest('hex');
const result = await uploadFileInTelegramChunks({
tempPath: path, tempPath: path,
partFileNamePrefix: 'test-hash', partFileNamePrefix: 'hash',
chunkSizeBytes: 10, chunkSizeBytes: 10,
compress: false, compress: false,
compressionMinSizeBytes: 4096, compressionMinSizeBytes: 4096,
}); });
await rmTemp(path); await rmTemp(path);
expect(result.fileHash).toBe(expectedHash); expect(result.fileHash).toBe(createHash('sha256').update(data).digest('hex'));
expect(result.totalSizeBytes).toBe(data.byteLength); expect(result.totalSizeBytes).toBe(data.byteLength);
}); });
it('should gzip compressible chunks and skip incompressible ones', async () => { it('gzip-compresses compressible chunks and records the algorithm', async () => {
const { uploadFileInTelegramChunks } = await import('../src/utils/chunked-storage');
// Use data large enough to exceed compressionMinSizeBytes
const data = Buffer.from('AAAAAAAAAA'.repeat(100)); // 1000 bytes, very compressible const data = Buffer.from('AAAAAAAAAA'.repeat(100)); // 1000 bytes, very compressible
const path = '/tmp/test-chunk-compress'; const path = '/tmp/test-chunk-compress';
await writeTemp(path, data); await writeTemp(path, data);
const result = await uploadFileInTelegramChunks({ const result = await storage.uploadFileInTelegramChunks({
tempPath: path, tempPath: path,
partFileNamePrefix: 'test-comp', partFileNamePrefix: 'comp',
chunkSizeBytes: 512, chunkSizeBytes: 512,
compress: true, compress: true,
compressionMinSizeBytes: 10, compressionMinSizeBytes: 10,
}); });
await rmTemp(path); await rmTemp(path);
expect(result.parts.length).toBe(2); expect(result.parts.length).toBe(2);
// At least one chunk was compressed (gzip)
for (const part of result.parts) { for (const part of result.parts) {
expect(part.storedSizeBytes).toBeLessThanOrEqual(part.sizeBytes); expect(part.storedSizeBytes).toBeLessThanOrEqual(part.sizeBytes);
if (part.storedSizeBytes < part.sizeBytes) { if (part.storedSizeBytes < part.sizeBytes) {
@@ -134,41 +131,66 @@ describe('chunked-storage utility', () => {
expect(result.totalSizeBytes).toBe(1000); expect(result.totalSizeBytes).toBe(1000);
}); });
it('should not attempt compression for incompressible data', async () => { it('does not compress already-incompressible (gzipped) data', async () => {
const { uploadFileInTelegramChunks } = await import('../src/utils/chunked-storage');
const { gzipSync } = await import('node:zlib'); const { gzipSync } = await import('node:zlib');
const original = Buffer.from('AAAA'.repeat(100)); 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'; const path = '/tmp/test-chunk-incompress';
await writeTemp(path, compressed); await writeTemp(path, compressed);
const result = await uploadFileInTelegramChunks({ const result = await storage.uploadFileInTelegramChunks({
tempPath: path, tempPath: path,
partFileNamePrefix: 'test-inc', partFileNamePrefix: 'inc',
chunkSizeBytes: 1024, chunkSizeBytes: 1024,
compress: true, compress: true,
compressionMinSizeBytes: 10, compressionMinSizeBytes: 10,
}); });
await rmTemp(path); await rmTemp(path);
// Incompressible data should stay uncompressed
expect(result.parts[0].compressionAlgorithm).toBeNull(); expect(result.parts[0].compressionAlgorithm).toBeNull();
expect(result.parts[0].sizeBytes).toBe(result.parts[0].storedSizeBytes); expect(result.parts[0].sizeBytes).toBe(result.parts[0].storedSizeBytes);
}); });
it('should reject chunk size of zero', async () => { it('rejects a zero chunk size', async () => {
const { uploadFileInTelegramChunks } = await import('../src/utils/chunked-storage'); await expect(
storage.uploadFileInTelegramChunks({
expect(
uploadFileInTelegramChunks({
tempPath: '/nonexistent', tempPath: '/nonexistent',
partFileNamePrefix: 'err', partFileNamePrefix: 'err',
chunkSizeBytes: 0, chunkSizeBytes: 0,
compress: false, compress: false,
compressionMinSizeBytes: 4096, 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);
}); });
}); });
+87
View File
@@ -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');
});
});
+13 -7
View File
@@ -257,16 +257,22 @@ describe('S3 Object Stream Timeouts', () => {
describe('S3 Route Rate Limiting', () => { 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(); const source = await Bun.file('src/interfaces/http/routes/index.ts').text();
// The route definitions for S3 should use withRateLimit // The S3 dispatcher intentionally bypasses the rate limiter.
expect(source).toContain('handleS3WithRateLimit'); expect(source).toContain('handleS3Direct');
expect(source).toContain('return handleS3Request(req, getS3RouteBucket(req));');
// The rate limit function should be imported // Non-S3 self-service routes ARE rate-limited (multipart-free /api/upload
expect(source).toContain('withRateLimit'); // 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. * Verifies that the S3 config has proper size limits for Docker usage.
*/ */
it('has appropriate size limits for Docker layer blobs', async () => { 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 // Docker layers can be multiple GB
expect(config.maxRequestBodyBytes).toBeGreaterThanOrEqual(500 * 1024 * 1024); expect(config.maxRequestBodyBytes).toBeGreaterThanOrEqual(500 * 1024 * 1024);
+234
View File
@@ -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
View File
@@ -1,24 +1,44 @@
import { afterEach, describe, expect, it } from 'bun:test'; 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 originalFetch = globalThis.fetch;
const streamText = (text: string) => new Response(text).body!; interface PartStub {
url: string;
const installFetch = () => { size: number;
globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => { content: string;
const range = new Headers(init?.headers).get('range'); gzip?: boolean;
const url = String(_url); /** If set, fetch returns this error for this part. */
const text = url.includes('part-1') ? 'hello ' : 'world'; status?: number;
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' },
});
} }
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; }) as typeof fetch;
}; };
@@ -27,29 +47,46 @@ afterEach(() => {
globalThis.fetch = originalFetch; 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', () => { describe('S3 object stream response builder', () => {
it('concatenates multiple Telegram part streams', async () => { it('concatenates multiple parts in order', async () => {
installFetch(); const p1 = part('part-1', 6, 'hello ');
const p2 = part('part-2', 5, 'world');
installFetch([p1, p2]);
const res = await createGetObjectResponse({ const res = await createGetObjectResponse({
reqId: 'req-1', reqId: 'req-1',
contentType: 'text/plain', contentType: 'text/plain',
etag: 'etag123', etag: 'etag123',
lastModified: new Date('2026-07-07T00:00:00Z'), lastModified: new Date('2026-07-07T00:00:00Z'),
totalSize: 11, totalSize: 11,
parts: [ parts: [src(p1, 1), src(p2, 2)],
{
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,
},
],
range: { type: 'none' }, range: { type: 'none' },
}); });
@@ -58,23 +95,19 @@ describe('S3 object stream response builder', () => {
expect(await res.text()).toBe('hello world'); expect(await res.text()).toBe('hello world');
}); });
it('returns 206 with content-range for a single-part byte range', async () => { it('returns 206 with content-range for a range within a single part', async () => {
installFetch(); const p1 = part('part-1', 6, 'hello ');
installFetch([p1]);
const range: RangeParseResult = { type: 'valid', start: 1, end: 3 };
const res = await createGetObjectResponse({ const res = await createGetObjectResponse({
reqId: 'req-2', reqId: 'req-2',
contentType: 'text/plain', contentType: 'text/plain',
etag: 'etag123', etag: 'etag123',
lastModified: new Date('2026-07-07T00:00:00Z'), lastModified: new Date('2026-07-07T00:00:00Z'),
totalSize: 6, totalSize: 6,
parts: [ parts: [src(p1, 1)],
{ range,
telegramFileId: 'part-1',
telegramUrl: 'https://telegram.test/part-1',
sizeBytes: 6,
partNumber: 1,
},
],
range: { type: 'valid', start: 1, end: 3 },
}); });
expect(res.status).toBe(206); expect(res.status).toBe(206);
@@ -82,4 +115,80 @@ describe('S3 object stream response builder', () => {
expect(res.headers.get('content-length')).toBe('3'); expect(res.headers.get('content-length')).toBe('3');
expect(await res.text()).toBe('ell'); 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
View File
@@ -1,39 +1,126 @@
import { describe, expect, it } from 'bun:test'; 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', () => { 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' }); 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 }); 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 }); 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 }); 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 }); 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' }); 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' }); expect(parseRangeHeader('bytes=10-12', 10)).toEqual({ type: 'invalid' });
});
it('rejects end before start', () => {
expect(parseRangeHeader('bytes=6-3', 10)).toEqual({ type: 'invalid' }); expect(parseRangeHeader('bytes=6-3', 10)).toEqual({ type: 'invalid' });
});
it('rejects zero-length suffix', () => {
expect(parseRangeHeader('bytes=-0', 10)).toEqual({ type: 'invalid' }); 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'); expect(contentRange(2, 5, 10)).toBe('bytes 2-5/10');
});
it('formats unsatisfied content-range', () => {
expect(unsatisfiedContentRange(10)).toBe('bytes */10'); expect(unsatisfiedContentRange(10)).toBe('bytes */10');
expect(unsatisfiedContentRange(0)).toBe('bytes */0');
});
}); });
}); });
+107
View File
@@ -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
View File
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'bun:test'; import { describe, expect, it } from 'bun:test';
import { unlink, writeFile } from 'node:fs/promises'; 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[]) => { const cleanup = async (...paths: string[]) => {
await Promise.all( await Promise.all(
@@ -41,5 +46,71 @@ describe('ZIP utilities', () => {
it('should sanitize unsafe entry names', () => { it('should sanitize unsafe entry names', () => {
expect(sanitizeZipEntryName('../secret.txt')).toBe('secret.txt'); expect(sanitizeZipEntryName('../secret.txt')).toBe('secret.txt');
expect(sanitizeZipEntryName('nested/path/file.txt')).toBe('file.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);
}
}); });
}); });