n8n/packages/core/test/NodeExecuteFunctions.test.ts
कारतोफ्फेलस्क्रिप्ट™ 41e9e39b5b
Some checks are pending
Test Master / install-and-build (push) Waiting to run
Test Master / Unit tests (18.x) (push) Blocked by required conditions
Test Master / Unit tests (20.x) (push) Blocked by required conditions
Test Master / Unit tests (22.4) (push) Blocked by required conditions
Test Master / Lint (push) Blocked by required conditions
Test Master / Notify Slack on failure (push) Blocked by required conditions
Benchmark Docker Image CI / build (push) Waiting to run
fix(core): Improve header parameter parsing on http client responses (#11953)
2024-11-28 15:53:18 +01:00

820 lines
23 KiB
TypeScript

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<IncomingMessage>({
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<IncomingMessage>({
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<IncomingMessage>({
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<IncomingMessage>({
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<IncomingMessage>({
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<IncomingMessage>({
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<IncomingMessage>({
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<Workflow>();
const hooks = mock<WorkflowHooks>();
const additionalData = mock<IWorkflowExecuteAdditionalData>({ hooks });
const node = mock<INode>();
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<string, any> = { 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);
});
});