[test] Add tests for downloadUrlPromise, postRequestPromise

This commit is contained in:
Pavel Ostrovskij
2025-03-25 13:40:33 +03:00
parent bfa3c76c25
commit addc7da26b
4 changed files with 369 additions and 53 deletions

View File

@ -320,7 +320,8 @@
"User-Agent": "Node.js/6.13", "User-Agent": "Node.js/6.13",
"Connection": "Keep-Alive" "Connection": "Keep-Alive"
}, },
"decompress": true "decompress": true,
"rejectUnauthorized": true
}, },
"autoAssembly": { "autoAssembly": {
"enable": false, "enable": false,

View File

@ -372,13 +372,13 @@ async function downloadUrlPromiseWithoutRedirect(ctx, uri, optTimeout, optLimit,
uri = URI.serialize(URI.parse(uri)); uri = URI.serialize(URI.parse(uri));
const connectionAndInactivity = optTimeout?.connectionAndInactivity ? ms(optTimeout.connectionAndInactivity) : undefined; const connectionAndInactivity = optTimeout?.connectionAndInactivity ? ms(optTimeout.connectionAndInactivity) : undefined;
const options = config.util.cloneDeep(tenTenantRequestDefaults); const options = config.util.cloneDeep(tenTenantRequestDefaults);
if (!addExternalRequestOptions(ctx, uri, opt_filterPrivate, options)) { if (!exports.addExternalRequestOptions(ctx, uri, opt_filterPrivate, options)) {
throw new Error('Block external request. See externalRequest config options'); throw new Error('Block external request. See externalRequest config options');
} }
const protocol = new URL(uri).protocol; const protocol = new URL(uri).protocol;
if (!options.httpsAgent && !options.httpAgent) { if (!options.httpsAgent && !options.httpAgent) {
const agentOptions = { ...https.globalAgent.options }; const agentOptions = { ...https.globalAgent.options, rejectUnauthorized: tenTenantRequestDefaults.rejectUnauthorized === false? false : true};
if (protocol === 'https:') { if (protocol === 'https:') {
options.httpsAgent = new https.Agent(agentOptions); options.httpsAgent = new https.Agent(agentOptions);
} else if (protocol === 'http:') { } else if (protocol === 'http:') {
@ -393,7 +393,7 @@ async function downloadUrlPromiseWithoutRedirect(ctx, uri, optTimeout, optLimit,
if (opt_headers) { if (opt_headers) {
Object.assign(headers, opt_headers); Object.assign(headers, opt_headers);
} }
const axiosConfig = { const axiosConfig = {
...options, ...options,
url: uri, url: uri,
@ -405,7 +405,9 @@ async function downloadUrlPromiseWithoutRedirect(ctx, uri, optTimeout, optLimit,
validateStatus: () => true, validateStatus: () => true,
cancelToken: new axios.CancelToken(cancel => { cancelToken: new axios.CancelToken(cancel => {
if (optTimeout?.wholeCycle) { if (optTimeout?.wholeCycle) {
setTimeout(() => cancel(`ETIMEDOUT: ${optTimeout.wholeCycle}`), ms(optTimeout.wholeCycle)); setTimeout(() => {
cancel(`ETIMEDOUT: ${optTimeout.wholeCycle}`);
}, ms(optTimeout.wholeCycle));
} }
}), }),
}; };
@ -564,7 +566,7 @@ async function postRequestPromise(ctx, uri, postData, postDataStream, postDataSi
} }
const protocol = new URL(uri).protocol; const protocol = new URL(uri).protocol;
if (!options.httpsAgent && !options.httpAgent) { if (!options.httpsAgent && !options.httpAgent) {
const agentOptions = { ...https.globalAgent.options }; const agentOptions = { ...https.globalAgent.options, rejectUnauthorized: tenTenantRequestDefaults.rejectUnauthorized === false? false : true};
if (protocol === 'https:') { if (protocol === 'https:') {
options.httpsAgent = new https.Agent(agentOptions); options.httpsAgent = new https.Agent(agentOptions);
} else if (protocol === 'http:') { } else if (protocol === 'http:') {
@ -627,6 +629,7 @@ async function postRequestPromise(ctx, uri, postData, postDataStream, postDataSi
} }
exports.postRequestPromise = postRequestPromise; exports.postRequestPromise = postRequestPromise;
exports.downloadUrlPromise = downloadUrlPromise; exports.downloadUrlPromise = downloadUrlPromise;
exports.addExternalRequestOptions = addExternalRequestOptions;
exports.mapAscServerErrorToOldError = function(error) { exports.mapAscServerErrorToOldError = function(error) {
var res = -1; var res = -1;
switch (error) { switch (error) {

View File

@ -21,6 +21,7 @@
"integration tests with server instance": "cd ./DocService && jest integration/withServerInstance --inject-globals=false --config=../tests/jest.config.js", "integration tests with server instance": "cd ./DocService && jest integration/withServerInstance --inject-globals=false --config=../tests/jest.config.js",
"integration database tests": "cd ./DocService && jest integration/databaseTests --inject-globals=false --config=../tests/jest.config.js", "integration database tests": "cd ./DocService && jest integration/databaseTests --inject-globals=false --config=../tests/jest.config.js",
"tests": "cd ./DocService && jest --inject-globals=false --config=../tests/jest.config.js", "tests": "cd ./DocService && jest --inject-globals=false --config=../tests/jest.config.js",
"tests:dev": "cd ./DocService && jest --inject-globals=false --config=../tests/jest.config.js --watch",
"install:Common": "npm ci --prefix ./Common", "install:Common": "npm ci --prefix ./Common",
"install:DocService": "npm ci --prefix ./DocService", "install:DocService": "npm ci --prefix ./DocService",
"install:FileConverter": "npm ci --prefix ./FileConverter", "install:FileConverter": "npm ci --prefix ./FileConverter",

View File

@ -2,26 +2,15 @@
const { describe, test, expect, beforeEach, afterAll, jest } = require('@jest/globals'); const { describe, test, expect, beforeEach, afterAll, jest } = require('@jest/globals');
const { Readable, Writable } = require('stream'); const { Readable, Writable } = require('stream');
// Setup mocks for axios // Setup mocks for axios
const axiosReal = require('axios');
jest.mock('axios'); jest.mock('axios');
const axios = require('axios'); const axios = require('axios');
const operationContext = require('../../Common/sources/operationContext'); const operationContext = require('../../Common/sources/operationContext');
const utils = require('../../Common/sources/utils'); const utils = require('../../Common/sources/utils');
// Mock axios CancelToken as both a constructor and an object with source method // Assign real CancelToken from the imported axiosReal to the mocked axios
const cancelFn = jest.fn(); axios.CancelToken = axiosReal.CancelToken;
axios.CancelToken = jest.fn().mockImplementation((executor) => { axios.CancelToken.source = axiosReal.CancelToken.source;
// Execute the function passed to CancelToken constructor with our cancel function
if (typeof executor === 'function') {
executor(cancelFn);
}
return { cancel: cancelFn };
});
// Also mock the source method
axios.CancelToken.source = jest.fn().mockReturnValue({
token: 'mock-token',
cancel: jest.fn()
});
// Create operation context for tests // Create operation context for tests
const ctx = new operationContext.Context(); const ctx = new operationContext.Context();
@ -47,9 +36,9 @@ const createMockWriter = () => {
// Common test parameters // Common test parameters
const commonTestParams = { const commonTestParams = {
uri: 'https://example.com/api/data', uri: 'https://example.com/api/data',
timeout: 5000, timeout: { wholeCycle: '500ms', connectionAndInactivity: '200s' },
limit: 1024 * 1024, // 1MB limit: 1024 * 1024, // 1MB
authorization: 'Bearer token123', authorization: 'token123',
filterPrivate: true, filterPrivate: true,
headers: { 'Accept': 'application/json' } headers: { 'Accept': 'application/json' }
}; };
@ -357,51 +346,373 @@ describe('HTTP Request Functionality', () => {
}); });
test('handles timeout correctly', async () => { test('handles timeout correctly', async () => {
// Reset mocks jest.useFakeTimers();
jest.clearAllMocks(); // Mock a never-resolving request
// Setup axios mock to simulate a timeout by triggering the CancelToken cancel function
axios.mockImplementation((config) => { axios.mockImplementation((config) => {
// Explicitly check for timeout config return new Promise((_, reject) => {
expect(config).toHaveProperty('timeout'); if (config.cancelToken) {
expect(config).toHaveProperty('cancelToken'); config.cancelToken.promise.then(cancel => {
reject(new axiosReal.Cancel(cancel.message));
// Return a promise that never resolves - the cancel function });
// will be called by setTimeout in the implementation }
return new Promise(() => {
// setTimeout is used in the real implementation, but we don't need to do anything here
// as the cancelFn mock will be called by the code under test
}); });
}); });
const promise = utils.downloadUrlPromise(
ctx,
'https://example.com/timeout-test',
{ wholeCycle: '500ms', connectionAndInactivity: '200s' },
1024,
null,
false,
null,
null
);
// Fast-forward exactly 1 second
jest.advanceTimersByTime(1000);
await Promise.resolve(); // Flush pending promises
await expect(promise).rejects.toThrow('ETIMEDOUT: 500ms');
jest.useRealTimers();
});
test('throws an error on max redirects limit reached', async () => {
// Create a counter to track calls
let callCount = 0;
// Mock Implementation will call canceFn after "timeout" - simulate this // Mock redirect response
cancelFn.mockImplementation((message) => { const redirectResponse = {
// This is what happens when the timeout occurs and cancel is called status: 302,
const err = new Error(message || 'ETIMEDOUT: timeout'); headers: {
err.isCancel = true; // axios sets this flag location: 'https://example.com/redirected'
throw err; }
};
// Mock success response after redirect
const successData = JSON.stringify({ success: true });
const successResponse = {
status: 200,
headers: {
'content-type': 'application/json',
'content-length': String(Buffer.byteLength(successData, 'utf8'))
},
data: createMockStream(successData)
};
// Reset mocks and implement redirect then success
jest.clearAllMocks();
axios.mockImplementation(() => {
if (callCount < 12) {
callCount++;
// First call - simulate redirect by throwing error with response
const err = new Error('Redirect');
err.response = redirectResponse;
return Promise.reject(err);
} else {
// Second call - return success
return Promise.resolve(successResponse);
}
}); });
// Call function and expect timeout error // Call function with original URL
await expect(utils.downloadUrlPromise( await expect(utils.downloadUrlPromise(
ctx, ctx,
'https://example.com/slow-response', 'https://example.com/original',
{ connection: '100ms', inactivity: '100ms', wholeCycle: '200ms' }, // short timeout commonTestParams.timeout,
commonTestParams.limit, commonTestParams.limit,
null, null,
false, false,
null, null,
null null
)).rejects.toThrow(/ETIMEDOUT/); )).rejects.toThrow('Redirect');
expect(axios).toHaveBeenCalledTimes(11);
});
test('should block external request', async () => {
const addExternalRequestOptionsMock = jest.spyOn(utils, 'addExternalRequestOptions');
addExternalRequestOptionsMock.mockReturnValue(false);
await expect(utils.downloadUrlPromise(
ctx,
'https://example.com/original',
commonTestParams.timeout,
commonTestParams.limit,
null,
false,
null,
null
)).rejects.toThrow('Block external request. See externalRequest config options');
addExternalRequestOptionsMock.mockRestore();
});
test('should throw error on redirect with followRedirect=false', async () => {
let callCount = 0;
// Mock redirect response
const redirectResponse = {
status: 302,
headers: {
location: 'https://example.com/redirected'
}
};
// Mock success response after redirect
const successData = JSON.stringify({ success: true });
const successResponse = {
status: 200,
headers: {
'content-type': 'application/json',
'content-length': String(Buffer.byteLength(successData, 'utf8'))
},
data: createMockStream(successData)
};
// Reset mocks and implement redirect then success
jest.clearAllMocks();
axios.mockImplementation(() => {
if (callCount < 2) {
callCount++;
// First call - simulate redirect by throwing error with response
const err = new Error('Redirect');
err.response = redirectResponse;
return Promise.reject(err);
} else {
// Second call - return success
return Promise.resolve(successResponse);
}
});
const ctx = {
getCfg: function(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": true,
"proxyUrl": "",
"proxyUser": {
"username": "",
"password": ""
},
"proxyHeaders": {
}
};
case 'services.CoAuthoring.request-filtering-agent':
return {
"allowPrivateIPAddress": false,
"allowMetaIPAddress": false
}
case 'externalRequest.directIfIn':
return {
"allowList": [],
"jwtToken": true
}
}
},
logger: {
debug: function() {},
}
}
await expect(utils.downloadUrlPromise(
ctx,
'https://example.com/original',
commonTestParams.timeout,
commonTestParams.limit,
null,
false,
null,
null
)).rejects.toThrow('Redirect');
});
});
describe('postRequestPromise', () => {
test('properly sends post data and returns response', async () => {
// Mock successful response
const mockData = { success: true };
const mockResponse = {
status: 200,
headers: { 'content-type': 'application/json' },
data: mockData
};
// Setup axios mock
axios.mockImplementation(() => Promise.resolve(mockResponse));
// Call the function
const result = await utils.postRequestPromise(
ctx,
'https://example.com/data',
{ key: 'value' },
null,
null,
commonTestParams.timeout,
commonTestParams.authorization,
false,
null
);
// Verify axios was called with the correct configuration
expect(axios).toHaveBeenCalledWith(expect.objectContaining({
method: 'post',
url: 'https://example.com/data',
data: { key: 'value' },
validateStatus: expect.any(Function),
timeout: expect.any(Number),
}));
expect(result).toBeDefined();
expect(result).toHaveProperty('response');
expect(result.response.statusCode).toBe(200);
expect(result.response.body).toBe(mockData);
});
test('handles timeout and cancels request', async () => {
// Mock cancellation
const cancelMessage = 'Whole request cycle timeout: 500ms';
axios.mockImplementation(() => {
const error = new Error(cancelMessage);
error.code = 'ETIMEDOUT';
throw error;
});
// Call function and expect it to throw ETIMEDOUT error
await expect(utils.postRequestPromise(
ctx,
'https://example.com/data',
{ key: 'value' },
null,
null,
commonTestParams.timeout,
commonTestParams.authorization,
false,
null
)).rejects.toThrowError(cancelMessage);
// Verify axios was called // Verify axios was called
expect(axios).toHaveBeenCalledTimes(1); expect(axios).toHaveBeenCalledTimes(1);
// Verify timeout was properly configured
expect(axios).toHaveBeenCalledWith(expect.objectContaining({
timeout: expect.any(Number),
cancelToken: expect.anything()
}));
}); });
});
test('throws error for non-200 status codes', async () => {
// Create mock error data
const errorData = JSON.stringify({ error: 'Not found' });
const mockErrorResponse = {
status: 404,
statusText: 'Not Found',
headers: {
'content-type': 'application/json',
'content-length': String(Buffer.byteLength(errorData, 'utf8'))
},
data: errorData
};
// Setup axios mock
axios.mockImplementation(() => Promise.reject({ response: mockErrorResponse }));
// Call function and expect it to throw error
await expect(utils.postRequestPromise(
ctx,
'https://example.com/not-found',
{ key: 'value' },
null,
null,
commonTestParams.timeout,
commonTestParams.authorization,
false,
null
)).rejects.toThrowError(/Error response: statusCode:404/);
// Verify axios was called
expect(axios).toHaveBeenCalledTimes(1);
});
test('handles post data stream correctly', async () => {
// Mock successful response with stream
const mockData = 'Sample streamed content';
const mockResponse = {
status: 200,
headers: {
'content-type': 'application/json',
'content-length': String(Buffer.byteLength(mockData, 'utf8'))
},
data: mockData
};
// Setup axios mock
axios.mockImplementation(() => Promise.resolve(mockResponse));
// Call the function with postDataStream
const result = await utils.postRequestPromise(
ctx,
'https://example.com/upload',
null,
mockData,
null,
commonTestParams.timeout,
commonTestParams.authorization,
false,
null
);
expect(axios).toHaveBeenCalledWith(expect.objectContaining({
method: 'post',
url: 'https://example.com/upload',
headers: expect.objectContaining({
'Authorization': 'Bearer token123'
}),
data: mockData,
validateStatus: expect.any(Function),
timeout: expect.any(Number),
}));
// Verify result
expect(result).toBeDefined();
expect(result.response.statusCode).toBe(200);
expect(result.response.body).toBe(mockData);
});
test('handles network errors correctly', async () => {
// Mock network error
const networkError = new Error('Network Error');
axios.mockImplementation(() => Promise.reject(networkError));
// Call function and expect it to throw network error
await expect(utils.postRequestPromise(
ctx,
'https://example.com/network-error',
{ key: 'value' },
null,
null,
commonTestParams.timeout,
commonTestParams.authorization,
false,
null
)).rejects.toThrowError('Network Error');
// Verify axios was called
expect(axios).toHaveBeenCalledTimes(1);
});
})
}); });