import { mkdtempSync, readFileSync } from 'fs'; import { IncomingMessage } from 'http'; import type { Agent } from 'https'; import { mock } from 'jest-mock-extended'; import type { IBinaryData, IHttpRequestMethods, IHttpRequestOptions, INode, IRequestOptions, ITaskDataConnections, IWorkflowExecuteAdditionalData, Workflow, WorkflowHooks, } from 'n8n-workflow'; import nock from 'nock'; import { tmpdir } from 'os'; import { join } from 'path'; import { Readable } from 'stream'; import type { SecureContextOptions } from 'tls'; import Container from 'typedi'; import { BinaryDataService } from '@/BinaryData/BinaryData.service'; import { InstanceSettings } from '@/InstanceSettings'; import { binaryToString, copyInputItems, getBinaryDataBuffer, isFilePathBlocked, parseContentDisposition, parseContentType, parseIncomingMessage, parseRequestObject, proxyRequestToAxios, removeEmptyBody, setBinaryDataBuffer, } from '@/NodeExecuteFunctions'; const temporaryDir = mkdtempSync(join(tmpdir(), 'n8n')); describe('NodeExecuteFunctions', () => { describe('test binary data helper methods', () => { test("test getBinaryDataBuffer(...) & setBinaryDataBuffer(...) methods in 'default' mode", async () => { // Setup a 'default' binary data manager instance Container.set(BinaryDataService, new BinaryDataService()); await Container.get(BinaryDataService).init({ mode: 'default', availableModes: ['default'], localStoragePath: temporaryDir, }); // Set our binary data buffer const inputData: Buffer = Buffer.from('This is some binary data', 'utf8'); const setBinaryDataBufferResponse: IBinaryData = await setBinaryDataBuffer( { mimeType: 'txt', data: 'This should be overwritten by the actual payload in the response', }, inputData, 'workflowId', 'executionId', ); // Expect our return object to contain the base64 encoding of the input data, as it should be stored in memory. expect(setBinaryDataBufferResponse.data).toEqual(inputData.toString('base64')); // Now, re-fetch our data. // An ITaskDataConnections object is used to share data between nodes. The top level property, 'main', represents the successful output object from a previous node. const taskDataConnectionsInput: ITaskDataConnections = { main: [], }; // We add an input set, with one item at index 0, to this input. It contains an empty json payload and our binary data. taskDataConnectionsInput.main.push([ { json: {}, binary: { data: setBinaryDataBufferResponse, }, }, ]); // Now, lets fetch our data! The item will be item index 0. const getBinaryDataBufferResponse: Buffer = await getBinaryDataBuffer( taskDataConnectionsInput, 0, 'data', 0, ); expect(getBinaryDataBufferResponse).toEqual(inputData); }); test("test getBinaryDataBuffer(...) & setBinaryDataBuffer(...) methods in 'filesystem' mode", async () => { Container.set(BinaryDataService, new BinaryDataService()); // Setup a 'filesystem' binary data manager instance await Container.get(BinaryDataService).init({ mode: 'filesystem', availableModes: ['filesystem'], localStoragePath: temporaryDir, }); // Set our binary data buffer const inputData: Buffer = Buffer.from('This is some binary data', 'utf8'); const setBinaryDataBufferResponse: IBinaryData = await setBinaryDataBuffer( { mimeType: 'txt', data: 'This should be overwritten with the name of the configured data manager', }, inputData, 'workflowId', 'executionId', ); // Expect our return object to contain the name of the configured data manager. expect(setBinaryDataBufferResponse.data).toEqual('filesystem-v2'); // Ensure that the input data was successfully persisted to disk. expect( readFileSync( `${temporaryDir}/${setBinaryDataBufferResponse.id?.replace('filesystem-v2:', '')}`, ), ).toEqual(inputData); // Now, re-fetch our data. // An ITaskDataConnections object is used to share data between nodes. The top level property, 'main', represents the successful output object from a previous node. const taskDataConnectionsInput: ITaskDataConnections = { main: [], }; // We add an input set, with one item at index 0, to this input. It contains an empty json payload and our binary data. taskDataConnectionsInput.main.push([ { json: {}, binary: { data: setBinaryDataBufferResponse, }, }, ]); // Now, lets fetch our data! The item will be item index 0. const getBinaryDataBufferResponse: Buffer = await getBinaryDataBuffer( taskDataConnectionsInput, 0, 'data', 0, ); expect(getBinaryDataBufferResponse).toEqual(inputData); }); }); describe('parseContentType', () => { const testCases = [ { input: 'text/plain', expected: { type: 'text/plain', parameters: { charset: 'utf-8', }, }, description: 'should parse basic content type', }, { input: 'TEXT/PLAIN', expected: { type: 'text/plain', parameters: { charset: 'utf-8', }, }, description: 'should convert type to lowercase', }, { input: 'text/html; charset=iso-8859-1', expected: { type: 'text/html', parameters: { charset: 'iso-8859-1', }, }, description: 'should parse content type with charset', }, { input: 'application/json; charset=utf-8; boundary=---123', expected: { type: 'application/json', parameters: { charset: 'utf-8', boundary: '---123', }, }, description: 'should parse content type with multiple parameters', }, { input: 'text/plain; charset="utf-8"; filename="test.txt"', expected: { type: 'text/plain', parameters: { charset: 'utf-8', filename: 'test.txt', }, }, description: 'should handle quoted parameter values', }, { input: 'text/plain; filename=%22test%20file.txt%22', expected: { type: 'text/plain', parameters: { charset: 'utf-8', filename: 'test file.txt', }, }, description: 'should handle encoded parameter values', }, { input: undefined, expected: null, description: 'should return null for undefined input', }, { input: '', expected: null, description: 'should return null for empty string', }, ]; test.each(testCases)('$description', ({ input, expected }) => { expect(parseContentType(input)).toEqual(expected); }); }); describe('parseContentDisposition', () => { const testCases = [ { input: 'attachment; filename="file.txt"', expected: { type: 'attachment', filename: 'file.txt' }, description: 'should parse basic content disposition', }, { input: 'attachment; filename=file.txt', expected: { type: 'attachment', filename: 'file.txt' }, description: 'should parse filename without quotes', }, { input: 'inline; filename="image.jpg"', expected: { type: 'inline', filename: 'image.jpg' }, description: 'should parse inline disposition', }, { input: 'attachment; filename="my file.pdf"', expected: { type: 'attachment', filename: 'my file.pdf' }, description: 'should parse filename with spaces', }, { input: "attachment; filename*=UTF-8''my%20file.txt", expected: { type: 'attachment', filename: 'my file.txt' }, description: 'should parse filename* parameter (RFC 5987)', }, { input: 'filename="test.txt"', expected: { type: 'attachment', filename: 'test.txt' }, description: 'should handle invalid syntax but with filename', }, { input: 'filename=test.txt', expected: { type: 'attachment', filename: 'test.txt' }, description: 'should handle invalid syntax with only filename parameter', }, { input: undefined, expected: null, description: 'should return null for undefined input', }, { input: '', expected: null, description: 'should return null for empty string', }, { input: 'attachment; filename="%F0%9F%98%80.txt"', expected: { type: 'attachment', filename: '😀.txt' }, description: 'should handle encoded filenames', }, { input: 'attachment; size=123; filename="test.txt"; creation-date="Thu, 1 Jan 2020"', expected: { type: 'attachment', filename: 'test.txt' }, description: 'should handle multiple parameters', }, ]; test.each(testCases)('$description', ({ input, expected }) => { expect(parseContentDisposition(input)).toEqual(expected); }); }); describe('parseIncomingMessage', () => { it('parses valid content-type header', () => { const message = mock({ headers: { 'content-type': 'application/json', 'content-disposition': undefined }, }); parseIncomingMessage(message); expect(message.contentType).toEqual('application/json'); }); it('parses valid content-type header with parameters', () => { const message = mock({ headers: { 'content-type': 'application/json; charset=utf-8', 'content-disposition': undefined, }, }); parseIncomingMessage(message); expect(message.contentType).toEqual('application/json'); expect(message.encoding).toEqual('utf-8'); }); it('parses valid content-type header with encoding wrapped in quotes', () => { const message = mock({ headers: { 'content-type': 'application/json; charset="utf-8"', 'content-disposition': undefined, }, }); parseIncomingMessage(message); expect(message.contentType).toEqual('application/json'); expect(message.encoding).toEqual('utf-8'); }); it('parses valid content-disposition header with filename*', () => { const message = mock({ headers: { 'content-type': undefined, 'content-disposition': 'attachment; filename="screenshot%20(1).png"; filename*=UTF-8\'\'screenshot%20(1).png', }, }); parseIncomingMessage(message); expect(message.contentDisposition).toEqual({ filename: 'screenshot (1).png', type: 'attachment', }); }); it('parses valid content-disposition header with filename* (quoted)', () => { const message = mock({ headers: { 'content-type': undefined, 'content-disposition': ' attachment;filename*="utf-8\' \'test-unsplash.jpg"', }, }); parseIncomingMessage(message); expect(message.contentDisposition).toEqual({ filename: 'test-unsplash.jpg', type: 'attachment', }); }); it('parses valid content-disposition header with filename and trailing ";"', () => { const message = mock({ headers: { 'content-type': undefined, 'content-disposition': 'inline; filename="screenshot%20(1).png";', }, }); parseIncomingMessage(message); expect(message.contentDisposition).toEqual({ filename: 'screenshot (1).png', type: 'inline', }); }); it('parses non standard content-disposition with missing type', () => { const message = mock({ headers: { 'content-type': undefined, 'content-disposition': 'filename="screenshot%20(1).png";', }, }); parseIncomingMessage(message); expect(message.contentDisposition).toEqual({ filename: 'screenshot (1).png', type: 'attachment', }); }); }); describe('proxyRequestToAxios', () => { const baseUrl = 'http://example.de'; const workflow = mock(); const hooks = mock(); const additionalData = mock({ hooks }); const node = mock(); beforeEach(() => { hooks.executeHookFunctions.mockClear(); }); test('should rethrow an error with `status` property', async () => { nock(baseUrl).get('/test').reply(400); try { await proxyRequestToAxios(workflow, additionalData, node, `${baseUrl}/test`); } catch (error) { expect(error.status).toEqual(400); } }); test('should not throw if the response status is 200', async () => { nock(baseUrl).get('/test').reply(200); await proxyRequestToAxios(workflow, additionalData, node, `${baseUrl}/test`); expect(hooks.executeHookFunctions).toHaveBeenCalledWith('nodeFetchedData', [ workflow.id, node, ]); }); test('should throw if the response status is 403', async () => { const headers = { 'content-type': 'text/plain' }; nock(baseUrl).get('/test').reply(403, 'Forbidden', headers); try { await proxyRequestToAxios(workflow, additionalData, node, `${baseUrl}/test`); } catch (error) { expect(error.statusCode).toEqual(403); expect(error.request).toBeUndefined(); expect(error.response).toMatchObject({ headers, status: 403 }); expect(error.options).toMatchObject({ headers: { Accept: '*/*' }, method: 'get', url: 'http://example.de/test', }); expect(error.config).toBeUndefined(); expect(error.message).toEqual('403 - "Forbidden"'); } expect(hooks.executeHookFunctions).not.toHaveBeenCalled(); }); test('should not throw if the response status is 404, but `simple` option is set to `false`', async () => { nock(baseUrl).get('/test').reply(404, 'Not Found'); const response = await proxyRequestToAxios(workflow, additionalData, node, { url: `${baseUrl}/test`, simple: false, }); expect(response).toEqual('Not Found'); expect(hooks.executeHookFunctions).toHaveBeenCalledWith('nodeFetchedData', [ workflow.id, node, ]); }); test('should return full response when `resolveWithFullResponse` is set to true', async () => { nock(baseUrl).get('/test').reply(404, 'Not Found'); const response = await proxyRequestToAxios(workflow, additionalData, node, { url: `${baseUrl}/test`, resolveWithFullResponse: true, simple: false, }); expect(response).toMatchObject({ body: 'Not Found', headers: {}, statusCode: 404, statusMessage: null, }); expect(hooks.executeHookFunctions).toHaveBeenCalledWith('nodeFetchedData', [ workflow.id, node, ]); }); describe('redirects', () => { test('should forward authorization header', async () => { nock(baseUrl).get('/redirect').reply(301, '', { Location: 'https://otherdomain.com/test' }); nock('https://otherdomain.com') .get('/test') .reply(200, function () { return this.req.headers; }); const response = await proxyRequestToAxios(workflow, additionalData, node, { url: `${baseUrl}/redirect`, auth: { username: 'testuser', password: 'testpassword', }, headers: { 'X-Other-Header': 'otherHeaderContent', }, resolveWithFullResponse: true, }); expect(response.statusCode).toBe(200); const forwardedHeaders = JSON.parse(response.body); expect(forwardedHeaders.authorization).toBe('Basic dGVzdHVzZXI6dGVzdHBhc3N3b3Jk'); expect(forwardedHeaders['x-other-header']).toBe('otherHeaderContent'); }); test('should follow redirects by default', async () => { nock(baseUrl) .get('/redirect') .reply(301, '', { Location: `${baseUrl}/test` }); nock(baseUrl).get('/test').reply(200, 'Redirected'); const response = await proxyRequestToAxios(workflow, additionalData, node, { url: `${baseUrl}/redirect`, resolveWithFullResponse: true, }); expect(response).toMatchObject({ body: 'Redirected', headers: {}, statusCode: 200, }); }); test('should not follow redirects when configured', async () => { nock(baseUrl) .get('/redirect') .reply(301, '', { Location: `${baseUrl}/test` }); nock(baseUrl).get('/test').reply(200, 'Redirected'); await expect( proxyRequestToAxios(workflow, additionalData, node, { url: `${baseUrl}/redirect`, resolveWithFullResponse: true, followRedirect: false, }), ).rejects.toThrowError(expect.objectContaining({ statusCode: 301 })); }); }); }); describe('parseRequestObject', () => { test('should not use Host header for SNI', async () => { const axiosOptions = await parseRequestObject({ url: 'https://example.de/foo/bar', headers: { Host: 'other.host.com' }, }); expect((axiosOptions.httpsAgent as Agent).options.servername).toEqual('example.de'); }); describe('should set SSL certificates', () => { const agentOptions: SecureContextOptions = { ca: '-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----', }; const requestObject: IRequestOptions = { method: 'GET', uri: 'https://example.de', agentOptions, }; test('on regular requests', async () => { const axiosOptions = await parseRequestObject(requestObject); expect((axiosOptions.httpsAgent as Agent).options).toEqual({ servername: 'example.de', ...agentOptions, noDelay: true, path: null, }); }); test('on redirected requests', async () => { const axiosOptions = await parseRequestObject(requestObject); expect(axiosOptions.beforeRedirect).toBeDefined; // eslint-disable-next-line @typescript-eslint/no-explicit-any const redirectOptions: Record = { agents: {}, hostname: 'example.de' }; axiosOptions.beforeRedirect!(redirectOptions, mock()); expect(redirectOptions.agent).toEqual(redirectOptions.agents.https); expect((redirectOptions.agent as Agent).options).toEqual({ servername: 'example.de', ...agentOptions, noDelay: true, path: null, }); }); }); describe('when followRedirect is true', () => { test.each(['GET', 'HEAD'] as IHttpRequestMethods[])( 'should set maxRedirects on %s ', async (method) => { const axiosOptions = await parseRequestObject({ method, followRedirect: true, maxRedirects: 1234, }); expect(axiosOptions.maxRedirects).toEqual(1234); }, ); test.each(['POST', 'PUT', 'PATCH', 'DELETE'] as IHttpRequestMethods[])( 'should not set maxRedirects on %s ', async (method) => { const axiosOptions = await parseRequestObject({ method, followRedirect: true, maxRedirects: 1234, }); expect(axiosOptions.maxRedirects).toEqual(0); }, ); }); describe('when followAllRedirects is true', () => { test.each(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'] as IHttpRequestMethods[])( 'should set maxRedirects on %s ', async (method) => { const axiosOptions = await parseRequestObject({ method, followAllRedirects: true, maxRedirects: 1234, }); expect(axiosOptions.maxRedirects).toEqual(1234); }, ); }); }); describe('copyInputItems', () => { it('should pick only selected properties', () => { const output = copyInputItems( [ { json: { a: 1, b: true, c: {}, }, }, ], ['a'], ); expect(output).toEqual([{ a: 1 }]); }); it('should convert undefined to null', () => { const output = copyInputItems( [ { json: { a: undefined, }, }, ], ['a'], ); expect(output).toEqual([{ a: null }]); }); it('should clone objects', () => { const input = { a: { b: 5 }, }; const output = copyInputItems( [ { json: input, }, ], ['a'], ); expect(output[0].a).toEqual(input.a); expect(output[0].a === input.a).toEqual(false); }); }); describe('removeEmptyBody', () => { test.each(['GET', 'HEAD', 'OPTIONS'] as IHttpRequestMethods[])( 'Should remove empty body for %s', async (method) => { const requestOptions = { method, body: {}, } as IHttpRequestOptions | IRequestOptions; removeEmptyBody(requestOptions); expect(requestOptions.body).toEqual(undefined); }, ); test.each(['GET', 'HEAD', 'OPTIONS'] as IHttpRequestMethods[])( 'Should not remove non-empty body for %s', async (method) => { const requestOptions = { method, body: { test: true }, } as IHttpRequestOptions | IRequestOptions; removeEmptyBody(requestOptions); expect(requestOptions.body).toEqual({ test: true }); }, ); test.each(['POST', 'PUT', 'PATCH', 'DELETE'] as IHttpRequestMethods[])( 'Should not remove empty body for %s', async (method) => { const requestOptions = { method, body: {}, } as IHttpRequestOptions | IRequestOptions; removeEmptyBody(requestOptions); expect(requestOptions.body).toEqual({}); }, ); }); describe('binaryToString', () => { const ENCODING_SAMPLES = { utf8: { text: 'Hello, 世界! τεστ мир ⚡️ é à ü ñ', buffer: Buffer.from([ 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x2c, 0x20, 0xe4, 0xb8, 0x96, 0xe7, 0x95, 0x8c, 0x21, 0x20, 0xcf, 0x84, 0xce, 0xb5, 0xcf, 0x83, 0xcf, 0x84, 0x20, 0xd0, 0xbc, 0xd0, 0xb8, 0xd1, 0x80, 0x20, 0xe2, 0x9a, 0xa1, 0xef, 0xb8, 0x8f, 0x20, 0xc3, 0xa9, 0x20, 0xc3, 0xa0, 0x20, 0xc3, 0xbc, 0x20, 0xc3, 0xb1, ]), }, 'iso-8859-15': { text: 'Café € personnalité', buffer: Buffer.from([ 0x43, 0x61, 0x66, 0xe9, 0x20, 0xa4, 0x20, 0x70, 0x65, 0x72, 0x73, 0x6f, 0x6e, 0x6e, 0x61, 0x6c, 0x69, 0x74, 0xe9, ]), }, latin1: { text: 'señor année déjà', buffer: Buffer.from([ 0x73, 0x65, 0xf1, 0x6f, 0x72, 0x20, 0x61, 0x6e, 0x6e, 0xe9, 0x65, 0x20, 0x64, 0xe9, 0x6a, 0xe0, ]), }, ascii: { text: 'Hello, World! 123', buffer: Buffer.from([ 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x2c, 0x20, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x21, 0x20, 0x31, 0x32, 0x33, ]), }, 'windows-1252': { text: '€ Smart "quotes" • bullet', buffer: Buffer.from([ 0x80, 0x20, 0x53, 0x6d, 0x61, 0x72, 0x74, 0x20, 0x22, 0x71, 0x75, 0x6f, 0x74, 0x65, 0x73, 0x22, 0x20, 0x95, 0x20, 0x62, 0x75, 0x6c, 0x6c, 0x65, 0x74, ]), }, 'shift-jis': { text: 'こんにちは世界', buffer: Buffer.from([ 0x82, 0xb1, 0x82, 0xf1, 0x82, 0xc9, 0x82, 0xbf, 0x82, 0xcd, 0x90, 0xa2, 0x8a, 0x45, ]), }, big5: { text: '哈囉世界', buffer: Buffer.from([0xab, 0xa2, 0xc5, 0x6f, 0xa5, 0x40, 0xac, 0xc9]), }, 'koi8-r': { text: 'Привет мир', buffer: Buffer.from([0xf0, 0xd2, 0xc9, 0xd7, 0xc5, 0xd4, 0x20, 0xcd, 0xc9, 0xd2]), }, }; describe('should handle Buffer', () => { for (const [encoding, { text, buffer }] of Object.entries(ENCODING_SAMPLES)) { test(`with ${encoding}`, async () => { const data = await binaryToString(buffer, encoding); expect(data).toBe(text); }); } }); describe('should handle streams', () => { for (const [encoding, { text, buffer }] of Object.entries(ENCODING_SAMPLES)) { test(`with ${encoding}`, async () => { const stream = Readable.from(buffer); const data = await binaryToString(stream, encoding); expect(data).toBe(text); }); } }); describe('should handle IncomingMessage', () => { for (const [encoding, { text, buffer }] of Object.entries(ENCODING_SAMPLES)) { test(`with ${encoding}`, async () => { const response = Readable.from(buffer) as IncomingMessage; response.headers = { 'content-type': `application/json;charset=${encoding}` }; // @ts-expect-error need this hack to fake `instanceof IncomingMessage` checks response.__proto__ = IncomingMessage.prototype; const data = await binaryToString(response); expect(data).toBe(text); }); } }); }); }); describe('isFilePathBlocked', () => { test('should return true for static cache dir', () => { const filePath = Container.get(InstanceSettings).staticCacheDir; expect(isFilePathBlocked(filePath)).toBe(true); }); });