Files
n8n/packages/nodes-base/nodes/Form/test/formCompletionUtils.test.ts
alighasami 3d5eaf9445
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
first commit
2026-03-17 16:22:57 +03:30

408 lines
12 KiB
TypeScript

import { type Response } from 'express';
import { type MockProxy, mock } from 'jest-mock-extended';
import { type INode, type IWebhookFunctions } from 'n8n-workflow';
import { binaryResponse, renderFormCompletion } from '../utils/formCompletionUtils';
import * as utils from '../utils/utils';
describe('formCompletionUtils', () => {
let mockWebhookFunctions: MockProxy<IWebhookFunctions>;
const mockNode: INode = mock<INode>({
id: 'test-node',
name: 'Test Node',
type: 'test',
typeVersion: 1,
position: [0, 0],
parameters: {},
});
const nodeNameWithFileToDownload = 'prevNode0';
const nodeNameWithFile = 'prevNode2';
const parentNodesWithAndWithoutFiles = [
{
name: nodeNameWithFileToDownload,
type: '',
typeVersion: 0,
disabled: false,
},
{
name: 'prevNode1',
type: '',
typeVersion: 0,
disabled: false,
},
];
const parentNodesWithMultipleBinaryFiles = [
{
name: nodeNameWithFileToDownload,
type: '',
typeVersion: 0,
disabled: false,
},
{
name: nodeNameWithFile,
type: '',
typeVersion: 0,
disabled: false,
},
];
const parentNodesWithSingleNodeFile = [
{
name: nodeNameWithFileToDownload,
type: '',
typeVersion: 0,
disabled: false,
},
];
const parentNodesTestCases = [
parentNodesWithAndWithoutFiles,
parentNodesWithMultipleBinaryFiles,
parentNodesWithSingleNodeFile,
];
beforeEach(() => {
mockWebhookFunctions = mock<IWebhookFunctions>();
mockWebhookFunctions.getNode.mockReturnValue(mockNode);
});
afterEach(() => {
jest.resetAllMocks();
});
describe('renderFormCompletion', () => {
const mockResponse: Response = mock<Response>({
send: jest.fn(),
render: jest.fn(),
});
const trigger = {
name: 'triggerNode',
type: 'trigger',
typeVersion: 1,
disabled: false,
};
afterEach(() => {
jest.resetAllMocks();
});
it('should render the form completion', async () => {
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: any } = {
completionTitle: 'Form Completion',
completionMessage: 'Form has been submitted successfully',
options: { formTitle: 'Form Title' },
};
return params[parameterName];
});
await renderFormCompletion(mockWebhookFunctions, mockResponse, trigger);
expect(mockResponse.render).toHaveBeenCalledWith('form-trigger-completion', {
appendAttribution: undefined,
formTitle: 'Form Title',
message: 'Form has been submitted successfully',
redirectUrl: undefined,
responseBinary: encodeURIComponent(JSON.stringify('')),
responseText: '',
title: 'Form Completion',
});
});
it('should call sanitizeHtml on completionMessage', async () => {
const sanitizeHtmlSpy = jest.spyOn(utils, 'sanitizeHtml');
const maliciousMessage = '<script>alert("xss")</script>Safe message<b>bold</b>';
const responseText = 'Response text';
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: any } = {
completionTitle: 'Form Completion',
completionMessage: maliciousMessage,
responseText,
options: { formTitle: 'Form Title' },
};
return params[parameterName];
});
await renderFormCompletion(mockWebhookFunctions, mockResponse, trigger);
expect(sanitizeHtmlSpy).toHaveBeenCalledWith(maliciousMessage);
expect(sanitizeHtmlSpy).toHaveBeenCalledTimes(1);
expect(mockResponse.render).toHaveBeenCalledWith('form-trigger-completion', {
appendAttribution: undefined,
formTitle: 'Form Title',
message: 'Safe message<b>bold</b>',
redirectUrl: undefined,
responseBinary: encodeURIComponent(JSON.stringify('')),
responseText: 'Response text',
title: 'Form Completion',
dangerousCustomCss: undefined,
});
sanitizeHtmlSpy.mockRestore();
});
it.each([
['\\n', '\n'],
['\\\\n', '\\n'],
])('should replace %j with %j in completionMessage', async (pattern, replacement) => {
const completionMessage = `Some message${pattern}Other text`;
const responseText = 'Response text';
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: any } = {
completionTitle: 'Form Completion',
completionMessage,
responseText,
options: { formTitle: 'Form Title' },
};
return params[parameterName];
});
await renderFormCompletion(mockWebhookFunctions, mockResponse, trigger);
expect(mockResponse.render).toHaveBeenCalledWith('form-trigger-completion', {
appendAttribution: undefined,
formTitle: 'Form Title',
message: `Some message${replacement}Other text`,
redirectUrl: undefined,
responseBinary: encodeURIComponent(JSON.stringify('')),
responseText: 'Response text',
title: 'Form Completion',
dangerousCustomCss: undefined,
});
});
it('throw an error if no binary data with the field name is found', async () => {
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: any } = {
completionTitle: 'Form Completion',
completionMessage: 'Form has been submitted successfully',
options: { formTitle: 'Form Title' },
respondWith: 'returnBinary',
inputDataFieldName: 'inputData',
};
return params[parameterName];
});
mockWebhookFunctions.getParentNodes.mockReturnValueOnce([]);
await expect(
renderFormCompletion(mockWebhookFunctions, mockResponse, trigger),
).rejects.toThrowError('No binary data with field inputData found.');
});
it('should render if respond with binary is set and binary mode is filesystem', async () => {
const expectedBinaryResponse = {
inputData: {
data: 'IyAxLiBHbyBpbiBwb3N0Z3',
fileExtension: 'txt',
fileName: 'file.txt',
fileSize: '458 B',
fileType: 'text',
mimeType: 'text/plain',
id: 555,
},
};
const buffer = Buffer.from(expectedBinaryResponse.inputData.data);
for (const parentNodes of parentNodesTestCases) {
mockWebhookFunctions.getParentNodes.mockReturnValueOnce(parentNodes);
mockWebhookFunctions.evaluateExpression.mockImplementation((arg) => {
if (arg === `{{ $('${nodeNameWithFileToDownload}').first().binary }}`) {
return expectedBinaryResponse;
} else if (arg === `{{ $('${nodeNameWithFile}').first().binary }}`) {
return { someData: {} };
} else {
return undefined;
}
});
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: any } = {
inputDataFieldName: 'inputData',
completionTitle: 'Form Completion',
completionMessage: 'Form has been submitted successfully',
options: { formTitle: 'Form Title' },
respondWith: 'returnBinary',
};
return params[parameterName];
});
mockWebhookFunctions.helpers.getBinaryStream = jest
.fn()
.mockResolvedValue(Promise.resolve({}));
mockWebhookFunctions.helpers.binaryToBuffer = jest
.fn()
.mockResolvedValue(Promise.resolve(buffer));
await renderFormCompletion(mockWebhookFunctions, mockResponse, trigger);
expect(mockResponse.render).toHaveBeenCalledWith('form-trigger-completion', {
appendAttribution: undefined,
formTitle: 'Form Title',
message: 'Form has been submitted successfully',
redirectUrl: undefined,
responseBinary: encodeURIComponent(
JSON.stringify({
data: buffer,
fileName: expectedBinaryResponse.inputData.fileName,
type: expectedBinaryResponse.inputData.mimeType,
}),
),
responseText: '',
title: 'Form Completion',
});
}
});
it('should render if respond with binary is set and binary mode is default', async () => {
const expectedBinaryResponse = {
inputData: {
data: 'IyAxLiBHbyBpbiBwb3N0Z3',
fileExtension: 'txt',
fileName: 'file.txt',
fileSize: '458 B',
fileType: 'text',
mimeType: 'text/plain',
},
};
for (const parentNodes of parentNodesTestCases) {
mockWebhookFunctions.getParentNodes.mockReturnValueOnce(parentNodes);
mockWebhookFunctions.evaluateExpression.mockImplementation((arg) => {
if (arg === `{{ $('${nodeNameWithFileToDownload}').first().binary }}`) {
return expectedBinaryResponse;
} else if (arg === `{{ $('${nodeNameWithFile}').first().binary }}`) {
return { someData: {} };
} else {
return undefined;
}
});
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: any } = {
inputDataFieldName: 'inputData',
completionTitle: 'Form Completion',
completionMessage: 'Form has been submitted successfully',
options: { formTitle: 'Form Title' },
respondWith: 'returnBinary',
};
return params[parameterName];
});
await renderFormCompletion(mockWebhookFunctions, mockResponse, trigger);
expect(mockResponse.render).toHaveBeenCalledWith('form-trigger-completion', {
appendAttribution: undefined,
formTitle: 'Form Title',
message: 'Form has been submitted successfully',
redirectUrl: undefined,
responseBinary: encodeURIComponent(
JSON.stringify({
data: atob(expectedBinaryResponse.inputData.data),
fileName: expectedBinaryResponse.inputData.fileName,
type: expectedBinaryResponse.inputData.mimeType,
}),
),
responseText: '',
title: 'Form Completion',
});
}
});
it('should set Content-Security-Policy header with sandbox CSP', async () => {
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: any } = {
completionTitle: 'Form Completion',
completionMessage: 'Form has been submitted successfully',
options: { formTitle: 'Form Title' },
};
return params[parameterName];
});
await renderFormCompletion(mockWebhookFunctions, mockResponse, trigger);
expect(mockResponse.setHeader).toHaveBeenCalledWith(
'Content-Security-Policy',
'sandbox allow-downloads allow-forms allow-modals allow-orientation-lock allow-pointer-lock allow-popups allow-presentation allow-scripts allow-top-navigation allow-top-navigation-by-user-activation allow-top-navigation-to-custom-protocols',
);
expect(mockResponse.render).toHaveBeenCalled();
});
it('should NOT set Content-Security-Policy header when respondWith is redirect', async () => {
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: any } = {
completionTitle: 'Form Completion',
completionMessage: 'Form has been submitted successfully',
options: { formTitle: 'Form Title' },
respondWith: 'redirect',
};
return params[parameterName];
});
await renderFormCompletion(mockWebhookFunctions, mockResponse, trigger);
expect(mockResponse.setHeader).not.toHaveBeenCalledWith(
'Content-Security-Policy',
expect.any(String),
);
expect(mockResponse.render).toHaveBeenCalled();
});
});
describe('binaryResponse', () => {
it('should get the latest binary data from the parent nodes', async () => {
const expectedBinaryResponse = {
inputData: {
data: 'IyAxLiBHbyBpbiBwb3N0Z3',
fileExtension: 'txt',
fileName: 'file.txt',
fileSize: '458 B',
fileType: 'text',
mimeType: 'text/plain',
},
};
const notExpectedBinaryResponse = {
inputData: {
data: 'notexpected',
fileExtension: 'txt',
fileName: 'file.txt',
fileSize: '458 B',
fileType: 'text',
mimeType: 'text/plain',
},
};
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: any } = {
inputDataFieldName: 'inputData',
};
return params[parameterName];
});
mockWebhookFunctions.getParentNodes.mockReturnValueOnce(parentNodesWithMultipleBinaryFiles);
mockWebhookFunctions.evaluateExpression.mockImplementation((arg) => {
if (arg === `{{ $('${nodeNameWithFile}').first().binary }}`) {
return expectedBinaryResponse;
} else {
return notExpectedBinaryResponse;
}
});
const result = await binaryResponse(mockWebhookFunctions);
expect(result).toEqual({
data: atob(expectedBinaryResponse.inputData.data),
fileName: expectedBinaryResponse.inputData.fileName,
type: expectedBinaryResponse.inputData.mimeType,
});
});
});
});