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:
+149
-40
@@ -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!;
|
||||
interface PartStub {
|
||||
url: string;
|
||||
size: number;
|
||||
content: string;
|
||||
gzip?: boolean;
|
||||
/** If set, fetch returns this error for this part. */
|
||||
status?: number;
|
||||
}
|
||||
|
||||
const installFetch = () => {
|
||||
/**
|
||||
* 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 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' },
|
||||
});
|
||||
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(streamText(text), {
|
||||
status: 200,
|
||||
headers: { 'content-length': String(text.length) },
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user