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); } }); }); });