Files
TREK/server/tests/unit/nest/doc-sync/provider-http.test.ts
T
2026-10-06 21:50:59 +02:00

334 lines
13 KiB
TypeScript

/**
* The transport the four document clients share.
*
* The clients' own suites pin what each product puts on the wire; this one
* pins the pieces they all stand on, because a regression here breaks five
* providers at once: which failures read as a timeout, a certificate or a dead
* host, that the timeout and the self-signed switch reach the fetch, and that
* a download can neither announce nor stream its way past its ceiling.
*
* Only `safeFetch` is mocked. `cappedFetch` runs for real, so releasing an
* oversized body is the release that ships.
*/
import {
classifyTransportFailure,
declaredLength,
guardDownload,
isUnresolvedHost,
providerFetch,
statusErrorCode,
type TransportFailure,
} from '../../../../src/nest/doc-sync/providers/provider-http';
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { safeFetchMock, SsrfBlockedErrorMock } = vi.hoisted(() => {
class SsrfBlockedError extends Error {
constructor(message: string) {
super(message);
this.name = 'SsrfBlockedError';
}
}
return { safeFetchMock: vi.fn(), SsrfBlockedErrorMock: SsrfBlockedError };
});
vi.mock('../../../../src/utils/ssrfGuard', () => ({
safeFetch: safeFetchMock,
SsrfBlockedError: SsrfBlockedErrorMock,
}));
/** `TypeError: fetch failed` with the real reason underneath, the way undici reports it. */
function wrapped(cause: Error): Error {
return new TypeError('fetch failed', { cause });
}
function coded(message: string, code: string): Error {
return Object.assign(new Error(message), { code });
}
function named(message: string, name: string): Error {
return Object.assign(new Error(message), { name });
}
class TransportError extends Error {
constructor(readonly failure: TransportFailure) {
super(`transport: ${failure.code}`);
}
}
const OPTIONS = {
timeoutMs: 5000,
allowInsecureTls: false,
onTransportFailure: (failure: TransportFailure) => new TransportError(failure),
};
/** A body that hands out `chunk`-sized pieces until `total` bytes, recording a cancel. */
function source(chunk: number, total: number): { stream: ReadableStream<Uint8Array>; cancelled: () => boolean } {
let sent = 0;
let cancelled = false;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
if (sent >= total) {
controller.close();
return;
}
sent += chunk;
controller.enqueue(new Uint8Array(chunk).fill(97));
},
cancel() {
cancelled = true;
},
});
return { stream, cancelled: () => cancelled };
}
function download(body: ReadableStream<Uint8Array> | null, headers: Record<string, string> = {}): Response {
return { ok: true, status: 200, headers: new Headers(headers), body } as unknown as Response;
}
class TooLarge extends Error {
constructor(readonly declared: number) {
super('too large');
}
}
const GUARD = {
maxBytes: 4096,
tooLarge: (declared: number) => new TooLarge(declared),
noBody: () => new Error('no body'),
};
async function drain(stream: NodeJS.ReadableStream): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of stream) chunks.push(Buffer.from(chunk as Uint8Array));
return Buffer.concat(chunks);
}
beforeEach(() => {
safeFetchMock.mockReset();
});
describe('classifyTransportFailure', () => {
it('PROVIDER-HTTP-001: the SSRF guard is its own verdict and keeps its reason', () => {
expect(classifyTransportFailure(new SsrfBlockedErrorMock('Requests to loopback are not allowed'))).toEqual({
code: 'ssrf_blocked',
detail: 'Requests to loopback are not allowed',
});
});
it.each([
['TimeoutError, as AbortSignal.timeout rejects', named('The operation was aborted due to timeout', 'TimeoutError')],
['AbortError', named('This operation was aborted', 'AbortError')],
["TimeoutError under undici's wrapper", wrapped(named('aborted', 'TimeoutError'))],
['UND_ERR_HEADERS_TIMEOUT', wrapped(coded('Headers Timeout Error', 'UND_ERR_HEADERS_TIMEOUT'))],
['UND_ERR_BODY_TIMEOUT', wrapped(coded('Body Timeout Error', 'UND_ERR_BODY_TIMEOUT'))],
['ABORT_ERR', wrapped(coded('The operation was aborted', 'ABORT_ERR'))],
])('PROVIDER-HTTP-002: a timeout, spelled as %s', (_label, error) => {
expect(classifyTransportFailure(error).code).toBe('timeout');
});
it.each([
'DEPTH_ZERO_SELF_SIGNED_CERT',
'SELF_SIGNED_CERT_IN_CHAIN',
// The private-CA case: the chain is fine, the issuer is simply unknown here.
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'CERT_HAS_EXPIRED',
'CERT_SIGNATURE_FAILURE',
'ERR_TLS_CERT_ALTNAME_INVALID',
'HOSTNAME_MISMATCH',
// A leaf without serverAuth, signed by a CA this host does trust.
'INVALID_PURPOSE',
'INVALID_CA',
'PATH_LENGTH_EXCEEDED',
])('PROVIDER-HTTP-003: %s is an untrusted certificate', (code) => {
expect(classifyTransportFailure(wrapped(coded('certificate verdict', code))).code).toBe('tls_untrusted');
});
it('PROVIDER-HTTP-004: a protocol failure in the handshake is not offered as a certificate problem', () => {
// https against a plain http port. The self-signed switch cannot fix it.
const error = wrapped(coded('wrong version number', 'ERR_SSL_WRONG_VERSION_NUMBER'));
expect(classifyTransportFailure(error).code).toBe('unreachable');
});
it('PROVIDER-HTTP-005: everything else is a host that cannot be reached, with the whole chain as detail', () => {
const error = wrapped(coded('connect ECONNREFUSED 192.168.1.9:5001', 'ECONNREFUSED'));
expect(classifyTransportFailure(error)).toEqual({
code: 'unreachable',
detail: 'fetch failed: connect ECONNREFUSED 192.168.1.9:5001',
});
expect(classifyTransportFailure('socket hang up')).toEqual({ code: 'unreachable', detail: 'socket hang up' });
});
it('PROVIDER-HTTP-006: the cause chain is followed five levels and no further', () => {
let error: Error = coded('self-signed certificate', 'DEPTH_ZERO_SELF_SIGNED_CERT');
for (let level = 0; level < 4; level++) error = wrapped(error);
expect(classifyTransportFailure(error).code).toBe('tls_untrusted');
expect(classifyTransportFailure(wrapped(error)).code).toBe('unreachable');
});
it('PROVIDER-HTTP-007: a host that never takes the connection is unreachable, not slow', () => {
// undici gives up on the handshake after ten seconds, before the request's
// own timeout does: a NAS that is switched off, or a firewall that drops the SYN.
const error = wrapped(
Object.assign(new Error('Connect Timeout Error'), {
name: 'ConnectTimeoutError',
code: 'UND_ERR_CONNECT_TIMEOUT',
}),
);
expect(classifyTransportFailure(error).code).toBe('unreachable');
});
it('PROVIDER-HTTP-008: a name DNS cannot resolve is unreachable, not a policy verdict', () => {
// The guard refuses it the same way it refuses loopback, but a typo in the
// host name is not something the SSRF settings can fix.
const error = new SsrfBlockedErrorMock('Could not resolve hostname (ENOTFOUND)');
expect(classifyTransportFailure(error)).toEqual({
code: 'unreachable',
detail: 'Could not resolve hostname (ENOTFOUND)',
});
expect(isUnresolvedHost('Could not resolve hostname (EAI_AGAIN)')).toBe(true);
expect(isUnresolvedHost('Requests to loopback are not allowed')).toBe(false);
});
});
describe('providerFetch', () => {
it('PROVIDER-HTTP-010: hands the request to the SSRF guard with a timeout and the certificate check on', async () => {
const answer = { ok: true, status: 200 };
safeFetchMock.mockResolvedValue(answer);
const body = new URLSearchParams({ a: '1' });
const response = await providerFetch(
'https://nas.example.org:5001/webapi/entry.cgi',
{ method: 'POST', headers: { Accept: 'application/json' }, body, duplex: 'half' } as RequestInit,
OPTIONS,
);
expect(response).toBe(answer);
const [url, init, options] = safeFetchMock.mock.calls[0] as [string, RequestInit & { duplex?: string }, unknown];
expect(url).toBe('https://nas.example.org:5001/webapi/entry.cgi');
expect(init).toMatchObject({ method: 'POST', headers: { Accept: 'application/json' }, body, duplex: 'half' });
expect(init.signal).toBeInstanceOf(AbortSignal);
expect(options).toEqual({ rejectUnauthorized: true });
});
it('PROVIDER-HTTP-011: relaxes the certificate check only when the connection says so', async () => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
await providerFetch('https://nas.example.org', {}, { ...OPTIONS, allowInsecureTls: true });
expect(safeFetchMock.mock.calls[0][2]).toEqual({ rejectUnauthorized: false });
});
it('PROVIDER-HTTP-012: an instance that never answers is cut off at the timeout', async () => {
safeFetchMock.mockImplementation(
(_url: string, init: RequestInit) =>
new Promise((_resolve, reject) => {
init.signal?.addEventListener('abort', () => reject(init.signal?.reason));
}),
);
const pending = providerFetch('https://nas.example.org', {}, { ...OPTIONS, timeoutMs: 20 });
await expect(pending).rejects.toBeInstanceOf(TransportError);
await expect(pending).rejects.toMatchObject({ failure: { code: 'timeout' } });
});
it("PROVIDER-HTTP-013: a refusal becomes the client's own error, built from the classified failure", async () => {
safeFetchMock.mockRejectedValue(new SsrfBlockedErrorMock('Requests to link-local addresses are not allowed'));
const onTransportFailure = vi.fn((failure: TransportFailure) => new TransportError(failure));
const pending = providerFetch('http://169.254.169.254/', {}, { ...OPTIONS, onTransportFailure });
await expect(pending).rejects.toMatchObject({
failure: { code: 'ssrf_blocked', detail: 'Requests to link-local addresses are not allowed' },
});
expect(onTransportFailure).toHaveBeenCalledTimes(1);
});
});
describe('statusErrorCode', () => {
it('PROVIDER-HTTP-020: maps the statuses every product answers alike', () => {
const expected: Array<[number, string]> = [
[401, 'unauthorized'],
[403, 'forbidden'],
[404, 'not_found'],
[409, 'conflict'],
[413, 'too_large'],
[415, 'unsupported_type'],
[429, 'rate_limited'],
[507, 'quota_exceeded'],
[500, 'provider_error'],
[418, 'provider_error'],
];
for (const [status, code] of expected) expect(statusErrorCode(status), `http ${status}`).toBe(code);
});
it("PROVIDER-HTTP-021: a client's own reading of a status wins, and its fallback answers the rest", () => {
expect(statusErrorCode(412, { 412: 'conflict' })).toBe('conflict');
expect(statusErrorCode(404, { 404: 'scope_missing' })).toBe('scope_missing');
expect(statusErrorCode(418, {}, 'unknown')).toBe('unknown');
expect(statusErrorCode(401, {}, 'unknown')).toBe('unauthorized');
});
});
describe('declaredLength', () => {
it('PROVIDER-HTTP-030: an absent or unreadable header is an unknown size, never zero', () => {
expect(declaredLength(null)).toBeNull();
expect(declaredLength('')).toBeNull();
expect(declaredLength(' ')).toBeNull();
expect(declaredLength('many')).toBeNull();
expect(declaredLength('-1')).toBeNull();
expect(declaredLength('0')).toBe(0);
expect(declaredLength('4096')).toBe(4096);
});
});
describe('guardDownload', () => {
it('PROVIDER-HTTP-040: streams a body within the limit and reports what it announced', async () => {
const { stream } = source(1024, 3072);
const guarded = guardDownload(download(stream, { 'content-length': '3072' }), GUARD);
expect(guarded.size).toBe(3072);
expect((await drain(guarded.body)).length).toBe(3072);
});
it('PROVIDER-HTTP-041: refuses an announced length over the limit before reading, and lets the socket go', () => {
const { stream, cancelled } = source(1024, 8192);
let thrown: unknown;
try {
guardDownload(download(stream, { 'content-length': '8192' }), GUARD);
} catch (error: unknown) {
thrown = error;
}
expect(thrown).toBeInstanceOf(TooLarge);
expect((thrown as TooLarge).declared).toBe(8192);
expect(cancelled()).toBe(true);
});
it('PROVIDER-HTTP-042: a body that announces nothing and runs past the limit fails mid-stream', async () => {
const { stream, cancelled } = source(1024, 64 * 1024);
const guarded = guardDownload(download(stream), GUARD);
expect(guarded.size).toBeNull();
await expect(drain(guarded.body)).rejects.toThrow(/exceeded 4096 bytes/);
await vi.waitFor(() => expect(cancelled()).toBe(true));
});
it('PROVIDER-HTTP-043: a body that lies about its length is held to the limit all the same', async () => {
const { stream } = source(1024, 64 * 1024);
const guarded = guardDownload(download(stream, { 'content-length': '100' }), GUARD);
await expect(drain(guarded.body)).rejects.toThrow(/exceeded/);
});
it('PROVIDER-HTTP-044: a consumer that stops reading releases the source', async () => {
const { stream, cancelled } = source(1024, 64 * 1024);
const guarded = guardDownload(download(stream), { ...GUARD, maxBytes: 1024 * 1024 });
guarded.body.destroy();
await vi.waitFor(() => expect(cancelled()).toBe(true));
});
it("PROVIDER-HTTP-045: an answer without a body is the client's own error", () => {
expect(() => guardDownload(download(null, { 'content-length': '10' }), GUARD)).toThrow('no body');
});
});