1286 lines
39 KiB
JavaScript
1286 lines
39 KiB
JavaScript
const {describe, test, expect, beforeAll, afterAll} = require('@jest/globals');
|
|
const {Readable} = require('stream');
|
|
const {buffer} = require('node:stream/consumers');
|
|
const http = require('http');
|
|
const express = require('express');
|
|
const operationContext = require('../../Common/sources/operationContext');
|
|
const utils = require('../../Common/sources/utils');
|
|
|
|
// Create operation context for tests
|
|
const ctx = new operationContext.Context();
|
|
|
|
// Test server setup
|
|
let server;
|
|
let proxyServer;
|
|
const PORT = 3456;
|
|
const BASE_URL = `http://localhost:${PORT}`;
|
|
const PROXY_PORT = PORT + 2000;
|
|
const PROXY_URL = `http://localhost:${PROXY_PORT}`;
|
|
// Track requests going through the proxy
|
|
const proxiedRequests = [];
|
|
|
|
const getStatusCode = response => response.statusCode || response.status;
|
|
|
|
function createMockContext(overrides = {}) {
|
|
const defaultCtx = {
|
|
getCfg(key, _) {
|
|
switch (key) {
|
|
case 'services.CoAuthoring.requestDefaults':
|
|
return {
|
|
headers: {
|
|
'User-Agent': 'Node.js/6.13',
|
|
Connection: 'Keep-Alive'
|
|
},
|
|
decompress: true,
|
|
rejectUnauthorized: true,
|
|
followRedirect: false
|
|
};
|
|
case 'services.CoAuthoring.token.outbox.header':
|
|
return 'Authorization';
|
|
case 'services.CoAuthoring.token.outbox.prefix':
|
|
return 'Bearer ';
|
|
case 'externalRequest.action':
|
|
return {
|
|
allow: true,
|
|
blockPrivateIP: false,
|
|
proxyUrl: '',
|
|
proxyUser: {
|
|
username: '',
|
|
password: ''
|
|
},
|
|
proxyHeaders: {}
|
|
};
|
|
case 'services.CoAuthoring.request-filtering-agent':
|
|
return {
|
|
allowPrivateIPAddress: false,
|
|
allowMetaIPAddress: false
|
|
};
|
|
case 'externalRequest.directIfIn':
|
|
return {
|
|
allowList: [],
|
|
jwtToken: true
|
|
};
|
|
default:
|
|
return undefined;
|
|
}
|
|
},
|
|
logger: {
|
|
debug() {}
|
|
}
|
|
};
|
|
|
|
// Return a mock context with overridden values if any
|
|
return {
|
|
...defaultCtx,
|
|
getCfg(key, _) {
|
|
// Return the override if it exists
|
|
if (overrides[key]) {
|
|
return overrides[key];
|
|
}
|
|
// Otherwise, return the default behavior
|
|
return defaultCtx.getCfg(key, _);
|
|
}
|
|
};
|
|
}
|
|
|
|
describe('HTTP Request Unit Tests', () => {
|
|
beforeAll(async () => {
|
|
// Setup test Express server
|
|
const app = express();
|
|
|
|
// Basic endpoint that returns JSON
|
|
app.get('/api/data', (req, res) => {
|
|
res.json({success: true});
|
|
});
|
|
|
|
// Endpoint that simulates timeout
|
|
app.get('/api/timeout', (_req, _res) => {
|
|
// Never send response to trigger timeout
|
|
});
|
|
|
|
app.use('/api/status/:code', (req, res) => {
|
|
res.status(Number(req.params.code)).send();
|
|
});
|
|
|
|
// Endpoint that redirects
|
|
app.get('/api/redirect', (req, res) => {
|
|
res.redirect('/api/data');
|
|
});
|
|
|
|
// Endpoint that returns error
|
|
app.get('/api/error', (req, res) => {
|
|
res.status(500).json({error: 'Internal Server Error'});
|
|
});
|
|
|
|
// Endpoint that simulates a slow response headers
|
|
app.get('/api/slow-headers', (_req, res) => {
|
|
// Delay sending headers
|
|
setTimeout(() => {
|
|
res.json({success: true});
|
|
}, 200); // 200 ms delay before sending any response
|
|
});
|
|
|
|
// Endpoint that simulates partial response with incomplete body
|
|
app.get('/api/partial-response', (req, res) => {
|
|
// Send headers immediately
|
|
res.setHeader('Content-Type', 'application/json');
|
|
// Start sending data
|
|
res.write('{"start": "This response');
|
|
|
|
// But never finish the response (simulates a server that hangs after starting to send data)
|
|
// This should trigger the wholeCycle timeout
|
|
});
|
|
|
|
// Endpoint that simulates slow/chunked response with inactivity periods
|
|
app.get('/api/slow-body', (_req, res) => {
|
|
// Send headers immediately
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.write('{"part1": "First part of the response",');
|
|
|
|
// Delay between chunks (simulates inactivity during response body transmission)
|
|
setTimeout(() => {
|
|
res.write('"part2": "Second part of the response",');
|
|
// Final delay - this delay is longer than the connectionAndInactivity timeout should be
|
|
setTimeout(() => {
|
|
res.write('"part3": "third part",');
|
|
setTimeout(() => {
|
|
res.write('"part4": "Final part"}');
|
|
res.end();
|
|
}, 200);
|
|
}, 100);
|
|
}, 100);
|
|
});
|
|
|
|
// POST endpoint
|
|
app.post('/api/post', express.json(), (req, res) => {
|
|
res.json({received: req.body});
|
|
});
|
|
|
|
// POST endpoint that times out
|
|
app.post('/api/timeout', express.json(), (_req, _res) => {
|
|
// Never send response to trigger timeout
|
|
});
|
|
|
|
app.get('/api/binary', (_req, res) => {
|
|
// PNG file signature as binary data
|
|
const binaryData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
res.setHeader('content-type', 'image/png');
|
|
res.setHeader('content-length', binaryData.length);
|
|
res.send(binaryData);
|
|
});
|
|
|
|
// Large file endpoint
|
|
app.get('/api/large', (_req, res) => {
|
|
res.setHeader('content-type', 'application/octet-stream');
|
|
res.send(Buffer.alloc(2 * 1024 * 1024)); //2MB
|
|
});
|
|
|
|
// Large file endpoint with truly no content-length header
|
|
app.get('/api/large-chunked', (_req, res) => {
|
|
res.setHeader('content-type', 'application/octet-stream');
|
|
res.setHeader('transfer-encoding', 'chunked');
|
|
res.write(Buffer.alloc(2 * 1024 * 1024));
|
|
res.end();
|
|
});
|
|
|
|
// Endpoint that mirrors whole request - handles any HTTP method
|
|
app.use('/api/mirror', express.json(), express.urlencoded({extended: true}), (req, res) => {
|
|
// Create a mirror response object with all request details
|
|
const mirror = {
|
|
method: req.method,
|
|
url: req.url,
|
|
path: req.path,
|
|
query: req.query,
|
|
params: req.params,
|
|
headers: req.headers,
|
|
body: req.body,
|
|
protocol: req.protocol,
|
|
ip: req.ip,
|
|
hostname: req.hostname,
|
|
originalUrl: req.originalUrl,
|
|
xhr: req.xhr,
|
|
secure: req.secure
|
|
};
|
|
|
|
// Send the mirror response back
|
|
res.json(mirror);
|
|
});
|
|
|
|
// Start server
|
|
server = http.createServer(app);
|
|
await new Promise(resolve => server.listen(PORT, resolve));
|
|
|
|
// Setup proxy server
|
|
const proxyApp = express();
|
|
proxyApp.use((req, res) => {
|
|
// Record request details
|
|
const requestInfo = {
|
|
method: req.method,
|
|
url: req.url,
|
|
headers: req.headers,
|
|
body: ''
|
|
};
|
|
proxiedRequests.push(requestInfo);
|
|
|
|
// Collect body data if present
|
|
req.on('data', chunk => {
|
|
requestInfo.body += chunk.toString();
|
|
});
|
|
|
|
// Validate proxy authentication
|
|
const authHeader = req.headers['proxy-authorization'];
|
|
if (!authHeader || !authHeader.startsWith('Basic ')) {
|
|
res.status(407).set('Proxy-Authenticate', 'Basic').send('Proxy authentication required');
|
|
return;
|
|
}
|
|
|
|
// Decode and verify credentials
|
|
const base64Credentials = authHeader.split(' ')[1];
|
|
const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8');
|
|
const [username, password] = credentials.split(':');
|
|
|
|
// Expected credentials from config (will be overridden by test-specific values)
|
|
const expectedUsername = 'proxyuser';
|
|
const expectedPassword = 'proxypass';
|
|
|
|
if (username !== expectedUsername || password !== expectedPassword) {
|
|
res.status(407).set('Proxy-Authenticate', 'Basic').send('Invalid proxy credentials');
|
|
return;
|
|
}
|
|
|
|
// Forward the request
|
|
const targetUrl = new URL(req.url);
|
|
const options = {
|
|
hostname: targetUrl.hostname,
|
|
port: targetUrl.port || (targetUrl.protocol === 'https:' ? 443 : 80),
|
|
path: targetUrl.pathname + targetUrl.search,
|
|
method: req.method,
|
|
headers: {...req.headers}
|
|
};
|
|
|
|
const proxyReq = http.request(options, proxyRes => {
|
|
// Copy status code
|
|
res.statusCode = proxyRes.statusCode;
|
|
|
|
// Copy headers
|
|
Object.keys(proxyRes.headers).forEach(key => {
|
|
res.setHeader(key, proxyRes.headers[key]);
|
|
});
|
|
|
|
// Pipe response data
|
|
proxyRes.pipe(res);
|
|
});
|
|
|
|
// Handle proxy errors
|
|
proxyReq.on('error', error => {
|
|
console.error('Proxy error:', error);
|
|
res.statusCode = 500;
|
|
res.end('Proxy Error');
|
|
});
|
|
|
|
// Pipe request data
|
|
if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
|
|
req.pipe(proxyReq);
|
|
} else {
|
|
proxyReq.end();
|
|
}
|
|
});
|
|
|
|
proxyServer = http.createServer(proxyApp);
|
|
await new Promise(resolve => proxyServer.listen(PROXY_PORT, resolve));
|
|
});
|
|
|
|
afterAll(async () => {
|
|
// Cleanup servers
|
|
await new Promise(resolve => server.close(resolve));
|
|
if (proxyServer) {
|
|
await new Promise(resolve => proxyServer.close(resolve));
|
|
}
|
|
});
|
|
|
|
describe('specific timeout behaviors', () => {
|
|
test.concurrent('connectionAndInactivity triggers when server delays response headers', async () => {
|
|
try {
|
|
await utils.downloadUrlPromise(
|
|
ctx,
|
|
`${BASE_URL}/api/slow-headers`,
|
|
{connectionAndInactivity: '100ms'}, // connectionAndInactivity shorter than the server delay
|
|
1024 * 1024,
|
|
null,
|
|
false,
|
|
null,
|
|
null
|
|
);
|
|
throw new Error('Expected an error to be thrown');
|
|
} catch (error) {
|
|
// Different implementations might throw different error messages/codes
|
|
expect(error.code).toBe('ESOCKETTIMEDOUT');
|
|
}
|
|
});
|
|
|
|
test.concurrent('connectionAndInactivity does not trigger when longer than server delay', async () => {
|
|
const result = await utils.downloadUrlPromise(
|
|
ctx,
|
|
`${BASE_URL}/api/slow-headers`,
|
|
{connectionAndInactivity: '300ms'}, // connectionAndInactivity longer than the server delay (200ms)
|
|
1024 * 1024,
|
|
null,
|
|
false,
|
|
null,
|
|
null
|
|
);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(getStatusCode(result.response)).toBe(200);
|
|
expect(JSON.parse(result.body.toString())).toEqual({success: true});
|
|
});
|
|
|
|
test.concurrent('wholeCycle triggers even when server starts sending data but does not complete', async () => {
|
|
try {
|
|
await utils.downloadUrlPromise(
|
|
ctx,
|
|
`${BASE_URL}/api/partial-response`,
|
|
{wholeCycle: '1s'}, // wholeCycle shorter than time needed for response
|
|
1024 * 1024,
|
|
null,
|
|
false,
|
|
null,
|
|
null
|
|
);
|
|
throw new Error('Expected an error to be thrown');
|
|
} catch (error) {
|
|
expect(error.code).toBe('ETIMEDOUT');
|
|
}
|
|
});
|
|
|
|
test.concurrent('connectionAndInactivity triggers when server stops sending data midway', async () => {
|
|
try {
|
|
await utils.downloadUrlPromise(
|
|
ctx,
|
|
`${BASE_URL}/api/slow-body`,
|
|
{connectionAndInactivity: '150ms'}, // connectionAndInactivity shorter than the second delay
|
|
1024 * 1024,
|
|
null,
|
|
false,
|
|
null,
|
|
null
|
|
);
|
|
throw new Error('Expected an error to be thrown');
|
|
} catch (error) {
|
|
// This should catch the inactivity timeout during body transmission
|
|
expect(error.code).toBe('ESOCKETTIMEDOUT');
|
|
}
|
|
});
|
|
|
|
test.concurrent('connectionAndInactivity does not trigger when longer than inactivity periods', async () => {
|
|
const result = await utils.downloadUrlPromise(
|
|
ctx,
|
|
`${BASE_URL}/api/slow-body`,
|
|
{connectionAndInactivity: '250ms'}, // connectionAndInactivity longer than the longest delay (2s)
|
|
1024 * 1024,
|
|
null,
|
|
false,
|
|
null,
|
|
null
|
|
);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(getStatusCode(result.response)).toBe(200);
|
|
const responseBody = JSON.parse(result.body.toString());
|
|
expect(responseBody.part1).toBe('First part of the response');
|
|
expect(responseBody.part2).toBe('Second part of the response');
|
|
expect(responseBody.part3).toBe('third part');
|
|
expect(responseBody.part4).toBe('Final part');
|
|
});
|
|
|
|
test.concurrent('wholeCycle does not trigger when longer than total response time', async () => {
|
|
const result = await utils.downloadUrlPromise(ctx, `${BASE_URL}/api/slow-body`, {wholeCycle: '1s'}, 1024 * 1024, null, false, null, null);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(getStatusCode(result.response)).toBe(200);
|
|
const responseBody = JSON.parse(result.body.toString());
|
|
expect(responseBody.part1).toBe('First part of the response');
|
|
expect(responseBody.part2).toBe('Second part of the response');
|
|
expect(responseBody.part3).toBe('third part');
|
|
expect(responseBody.part4).toBe('Final part');
|
|
});
|
|
});
|
|
|
|
describe('downloadUrlPromise', () => {
|
|
test.concurrent('successfully downloads JSON data', async () => {
|
|
const result = await utils.downloadUrlPromise(
|
|
ctx,
|
|
`${BASE_URL}/api/data`,
|
|
{wholeCycle: '5s', connectionAndInactivity: '3s'},
|
|
1024 * 1024,
|
|
null,
|
|
false,
|
|
null,
|
|
null
|
|
);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(getStatusCode(result.response)).toBe(200);
|
|
expect(JSON.parse(result.body.toString())).toEqual({success: true});
|
|
});
|
|
|
|
test.concurrent('throws error on timeout', async () => {
|
|
try {
|
|
await utils.downloadUrlPromise(
|
|
ctx,
|
|
`${BASE_URL}/api/timeout`,
|
|
{wholeCycle: '1s', connectionAndInactivity: '500ms'},
|
|
1024 * 1024,
|
|
null,
|
|
false,
|
|
null,
|
|
null
|
|
);
|
|
throw new Error('Expected an error to be thrown');
|
|
} catch (error) {
|
|
expect(error.code).toBe('ESOCKETTIMEDOUT');
|
|
}
|
|
});
|
|
|
|
test.concurrent('throws error on wholeCycle timeout', async () => {
|
|
try {
|
|
await utils.downloadUrlPromise(
|
|
ctx,
|
|
`${BASE_URL}/api/timeout`,
|
|
{wholeCycle: '1s', connectionAndInactivity: '5000ms'},
|
|
1024 * 1024,
|
|
null,
|
|
false,
|
|
null,
|
|
null
|
|
);
|
|
throw new Error('Expected an error to be thrown');
|
|
} catch (error) {
|
|
expect(error.code).toBe('ETIMEDOUT');
|
|
}
|
|
});
|
|
|
|
test.concurrent('follows redirects correctly', async () => {
|
|
const result = await utils.downloadUrlPromise(
|
|
ctx,
|
|
`${BASE_URL}/api/redirect`,
|
|
{wholeCycle: '5s', connectionAndInactivity: '3s'},
|
|
1024 * 1024,
|
|
null,
|
|
false,
|
|
null,
|
|
null
|
|
);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(getStatusCode(result.response)).toBe(200);
|
|
expect(JSON.parse(result.body.toString())).toEqual({success: true});
|
|
});
|
|
|
|
test.concurrent(`doesn't follow redirects(maxRedirects=0)`, async () => {
|
|
const mockCtx = createMockContext({
|
|
'services.CoAuthoring.requestDefaults': {
|
|
headers: {
|
|
'User-Agent': 'Node.js/6.13',
|
|
Connection: 'Keep-Alive'
|
|
},
|
|
decompress: true,
|
|
rejectUnauthorized: false,
|
|
followRedirect: true,
|
|
maxRedirects: 0
|
|
}
|
|
});
|
|
|
|
try {
|
|
await utils.downloadUrlPromise(
|
|
mockCtx,
|
|
`${BASE_URL}/api/redirect`,
|
|
{wholeCycle: '5s', connectionAndInactivity: '3s'},
|
|
1024 * 1024,
|
|
null,
|
|
false,
|
|
null,
|
|
null
|
|
);
|
|
} catch (error) {
|
|
// New implementation path (Axios)
|
|
expect(error.statusCode).toBe(302);
|
|
}
|
|
});
|
|
|
|
test.concurrent(`doesn't follow redirects(followRedirect=false)`, async () => {
|
|
const mockCtx = createMockContext({
|
|
'services.CoAuthoring.requestDefaults': {
|
|
headers: {
|
|
'User-Agent': 'Node.js/6.13',
|
|
Connection: 'Keep-Alive'
|
|
},
|
|
decompress: true,
|
|
rejectUnauthorized: false,
|
|
followRedirect: false,
|
|
maxRedirects: 100
|
|
}
|
|
});
|
|
|
|
try {
|
|
await utils.downloadUrlPromise(
|
|
mockCtx,
|
|
`${BASE_URL}/api/redirect`,
|
|
{wholeCycle: '5s', connectionAndInactivity: '3s'},
|
|
1024 * 1024,
|
|
null,
|
|
false,
|
|
null,
|
|
null
|
|
);
|
|
|
|
// Old implementation path
|
|
} catch (error) {
|
|
// New implementation path (Axios)
|
|
expect(error.statusCode).toBe(302);
|
|
}
|
|
});
|
|
|
|
test.concurrent('throws error on server error', async () => {
|
|
await expect(
|
|
utils.downloadUrlPromise(
|
|
ctx,
|
|
`${BASE_URL}/api/error`,
|
|
{wholeCycle: '5s', connectionAndInactivity: '3s'},
|
|
1024 * 1024,
|
|
null,
|
|
false,
|
|
null,
|
|
null
|
|
)
|
|
).rejects.toMatchObject({code: 'ERR_BAD_RESPONSE'});
|
|
});
|
|
|
|
test.concurrent('throws error when content-length exceeds limit', async () => {
|
|
try {
|
|
await utils.downloadUrlPromise(
|
|
ctx,
|
|
`${BASE_URL}/api/large`,
|
|
{wholeCycle: '5s', connectionAndInactivity: '3s'},
|
|
1024 * 1024,
|
|
null,
|
|
false,
|
|
null,
|
|
null
|
|
);
|
|
throw new Error('Expected an error to be thrown');
|
|
} catch (error) {
|
|
expect(error.code).toBe('EMSGSIZE');
|
|
}
|
|
|
|
try {
|
|
await utils.downloadUrlPromise(
|
|
ctx,
|
|
`${BASE_URL}/api/large-chunked`,
|
|
{wholeCycle: '5s', connectionAndInactivity: '3s'},
|
|
1024 * 1024,
|
|
null,
|
|
false,
|
|
null,
|
|
null
|
|
);
|
|
throw new Error('Expected an error to be thrown');
|
|
} catch (error) {
|
|
expect(error.code).toBe('EMSGSIZE');
|
|
}
|
|
});
|
|
|
|
test.concurrent('throws error when content-length exceeds limit with stream', async () => {
|
|
try {
|
|
const {stream} = await utils.downloadUrlPromise(
|
|
ctx,
|
|
`${BASE_URL}/api/large`,
|
|
{wholeCycle: '5s', connectionAndInactivity: '3s'},
|
|
1024 * 1024,
|
|
null,
|
|
false,
|
|
null,
|
|
true
|
|
);
|
|
await buffer(stream);
|
|
throw new Error('Expected an error to be thrown');
|
|
} catch (error) {
|
|
expect(error.code).toBe('EMSGSIZE');
|
|
}
|
|
try {
|
|
const {stream} = await utils.downloadUrlPromise(
|
|
ctx,
|
|
`${BASE_URL}/api/large-chunked`,
|
|
{wholeCycle: '5s', connectionAndInactivity: '3s'},
|
|
1024 * 1024,
|
|
null,
|
|
false,
|
|
null,
|
|
true
|
|
);
|
|
await buffer(stream);
|
|
throw new Error('Expected an error to be thrown');
|
|
} catch (error) {
|
|
expect(error.code).toBe('EMSGSIZE');
|
|
}
|
|
});
|
|
|
|
test.concurrent('enables compression when gzip is true', async () => {
|
|
const mockCtx = createMockContext({
|
|
'services.CoAuthoring.requestDefaults': {
|
|
headers: {'User-Agent': 'Node.js/6.13'},
|
|
gzip: true,
|
|
rejectUnauthorized: false
|
|
}
|
|
});
|
|
|
|
const response = await utils.downloadUrlPromise(mockCtx, `${BASE_URL}/api/mirror`, {wholeCycle: '2s'}, 1024 * 1024, null, false, null, null);
|
|
|
|
// Parse the response body assuming it's JSON
|
|
const responseBody = JSON.parse(response.body);
|
|
|
|
// When gzip is true, 'accept-encoding' should include 'gzip'
|
|
expect(responseBody.headers?.['accept-encoding']).toBeDefined();
|
|
expect(responseBody.headers?.['accept-encoding']).toMatch(/gzip/i);
|
|
});
|
|
|
|
test.concurrent('disables compression when gzip is false', async () => {
|
|
const mockCtx = createMockContext({
|
|
'services.CoAuthoring.requestDefaults': {
|
|
headers: {'User-Agent': 'Node.js/6.13'},
|
|
gzip: false,
|
|
rejectUnauthorized: false
|
|
}
|
|
});
|
|
|
|
const response = await utils.downloadUrlPromise(mockCtx, `${BASE_URL}/api/mirror`, {wholeCycle: '2s'}, 1024 * 1024, null, false, null, null);
|
|
|
|
// Parse the response body assuming it's JSON
|
|
const responseBody = JSON.parse(response.body);
|
|
|
|
expect(responseBody.headers?.['accept-encoding'] === 'identity' || responseBody.headers?.['accept-encoding'] === undefined).toBe(true);
|
|
});
|
|
|
|
test.concurrent('enables keep-alive when forever is true', async () => {
|
|
const mockCtx = createMockContext({
|
|
'services.CoAuthoring.requestDefaults': {
|
|
headers: {'User-Agent': 'Node.js/6.13'},
|
|
forever: true,
|
|
rejectUnauthorized: false
|
|
}
|
|
});
|
|
|
|
const response = await utils.downloadUrlPromise(mockCtx, `${BASE_URL}/api/mirror`, {wholeCycle: '2s'}, 1024 * 1024, null, false, null, null);
|
|
|
|
// Parse the response body assuming it's JSON
|
|
const responseBody = JSON.parse(response.body);
|
|
|
|
// When forever is true, connection should be 'keep-alive'
|
|
expect(responseBody.headers?.connection?.toLowerCase()).toMatch(/keep-alive/i);
|
|
});
|
|
|
|
test.concurrent('disables keep-alive when forever is false', async () => {
|
|
const mockCtx = createMockContext({
|
|
'services.CoAuthoring.requestDefaults': {
|
|
headers: {
|
|
'User-Agent': 'Node.js/6.13'
|
|
},
|
|
forever: false,
|
|
rejectUnauthorized: false
|
|
}
|
|
});
|
|
|
|
const result = await utils.downloadUrlPromise(
|
|
mockCtx,
|
|
`${BASE_URL}/api/mirror`,
|
|
{wholeCycle: '5s', connectionAndInactivity: '3s'},
|
|
1024 * 1024,
|
|
null,
|
|
false,
|
|
null,
|
|
null
|
|
);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.response).toBeDefined();
|
|
expect(getStatusCode(result.response)).toBe(200);
|
|
const responseBody = JSON.parse(result.body.toString());
|
|
|
|
// When forever is false, connection should NOT be 'keep-alive'
|
|
// Note: Different HTTP clients might handle this differently,
|
|
// so we're checking that keepAlive is false
|
|
expect(responseBody.headers?.connection?.toLowerCase()).not.toMatch(/keep-alive/i);
|
|
});
|
|
|
|
test.concurrent('test requestDefaults', async () => {
|
|
const defaultHeaders = {'user-agent': 'Node.js/6.13'};
|
|
const mockCtx = createMockContext({
|
|
'services.CoAuthoring.requestDefaults': {
|
|
headers: defaultHeaders
|
|
}
|
|
});
|
|
const customHeaders = {'custom-header': 'test-value', 'set-cookie': ['cookie']};
|
|
const customQueryParams = {'custom-query-param': 'value'};
|
|
const result = await utils.downloadUrlPromise(
|
|
mockCtx,
|
|
`${BASE_URL}/api/mirror?${new URLSearchParams(customQueryParams).toString()}`,
|
|
{wholeCycle: '5s', connectionAndInactivity: '3s'},
|
|
1024 * 1024,
|
|
null,
|
|
false,
|
|
customHeaders
|
|
);
|
|
expect(result).toBeDefined();
|
|
expect(getStatusCode(result.response)).toBe(200);
|
|
const body = JSON.parse(result.body);
|
|
expect(body.headers).toMatchObject({...defaultHeaders, ...customHeaders});
|
|
expect(body.query).toMatchObject(customQueryParams);
|
|
});
|
|
|
|
test.concurrent('successfully routes GET request through a real proxy', async () => {
|
|
try {
|
|
// Create context with proxy configuration
|
|
const mockCtx = createMockContext({
|
|
'externalRequest.action': {
|
|
allow: true,
|
|
blockPrivateIP: false,
|
|
proxyUrl: PROXY_URL,
|
|
proxyUser: {
|
|
username: 'proxyuser',
|
|
password: 'proxypass'
|
|
},
|
|
proxyHeaders: {
|
|
'X-Proxy-Custom': 'custom-value'
|
|
}
|
|
}
|
|
});
|
|
|
|
// Make a GET request through the proxy
|
|
const result = await utils.downloadUrlPromise(
|
|
mockCtx,
|
|
`${BASE_URL}/api/data`,
|
|
{wholeCycle: '5s', connectionAndInactivity: '3s'},
|
|
1024 * 1024,
|
|
null,
|
|
false,
|
|
null,
|
|
null
|
|
);
|
|
|
|
// Verify the request was successful
|
|
expect(result).toBeDefined();
|
|
expect(getStatusCode(result.response)).toBe(200);
|
|
expect(JSON.parse(result.body.toString())).toEqual({success: true});
|
|
|
|
// Verify the request went through our proxy
|
|
expect(proxiedRequests.length).toBeGreaterThan(0);
|
|
const proxyRequest = proxiedRequests.find(r => r.method === 'GET' && r.url.includes('/api/data'));
|
|
expect(proxyRequest).toBeDefined();
|
|
expect(proxyRequest.url).toContain(`${BASE_URL}/api/data`);
|
|
// Check for Base64 encoded authorization header (starts with "Basic ")
|
|
expect(proxyRequest.headers['proxy-authorization']).toMatch(/^Basic /);
|
|
expect(proxyRequest.headers['x-proxy-custom']).toBe('custom-value');
|
|
} finally {
|
|
// No need to clean up proxy server here anymore
|
|
}
|
|
});
|
|
|
|
test.concurrent('should return 205 status code for /status/205', async () => {
|
|
try {
|
|
await utils.downloadUrlPromise(
|
|
ctx,
|
|
`${BASE_URL}/api/status/205`,
|
|
{wholeCycle: '5s', connectionAndInactivity: '3s'},
|
|
1024 * 1024,
|
|
null,
|
|
false,
|
|
null,
|
|
null
|
|
);
|
|
throw new Error('Expected an error to be thrown');
|
|
} catch (error) {
|
|
expect(error.message).toContain('Error response:');
|
|
expect(error.statusCode).toBe(205);
|
|
}
|
|
});
|
|
});
|
|
|
|
test.concurrent('handles binary data correctly', async () => {
|
|
const result = await utils.downloadUrlPromise(
|
|
ctx,
|
|
`${BASE_URL}/api/binary`,
|
|
{wholeCycle: '5s', connectionAndInactivity: '3s'},
|
|
1024 * 1024,
|
|
null,
|
|
false,
|
|
null,
|
|
null
|
|
);
|
|
|
|
// Expected binary data (PNG file signature)
|
|
const expectedData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.response).toBeDefined();
|
|
expect(getStatusCode(result.response)).toBe(200);
|
|
expect(result.response.headers['content-type']).toBe('image/png');
|
|
|
|
// Verify binary data
|
|
expect(Buffer.isBuffer(result.body)).toBe(true);
|
|
expect(result.body.length).toBe(expectedData.length);
|
|
expect(Buffer.compare(result.body, expectedData)).toBe(0);
|
|
});
|
|
|
|
test.concurrent('handles binary data with stream writer', async () => {
|
|
const {stream} = await utils.downloadUrlPromise(
|
|
ctx,
|
|
`${BASE_URL}/api/binary`,
|
|
{wholeCycle: '5s', connectionAndInactivity: '3s'},
|
|
1024 * 1024,
|
|
null,
|
|
false,
|
|
null,
|
|
true
|
|
);
|
|
const receivedData = await buffer(stream);
|
|
const expectedData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
|
|
expect(Buffer.isBuffer(receivedData)).toBe(true);
|
|
expect(receivedData.length).toBe(expectedData.length);
|
|
expect(Buffer.compare(receivedData, expectedData)).toBe(0);
|
|
});
|
|
|
|
test.concurrent('block external requests', async () => {
|
|
const mockCtx = createMockContext({
|
|
'externalRequest.action': {
|
|
allow: false, // Block all external requests
|
|
blockPrivateIP: false,
|
|
proxyUrl: '',
|
|
proxyUser: {
|
|
username: '',
|
|
password: ''
|
|
},
|
|
proxyHeaders: {}
|
|
}
|
|
});
|
|
|
|
// Use rejects.toThrow to test the error message
|
|
await expect(
|
|
utils.downloadUrlPromise(
|
|
mockCtx,
|
|
'https://example.com/test',
|
|
{wholeCycle: '5s', connectionAndInactivity: '3s'},
|
|
1024 * 1024,
|
|
null,
|
|
false,
|
|
null,
|
|
null
|
|
)
|
|
).rejects.toThrow('Block external request. See externalRequest config options');
|
|
});
|
|
|
|
test.concurrent('allows request to external url in allowlist', async () => {
|
|
const mockCtx = createMockContext({
|
|
'externalRequest.action': {
|
|
allow: false, // Block external requests by default
|
|
blockPrivateIP: false,
|
|
proxyUrl: '',
|
|
proxyUser: {
|
|
username: '',
|
|
password: ''
|
|
},
|
|
proxyHeaders: {}
|
|
},
|
|
'externalRequest.directIfIn': {
|
|
allowList: [`${BASE_URL}`], // Allow our test server
|
|
jwtToken: false
|
|
}
|
|
});
|
|
|
|
const result = await utils.downloadUrlPromise(
|
|
mockCtx,
|
|
`${BASE_URL}/api/data`,
|
|
{wholeCycle: '5s', connectionAndInactivity: '3s'},
|
|
1024 * 1024,
|
|
null,
|
|
false,
|
|
null,
|
|
null
|
|
);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(getStatusCode(result.response)).toBe(200);
|
|
expect(JSON.parse(result.body.toString())).toEqual({success: true});
|
|
});
|
|
|
|
test.concurrent('allows request when URL is in JWT token', async () => {
|
|
const mockCtx = createMockContext({
|
|
'externalRequest.action': {
|
|
allow: false, // Block external requests by default
|
|
blockPrivateIP: false,
|
|
proxyUrl: '',
|
|
proxyUser: {
|
|
username: '',
|
|
password: ''
|
|
},
|
|
proxyHeaders: {}
|
|
},
|
|
'externalRequest.directIfIn': {
|
|
allowList: [], // Empty allowlist
|
|
jwtToken: true // Allow URLs from JWT token
|
|
}
|
|
});
|
|
|
|
const result = await utils.downloadUrlPromise(
|
|
mockCtx,
|
|
`${BASE_URL}/api/data`,
|
|
{wholeCycle: '5s', connectionAndInactivity: '3s'},
|
|
1024 * 1024,
|
|
null,
|
|
true, // Indicate URL is from JWT token
|
|
null,
|
|
null
|
|
);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(getStatusCode(result.response)).toBe(200);
|
|
expect(JSON.parse(result.body.toString())).toEqual({success: true});
|
|
});
|
|
|
|
describe('postRequestPromise', () => {
|
|
test.concurrent('successfully posts data', async () => {
|
|
const postData = JSON.stringify({test: 'data'});
|
|
|
|
const result = await utils.postRequestPromise(
|
|
ctx,
|
|
`${BASE_URL}/api/post`,
|
|
postData,
|
|
null,
|
|
postData.length,
|
|
{wholeCycle: '5s', connectionAndInactivity: '3s'},
|
|
null,
|
|
false,
|
|
{'Content-Type': 'application/json'}
|
|
);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.response.statusCode).toBe(200);
|
|
expect(JSON.parse(result.body)).toEqual({received: {test: 'data'}});
|
|
});
|
|
|
|
test.concurrent('handles timeout during post', async () => {
|
|
const postData = JSON.stringify({test: 'data'});
|
|
|
|
await expect(
|
|
utils.postRequestPromise(
|
|
ctx,
|
|
`${BASE_URL}/api/timeout`,
|
|
postData,
|
|
null,
|
|
postData.length,
|
|
{wholeCycle: '1s', connectionAndInactivity: '500ms'},
|
|
null,
|
|
false,
|
|
{'Content-Type': 'application/json'}
|
|
)
|
|
).rejects.toMatchObject({code: 'ESOCKETTIMEDOUT'});
|
|
});
|
|
|
|
test.concurrent('handles post with Authorization header', async () => {
|
|
const postData = JSON.stringify({test: 'data'});
|
|
const authToken = 'test-auth-token';
|
|
|
|
const result = await utils.postRequestPromise(
|
|
ctx,
|
|
`${BASE_URL}/api/post`,
|
|
postData,
|
|
null,
|
|
postData.length,
|
|
{wholeCycle: '5s', connectionAndInactivity: '3s'},
|
|
authToken,
|
|
false,
|
|
{'Content-Type': 'application/json'}
|
|
);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.response.statusCode).toBe(200);
|
|
expect(JSON.parse(result.body)).toEqual({received: {test: 'data'}});
|
|
});
|
|
|
|
test.concurrent('handles post with custom headers', async () => {
|
|
const postData = JSON.stringify({test: 'data'});
|
|
const customHeaders = {
|
|
'X-Custom-Header': 'test-value',
|
|
'Content-Type': 'application/json'
|
|
};
|
|
|
|
const result = await utils.postRequestPromise(
|
|
ctx,
|
|
`${BASE_URL}/api/post`,
|
|
postData,
|
|
null,
|
|
postData.length,
|
|
{wholeCycle: '5s', connectionAndInactivity: '3s'},
|
|
null,
|
|
false,
|
|
customHeaders
|
|
);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.response.statusCode).toBe(200);
|
|
expect(JSON.parse(result.body)).toEqual({received: {test: 'data'}});
|
|
});
|
|
|
|
test.concurrent('handles post with stream data', async () => {
|
|
const postData = JSON.stringify({test: 'stream-data'});
|
|
const postStream = new Readable({
|
|
read() {
|
|
this.push(postData);
|
|
this.push(null);
|
|
}
|
|
});
|
|
|
|
const result = await utils.postRequestPromise(
|
|
ctx,
|
|
`${BASE_URL}/api/post`,
|
|
null,
|
|
postStream,
|
|
postData.length,
|
|
{wholeCycle: '5s', connectionAndInactivity: '3s'},
|
|
null,
|
|
false,
|
|
{'Content-Type': 'application/json'}
|
|
);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.response.statusCode).toBe(200);
|
|
expect(JSON.parse(result.body)).toEqual({received: {test: 'stream-data'}});
|
|
});
|
|
|
|
test.concurrent('throws error on wholeCycle timeout during post', async () => {
|
|
const postData = JSON.stringify({test: 'data'});
|
|
|
|
await expect(
|
|
utils.postRequestPromise(
|
|
ctx,
|
|
`${BASE_URL}/api/timeout`,
|
|
postData,
|
|
null,
|
|
postData.length,
|
|
{wholeCycle: '1s', connectionAndInactivity: '5s'},
|
|
null,
|
|
false,
|
|
{'Content-Type': 'application/json'}
|
|
)
|
|
).rejects.toMatchObject({code: 'ETIMEDOUT'});
|
|
});
|
|
|
|
test.concurrent('blocks external post requests when configured', async () => {
|
|
const mockCtx = createMockContext({
|
|
'externalRequest.action': {
|
|
allow: false,
|
|
blockPrivateIP: false,
|
|
proxyUrl: '',
|
|
proxyUser: {
|
|
username: '',
|
|
password: ''
|
|
},
|
|
proxyHeaders: {}
|
|
}
|
|
});
|
|
|
|
const postData = JSON.stringify({test: 'data'});
|
|
|
|
await expect(
|
|
utils.postRequestPromise(
|
|
mockCtx,
|
|
'https://example.com/api/post',
|
|
postData,
|
|
null,
|
|
postData.length,
|
|
{wholeCycle: '5s', connectionAndInactivity: '3s'},
|
|
null,
|
|
false,
|
|
{'Content-Type': 'application/json'}
|
|
)
|
|
).rejects.toThrow('Block external request. See externalRequest config options');
|
|
});
|
|
|
|
test.concurrent('allows post request when URL is in JWT token', async () => {
|
|
const mockCtx = createMockContext({
|
|
'externalRequest.action': {
|
|
allow: false,
|
|
blockPrivateIP: false,
|
|
proxyUrl: '',
|
|
proxyUser: {
|
|
username: '',
|
|
password: ''
|
|
},
|
|
proxyHeaders: {}
|
|
},
|
|
'externalRequest.directIfIn': {
|
|
allowList: [],
|
|
jwtToken: true
|
|
}
|
|
});
|
|
|
|
const postData = JSON.stringify({test: 'data'});
|
|
|
|
const result = await utils.postRequestPromise(
|
|
mockCtx,
|
|
`${BASE_URL}/api/post`,
|
|
postData,
|
|
null,
|
|
postData.length,
|
|
{wholeCycle: '5s', connectionAndInactivity: '3s'},
|
|
null,
|
|
true, // Indicate URL is from JWT token
|
|
{'Content-Type': 'application/json'}
|
|
);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.response.statusCode).toBe(200);
|
|
expect(JSON.parse(result.body)).toEqual({received: {test: 'data'}});
|
|
});
|
|
|
|
test.concurrent('applies gzip setting to POST requests', async () => {
|
|
const mockCtx = createMockContext({
|
|
'services.CoAuthoring.requestDefaults': {
|
|
headers: {'User-Agent': 'Node.js/6.13'},
|
|
gzip: false,
|
|
rejectUnauthorized: false
|
|
}
|
|
});
|
|
|
|
const postData = JSON.stringify({test: 'data'});
|
|
|
|
const response = await utils.postRequestPromise(
|
|
mockCtx,
|
|
`${BASE_URL}/api/mirror`,
|
|
postData,
|
|
null,
|
|
postData.length,
|
|
{wholeCycle: '2s'},
|
|
null,
|
|
false,
|
|
{'Content-Type': 'application/json'}
|
|
);
|
|
|
|
// Parse the response body assuming it's JSON
|
|
const responseBody = JSON.parse(response.body);
|
|
|
|
expect(responseBody.headers?.['accept-encoding'] === 'identity' || responseBody.headers?.['accept-encoding'] === undefined).toBe(true);
|
|
});
|
|
|
|
test.concurrent('applies forever setting to POST requests', async () => {
|
|
const mockCtx = createMockContext({
|
|
'services.CoAuthoring.requestDefaults': {
|
|
headers: {'User-Agent': 'Node.js/6.13'},
|
|
forever: true,
|
|
rejectUnauthorized: false
|
|
}
|
|
});
|
|
|
|
const postData = JSON.stringify({test: 'data'});
|
|
|
|
const response = await utils.postRequestPromise(
|
|
mockCtx,
|
|
`${BASE_URL}/api/mirror`,
|
|
postData,
|
|
null,
|
|
postData.length,
|
|
{wholeCycle: '2s'},
|
|
null,
|
|
false,
|
|
{'Content-Type': 'application/json'}
|
|
);
|
|
|
|
// Parse the response body assuming it's JSON
|
|
const responseBody = JSON.parse(response.body);
|
|
|
|
// When forever is true, connection should be 'keep-alive'
|
|
expect(responseBody.headers?.connection?.toLowerCase()).toMatch(/keep-alive/i);
|
|
});
|
|
|
|
test.concurrent('successfully routes POST request through a real proxy', async () => {
|
|
try {
|
|
// Create context with proxy configuration
|
|
const mockCtx = createMockContext({
|
|
'externalRequest.action': {
|
|
allow: true,
|
|
blockPrivateIP: false,
|
|
proxyUrl: PROXY_URL,
|
|
proxyUser: {
|
|
username: 'proxyuser',
|
|
password: 'proxypass'
|
|
},
|
|
proxyHeaders: {
|
|
'X-Post-Proxy': 'post-proxy-value'
|
|
}
|
|
}
|
|
});
|
|
|
|
// Test POST request
|
|
const postData = JSON.stringify({nested: {test: 'complex-data'}});
|
|
|
|
const postResult = await utils.postRequestPromise(
|
|
mockCtx,
|
|
`${BASE_URL}/api/post`,
|
|
postData,
|
|
null,
|
|
postData.length,
|
|
{wholeCycle: '5s', connectionAndInactivity: '3s'},
|
|
'auth-token', // With auth token
|
|
false,
|
|
{'Content-Type': 'application/json', 'X-Custom': 'test-value'}
|
|
);
|
|
|
|
// Verify the post request
|
|
expect(postResult).toBeDefined();
|
|
expect(postResult.response.statusCode).toBe(200);
|
|
expect(JSON.parse(postResult.body)).toEqual({
|
|
received: {nested: {test: 'complex-data'}}
|
|
});
|
|
|
|
// Verify proxy headers and auth
|
|
const postProxyRequest = proxiedRequests.find(r => r.method === 'POST' && r.url.includes('/api/post'));
|
|
|
|
expect(postProxyRequest).toBeDefined();
|
|
// Check for Base64 encoded authorization header (starts with "Basic ")
|
|
expect(postProxyRequest.headers['proxy-authorization']).toMatch(/^Basic /);
|
|
expect(postProxyRequest.headers['x-post-proxy']).toBe('post-proxy-value');
|
|
expect(postProxyRequest.headers['content-type']).toBe('application/json');
|
|
expect(postProxyRequest.headers['x-custom']).toBe('test-value');
|
|
expect(postProxyRequest.headers['authorization']).toContain('Bearer auth-token');
|
|
|
|
// Verify post body was correctly sent
|
|
expect(JSON.parse(postProxyRequest.body)).toEqual({nested: {test: 'complex-data'}});
|
|
} finally {
|
|
// No need to clean up proxy server here anymore
|
|
}
|
|
});
|
|
|
|
test.concurrent('should return 205 status code for /status/205', async () => {
|
|
try {
|
|
const postData = JSON.stringify({test: 'data'});
|
|
|
|
await utils.postRequestPromise(
|
|
ctx,
|
|
`${BASE_URL}/api/status/205`,
|
|
postData,
|
|
null,
|
|
postData.length,
|
|
{wholeCycle: '5s', connectionAndInactivity: '3s'},
|
|
null,
|
|
false,
|
|
{'Content-Type': 'application/json'}
|
|
);
|
|
throw new Error('Expected an error to be thrown');
|
|
} catch (error) {
|
|
expect(error.message).toContain('Error response:');
|
|
expect(error.statusCode).toBe(205);
|
|
}
|
|
});
|
|
});
|
|
});
|