mirror of
https://github.com/ONLYOFFICE/server.git
synced 2026-02-10 18:05:07 +08:00
[feature] Refactor tests, proxy, timeouts
This commit is contained in:
@ -315,9 +315,11 @@ function addExternalRequestOptions(ctx, uri, isInJwtToken, options, httpAgentOpt
|
|||||||
const proxyUrl = tenExternalRequestAction.proxyUrl;
|
const proxyUrl = tenExternalRequestAction.proxyUrl;
|
||||||
const parsedProxyUrl = url.parse(proxyUrl);
|
const parsedProxyUrl = url.parse(proxyUrl);
|
||||||
|
|
||||||
options.proxy.host = parsedProxyUrl.hostname;
|
options.proxy = {
|
||||||
options.proxy.port = parsedProxyUrl.port;
|
host: parsedProxyUrl.hostname,
|
||||||
options.proxy.protocol = parsedProxyUrl.protocol;
|
port: parsedProxyUrl.port,
|
||||||
|
protocol: parsedProxyUrl.protocol
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tenExternalRequestAction.proxyUser?.username) {
|
if (tenExternalRequestAction.proxyUser?.username) {
|
||||||
@ -375,10 +377,10 @@ async function downloadUrlPromise(ctx, uri, optTimeout, optLimit, opt_Authorizat
|
|||||||
const httpAgentOptions = { ...http.globalAgent.options, ...options};
|
const httpAgentOptions = { ...http.globalAgent.options, ...options};
|
||||||
changeOptionsForCompatibilityWithRequest(options, httpAgentOptions, httpsAgentOptions);
|
changeOptionsForCompatibilityWithRequest(options, httpAgentOptions, httpsAgentOptions);
|
||||||
|
|
||||||
if (optTimeout.connectionAndInactivity) {
|
// if (optTimeout.connectionAndInactivity) {
|
||||||
httpAgentOptions.timeout = ms(optTimeout.connectionAndInactivity);
|
// httpAgentOptions.timeout = ms(optTimeout.connectionAndInactivity);
|
||||||
httpsAgentOptions.timeout = ms(optTimeout.connectionAndInactivity);
|
// httpsAgentOptions.timeout = ms(optTimeout.connectionAndInactivity);
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (!addExternalRequestOptions(ctx, uri, opt_filterPrivate, options, httpAgentOptions, httpsAgentOptions)) {
|
if (!addExternalRequestOptions(ctx, uri, opt_filterPrivate, options, httpAgentOptions, httpsAgentOptions)) {
|
||||||
throw new Error('Block external request. See externalRequest config options');
|
throw new Error('Block external request. See externalRequest config options');
|
||||||
@ -405,6 +407,7 @@ async function downloadUrlPromise(ctx, uri, optTimeout, optLimit, opt_Authorizat
|
|||||||
headers,
|
headers,
|
||||||
validateStatus: (status) => status >= 200 && status < 300,
|
validateStatus: (status) => status >= 200 && status < 300,
|
||||||
signal: optTimeout.wholeCycle && AbortSignal.timeout ? AbortSignal.timeout(ms(optTimeout.wholeCycle)) : undefined,
|
signal: optTimeout.wholeCycle && AbortSignal.timeout ? AbortSignal.timeout(ms(optTimeout.wholeCycle)) : undefined,
|
||||||
|
timeout: optTimeout.connectionAndInactivity ? ms(optTimeout.connectionAndInactivity) : undefined,
|
||||||
// cancelToken: new axios.CancelToken(cancel => {
|
// cancelToken: new axios.CancelToken(cancel => {
|
||||||
// if (optTimeout?.wholeCycle) {
|
// if (optTimeout?.wholeCycle) {
|
||||||
// setTimeout(() => {
|
// setTimeout(() => {
|
||||||
@ -444,106 +447,16 @@ async function downloadUrlPromise(ctx, uri, optTimeout, optLimit, opt_Authorizat
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if('ERR_CANCELED' === err.code) {
|
if('ERR_CANCELED' === err.code) {
|
||||||
err.code = 'ETIMEDOUT';
|
err.code = 'ETIMEDOUT';
|
||||||
|
} else if(['ECONNABORTED', 'ECONNRESET'].includes(err.code)) {
|
||||||
|
err.code = 'ESOCKETTIMEDOUT';
|
||||||
|
}
|
||||||
|
if (err.status){
|
||||||
|
err.statusCode = err.status;
|
||||||
}
|
}
|
||||||
// if (axios.isCancel(err)) {
|
|
||||||
// const error = new Error(err.message);
|
|
||||||
// error.code = 'ETIMEDOUT';
|
|
||||||
// throw error;
|
|
||||||
// }
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processResponseStream(ctx, { response, sizeLimit, uri, opt_streamWriter, contentLength, timeout }) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const hash = crypto.createHash('sha256');
|
|
||||||
let buffer = [];
|
|
||||||
let bufferLength = 0;
|
|
||||||
let timeoutId;
|
|
||||||
|
|
||||||
const stream = response.data;
|
|
||||||
|
|
||||||
if (timeout) {
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
stream.destroy();
|
|
||||||
reject(new Error(`ETIMEDOUT: ${timeout}`));
|
|
||||||
}, timeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
stream.on('data', chunk => {
|
|
||||||
hash.update(chunk);
|
|
||||||
bufferLength += chunk.length;
|
|
||||||
if (bufferLength > sizeLimit) {
|
|
||||||
stream.destroy();
|
|
||||||
throw new Error('EMSGSIZE: Error response body.length');
|
|
||||||
}
|
|
||||||
if (!opt_streamWriter) buffer.push(chunk);
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on('error', reject);
|
|
||||||
|
|
||||||
stream.on('end', () => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
const result = {
|
|
||||||
response,
|
|
||||||
sha256: hash.digest('hex'),
|
|
||||||
body: opt_streamWriter ? null : Buffer.concat(buffer)
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!opt_streamWriter && contentLength && result.body?.length !== parseInt(contentLength)) {
|
|
||||||
ctx.logger.warn('Body size mismatch: %s (expected %s, got %d)',
|
|
||||||
uri, contentLength, result.body?.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(opt_streamWriter ? undefined : result);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (opt_streamWriter) {
|
|
||||||
pipeline(stream, opt_streamWriter)
|
|
||||||
.catch(reject);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processErrorResponseStream(response, { sizeLimit, opt_streamWriter, timeout }) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const hash = crypto.createHash('sha256');
|
|
||||||
let bufferLength = 0;
|
|
||||||
let timeoutId;
|
|
||||||
|
|
||||||
const stream = response.data;
|
|
||||||
|
|
||||||
if (timeout) {
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
stream.destroy();
|
|
||||||
reject(new Error(`ETIMEDOUT: ${timeout}`));
|
|
||||||
}, timeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
stream.on('data', chunk => {
|
|
||||||
hash.update(chunk);
|
|
||||||
bufferLength += chunk.length;
|
|
||||||
if (bufferLength > sizeLimit) {
|
|
||||||
stream.destroy();
|
|
||||||
reject(new Error('EMSGSIZE'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on('error', reject);
|
|
||||||
|
|
||||||
stream.on('end', () => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
resolve({
|
|
||||||
response,
|
|
||||||
sha256: hash.digest('hex')
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
pipeline(stream, opt_streamWriter)
|
|
||||||
.catch(reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function postRequestPromise(ctx, uri, postData, postDataStream, postDataSize, optTimeout, opt_Authorization, opt_isInJwtToken, opt_headers) {
|
async function postRequestPromise(ctx, uri, postData, postDataStream, postDataSize, optTimeout, opt_Authorization, opt_isInJwtToken, opt_headers) {
|
||||||
const tenTenantRequestDefaults = ctx.getCfg('services.CoAuthoring.requestDefaults', cfgRequestDefaults);
|
const tenTenantRequestDefaults = ctx.getCfg('services.CoAuthoring.requestDefaults', cfgRequestDefaults);
|
||||||
const tenTokenOutboxHeader = ctx.getCfg('services.CoAuthoring.token.outbox.header', cfgTokenOutboxHeader);
|
const tenTokenOutboxHeader = ctx.getCfg('services.CoAuthoring.token.outbox.header', cfgTokenOutboxHeader);
|
||||||
|
|||||||
@ -15,9 +15,13 @@ const ctx = new operationContext.Context();
|
|||||||
// Test server setup
|
// Test server setup
|
||||||
let server;
|
let server;
|
||||||
let testServer;
|
let testServer;
|
||||||
|
let proxyServer;
|
||||||
const PORT = 3456;
|
const PORT = 3456;
|
||||||
const BASE_URL = `http://localhost:${PORT}`;
|
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;
|
const getStatusCode = (response) => response.statusCode || response.status;
|
||||||
|
|
||||||
@ -83,7 +87,7 @@ function createMockContext(overrides = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('HTTP Request Integration Tests', () => {
|
describe('HTTP Request Unit Tests', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Setup test Express server
|
// Setup test Express server
|
||||||
const app = express();
|
const app = express();
|
||||||
@ -108,6 +112,46 @@ describe('HTTP Request Integration Tests', () => {
|
|||||||
app.get('/api/error', (req, res) => {
|
app.get('/api/error', (req, res) => {
|
||||||
res.status(500).json({ error: 'Internal Server Error' });
|
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 });
|
||||||
|
}, 2000); // 2 seconds 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();
|
||||||
|
}, 2000);
|
||||||
|
}, 1000);
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
// POST endpoint
|
// POST endpoint
|
||||||
app.post('/api/post', express.json(), (req, res) => {
|
app.post('/api/post', express.json(), (req, res) => {
|
||||||
@ -192,11 +236,214 @@ describe('HTTP Request Integration Tests', () => {
|
|||||||
// Start server
|
// Start server
|
||||||
server = http.createServer(app);
|
server = http.createServer(app);
|
||||||
await new Promise(resolve => server.listen(PORT, resolve));
|
await new Promise(resolve => server.listen(PORT, resolve));
|
||||||
|
|
||||||
|
// Setup proxy server
|
||||||
|
const proxyApp = express();
|
||||||
|
proxyApp.use((req, res, next) => {
|
||||||
|
// 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 () => {
|
afterAll(async () => {
|
||||||
// Cleanup server
|
// Cleanup servers
|
||||||
await new Promise(resolve => server.close(resolve));
|
await new Promise(resolve => server.close(resolve));
|
||||||
|
if (proxyServer) {
|
||||||
|
await new Promise(resolve => proxyServer.close(resolve));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('specific timeout behaviors', () => {
|
||||||
|
test('connectionAndInactivity triggers when server delays response headers', async () => {
|
||||||
|
try {
|
||||||
|
await utils.downloadUrlPromise(
|
||||||
|
ctx,
|
||||||
|
`${BASE_URL}/api/slow-headers`,
|
||||||
|
{ connectionAndInactivity: '1s' }, // 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('connectionAndInactivity does not trigger when longer than server delay', async () => {
|
||||||
|
const result = await utils.downloadUrlPromise(
|
||||||
|
ctx,
|
||||||
|
`${BASE_URL}/api/slow-headers`,
|
||||||
|
{ connectionAndInactivity: '3s' }, // connectionAndInactivity longer than the server delay (2s)
|
||||||
|
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('wholeCycle triggers even when server starts sending data but does not complete', async () => {
|
||||||
|
try {
|
||||||
|
await utils.downloadUrlPromise(
|
||||||
|
ctx,
|
||||||
|
`${BASE_URL}/api/partial-response`,
|
||||||
|
{ wholeCycle: '2s' }, // 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('connectionAndInactivity triggers when server stops sending data midway', async () => {
|
||||||
|
try {
|
||||||
|
await utils.downloadUrlPromise(
|
||||||
|
ctx,
|
||||||
|
`${BASE_URL}/api/slow-body`,
|
||||||
|
{ connectionAndInactivity: '1500ms' }, // 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('connectionAndInactivity does not trigger when longer than inactivity periods', async () => {
|
||||||
|
const result = await utils.downloadUrlPromise(
|
||||||
|
ctx,
|
||||||
|
`${BASE_URL}/api/slow-body`,
|
||||||
|
{ connectionAndInactivity: '2100ms' }, // 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('wholeCycle does not trigger when longer than total response time', async () => {
|
||||||
|
const result = await utils.downloadUrlPromise(
|
||||||
|
ctx,
|
||||||
|
`${BASE_URL}/api/slow-body`,
|
||||||
|
{ wholeCycle: '7s' },
|
||||||
|
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', () => {
|
describe('downloadUrlPromise', () => {
|
||||||
@ -229,10 +476,9 @@ describe('HTTP Request Integration Tests', () => {
|
|||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
fail('Expected an error to be thrown');
|
throw new Error('Expected an error to be thrown');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expect(error.message).toContain('canceled');
|
expect(error.code).toBe('ESOCKETTIMEDOUT');
|
||||||
expect(error.code).toBe('ETIMEDOUT');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -248,9 +494,8 @@ describe('HTTP Request Integration Tests', () => {
|
|||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
fail('Expected an error to be thrown');
|
throw new Error('Expected an error to be thrown');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expect(error.message).toContain('canceled');
|
|
||||||
expect(error.code).toBe('ETIMEDOUT');
|
expect(error.code).toBe('ETIMEDOUT');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -298,13 +543,9 @@ describe('HTTP Request Integration Tests', () => {
|
|||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
// Old implementation path
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
expect(getStatusCode(result.response)).toBe(302);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// New implementation path (Axios)
|
// New implementation path (Axios)
|
||||||
expect(error.message).toMatch(/(?:Request failed with status code 302|Error response: statusCode:302)/);
|
expect(error.statusCode).toBe(302)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -335,11 +576,9 @@ describe('HTTP Request Integration Tests', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Old implementation path
|
// Old implementation path
|
||||||
expect(result).toBeDefined();
|
|
||||||
expect(getStatusCode(result.response)).toBe(302);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// New implementation path (Axios)
|
// New implementation path (Axios)
|
||||||
expect(error.message).toMatch(/(?:Request failed with status code 302|Error response: statusCode:302)/);
|
expect(error.statusCode).toBe(302);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -353,7 +592,7 @@ describe('HTTP Request Integration Tests', () => {
|
|||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
)).rejects.toThrow(/(?:Error response: statusCode:500|Request failed with status code 500)/);
|
)).rejects.toMatchObject({ code: 'ERR_BAD_RESPONSE' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('throws error when content-length exceeds limit', async () => {
|
test('throws error when content-length exceeds limit', async () => {
|
||||||
@ -367,9 +606,8 @@ describe('HTTP Request Integration Tests', () => {
|
|||||||
false,
|
false,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
fail('Expected an error to be thrown');
|
throw new Error('Expected an error to be thrown');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expect(error.message).toContain('EMSGSIZE:');
|
|
||||||
expect(error.code).toBe('EMSGSIZE');
|
expect(error.code).toBe('EMSGSIZE');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -383,9 +621,8 @@ describe('HTTP Request Integration Tests', () => {
|
|||||||
false,
|
false,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
fail('Expected an error to be thrown');
|
throw new Error('Expected an error to be thrown');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expect(error.message).toContain('EMSGSIZE:');
|
|
||||||
expect(error.code).toBe('EMSGSIZE');
|
expect(error.code).toBe('EMSGSIZE');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -403,9 +640,8 @@ describe('HTTP Request Integration Tests', () => {
|
|||||||
true
|
true
|
||||||
);
|
);
|
||||||
const receivedData = await buffer(stream);
|
const receivedData = await buffer(stream);
|
||||||
fail('Expected an error to be thrown');
|
throw new Error('Expected an error to be thrown');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expect(error.message).toContain('EMSGSIZE:');
|
|
||||||
expect(error.code).toBe('EMSGSIZE');
|
expect(error.code).toBe('EMSGSIZE');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@ -420,9 +656,8 @@ describe('HTTP Request Integration Tests', () => {
|
|||||||
true
|
true
|
||||||
);
|
);
|
||||||
const receivedData = await buffer(stream);
|
const receivedData = await buffer(stream);
|
||||||
fail('Expected an error to be thrown');
|
throw new Error('Expected an error to be thrown');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expect(error.message).toContain('EMSGSIZE:');
|
|
||||||
expect(error.code).toBe('EMSGSIZE');
|
expect(error.code).toBe('EMSGSIZE');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -608,6 +843,56 @@ describe('HTTP Request Integration Tests', () => {
|
|||||||
expect(body.headers).toMatchObject({...defaultHeaders, ...customHeaders});
|
expect(body.headers).toMatchObject({...defaultHeaders, ...customHeaders});
|
||||||
expect(body.query).toMatchObject(customQueryParams);
|
expect(body.query).toMatchObject(customQueryParams);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('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('handles binary data correctly', async () => {
|
test('handles binary data correctly', async () => {
|
||||||
@ -669,6 +954,7 @@ describe('HTTP Request Integration Tests', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Use rejects.toThrow to test the error message
|
||||||
await expect(utils.downloadUrlPromise(
|
await expect(utils.downloadUrlPromise(
|
||||||
mockCtx,
|
mockCtx,
|
||||||
'https://example.com/test',
|
'https://example.com/test',
|
||||||
@ -749,50 +1035,6 @@ describe('HTTP Request Integration Tests', () => {
|
|||||||
expect(JSON.parse(result.body.toString())).toEqual({ success: true });
|
expect(JSON.parse(result.body.toString())).toEqual({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('request with proxy configuration', async () => {
|
|
||||||
const mockCtx = createMockContext({
|
|
||||||
'externalRequest.action': {
|
|
||||||
"allow": true,
|
|
||||||
"blockPrivateIP": true,
|
|
||||||
"proxyUrl": "http://proxy.example.com:8080",
|
|
||||||
"proxyUser": {
|
|
||||||
"username": "testuser",
|
|
||||||
"password": "testpass"
|
|
||||||
},
|
|
||||||
"proxyHeaders": {
|
|
||||||
"X-Custom-Header": "test-value"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await utils.downloadUrlPromise(
|
|
||||||
mockCtx,
|
|
||||||
'https://example.com/test',
|
|
||||||
{ wholeCycle: '5s', connectionAndInactivity: '3s' },
|
|
||||||
1024 * 1024,
|
|
||||||
null,
|
|
||||||
false,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
fail('Expected request to fail');
|
|
||||||
} catch (error) {
|
|
||||||
// Different error structures between implementations
|
|
||||||
const headers = error.config?.headers || error.request?._headers || error._headers;
|
|
||||||
if (headers) {
|
|
||||||
expect(headers).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
'proxy-authorization': expect.stringContaining('testuser:testpass'),
|
|
||||||
'X-Custom-Header': 'test-value'
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// If headers aren't available, at least verify the error occurred
|
|
||||||
expect(error).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('postRequestPromise', () => {
|
describe('postRequestPromise', () => {
|
||||||
test('successfully posts data', async () => {
|
test('successfully posts data', async () => {
|
||||||
const postData = JSON.stringify({ test: 'data' });
|
const postData = JSON.stringify({ test: 'data' });
|
||||||
@ -827,7 +1069,7 @@ describe('HTTP Request Integration Tests', () => {
|
|||||||
null,
|
null,
|
||||||
false,
|
false,
|
||||||
{ 'Content-Type': 'application/json' }
|
{ 'Content-Type': 'application/json' }
|
||||||
)).rejects.toThrow(/(?:ESOCKETTIMEDOUT|timeout of 500ms exceeded)/);
|
)).rejects.toMatchObject({ code: 'ESOCKETTIMEDOUT' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handles post with Authorization header', async () => {
|
test('handles post with Authorization header', async () => {
|
||||||
@ -914,7 +1156,7 @@ describe('HTTP Request Integration Tests', () => {
|
|||||||
null,
|
null,
|
||||||
false,
|
false,
|
||||||
{ 'Content-Type': 'application/json' }
|
{ 'Content-Type': 'application/json' }
|
||||||
)).rejects.toThrow(/(?:ETIMEDOUT|ESOCKETTIMEDOUT|whole request cycle timeout: 1s|Whole request cycle timeout: 1s)/);
|
)).rejects.toMatchObject({ code: 'ETIMEDOUT' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('blocks external post requests when configured', async () => {
|
test('blocks external post requests when configured', async () => {
|
||||||
@ -983,54 +1225,6 @@ describe('HTTP Request Integration Tests', () => {
|
|||||||
expect(JSON.parse(result.body)).toEqual({ received: { test: 'data' } });
|
expect(JSON.parse(result.body)).toEqual({ received: { test: 'data' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handles post with proxy configuration', async () => {
|
|
||||||
const mockCtx = createMockContext({
|
|
||||||
'externalRequest.action': {
|
|
||||||
"allow": true,
|
|
||||||
"blockPrivateIP": true,
|
|
||||||
"proxyUrl": "http://proxy.example.com:8080",
|
|
||||||
"proxyUser": {
|
|
||||||
"username": "testuser",
|
|
||||||
"password": "testpass"
|
|
||||||
},
|
|
||||||
"proxyHeaders": {
|
|
||||||
"X-Custom-Proxy-Header": "test-value"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const postData = JSON.stringify({ test: 'data' });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await utils.postRequestPromise(
|
|
||||||
mockCtx,
|
|
||||||
'https://example.com/api/post',
|
|
||||||
postData,
|
|
||||||
null,
|
|
||||||
postData.length,
|
|
||||||
{ wholeCycle: '5s', connectionAndInactivity: '3s' },
|
|
||||||
null,
|
|
||||||
false,
|
|
||||||
{ 'Content-Type': 'application/json' }
|
|
||||||
);
|
|
||||||
fail('Expected request to fail');
|
|
||||||
} catch (error) {
|
|
||||||
// Different error structures between implementations
|
|
||||||
const headers = error.config?.headers || error.request?._headers || error._headers;
|
|
||||||
if (headers) {
|
|
||||||
expect(headers).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
'proxy-authorization': expect.stringContaining('testuser:testpass'),
|
|
||||||
'X-Custom-Proxy-Header': 'test-value',
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// If headers aren't available, at least verify the error occurred
|
|
||||||
expect(error).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('applies gzip setting to POST requests', async () => {
|
test('applies gzip setting to POST requests', async () => {
|
||||||
// Setup a simple server that captures headers
|
// Setup a simple server that captures headers
|
||||||
let capturedHeaders = {};
|
let capturedHeaders = {};
|
||||||
@ -1119,5 +1313,65 @@ describe('HTTP Request Integration Tests', () => {
|
|||||||
await new Promise(resolve => testServer.close(resolve));
|
await new Promise(resolve => testServer.close(resolve));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('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
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user