refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,993 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {HttpStatus} from '@fluxer/constants/src/HttpConstants';
import {createTestServer, readRequestBody, type TestServer} from '@fluxer/http_client/src/__tests__/TestHttpServer';
import {createHttpClient} from '@fluxer/http_client/src/HttpClient';
import type {
HttpClientMetrics,
HttpClientTelemetry,
HttpClientTracing,
} from '@fluxer/http_client/src/HttpClientTelemetryTypes';
import type {RequestUrlPolicy, RequestUrlValidationContext} from '@fluxer/http_client/src/HttpClientTypes';
import {HttpError} from '@fluxer/http_client/src/HttpError';
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
const TEST_USER_AGENT = 'FluxerHttpClient/1.0 (Test)';
describe('HttpClient', () => {
let testServer: TestServer;
let redirectServer: TestServer;
beforeAll(async () => {
testServer = await createTestServer();
redirectServer = await createTestServer();
});
afterAll(async () => {
await testServer.close();
await redirectServer.close();
});
describe('createHttpClient', () => {
it('creates an HTTP client with the provided user agent', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end(req.headers['user-agent'] ?? '');
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
expect(body).toBe(TEST_USER_AGENT);
});
});
describe('sendRequest', () => {
describe('basic requests', () => {
it('sends a GET request by default', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({method: req.method}));
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('GET');
});
it('sends a POST request with body', async () => {
const client = createHttpClient(TEST_USER_AGENT);
const requestBody = {message: 'Hello, World!'};
testServer.setHandler(async (req, res) => {
const body = await readRequestBody(req);
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({method: req.method, receivedBody: JSON.parse(body)}));
});
const response = await client.sendRequest({
url: testServer.url,
method: 'POST',
body: requestBody,
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('POST');
expect(json.receivedBody).toEqual(requestBody);
});
it('sends a HEAD request', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain', 'X-Custom-Header': 'test-value'});
res.end();
});
const response = await client.sendRequest({
url: testServer.url,
method: 'HEAD',
});
expect(response.status).toBe(200);
expect(response.headers.get('X-Custom-Header')).toBe('test-value');
});
});
describe('headers', () => {
it('sends default headers including user agent', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(
JSON.stringify({
userAgent: req.headers['user-agent'],
accept: req.headers['accept'],
cacheControl: req.headers['cache-control'],
pragma: req.headers['pragma'],
}),
);
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(json.userAgent).toBe(TEST_USER_AGENT);
expect(json.accept).toBe('*/*');
expect(json.cacheControl).toBe('no-cache, no-store, must-revalidate');
expect(json.pragma).toBe('no-cache');
});
it('allows custom headers to override defaults', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(
JSON.stringify({
accept: req.headers['accept'],
customHeader: req.headers['x-custom-header'],
}),
);
});
const response = await client.sendRequest({
url: testServer.url,
headers: {
Accept: 'application/json',
'X-Custom-Header': 'custom-value',
},
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(json.accept).toBe('application/json');
expect(json.customHeader).toBe('custom-value');
});
it('normalizes response headers correctly', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(200, {
'Content-Type': 'text/plain',
'X-Single-Value': 'single',
});
res.end('OK');
});
const response = await client.sendRequest({url: testServer.url});
expect(response.headers.get('content-type')).toBe('text/plain');
expect(response.headers.get('x-single-value')).toBe('single');
});
});
describe('status codes', () => {
it('returns 200 status for successful requests', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(200);
res.end('OK');
});
const response = await client.sendRequest({url: testServer.url});
expect(response.status).toBe(200);
});
it('returns 404 status for not found', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(404);
res.end('Not Found');
});
const response = await client.sendRequest({url: testServer.url});
expect(response.status).toBe(404);
});
it('returns 500 status for server errors', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(500);
res.end('Internal Server Error');
});
const response = await client.sendRequest({url: testServer.url});
expect(response.status).toBe(500);
});
it('handles 304 Not Modified without following redirects', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(HttpStatus.NOT_MODIFIED);
res.end();
});
const response = await client.sendRequest({url: testServer.url});
expect(response.status).toBe(HttpStatus.NOT_MODIFIED);
expect(response.url).toBe(new URL(testServer.url).href);
});
});
describe('redirects', () => {
it('follows 301 redirect', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(301, {Location: `${redirectServer.url}/target`});
res.end();
});
redirectServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({path: req.url, method: req.method}));
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(response.url).toBe(`${redirectServer.url}/target`);
expect(json.path).toBe('/target');
});
it('follows 301 redirect and changes method to GET for non-GET requests', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(301, {Location: `${redirectServer.url}/moved`});
res.end();
});
redirectServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({method: req.method}));
});
const response = await client.sendRequest({
url: testServer.url,
method: 'POST',
body: {value: 'test'},
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('GET');
});
it('follows 302 redirect', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(302, {Location: `${redirectServer.url}/found`});
res.end();
});
redirectServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end(req.url ?? '');
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
expect(response.status).toBe(200);
expect(body).toBe('/found');
});
it('follows 302 redirect and changes method to GET for non-GET requests', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(302, {Location: `${redirectServer.url}/found`});
res.end();
});
redirectServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({method: req.method}));
});
const response = await client.sendRequest({
url: testServer.url,
method: 'PATCH',
body: {value: 'test'},
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('GET');
});
it('follows 303 redirect and changes method to GET', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(303, {Location: `${redirectServer.url}/see-other`});
res.end();
});
redirectServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({method: req.method}));
});
const response = await client.sendRequest({
url: testServer.url,
method: 'POST',
body: {data: 'test'},
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('GET');
});
it('drops content headers when redirect switches to GET', async () => {
const client = createHttpClient(TEST_USER_AGENT);
let contentType: string | undefined;
let contentLength: string | undefined;
testServer.setHandler((_req, res) => {
res.writeHead(303, {Location: `${redirectServer.url}/see-other`});
res.end();
});
redirectServer.setHandler((req, res) => {
contentType = req.headers['content-type'] as string | undefined;
contentLength = req.headers['content-length'] as string | undefined;
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('OK');
});
await client.sendRequest({
url: testServer.url,
method: 'POST',
body: {data: 'test'},
});
expect(contentType).toBeUndefined();
expect(contentLength).toBeUndefined();
});
it('drops content headers when 301 redirect switches to GET', async () => {
const client = createHttpClient(TEST_USER_AGENT);
let contentType: string | undefined;
let contentLength: string | undefined;
testServer.setHandler((_req, res) => {
res.writeHead(301, {Location: `${redirectServer.url}/moved`});
res.end();
});
redirectServer.setHandler((req, res) => {
contentType = req.headers['content-type'] as string | undefined;
contentLength = req.headers['content-length'] as string | undefined;
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('OK');
});
await client.sendRequest({
url: testServer.url,
method: 'POST',
body: {data: 'test'},
});
expect(contentType).toBeUndefined();
expect(contentLength).toBeUndefined();
});
it('strips sensitive headers on cross-origin redirects', async () => {
const client = createHttpClient(TEST_USER_AGENT);
const secretToken = 'Bearer ultra-secret-token';
const secretCookie = 'session=super-secret';
const secretProxyAuth = 'Basic dXNlcjpwYXNz';
let leakedAuthorization: string | undefined;
let leakedCookie: string | undefined;
let leakedProxyAuthorization: string | undefined;
testServer.setHandler((_req, res) => {
res.writeHead(302, {Location: `${redirectServer.url}/steal`});
res.end();
});
redirectServer.setHandler((req, res) => {
leakedAuthorization = req.headers['authorization'] as string | undefined;
leakedCookie = req.headers['cookie'] as string | undefined;
leakedProxyAuthorization = req.headers['proxy-authorization'] as string | undefined;
res.writeHead(200);
res.end('OK');
});
await client.sendRequest({
url: testServer.url,
headers: {
Authorization: secretToken,
Cookie: secretCookie,
'Proxy-Authorization': secretProxyAuth,
},
});
expect(leakedAuthorization).toBeUndefined();
expect(leakedCookie).toBeUndefined();
expect(leakedProxyAuthorization).toBeUndefined();
});
it('keeps sensitive headers on same-origin redirects', async () => {
const client = createHttpClient(TEST_USER_AGENT);
const secretToken = 'Bearer safe-token';
let receivedAuthorization: string | undefined;
let requestCount = 0;
testServer.setHandler((req, res) => {
requestCount += 1;
if (requestCount === 1) {
res.writeHead(302, {Location: '/same-origin'});
res.end();
return;
}
receivedAuthorization = req.headers['authorization'] as string | undefined;
res.writeHead(200);
res.end('OK');
});
await client.sendRequest({
url: testServer.url,
headers: {
Authorization: secretToken,
},
});
expect(receivedAuthorization).toBe(secretToken);
});
it('follows 307 redirect preserving method', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(307, {Location: `${redirectServer.url}/temp`});
res.end();
});
redirectServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({method: req.method}));
});
const response = await client.sendRequest({
url: testServer.url,
method: 'POST',
body: {data: 'test'},
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('POST');
});
it('follows 308 redirect preserving method', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(308, {Location: `${redirectServer.url}/permanent`});
res.end();
});
redirectServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({method: req.method}));
});
const response = await client.sendRequest({
url: testServer.url,
method: 'POST',
body: {data: 'test'},
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('POST');
});
it('follows multiple redirects up to max limit', async () => {
const client = createHttpClient(TEST_USER_AGENT);
let redirectCount = 0;
testServer.setHandler((_req, res) => {
redirectCount++;
if (redirectCount < 5) {
res.writeHead(302, {Location: `${testServer.url}/redirect${redirectCount}`});
res.end();
} else {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({redirectCount}));
}
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.redirectCount).toBe(5);
});
it('throws error when exceeding max redirects', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(302, {Location: `${testServer.url}/redirect`});
res.end();
});
await expect(client.sendRequest({url: testServer.url})).rejects.toThrow(
'Maximum number of redirects (5) exceeded',
);
});
it('throws error when redirect has no Location header', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(302);
res.end();
});
await expect(client.sendRequest({url: testServer.url})).rejects.toThrow(
'Received redirect response without Location header',
);
});
it('handles relative redirect URLs', async () => {
const client = createHttpClient(TEST_USER_AGENT);
let requestCount = 0;
testServer.setHandler((req, res) => {
requestCount++;
if (requestCount === 1) {
res.writeHead(302, {Location: '/relative-path'});
res.end();
} else {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({path: req.url}));
}
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.path).toBe('/relative-path');
});
it('validates redirect targets with request URL policy before following', async () => {
const validationCalls: Array<RequestUrlValidationContext> = [];
const requestUrlPolicy: RequestUrlPolicy = {
async validate(_url, context) {
validationCalls.push(context);
if (context.phase === 'redirect') {
throw new HttpError('Blocked redirect target', undefined, undefined, true, 'network_error');
}
},
};
const client = createHttpClient({
userAgent: TEST_USER_AGENT,
requestUrlPolicy,
});
testServer.setHandler((_req, res) => {
res.writeHead(302, {Location: `${redirectServer.url}/blocked`});
res.end();
});
redirectServer.setHandler((_req, res) => {
res.writeHead(200);
res.end('should-not-be-called');
});
await expect(client.sendRequest({url: testServer.url})).rejects.toThrow('Blocked redirect target');
expect(validationCalls).toHaveLength(2);
expect(validationCalls[0]?.phase).toBe('initial');
expect(validationCalls[1]?.phase).toBe('redirect');
});
});
describe('timeout', () => {
it('uses default timeout of 30 seconds', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(200);
res.end('OK');
});
const response = await client.sendRequest({url: testServer.url});
expect(response.status).toBe(200);
});
it('respects custom timeout', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
setTimeout(() => {
res.writeHead(200);
res.end('OK');
}, 500);
});
await expect(
client.sendRequest({
url: testServer.url,
timeout: 100,
}),
).rejects.toThrow();
});
});
describe('abort signal', () => {
it('aborts request when signal is triggered', async () => {
const client = createHttpClient(TEST_USER_AGENT);
const controller = new AbortController();
testServer.setHandler((_req, res) => {
setTimeout(() => {
res.writeHead(200);
res.end('OK');
}, 1000);
});
const requestPromise = client.sendRequest({
url: testServer.url,
signal: controller.signal,
});
setTimeout(() => controller.abort(), 50);
await expect(requestPromise).rejects.toThrow();
});
it('handles pre-aborted signal', async () => {
const client = createHttpClient(TEST_USER_AGENT);
const controller = new AbortController();
controller.abort('Pre-aborted');
testServer.setHandler((_req, res) => {
res.writeHead(200);
res.end('OK');
});
await expect(
client.sendRequest({
url: testServer.url,
signal: controller.signal,
}),
).rejects.toThrow();
});
});
describe('error handling', () => {
it('throws HttpError for connection refused', async () => {
const client = createHttpClient(TEST_USER_AGENT);
try {
await client.sendRequest({url: 'http://127.0.0.1:1'});
expect.fail('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(HttpError);
const httpError = error as HttpError;
expect(httpError.isExpected).toBe(true);
}
});
it('throws HttpError for DNS resolution failure', async () => {
const client = createHttpClient(TEST_USER_AGENT);
try {
await client.sendRequest({url: 'http://this-domain-does-not-exist-12345.invalid'});
expect.fail('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(HttpError);
const httpError = error as HttpError;
expect(httpError.isExpected).toBe(true);
}
});
});
});
describe('streamToString', () => {
it('converts response stream to string', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello, World!');
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
expect(body).toBe('Hello, World!');
});
it('handles empty response body', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(204);
res.end();
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
expect(body).toBe('');
});
it('handles large response body', async () => {
const client = createHttpClient(TEST_USER_AGENT);
const largeContent = 'x'.repeat(1024 * 1024);
testServer.setHandler((_req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end(largeContent);
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
expect(body.length).toBe(largeContent.length);
});
it('handles UTF-8 content correctly', async () => {
const client = createHttpClient(TEST_USER_AGENT);
const unicodeContent = 'Hello, World! Emoji: \u{1F600} Chinese: \u4E2D\u6587 Arabic: \u0639\u0631\u0628\u064A';
testServer.setHandler((_req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain; charset=utf-8'});
res.end(unicodeContent);
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
expect(body).toBe(unicodeContent);
});
});
describe('telemetry', () => {
it('records metrics for successful requests', async () => {
const recordedMetrics: Array<{type: string; name: string; dimensions?: Record<string, string>}> = [];
const metrics: HttpClientMetrics = {
counter: (params) => {
recordedMetrics.push({type: 'counter', ...params});
},
histogram: (params) => {
recordedMetrics.push({type: 'histogram', ...params});
},
};
const telemetry: HttpClientTelemetry = {metrics};
const client = createHttpClient(TEST_USER_AGENT, telemetry);
testServer.setHandler((_req, res) => {
res.writeHead(200);
res.end('OK');
});
await client.sendRequest({url: testServer.url, serviceName: 'test-service'});
const latencyMetric = recordedMetrics.find((m) => m.name === 'http_client.latency');
const requestMetric = recordedMetrics.find((m) => m.name === 'http_client.request');
const responseMetric = recordedMetrics.find((m) => m.name === 'http_client.response');
expect(latencyMetric).toBeDefined();
expect(latencyMetric?.dimensions?.service).toBe('test-service');
expect(latencyMetric?.dimensions?.method).toBe('GET');
expect(requestMetric).toBeDefined();
expect(requestMetric?.dimensions?.service).toBe('test-service');
expect(requestMetric?.dimensions?.status).toBe('2xx');
expect(responseMetric).toBeDefined();
expect(responseMetric?.dimensions?.service).toBe('test-service');
expect(responseMetric?.dimensions?.status_code).toBe('2xx');
});
it('records metrics for error responses', async () => {
const recordedMetrics: Array<{type: string; name: string; dimensions?: Record<string, string>}> = [];
const metrics: HttpClientMetrics = {
counter: (params) => {
recordedMetrics.push({type: 'counter', ...params});
},
histogram: (params) => {
recordedMetrics.push({type: 'histogram', ...params});
},
};
const telemetry: HttpClientTelemetry = {metrics};
const client = createHttpClient(TEST_USER_AGENT, telemetry);
testServer.setHandler((_req, res) => {
res.writeHead(404);
res.end('Not Found');
});
await client.sendRequest({url: testServer.url, serviceName: 'test-service'});
const requestMetric = recordedMetrics.find((m) => m.name === 'http_client.request');
expect(requestMetric?.dimensions?.status).toBe('404');
});
it('records metrics for network errors', async () => {
const recordedMetrics: Array<{type: string; name: string; dimensions?: Record<string, string>}> = [];
const metrics: HttpClientMetrics = {
counter: (params) => {
recordedMetrics.push({type: 'counter', ...params});
},
histogram: (params) => {
recordedMetrics.push({type: 'histogram', ...params});
},
};
const telemetry: HttpClientTelemetry = {metrics};
const client = createHttpClient(TEST_USER_AGENT, telemetry);
try {
await client.sendRequest({url: 'http://127.0.0.1:1', serviceName: 'test-service'});
} catch {}
const requestMetric = recordedMetrics.find((m) => m.name === 'http_client.request');
expect(requestMetric?.dimensions?.status).toBe('network_error');
});
it('wraps request in tracing span when tracing is provided', async () => {
let spanName: string | undefined;
let spanAttributes: Record<string, unknown> | undefined;
const tracing: HttpClientTracing = {
withSpan: async (options, fn) => {
spanName = options.name;
spanAttributes = options.attributes;
return fn();
},
};
const telemetry: HttpClientTelemetry = {tracing};
const client = createHttpClient(TEST_USER_AGENT, telemetry);
testServer.setHandler((_req, res) => {
res.writeHead(200);
res.end('OK');
});
await client.sendRequest({url: testServer.url, serviceName: 'test-service'});
expect(spanName).toBe('http_client.fetch');
expect(spanAttributes?.['http.request.method']).toBe('GET');
expect(spanAttributes?.['url.full']).toBe(testServer.url);
expect(spanAttributes?.['service.name']).toBe('test-service');
});
it('uses default service name when not provided', async () => {
const recordedMetrics: Array<{type: string; name: string; dimensions?: Record<string, string>}> = [];
const metrics: HttpClientMetrics = {
counter: (params) => {
recordedMetrics.push({type: 'counter', ...params});
},
histogram: (params) => {
recordedMetrics.push({type: 'histogram', ...params});
},
};
const telemetry: HttpClientTelemetry = {metrics};
const client = createHttpClient(TEST_USER_AGENT, telemetry);
testServer.setHandler((_req, res) => {
res.writeHead(200);
res.end('OK');
});
await client.sendRequest({url: testServer.url});
const latencyMetric = recordedMetrics.find((m) => m.name === 'http_client.latency');
expect(latencyMetric?.dimensions?.service).toBe('unknown');
});
});
});
describe('HttpError', () => {
it('creates error with message', () => {
const error = new HttpError('Test error');
expect(error.message).toBe('Test error');
expect(error.name).toBe('HttpError');
expect(error.status).toBeUndefined();
expect(error.response).toBeUndefined();
expect(error.isExpected).toBe(false);
expect(error.errorType).toBeUndefined();
});
it('creates error with status code', () => {
const error = new HttpError('Not Found', 404);
expect(error.message).toBe('Not Found');
expect(error.status).toBe(404);
});
it('creates error with response', () => {
const response = new Response('Error body', {status: 500});
const error = new HttpError('Server Error', 500, response);
expect(error.status).toBe(500);
expect(error.response).toBe(response);
});
it('creates error with isExpected flag', () => {
const error = new HttpError('Expected error', 400, undefined, true);
expect(error.isExpected).toBe(true);
});
it('creates error with errorType', () => {
const error = new HttpError('Aborted', undefined, undefined, false, 'aborted');
expect(error.errorType).toBe('aborted');
});
it('is an instance of Error', () => {
const error = new HttpError('Test');
expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(HttpError);
});
});

View File

@@ -0,0 +1,92 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {RequestUrlValidationContext} from '@fluxer/http_client/src/HttpClientTypes';
import {createPublicInternetRequestUrlPolicy} from '@fluxer/http_client/src/PublicInternetRequestUrlPolicy';
import {describe, expect, it, vi} from 'vitest';
function createContext(overrides?: Partial<RequestUrlValidationContext>): RequestUrlValidationContext {
return {
phase: 'initial',
redirectCount: 0,
...overrides,
};
}
describe('createPublicInternetRequestUrlPolicy', () => {
it('blocks non-http protocols', async () => {
const policy = createPublicInternetRequestUrlPolicy();
await expect(policy.validate(new URL('ftp://example.com/file.txt'), createContext())).rejects.toThrow(
'Only HTTP and HTTPS protocols are allowed',
);
});
it('blocks hostnames that are not FQDNs', async () => {
const lookupHost = vi.fn(async () => ['93.184.216.34']);
const policy = createPublicInternetRequestUrlPolicy({lookupHost});
await expect(policy.validate(new URL('https://localhost/path'), createContext())).rejects.toThrow(
'Hostname is not a valid FQDN',
);
expect(lookupHost).not.toHaveBeenCalled();
});
it('blocks internal IPv4 literal addresses', async () => {
const policy = createPublicInternetRequestUrlPolicy();
await expect(policy.validate(new URL('http://127.0.0.1/admin'), createContext())).rejects.toThrow(
'IP address is in an internal or special-use range',
);
});
it('blocks internal IPv6 literal addresses', async () => {
const policy = createPublicInternetRequestUrlPolicy();
await expect(policy.validate(new URL('http://[::1]/admin'), createContext())).rejects.toThrow(
'IP address is in an internal or special-use range',
);
});
it('blocks IPv4-mapped IPv6 loopback literals', async () => {
const policy = createPublicInternetRequestUrlPolicy();
await expect(policy.validate(new URL('http://[::ffff:7f00:1]/admin'), createContext())).rejects.toThrow(
'IP address is in an internal or special-use range',
);
});
it('blocks FQDNs that resolve to internal addresses', async () => {
const policy = createPublicInternetRequestUrlPolicy({
lookupHost: async () => ['10.0.0.5'],
});
await expect(policy.validate(new URL('https://cdn.example.com/image.png'), createContext())).rejects.toThrow(
'Hostname resolved to disallowed address',
);
});
it('allows FQDNs that resolve only to public addresses', async () => {
const policy = createPublicInternetRequestUrlPolicy({
lookupHost: async () => ['93.184.216.34', '2606:2800:220:1:248:1893:25c8:1946'],
});
await expect(policy.validate(new URL('https://example.com/image.png'), createContext())).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,87 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createServer, type IncomingMessage, type Server, type ServerResponse} from 'node:http';
export type RouteHandler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;
export interface TestServer {
url: string;
port: number;
server: Server;
close: () => Promise<void>;
setHandler: (handler: RouteHandler) => void;
}
export async function createTestServer(): Promise<TestServer> {
let currentHandler: RouteHandler = (_req, res) => {
res.writeHead(404);
res.end('Not Found');
};
const server = createServer((req, res) => {
Promise.resolve(currentHandler(req, res)).catch((error) => {
res.writeHead(500);
res.end(String(error));
});
});
return new Promise((resolve, reject) => {
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') {
reject(new Error('Failed to get server address'));
return;
}
const port = address.port;
const url = `http://127.0.0.1:${port}`;
resolve({
url,
port,
server,
close: () =>
new Promise<void>((resolveClose, rejectClose) => {
server.close((err) => {
if (err) {
rejectClose(err);
} else {
resolveClose();
}
});
}),
setHandler: (handler: RouteHandler) => {
currentHandler = handler;
},
});
});
});
}
export function readRequestBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Array<Buffer> = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
req.on('error', reject);
});
}