refactor progress
This commit is contained in:
993
packages/http_client/src/__tests__/HttpClient.test.tsx
Normal file
993
packages/http_client/src/__tests__/HttpClient.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
87
packages/http_client/src/__tests__/TestHttpServer.tsx
Normal file
87
packages/http_client/src/__tests__/TestHttpServer.tsx
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user