first commit
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
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
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,289 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IBinaryData, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import { BINARY_ENCODING } from 'n8n-workflow';
|
||||
import { type WorkSheet, utils as xlsxUtils, write as xlsxWrite } from 'xlsx';
|
||||
|
||||
import {
|
||||
convertJsonToSpreadsheetBinary,
|
||||
extractDataFromPDF,
|
||||
prepareBinariesDataList,
|
||||
} from '@utils/binary';
|
||||
|
||||
jest.mock('xlsx', () => ({
|
||||
utils: {
|
||||
json_to_sheet: jest.fn(),
|
||||
},
|
||||
write: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('pdfjs-dist/legacy/build/pdf.mjs', () => ({
|
||||
getDocument: jest.fn(),
|
||||
version: '5.3.31',
|
||||
}));
|
||||
|
||||
describe('convertJsonToSpreadsheetBinary', () => {
|
||||
const helpers = mock<IExecuteFunctions['helpers']>();
|
||||
const executeFunctions = mock<IExecuteFunctions>({ helpers });
|
||||
const items = [
|
||||
{ json: { key1: 'value1', key2: 'value2' } },
|
||||
{ json: { key1: 'value3', key2: 'value4' } },
|
||||
] as INodeExecutionData[];
|
||||
const mockSheet = mock<WorkSheet>();
|
||||
const workBook = {
|
||||
SheetNames: ['Sheet'],
|
||||
Sheets: {
|
||||
Sheet: mockSheet,
|
||||
},
|
||||
};
|
||||
const mockBuffer = mock<Buffer>();
|
||||
const mockBinaryData = mock<IBinaryData>({ id: 'binaryId' });
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(xlsxUtils.json_to_sheet as jest.Mock).mockReturnValue(mockSheet);
|
||||
(xlsxWrite as jest.Mock).mockReturnValue(mockBuffer);
|
||||
helpers.prepareBinaryData.mockResolvedValue(mockBinaryData);
|
||||
});
|
||||
|
||||
describe('for fileFormat xlsx', () => {
|
||||
it('should convert from JSON', async () => {
|
||||
const result = await convertJsonToSpreadsheetBinary.call(executeFunctions, items, 'xlsx', {});
|
||||
|
||||
expect(result).toEqual(mockBinaryData);
|
||||
expect(xlsxUtils.json_to_sheet).toHaveBeenCalledWith(
|
||||
items.map((item) => item.json),
|
||||
undefined,
|
||||
);
|
||||
expect(xlsxWrite).toHaveBeenCalledWith(workBook, {
|
||||
bookType: 'xlsx',
|
||||
bookSST: false,
|
||||
type: 'buffer',
|
||||
});
|
||||
expect(helpers.prepareBinaryData).toHaveBeenCalledWith(mockBuffer, 'spreadsheet.xlsx');
|
||||
});
|
||||
});
|
||||
|
||||
describe('for fileFormat csv', () => {
|
||||
it('should convert from JSON', async () => {
|
||||
const result = await convertJsonToSpreadsheetBinary.call(executeFunctions, items, 'csv', {});
|
||||
|
||||
expect(result).toEqual(mockBinaryData);
|
||||
expect(xlsxUtils.json_to_sheet).toHaveBeenCalledWith(
|
||||
items.map((item) => item.json),
|
||||
undefined,
|
||||
);
|
||||
expect(xlsxWrite).toHaveBeenCalledWith(workBook, {
|
||||
bookType: 'csv',
|
||||
bookSST: false,
|
||||
type: 'buffer',
|
||||
});
|
||||
expect(helpers.prepareBinaryData).toHaveBeenCalledWith(mockBuffer, 'spreadsheet.csv');
|
||||
});
|
||||
|
||||
it('should handle custom delimiter', async () => {
|
||||
const result = await convertJsonToSpreadsheetBinary.call(executeFunctions, items, 'csv', {
|
||||
delimiter: ';',
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockBinaryData);
|
||||
expect(xlsxUtils.json_to_sheet).toHaveBeenCalledWith(
|
||||
items.map((item) => item.json),
|
||||
undefined,
|
||||
);
|
||||
expect(xlsxWrite).toHaveBeenCalledWith(workBook, {
|
||||
bookType: 'csv',
|
||||
bookSST: false,
|
||||
type: 'buffer',
|
||||
FS: ';',
|
||||
});
|
||||
expect(helpers.prepareBinaryData).toHaveBeenCalledWith(mockBuffer, 'spreadsheet.csv');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractDataFromPDF', () => {
|
||||
const helpers = mock<IExecuteFunctions['helpers']>();
|
||||
const executeFunctions = mock<IExecuteFunctions>({ helpers });
|
||||
|
||||
const originalDOMMatrix = globalThis.DOMMatrix;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalDOMMatrix) {
|
||||
globalThis.DOMMatrix = originalDOMMatrix;
|
||||
} else {
|
||||
// @ts-expect-error - Intentionally deleting for test cleanup
|
||||
delete globalThis.DOMMatrix;
|
||||
}
|
||||
});
|
||||
|
||||
describe('DOMMatrix polyfill', () => {
|
||||
it('should polyfill DOMMatrix when it is undefined', async () => {
|
||||
// @ts-expect-error - Intentionally deleting for test
|
||||
delete globalThis.DOMMatrix;
|
||||
expect(globalThis.DOMMatrix).toBeUndefined();
|
||||
|
||||
const mockPage = {
|
||||
getTextContent: jest.fn().mockResolvedValue({ items: [] }),
|
||||
};
|
||||
const mockDocument = {
|
||||
numPages: 1,
|
||||
getMetadata: jest.fn().mockResolvedValue({ info: {}, metadata: null }),
|
||||
getPage: jest.fn().mockResolvedValue(mockPage),
|
||||
};
|
||||
const { getDocument } = await import('pdfjs-dist/legacy/build/pdf.mjs');
|
||||
(getDocument as jest.Mock).mockReturnValue({
|
||||
promise: Promise.resolve(mockDocument),
|
||||
});
|
||||
|
||||
const mockBinaryData = {
|
||||
data: Buffer.from('fake pdf content').toString(BINARY_ENCODING),
|
||||
mimeType: 'application/pdf',
|
||||
};
|
||||
helpers.assertBinaryData.mockReturnValue(mockBinaryData);
|
||||
|
||||
await extractDataFromPDF.call(executeFunctions, 'data', undefined, undefined, true, 0);
|
||||
|
||||
expect(globalThis.DOMMatrix).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not re-polyfill DOMMatrix when it is already defined', async () => {
|
||||
const mockDOMMatrix = class MockDOMMatrix {};
|
||||
globalThis.DOMMatrix = mockDOMMatrix as unknown as typeof DOMMatrix;
|
||||
|
||||
const mockPage = {
|
||||
getTextContent: jest.fn().mockResolvedValue({ items: [] }),
|
||||
};
|
||||
const mockDocument = {
|
||||
numPages: 1,
|
||||
getMetadata: jest.fn().mockResolvedValue({ info: {}, metadata: null }),
|
||||
getPage: jest.fn().mockResolvedValue(mockPage),
|
||||
};
|
||||
const { getDocument } = await import('pdfjs-dist/legacy/build/pdf.mjs');
|
||||
(getDocument as jest.Mock).mockReturnValue({
|
||||
promise: Promise.resolve(mockDocument),
|
||||
});
|
||||
|
||||
const mockBinaryData = {
|
||||
data: Buffer.from('fake pdf content').toString(BINARY_ENCODING),
|
||||
mimeType: 'application/pdf',
|
||||
};
|
||||
helpers.assertBinaryData.mockReturnValue(mockBinaryData);
|
||||
|
||||
await extractDataFromPDF.call(executeFunctions, 'data', undefined, undefined, true, 0);
|
||||
|
||||
expect(globalThis.DOMMatrix).toBe(mockDOMMatrix);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareBinariesDataList', () => {
|
||||
describe('string input', () => {
|
||||
it('should split comma-separated string with spaces', () => {
|
||||
const input = 'file1, file2, file3';
|
||||
const result = prepareBinariesDataList(input);
|
||||
expect(result).toEqual(['file1', 'file2', 'file3']);
|
||||
});
|
||||
|
||||
it('should split comma-separated string without spaces', () => {
|
||||
const input = 'file1,file2,file3';
|
||||
const result = prepareBinariesDataList(input);
|
||||
expect(result).toEqual(['file1', 'file2', 'file3']);
|
||||
});
|
||||
|
||||
it('should trim whitespace from property names', () => {
|
||||
const input = 'file1 , file2 , file3';
|
||||
const result = prepareBinariesDataList(input);
|
||||
expect(result).toEqual(['file1', 'file2', 'file3']);
|
||||
});
|
||||
|
||||
it('should return single item as array', () => {
|
||||
const input = 'singleFile';
|
||||
const result = prepareBinariesDataList(input);
|
||||
expect(result).toEqual(['singleFile']);
|
||||
});
|
||||
|
||||
it('should return array with empty string for empty input', () => {
|
||||
const input = '';
|
||||
const result = prepareBinariesDataList(input);
|
||||
expect(result).toEqual(['']);
|
||||
});
|
||||
|
||||
it('should handle trailing comma', () => {
|
||||
const input = 'file1, file2,';
|
||||
const result = prepareBinariesDataList(input);
|
||||
expect(result).toEqual(['file1', 'file2', '']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('array input', () => {
|
||||
it('should return string array as-is', () => {
|
||||
const input = ['file1', 'file2', 'file3'];
|
||||
const result = prepareBinariesDataList(input);
|
||||
expect(result).toEqual(['file1', 'file2', 'file3']);
|
||||
});
|
||||
|
||||
it('should return empty array as-is', () => {
|
||||
const input: string[] = [];
|
||||
const result = prepareBinariesDataList(input);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return IBinaryData array as-is', () => {
|
||||
const input = [
|
||||
{ data: 'data1', mimeType: 'text/plain' },
|
||||
{ data: 'data2', mimeType: 'text/plain' },
|
||||
];
|
||||
const result = prepareBinariesDataList(input);
|
||||
expect(result).toEqual(input);
|
||||
});
|
||||
|
||||
it('should return IBinaryData array with multiple items', () => {
|
||||
const input = [
|
||||
{ data: 'data1', mimeType: 'text/plain', fileName: 'file1.txt' },
|
||||
{ data: 'data2', mimeType: 'image/png', fileName: 'file2.png' },
|
||||
{ data: 'data3', mimeType: 'application/pdf', fileName: 'file3.pdf' },
|
||||
];
|
||||
const result = prepareBinariesDataList(input);
|
||||
expect(result).toEqual(input);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should return single-item IBinaryData array as-is', () => {
|
||||
const input = [{ data: 'data1', mimeType: 'text/plain', fileName: 'single.txt' }];
|
||||
const result = prepareBinariesDataList(input);
|
||||
expect(result).toEqual(input);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('object input', () => {
|
||||
it('should wrap single IBinaryData object in array', () => {
|
||||
const input = { data: 'data1', mimeType: 'text/plain' };
|
||||
const result = prepareBinariesDataList(input);
|
||||
expect(result).toEqual([input]);
|
||||
});
|
||||
|
||||
it('should wrap IBinaryData object with fileName property in array', () => {
|
||||
const input = { data: 'data1', mimeType: 'text/plain', fileName: 'test.txt' };
|
||||
const result = prepareBinariesDataList(input);
|
||||
expect(result).toEqual([input]);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should wrap IBinaryData object with all properties in array', () => {
|
||||
const input = {
|
||||
data: 'base64data',
|
||||
mimeType: 'application/pdf',
|
||||
fileName: 'document.pdf',
|
||||
fileExtension: 'pdf',
|
||||
};
|
||||
const result = prepareBinariesDataList(input);
|
||||
expect(result).toEqual([input]);
|
||||
expect(result[0]).toMatchObject(input);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { OperationalError, type Logger } from 'n8n-workflow';
|
||||
|
||||
import { ConnectionPoolManager } from '@utils/connection-pool-manager';
|
||||
|
||||
const ttl = 5 * 60 * 1000;
|
||||
const cleanUpInterval = 60 * 1000;
|
||||
|
||||
const logger = mock<Logger>();
|
||||
|
||||
let cpm: ConnectionPoolManager;
|
||||
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
cpm = ConnectionPoolManager.getInstance(logger);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
cpm.purgeConnections();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cpm.purgeConnections();
|
||||
});
|
||||
|
||||
test('getInstance returns a singleton', () => {
|
||||
const instance1 = ConnectionPoolManager.getInstance(logger);
|
||||
const instance2 = ConnectionPoolManager.getInstance(logger);
|
||||
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
|
||||
describe('getConnection', () => {
|
||||
test('calls fallBackHandler only once and returns the first value', async () => {
|
||||
// ARRANGE
|
||||
const connectionType = {};
|
||||
const fallBackHandler = jest.fn(async () => {
|
||||
return connectionType;
|
||||
});
|
||||
|
||||
const options = {
|
||||
credentials: {},
|
||||
nodeType: 'example',
|
||||
nodeVersion: '1',
|
||||
fallBackHandler,
|
||||
wasUsed: jest.fn(),
|
||||
};
|
||||
|
||||
// ACT 1
|
||||
const connection = await cpm.getConnection(options);
|
||||
|
||||
// ASSERT 1
|
||||
expect(fallBackHandler).toHaveBeenCalledTimes(1);
|
||||
expect(connection).toBe(connectionType);
|
||||
|
||||
// ACT 2
|
||||
const connection2 = await cpm.getConnection(options);
|
||||
// ASSERT 2
|
||||
expect(fallBackHandler).toHaveBeenCalledTimes(1);
|
||||
expect(connection2).toBe(connectionType);
|
||||
});
|
||||
|
||||
test('creates different pools for different node versions', async () => {
|
||||
// ARRANGE
|
||||
const connectionType1 = {};
|
||||
const fallBackHandler1 = jest.fn(async () => {
|
||||
return connectionType1;
|
||||
});
|
||||
|
||||
const connectionType2 = {};
|
||||
const fallBackHandler2 = jest.fn(async () => {
|
||||
return connectionType2;
|
||||
});
|
||||
|
||||
// ACT 1
|
||||
const connection1 = await cpm.getConnection({
|
||||
credentials: {},
|
||||
nodeType: 'example',
|
||||
nodeVersion: '1',
|
||||
fallBackHandler: fallBackHandler1,
|
||||
wasUsed: jest.fn(),
|
||||
});
|
||||
const connection2 = await cpm.getConnection({
|
||||
credentials: {},
|
||||
nodeType: 'example',
|
||||
nodeVersion: '2',
|
||||
fallBackHandler: fallBackHandler2,
|
||||
wasUsed: jest.fn(),
|
||||
});
|
||||
|
||||
// ASSERT
|
||||
expect(fallBackHandler1).toHaveBeenCalledTimes(1);
|
||||
expect(connection1).toBe(connectionType1);
|
||||
|
||||
expect(fallBackHandler2).toHaveBeenCalledTimes(1);
|
||||
expect(connection2).toBe(connectionType2);
|
||||
|
||||
expect(connection1).not.toBe(connection2);
|
||||
});
|
||||
|
||||
test('calls cleanUpHandler after TTL expires', async () => {
|
||||
// ARRANGE
|
||||
const connectionType = {};
|
||||
let abortController: AbortController | undefined;
|
||||
const fallBackHandler = jest.fn(async (ac: AbortController) => {
|
||||
abortController = ac;
|
||||
return connectionType;
|
||||
});
|
||||
await cpm.getConnection({
|
||||
credentials: {},
|
||||
nodeType: 'example',
|
||||
nodeVersion: '1',
|
||||
fallBackHandler,
|
||||
wasUsed: jest.fn(),
|
||||
});
|
||||
|
||||
// ACT
|
||||
jest.advanceTimersByTime(ttl + cleanUpInterval * 2);
|
||||
|
||||
// ASSERT
|
||||
if (abortController === undefined) {
|
||||
fail("abortController haven't been initialized");
|
||||
}
|
||||
expect(abortController.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
test('throws OperationsError if the fallBackHandler aborts during connection initialization', async () => {
|
||||
// ARRANGE
|
||||
const connectionType = {};
|
||||
const fallBackHandler = jest.fn(async (ac: AbortController) => {
|
||||
ac.abort();
|
||||
return connectionType;
|
||||
});
|
||||
|
||||
// ACT
|
||||
const connectionPromise = cpm.getConnection({
|
||||
credentials: {},
|
||||
nodeType: 'example',
|
||||
nodeVersion: '1',
|
||||
fallBackHandler,
|
||||
wasUsed: jest.fn(),
|
||||
});
|
||||
|
||||
// ASSERT
|
||||
|
||||
await expect(connectionPromise).rejects.toThrow(OperationalError);
|
||||
await expect(connectionPromise).rejects.toThrow(
|
||||
'Could not create pool. Connection attempt was aborted.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onShutdown', () => {
|
||||
test('calls all clean up handlers', async () => {
|
||||
// ARRANGE
|
||||
const connectionType1 = {};
|
||||
let abortController1: AbortController | undefined;
|
||||
const fallBackHandler1 = jest.fn(async (ac: AbortController) => {
|
||||
abortController1 = ac;
|
||||
return connectionType1;
|
||||
});
|
||||
await cpm.getConnection({
|
||||
credentials: {},
|
||||
nodeType: 'example',
|
||||
nodeVersion: '1',
|
||||
fallBackHandler: fallBackHandler1,
|
||||
wasUsed: jest.fn(),
|
||||
});
|
||||
|
||||
const connectionType2 = {};
|
||||
let abortController2: AbortController | undefined;
|
||||
const fallBackHandler2 = jest.fn(async (ac: AbortController) => {
|
||||
abortController2 = ac;
|
||||
return connectionType2;
|
||||
});
|
||||
await cpm.getConnection({
|
||||
credentials: {},
|
||||
nodeType: 'example',
|
||||
nodeVersion: '2',
|
||||
fallBackHandler: fallBackHandler2,
|
||||
wasUsed: jest.fn(),
|
||||
});
|
||||
|
||||
// ACT
|
||||
cpm.purgeConnections();
|
||||
|
||||
// ASSERT
|
||||
if (abortController1 === undefined || abortController2 === undefined) {
|
||||
fail("abortController haven't been initialized");
|
||||
}
|
||||
expect(abortController1.signal.aborted).toBe(true);
|
||||
expect(abortController2.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
test('calls all clean up handlers when `exit` is emitted on process', async () => {
|
||||
// ARRANGE
|
||||
const connectionType1 = {};
|
||||
let abortController1: AbortController | undefined;
|
||||
const fallBackHandler1 = jest.fn(async (ac: AbortController) => {
|
||||
abortController1 = ac;
|
||||
return connectionType1;
|
||||
});
|
||||
await cpm.getConnection({
|
||||
credentials: {},
|
||||
nodeType: 'example',
|
||||
nodeVersion: '1',
|
||||
fallBackHandler: fallBackHandler1,
|
||||
wasUsed: jest.fn(),
|
||||
});
|
||||
|
||||
const connectionType2 = {};
|
||||
let abortController2: AbortController | undefined;
|
||||
const fallBackHandler2 = jest.fn(async (ac: AbortController) => {
|
||||
abortController2 = ac;
|
||||
return connectionType2;
|
||||
});
|
||||
await cpm.getConnection({
|
||||
credentials: {},
|
||||
nodeType: 'example',
|
||||
nodeVersion: '2',
|
||||
fallBackHandler: fallBackHandler2,
|
||||
wasUsed: jest.fn(),
|
||||
});
|
||||
|
||||
// ACT
|
||||
// @ts-expect-error we're not supposed to emit `exit` so it's missing from
|
||||
// the type definition
|
||||
process.emit('exit');
|
||||
|
||||
// ASSERT
|
||||
if (abortController1 === undefined || abortController2 === undefined) {
|
||||
fail("abortController haven't been initialized");
|
||||
}
|
||||
expect(abortController1.signal.aborted).toBe(true);
|
||||
expect(abortController2.signal.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wasUsed', () => {
|
||||
test('is called for every successive `getConnection` call', async () => {
|
||||
// ARRANGE
|
||||
const connectionType = {};
|
||||
const fallBackHandler = jest.fn(async () => {
|
||||
return connectionType;
|
||||
});
|
||||
|
||||
const wasUsed = jest.fn();
|
||||
const options = {
|
||||
credentials: {},
|
||||
nodeType: 'example',
|
||||
nodeVersion: '1',
|
||||
fallBackHandler,
|
||||
wasUsed,
|
||||
};
|
||||
|
||||
// ACT 1
|
||||
await cpm.getConnection(options);
|
||||
|
||||
// ASSERT 1
|
||||
expect(wasUsed).toHaveBeenCalledTimes(0);
|
||||
|
||||
// ACT 2
|
||||
await cpm.getConnection(options);
|
||||
|
||||
// ASSERT 2
|
||||
expect(wasUsed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,414 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { INode, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import { MYSQL_NODE_TYPE, POSTGRES_NODE_TYPE } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
addExecutionHints,
|
||||
compareItems,
|
||||
flattenKeys,
|
||||
fuzzyCompare,
|
||||
getResolvables,
|
||||
keysToLowercase,
|
||||
removeTrailingSlash,
|
||||
shuffleArray,
|
||||
sortItemKeysByPriorityList,
|
||||
wrapData,
|
||||
} from '@utils/utilities';
|
||||
|
||||
//most test cases for fuzzyCompare are done in Compare Datasets node tests
|
||||
describe('Test fuzzyCompare', () => {
|
||||
it('should do strict comparison', () => {
|
||||
const compareFunction = fuzzyCompare(false);
|
||||
|
||||
expect(compareFunction(1, '1')).toEqual(false);
|
||||
});
|
||||
|
||||
it('should do fuzzy comparison', () => {
|
||||
const compareFunction = fuzzyCompare(true);
|
||||
|
||||
expect(compareFunction(1, '1')).toEqual(true);
|
||||
});
|
||||
|
||||
it('should treat null, 0 and "0" as equal', () => {
|
||||
const compareFunction = fuzzyCompare(true, 2);
|
||||
|
||||
expect(compareFunction(null, null)).toEqual(true);
|
||||
expect(compareFunction(null, 0)).toEqual(true);
|
||||
expect(compareFunction(null, '0')).toEqual(true);
|
||||
});
|
||||
|
||||
it('should not treat null, 0 and "0" as equal', () => {
|
||||
const compareFunction = fuzzyCompare(true);
|
||||
|
||||
expect(compareFunction(null, 0)).toEqual(false);
|
||||
expect(compareFunction(null, '0')).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test wrapData', () => {
|
||||
it('should wrap object in json', () => {
|
||||
const data = {
|
||||
id: 1,
|
||||
name: 'Name',
|
||||
};
|
||||
const wrappedData = wrapData(data);
|
||||
expect(wrappedData).toBeDefined();
|
||||
expect(wrappedData).toEqual([{ json: data }]);
|
||||
});
|
||||
it('should wrap each object in array in json', () => {
|
||||
const data = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Name',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Name 2',
|
||||
},
|
||||
];
|
||||
const wrappedData = wrapData(data);
|
||||
expect(wrappedData).toBeDefined();
|
||||
expect(wrappedData).toEqual([{ json: data[0] }, { json: data[1] }]);
|
||||
});
|
||||
it('json key from source should be inside json', () => {
|
||||
const data = {
|
||||
json: {
|
||||
id: 1,
|
||||
name: 'Name',
|
||||
},
|
||||
};
|
||||
const wrappedData = wrapData(data);
|
||||
expect(wrappedData).toBeDefined();
|
||||
expect(wrappedData).toEqual([{ json: data }]);
|
||||
expect(Object.keys(wrappedData[0].json)).toContain('json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test keysToLowercase', () => {
|
||||
it('should convert keys to lowercase', () => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Test-Header': 'Test',
|
||||
Accept: 'application/json',
|
||||
};
|
||||
|
||||
const newHeaders = keysToLowercase(headers);
|
||||
|
||||
expect(newHeaders).toEqual({
|
||||
'content-type': 'application/json',
|
||||
'x-test-header': 'Test',
|
||||
accept: 'application/json',
|
||||
});
|
||||
});
|
||||
it('should return original value if it is not an object', () => {
|
||||
const test1 = keysToLowercase(['hello']);
|
||||
const test2 = keysToLowercase('test');
|
||||
const test3 = keysToLowercase(1);
|
||||
const test4 = keysToLowercase(true);
|
||||
const test5 = keysToLowercase(null);
|
||||
const test6 = keysToLowercase(undefined);
|
||||
|
||||
expect(test1).toEqual(['hello']);
|
||||
expect(test2).toEqual('test');
|
||||
expect(test3).toEqual(1);
|
||||
expect(test4).toEqual(true);
|
||||
expect(test5).toEqual(null);
|
||||
expect(test6).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test getResolvables', () => {
|
||||
it('should return empty array when there are no resolvables', () => {
|
||||
expect(getResolvables('Plain String, no resolvables here.')).toEqual([]);
|
||||
});
|
||||
it('should properly handle resovables in SQL query', () => {
|
||||
expect(getResolvables('SELECT * FROM {{ $json.db }}.{{ $json.table }};')).toEqual([
|
||||
'{{ $json.db }}',
|
||||
'{{ $json.table }}',
|
||||
]);
|
||||
});
|
||||
it('should properly handle resovables in HTML string', () => {
|
||||
expect(
|
||||
getResolvables(
|
||||
`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>{{ $json.pageTitle }}</title></head>
|
||||
<body><h1>{{ $json.heading }}</h1></body>
|
||||
<html>
|
||||
<style>
|
||||
body { height: {{ $json.pageHeight }}; }
|
||||
</style>
|
||||
<script>
|
||||
console.log('{{ $json.welcomeMessage }}');
|
||||
</script>
|
||||
`,
|
||||
),
|
||||
).toEqual([
|
||||
'{{ $json.pageTitle }}',
|
||||
'{{ $json.heading }}',
|
||||
'{{ $json.pageHeight }}',
|
||||
'{{ $json.welcomeMessage }}',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shuffleArray', () => {
|
||||
it('should shuffle array', () => {
|
||||
const array = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
const toShuffle = [...array];
|
||||
shuffleArray(toShuffle);
|
||||
expect(toShuffle).not.toEqual(array);
|
||||
expect(toShuffle).toHaveLength(array.length);
|
||||
expect(toShuffle).toEqual(expect.arrayContaining(array));
|
||||
});
|
||||
});
|
||||
|
||||
describe('flattenKeys', () => {
|
||||
const name = 'Lisa';
|
||||
const city1 = 'Berlin';
|
||||
const city2 = 'Schoenwald';
|
||||
const withNestedObject = {
|
||||
name,
|
||||
address: { city: city1 },
|
||||
};
|
||||
|
||||
const withNestedArrays = {
|
||||
name,
|
||||
addresses: [{ city: city1 }, { city: city2 }],
|
||||
};
|
||||
|
||||
it('should handle empty object', () => {
|
||||
const flattenedObj = flattenKeys({});
|
||||
expect(flattenedObj).toEqual({});
|
||||
});
|
||||
|
||||
it('should flatten object with nested object', () => {
|
||||
const flattenedObj = flattenKeys(withNestedObject);
|
||||
expect(flattenedObj).toEqual({
|
||||
name,
|
||||
'address.city': city1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle object with nested arrays', () => {
|
||||
const flattenedObj = flattenKeys(withNestedArrays);
|
||||
expect(flattenedObj).toEqual({
|
||||
name,
|
||||
'addresses.0.city': city1,
|
||||
'addresses.1.city': city2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should flatten object with nested object and specified prefix', () => {
|
||||
const flattenedObj = flattenKeys(withNestedObject, ['test']);
|
||||
expect(flattenedObj).toEqual({
|
||||
'test.name': name,
|
||||
'test.address.city': city1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle object with nested arrays and specified prefix', () => {
|
||||
const flattenedObj = flattenKeys(withNestedArrays, ['test']);
|
||||
expect(flattenedObj).toEqual({
|
||||
'test.name': name,
|
||||
'test.addresses.0.city': city1,
|
||||
'test.addresses.1.city': city2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareItems', () => {
|
||||
it('should return true if all values of specified keys are equal', () => {
|
||||
const obj1 = { json: { a: 1, b: 2, c: 3 } };
|
||||
const obj2 = { json: { a: 1, b: 2, c: 3 } };
|
||||
const keys = ['a', 'b', 'c'];
|
||||
const result = compareItems(obj1, obj2, keys);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if any values of specified keys are not equal', () => {
|
||||
const obj1 = { json: { a: 1, b: 2, c: 3 } };
|
||||
const obj2 = { json: { a: 1, b: 2, c: 4 } };
|
||||
const keys = ['a', 'b', 'c'];
|
||||
const result = compareItems(obj1, obj2, keys);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if all values of specified keys are equal using dot notation', () => {
|
||||
const obj1 = { json: { a: { b: { c: 1 } } } };
|
||||
const obj2 = { json: { a: { b: { c: 1 } } } };
|
||||
const keys = ['a.b.c'];
|
||||
const result = compareItems(obj1, obj2, keys);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if any values of specified keys are not equal using dot notation', () => {
|
||||
const obj1 = { json: { a: { b: { c: 1 } } } };
|
||||
const obj2 = { json: { a: { b: { c: 2 } } } };
|
||||
const keys = ['a.b.c'];
|
||||
const result = compareItems(obj1, obj2, keys);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if all values of specified keys are equal using bracket notation', () => {
|
||||
const obj1 = { json: { 'a.b': { 'c.d': 1 } } };
|
||||
const obj2 = { json: { 'a.b': { 'c.d': 1 } } };
|
||||
const keys = ['a.b.c.d'];
|
||||
const result = compareItems(obj1, obj2, keys, true);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortItemKeysByPriorityList', () => {
|
||||
it('should reorder keys based on priority list', () => {
|
||||
const data: INodeExecutionData[] = [{ json: { c: 3, a: 1, b: 2 } }];
|
||||
const priorityList = ['b', 'a'];
|
||||
|
||||
const result = sortItemKeysByPriorityList(data, priorityList);
|
||||
|
||||
expect(Object.keys(result[0].json)).toEqual(['b', 'a', 'c']);
|
||||
});
|
||||
|
||||
it('should sort keys not in the priority list alphabetically', () => {
|
||||
const data: INodeExecutionData[] = [{ json: { c: 3, a: 1, b: 2, d: 4 } }];
|
||||
const priorityList = ['b', 'a'];
|
||||
|
||||
const result = sortItemKeysByPriorityList(data, priorityList);
|
||||
|
||||
expect(Object.keys(result[0].json)).toEqual(['b', 'a', 'c', 'd']);
|
||||
});
|
||||
|
||||
it('should sort all keys alphabetically when priority list is empty', () => {
|
||||
const data: INodeExecutionData[] = [{ json: { c: 3, a: 1, b: 2 } }];
|
||||
const priorityList: string[] = [];
|
||||
|
||||
const result = sortItemKeysByPriorityList(data, priorityList);
|
||||
|
||||
expect(Object.keys(result[0].json)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('should handle an empty data array', () => {
|
||||
const data: INodeExecutionData[] = [];
|
||||
const priorityList = ['b', 'a'];
|
||||
|
||||
const result = sortItemKeysByPriorityList(data, priorityList);
|
||||
|
||||
// Expect an empty array since there is no data
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle a single object in the data array', () => {
|
||||
const data: INodeExecutionData[] = [{ json: { d: 4, b: 2, a: 1 } }];
|
||||
const priorityList = ['a', 'b', 'c'];
|
||||
|
||||
const result = sortItemKeysByPriorityList(data, priorityList);
|
||||
|
||||
expect(Object.keys(result[0].json)).toEqual(['a', 'b', 'd']);
|
||||
});
|
||||
|
||||
it('should handle duplicate keys in the priority list gracefully', () => {
|
||||
const data: INodeExecutionData[] = [{ json: { d: 4, b: 2, a: 1 } }];
|
||||
const priorityList = ['a', 'b', 'a'];
|
||||
|
||||
const result = sortItemKeysByPriorityList(data, priorityList);
|
||||
|
||||
expect(Object.keys(result[0].json)).toEqual(['a', 'b', 'd']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeTrailingSlash', () => {
|
||||
it('removes trailing slash', () => {
|
||||
expect(removeTrailingSlash('https://example.com/')).toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('does not change a URL without trailing slash', () => {
|
||||
expect(removeTrailingSlash('https://example.com')).toBe('https://example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addExecutionHints', () => {
|
||||
const executeQueryOperationContext = {
|
||||
getNodeParameter: (parameterName: string) => {
|
||||
if (parameterName === 'options.queryBatching') {
|
||||
return 'single';
|
||||
}
|
||||
if (parameterName === 'query') {
|
||||
return 'INSERT INTO my_test_table VALUES (`{{ $json.name }}`)';
|
||||
}
|
||||
},
|
||||
addExecutionHints: jest.fn(),
|
||||
} as unknown as IExecuteFunctions;
|
||||
|
||||
const insertHint = {
|
||||
message:
|
||||
"Inserts were batched for performance. If you need to preserve item matching, consider changing 'Query batching' to 'Independent' in the options.",
|
||||
location: 'outputPane',
|
||||
};
|
||||
|
||||
const selectHint = {
|
||||
location: 'outputPane',
|
||||
message:
|
||||
"This node ran 2 times, once for each input item. To run for the first item only, enable 'execute once' in the node settings",
|
||||
};
|
||||
|
||||
it('should add batching insert hint to Postgres executeQuery operation', () => {
|
||||
addExecutionHints(
|
||||
executeQueryOperationContext,
|
||||
mock<INode>({
|
||||
type: POSTGRES_NODE_TYPE,
|
||||
}),
|
||||
[{ json: {} }, { json: {} }],
|
||||
'executeQuery',
|
||||
false,
|
||||
);
|
||||
expect(executeQueryOperationContext.addExecutionHints).toHaveBeenCalledWith(insertHint);
|
||||
});
|
||||
|
||||
it('should add batching insert hint to MySql executeQuery operation', () => {
|
||||
addExecutionHints(
|
||||
executeQueryOperationContext,
|
||||
mock<INode>({
|
||||
type: MYSQL_NODE_TYPE,
|
||||
}),
|
||||
[{ json: {} }, { json: {} }],
|
||||
'executeQuery',
|
||||
false,
|
||||
);
|
||||
expect(executeQueryOperationContext.addExecutionHints).toHaveBeenCalledWith(insertHint);
|
||||
});
|
||||
|
||||
it('should add run per item hint to Postgres select operation ', () => {
|
||||
const context = {
|
||||
addExecutionHints: jest.fn(),
|
||||
} as unknown as IExecuteFunctions;
|
||||
|
||||
addExecutionHints(
|
||||
context,
|
||||
mock<INode>({
|
||||
type: POSTGRES_NODE_TYPE,
|
||||
}),
|
||||
[{ json: {} }, { json: {} }],
|
||||
'select',
|
||||
false,
|
||||
);
|
||||
expect(context.addExecutionHints).toHaveBeenCalledWith(selectHint);
|
||||
});
|
||||
|
||||
it('should add run per item hint to MySQL select operation', () => {
|
||||
const context = {
|
||||
addExecutionHints: jest.fn(),
|
||||
} as unknown as IExecuteFunctions;
|
||||
|
||||
addExecutionHints(
|
||||
context,
|
||||
mock<INode>({
|
||||
type: MYSQL_NODE_TYPE,
|
||||
}),
|
||||
[{ json: {} }, { json: {} }],
|
||||
'select',
|
||||
false,
|
||||
);
|
||||
expect(context.addExecutionHints).toHaveBeenCalledWith(selectHint);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
import { timingSafeEqual } from 'crypto';
|
||||
|
||||
import { verifySignature } from '../webhook-signature-verification';
|
||||
|
||||
jest.mock('crypto', () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
...jest.requireActual('crypto'),
|
||||
timingSafeEqual: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('webhook-signature-verification', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('verifySignature', () => {
|
||||
it('should return true when signatures match', () => {
|
||||
const expectedSignature = 'sha256=abc123';
|
||||
const actualSignature = 'sha256=abc123';
|
||||
|
||||
(timingSafeEqual as jest.Mock).mockReturnValue(true);
|
||||
|
||||
const result = verifySignature({
|
||||
getExpectedSignature: () => expectedSignature,
|
||||
getActualSignature: () => actualSignature,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(timingSafeEqual).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false when signatures do not match', () => {
|
||||
const expectedSignature = 'sha256=abc123';
|
||||
const actualSignature = 'sha256=xyz789';
|
||||
|
||||
(timingSafeEqual as jest.Mock).mockReturnValue(false);
|
||||
|
||||
const result = verifySignature({
|
||||
getExpectedSignature: () => expectedSignature,
|
||||
getActualSignature: () => actualSignature,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(timingSafeEqual).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should verify signature even when timestamp is skipped', () => {
|
||||
const expectedSignature = 'sha256=abc123';
|
||||
const actualSignature = 'sha256=xyz789';
|
||||
|
||||
(timingSafeEqual as jest.Mock).mockReturnValue(false);
|
||||
|
||||
const result = verifySignature({
|
||||
getExpectedSignature: () => expectedSignature,
|
||||
getActualSignature: () => actualSignature,
|
||||
getTimestamp: () => null,
|
||||
skipIfNoTimestamp: true,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(timingSafeEqual).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false when signatures have different lengths', () => {
|
||||
const expectedSignature = 'sha256=abc123';
|
||||
const actualSignature = 'sha256=abc1234';
|
||||
|
||||
const result = verifySignature({
|
||||
getExpectedSignature: () => expectedSignature,
|
||||
getActualSignature: () => actualSignature,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(timingSafeEqual).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false when expected signature is missing', () => {
|
||||
const result = verifySignature({
|
||||
getExpectedSignature: () => null,
|
||||
getActualSignature: () => 'sha256=abc123',
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when expected signature is undefined', () => {
|
||||
const result = verifySignature({
|
||||
getExpectedSignature: () => undefined as unknown as string,
|
||||
getActualSignature: () => 'sha256=abc123',
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when actual signature is missing', () => {
|
||||
const result = verifySignature({
|
||||
getExpectedSignature: () => 'sha256=abc123',
|
||||
getActualSignature: () => null,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when actual signature is undefined', () => {
|
||||
const result = verifySignature({
|
||||
getExpectedSignature: () => 'sha256=abc123',
|
||||
getActualSignature: () => undefined as unknown as string,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when skipIfNoExpectedSignature is true and no expected signature', () => {
|
||||
const result = verifySignature({
|
||||
getExpectedSignature: () => null,
|
||||
getActualSignature: () => 'sha256=abc123',
|
||||
skipIfNoExpectedSignature: true,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate timestamp when provided and within window', () => {
|
||||
const currentTimeSec = Math.floor(Date.now() / 1000);
|
||||
const recentTimestamp = currentTimeSec - 60; // 1 minute ago
|
||||
|
||||
(timingSafeEqual as jest.Mock).mockReturnValue(true);
|
||||
|
||||
const result = verifySignature({
|
||||
getExpectedSignature: () => 'sha256=abc123',
|
||||
getActualSignature: () => 'sha256=abc123',
|
||||
getTimestamp: () => recentTimestamp,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when timestamp is too old', () => {
|
||||
const currentTimeSec = Math.floor(Date.now() / 1000);
|
||||
const oldTimestamp = currentTimeSec - 400; // More than 5 minutes ago
|
||||
|
||||
const result = verifySignature({
|
||||
getExpectedSignature: () => 'sha256=abc123',
|
||||
getActualSignature: () => 'sha256=abc123',
|
||||
getTimestamp: () => oldTimestamp,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when timestamp is too far in future', () => {
|
||||
const currentTimeSec = Math.floor(Date.now() / 1000);
|
||||
const futureTimestamp = currentTimeSec + 400; // More than 5 minutes in future
|
||||
|
||||
const result = verifySignature({
|
||||
getExpectedSignature: () => 'sha256=abc123',
|
||||
getActualSignature: () => 'sha256=abc123',
|
||||
getTimestamp: () => futureTimestamp,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle timestamp as string', () => {
|
||||
const currentTimeSec = Math.floor(Date.now() / 1000);
|
||||
const recentTimestamp = String(currentTimeSec - 60);
|
||||
|
||||
(timingSafeEqual as jest.Mock).mockReturnValue(true);
|
||||
|
||||
const result = verifySignature({
|
||||
getExpectedSignature: () => 'sha256=abc123',
|
||||
getActualSignature: () => 'sha256=abc123',
|
||||
getTimestamp: () => recentTimestamp,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should convert milliseconds timestamp to seconds', () => {
|
||||
const currentTimeMs = Date.now();
|
||||
const recentTimestampMs = currentTimeMs - 60 * 1000; // 1 minute ago in ms
|
||||
|
||||
(timingSafeEqual as jest.Mock).mockReturnValue(true);
|
||||
|
||||
const result = verifySignature({
|
||||
getExpectedSignature: () => 'sha256=abc123',
|
||||
getActualSignature: () => 'sha256=abc123',
|
||||
getTimestamp: () => recentTimestampMs,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should use custom maxTimestampAgeSeconds', () => {
|
||||
const currentTimeSec = Math.floor(Date.now() / 1000);
|
||||
const timestamp = currentTimeSec - 120; // 2 minutes ago
|
||||
|
||||
(timingSafeEqual as jest.Mock).mockReturnValue(true);
|
||||
|
||||
const result = verifySignature({
|
||||
getExpectedSignature: () => 'sha256=abc123',
|
||||
getActualSignature: () => 'sha256=abc123',
|
||||
getTimestamp: () => timestamp,
|
||||
maxTimestampAgeSeconds: 60, // 1 minute window
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when skipIfNoTimestamp is true and timestamp is null', () => {
|
||||
(timingSafeEqual as jest.Mock).mockReturnValue(true);
|
||||
|
||||
const result = verifySignature({
|
||||
getExpectedSignature: () => 'sha256=abc123',
|
||||
getActualSignature: () => 'sha256=abc123',
|
||||
getTimestamp: () => null,
|
||||
skipIfNoTimestamp: true,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when timestamp is null and skipIfNoTimestamp is false', () => {
|
||||
const result = verifySignature({
|
||||
getExpectedSignature: () => 'sha256=abc123',
|
||||
getActualSignature: () => 'sha256=abc123',
|
||||
getTimestamp: () => null,
|
||||
skipIfNoTimestamp: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when timestamp is invalid (NaN)', () => {
|
||||
const result = verifySignature({
|
||||
getExpectedSignature: () => 'sha256=abc123',
|
||||
getActualSignature: () => 'sha256=abc123',
|
||||
getTimestamp: () => 'invalid-timestamp',
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully and return false', () => {
|
||||
const result = verifySignature({
|
||||
getExpectedSignature: () => {
|
||||
throw new Error('Test error');
|
||||
},
|
||||
getActualSignature: () => 'sha256=abc123',
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle timingSafeEqual errors gracefully', () => {
|
||||
(timingSafeEqual as jest.Mock).mockImplementation(() => {
|
||||
throw new Error('Buffer length mismatch');
|
||||
});
|
||||
|
||||
const result = verifySignature({
|
||||
getExpectedSignature: () => 'sha256=abc123',
|
||||
getActualSignature: () => 'sha256=abc123',
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
export const allCurrencies = [
|
||||
{ name: 'Euro', value: 'eur' },
|
||||
{ name: 'United States Dollar', value: 'usd' },
|
||||
{ name: 'British Pound Sterling', value: 'gbp' },
|
||||
{ name: 'Swiss Franc', value: 'chf' },
|
||||
{ name: 'Renminbi', value: 'cny' },
|
||||
{ name: '--------', value: '' },
|
||||
{ name: 'United Arab Emirates Dirham', value: 'aed' },
|
||||
{ name: 'Afghan Afghani', value: 'afn' },
|
||||
{ name: 'Albanian Lek', value: 'all' },
|
||||
{ name: 'Armenian Dram', value: 'amd' },
|
||||
{ name: 'Netherlands Antillean Guilder', value: 'ang' },
|
||||
{ name: 'Angolan Kwanza', value: 'aoa' },
|
||||
{ name: 'Argentine Peso', value: 'ars' },
|
||||
{ name: 'Australian Dollar', value: 'aud' },
|
||||
{ name: 'Aruban Florin', value: 'awg' },
|
||||
{ name: 'Azerbaijani Manat', value: 'azn' },
|
||||
{ name: 'Bosnia-Herzegovina Convertible Mark', value: 'bam' },
|
||||
{ name: 'Barbadian Dollar', value: 'bbd' },
|
||||
{ name: 'Bangladeshi Taka', value: 'bdt' },
|
||||
{ name: 'Bulgarian Lev', value: 'bgn' },
|
||||
{ name: 'Bahraini Dinar', value: 'bhd' },
|
||||
{ name: 'Burundian Franc', value: 'bif' },
|
||||
{ name: 'Bermudan Dollar', value: 'bmd' },
|
||||
{ name: 'Brunei Dollar', value: 'bnd' },
|
||||
{ name: 'Bolivian Boliviano', value: 'bob' },
|
||||
{ name: 'Brazilian Real', value: 'brl' },
|
||||
{ name: 'Bahamian Dollar', value: 'bsd' },
|
||||
{ name: 'Bitcoin', value: 'btc' },
|
||||
{ name: 'Bhutanese Ngultrum', value: 'btn' },
|
||||
{ name: 'Botswanan Pula', value: 'bwp' },
|
||||
{ name: 'Belarusian Ruble', value: 'byn' },
|
||||
{ name: 'Belize Dollar', value: 'bzd' },
|
||||
{ name: 'Canadian Dollar', value: 'cad' },
|
||||
{ name: 'Congolese Franc', value: 'cdf' },
|
||||
{ name: 'Chilean Unit of Account (UF)', value: 'clf' },
|
||||
{ name: 'Chilean Peso', value: 'clp' },
|
||||
{ name: 'Chinese Yuan (Offshore)', value: 'cnh' },
|
||||
{ name: 'Colombian Peso', value: 'cop' },
|
||||
{ name: 'Costa Rican Colón', value: 'crc' },
|
||||
{ name: 'Cuban Convertible Peso', value: 'cuc' },
|
||||
{ name: 'Cuban Peso', value: 'cup' },
|
||||
{ name: 'Cape Verdean Escudo', value: 'cve' },
|
||||
{ name: 'Czech Republic Koruna', value: 'czk' },
|
||||
{ name: 'Djiboutian Franc', value: 'djf' },
|
||||
{ name: 'Danish Krone', value: 'dkk' },
|
||||
{ name: 'Dominican Peso', value: 'dop' },
|
||||
{ name: 'Algerian Dinar', value: 'dzd' },
|
||||
{ name: 'Egyptian Pound', value: 'egp' },
|
||||
{ name: 'Eritrean Nakfa', value: 'ern' },
|
||||
{ name: 'Ethiopian Birr', value: 'etb' },
|
||||
{ name: 'Fijian Dollar', value: 'fjd' },
|
||||
{ name: 'Falkland Islands Pound', value: 'fkp' },
|
||||
{ name: 'Georgian Lari', value: 'gel' },
|
||||
{ name: 'Guernsey Pound', value: 'ggp' },
|
||||
{ name: 'Ghanaian Cedi', value: 'ghs' },
|
||||
{ name: 'Gibraltar Pound', value: 'gip' },
|
||||
{ name: 'Gambian Dalasi', value: 'gmd' },
|
||||
{ name: 'Guinean Franc', value: 'gnf' },
|
||||
{ name: 'Guatemalan Quetzal', value: 'gtq' },
|
||||
{ name: 'Guyanaese Dollar', value: 'gyd' },
|
||||
{ name: 'Hong Kong Dollar', value: 'hkd' },
|
||||
{ name: 'Honduran Lempira', value: 'hnl' },
|
||||
{ name: 'Croatian Kuna', value: 'hrk' },
|
||||
{ name: 'Haitian Gourde', value: 'htg' },
|
||||
{ name: 'Hungarian Forint', value: 'huf' },
|
||||
{ name: 'Indonesian Rupiah', value: 'idr' },
|
||||
{ name: 'Israeli New Sheqel', value: 'ils' },
|
||||
{ name: 'Manx Pound', value: 'imp' },
|
||||
{ name: 'Indian Rupee', value: 'inr' },
|
||||
{ name: 'Iraqi Dinar', value: 'iqd' },
|
||||
{ name: 'Iranian Rial', value: 'irr' },
|
||||
{ name: 'Icelandic Króna', value: 'isk' },
|
||||
{ name: 'Jersey Pound', value: 'jep' },
|
||||
{ name: 'Jamaican Dollar', value: 'jmd' },
|
||||
{ name: 'Jordanian Dinar', value: 'jod' },
|
||||
{ name: 'Japanese Yen', value: 'jpy' },
|
||||
{ name: 'Kenyan Shilling', value: 'kes' },
|
||||
{ name: 'Kyrgystani Som', value: 'kgs' },
|
||||
{ name: 'Cambodian Riel', value: 'khr' },
|
||||
{ name: 'Comorian Franc', value: 'kmf' },
|
||||
{ name: 'North Korean Won', value: 'kpw' },
|
||||
{ name: 'South Korean Won', value: 'krw' },
|
||||
{ name: 'Kuwaiti Dinar', value: 'kwd' },
|
||||
{ name: 'Cayman Islands Dollar', value: 'kyd' },
|
||||
{ name: 'Kazakhstani Tenge', value: 'kzt' },
|
||||
{ name: 'Laotian Kip', value: 'lak' },
|
||||
{ name: 'Lebanese Pound', value: 'lbp' },
|
||||
{ name: 'Sri Lankan Rupee', value: 'lkr' },
|
||||
{ name: 'Liberian Dollar', value: 'lrd' },
|
||||
{ name: 'Lesotho Loti', value: 'lsl' },
|
||||
{ name: 'Libyan Dinar', value: 'lyd' },
|
||||
{ name: 'Moroccan Dirham', value: 'mad' },
|
||||
{ name: 'Moldovan Leu', value: 'mdl' },
|
||||
{ name: 'Malagasy Ariary', value: 'mga' },
|
||||
{ name: 'Macedonian Denar', value: 'mkd' },
|
||||
{ name: 'Myanma Kyat', value: 'mmk' },
|
||||
{ name: 'Mongolian Tugrik', value: 'mnt' },
|
||||
{ name: 'Macanese Pataca', value: 'mop' },
|
||||
{ name: 'Mauritanian Ouguiya (Pre-2018)', value: 'mro' },
|
||||
{ name: 'Mauritanian Ouguiya', value: 'mru' },
|
||||
{ name: 'Mauritian Rupee', value: 'mur' },
|
||||
{ name: 'Maldivian Rufiyaa', value: 'mvr' },
|
||||
{ name: 'Malawian Kwacha', value: 'mwk' },
|
||||
{ name: 'Mexican Peso', value: 'mxn' },
|
||||
{ name: 'Malaysian Ringgit', value: 'myr' },
|
||||
{ name: 'Mozambican Metical', value: 'mzn' },
|
||||
{ name: 'Namibian Dollar', value: 'nad' },
|
||||
{ name: 'Nigerian Naira', value: 'ngn' },
|
||||
{ name: 'Nicaraguan Córdoba', value: 'nio' },
|
||||
{ name: 'Norwegian Krone', value: 'nok' },
|
||||
{ name: 'Nepalese Rupee', value: 'npr' },
|
||||
{ name: 'New Zealand Dollar', value: 'nzd' },
|
||||
{ name: 'Omani Rial', value: 'omr' },
|
||||
{ name: 'Panamanian Balboa', value: 'pab' },
|
||||
{ name: 'Peruvian Nuevo Sol', value: 'pen' },
|
||||
{ name: 'Papua New Guinean Kina', value: 'pgk' },
|
||||
{ name: 'Philippine Peso', value: 'php' },
|
||||
{ name: 'Pakistani Rupee', value: 'pkr' },
|
||||
{ name: 'Polish Zloty', value: 'pln' },
|
||||
{ name: 'Paraguayan Guarani', value: 'pyg' },
|
||||
{ name: 'Qatari Rial', value: 'qar' },
|
||||
{ name: 'Romanian Leu', value: 'ron' },
|
||||
{ name: 'Serbian Dinar', value: 'rsd' },
|
||||
{ name: 'Russian Ruble', value: 'rub' },
|
||||
{ name: 'Rwandan Franc', value: 'rwf' },
|
||||
{ name: 'Saudi Riyal', value: 'sar' },
|
||||
{ name: 'Solomon Islands Dollar', value: 'sbd' },
|
||||
{ name: 'Seychellois Rupee', value: 'scr' },
|
||||
{ name: 'Sudanese Pound', value: 'sdg' },
|
||||
{ name: 'Swedish Krona', value: 'sek' },
|
||||
{ name: 'Singapore Dollar', value: 'sgd' },
|
||||
{ name: 'Saint Helena Pound', value: 'shp' },
|
||||
{ name: 'Sierra Leonean Leone', value: 'sll' },
|
||||
{ name: 'Somali Shilling', value: 'sos' },
|
||||
{ name: 'Surinamese Dollar', value: 'srd' },
|
||||
{ name: 'South Sudanese Pound', value: 'ssp' },
|
||||
{ name: 'São Tomé and Príncipe Dobra (Pre-2018)', value: 'std' },
|
||||
{ name: 'São Tomé and Príncipe Dobra', value: 'stn' },
|
||||
{ name: 'Salvadoran Colón', value: 'svc' },
|
||||
{ name: 'Syrian Pound', value: 'syp' },
|
||||
{ name: 'Swazi Lilangeni', value: 'szl' },
|
||||
{ name: 'Thai Baht', value: 'thb' },
|
||||
{ name: 'Tajikistani Somoni', value: 'tjs' },
|
||||
{ name: 'Turkmenistani Manat', value: 'tmt' },
|
||||
{ name: 'Tunisian Dinar', value: 'tnd' },
|
||||
{ name: "Tongan Pa'anga", value: 'top' },
|
||||
{ name: 'Turkish Lira', value: 'try' },
|
||||
{ name: 'Trinidad and Tobago Dollar', value: 'ttd' },
|
||||
{ name: 'New Taiwan Dollar', value: 'twd' },
|
||||
{ name: 'Tanzanian Shilling', value: 'tzs' },
|
||||
{ name: 'Ukrainian Hryvnia', value: 'uah' },
|
||||
{ name: 'Ugandan Shilling', value: 'ugx' },
|
||||
{ name: 'Uruguayan Peso', value: 'uyu' },
|
||||
{ name: 'Uzbekistan Som', value: 'uzs' },
|
||||
{ name: 'Venezuelan Bolívar Fuerte', value: 'vef' },
|
||||
{ name: 'Vietnamese Dong', value: 'vnd' },
|
||||
{ name: 'Vanuatu Vatu', value: 'vuv' },
|
||||
{ name: 'Samoan Tala', value: 'wst' },
|
||||
{ name: 'CFA Franc BEAC', value: 'xaf' },
|
||||
{ name: 'Silver Ounce', value: 'xag' },
|
||||
{ name: 'Gold Ounce', value: 'xau' },
|
||||
{ name: 'East Caribbean Dollar', value: 'xcd' },
|
||||
{ name: 'Special Drawing Rights', value: 'xdr' },
|
||||
{ name: 'CFA Franc BCEAO', value: 'xof' },
|
||||
{ name: 'Palladium Ounce', value: 'xpd' },
|
||||
{ name: 'CFP Franc', value: 'xpf' },
|
||||
{ name: 'Platinum Ounce', value: 'xpt' },
|
||||
{ name: 'Yemeni Rial', value: 'yer' },
|
||||
{ name: 'South African Rand', value: 'zar' },
|
||||
{ name: 'Zambian Kwacha', value: 'zmw' },
|
||||
{ name: 'Zimbabwean Dollar', value: 'zwl' },
|
||||
];
|
||||
@@ -0,0 +1,217 @@
|
||||
import iconv from 'iconv-lite';
|
||||
import get from 'lodash/get';
|
||||
import type { IBinaryData, IDataObject, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import { NodeOperationError, BINARY_ENCODING } from 'n8n-workflow';
|
||||
import type { TextContent as PdfTextContent } from 'pdfjs-dist/types/src/display/api';
|
||||
import type { WorkBook, WritingOptions } from 'xlsx';
|
||||
import { utils as xlsxUtils, write as xlsxWrite } from 'xlsx';
|
||||
|
||||
import { flattenObject } from '@utils/utilities';
|
||||
|
||||
export type JsonToSpreadsheetBinaryFormat = 'csv' | 'html' | 'rtf' | 'ods' | 'xls' | 'xlsx';
|
||||
|
||||
export type JsonToSpreadsheetBinaryOptions = {
|
||||
headerRow?: boolean;
|
||||
compression?: boolean;
|
||||
fileName?: string;
|
||||
sheetName?: string;
|
||||
delimiter?: string;
|
||||
};
|
||||
|
||||
export type JsonToBinaryOptions = {
|
||||
fileName?: string;
|
||||
sourceKey?: string;
|
||||
encoding?: string;
|
||||
addBOM?: boolean;
|
||||
mimeType?: string;
|
||||
dataIsBase64?: boolean;
|
||||
itemIndex?: number;
|
||||
format?: boolean;
|
||||
};
|
||||
|
||||
export async function convertJsonToSpreadsheetBinary(
|
||||
this: IExecuteFunctions,
|
||||
items: INodeExecutionData[],
|
||||
fileFormat: JsonToSpreadsheetBinaryFormat,
|
||||
options: JsonToSpreadsheetBinaryOptions,
|
||||
defaultFileName = 'spreadsheet',
|
||||
): Promise<IBinaryData> {
|
||||
const itemData: IDataObject[] = [];
|
||||
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
|
||||
itemData.push(flattenObject(items[itemIndex].json));
|
||||
}
|
||||
|
||||
let sheetToJsonOptions;
|
||||
if (options.headerRow === false) {
|
||||
sheetToJsonOptions = { skipHeader: true };
|
||||
}
|
||||
|
||||
const sheet = xlsxUtils.json_to_sheet(itemData, sheetToJsonOptions);
|
||||
|
||||
const writingOptions: WritingOptions = {
|
||||
bookType: fileFormat,
|
||||
bookSST: false,
|
||||
type: 'buffer',
|
||||
};
|
||||
|
||||
if (fileFormat === 'csv' && options.delimiter?.length) {
|
||||
writingOptions.FS = options.delimiter ?? ',';
|
||||
}
|
||||
|
||||
if (['xlsx', 'ods'].includes(fileFormat) && options.compression) {
|
||||
writingOptions.compression = true;
|
||||
}
|
||||
|
||||
// Convert the data in the correct format
|
||||
const sheetName = (options.sheetName as string) || 'Sheet';
|
||||
const workbook: WorkBook = {
|
||||
SheetNames: [sheetName],
|
||||
Sheets: {
|
||||
[sheetName]: sheet,
|
||||
},
|
||||
};
|
||||
|
||||
const buffer: Buffer = xlsxWrite(workbook, writingOptions);
|
||||
const fileName =
|
||||
options.fileName !== undefined ? options.fileName : `${defaultFileName}.${fileFormat}`;
|
||||
const binaryData = await this.helpers.prepareBinaryData(buffer, fileName);
|
||||
|
||||
return binaryData;
|
||||
}
|
||||
|
||||
export async function createBinaryFromJson(
|
||||
this: IExecuteFunctions,
|
||||
data: IDataObject | IDataObject[],
|
||||
options: JsonToBinaryOptions,
|
||||
): Promise<IBinaryData> {
|
||||
let value;
|
||||
if (options.sourceKey) {
|
||||
value = get(data, options.sourceKey) as IDataObject;
|
||||
} else {
|
||||
value = data;
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
throw new NodeOperationError(this.getNode(), `The value in "${options.sourceKey}" is not set`, {
|
||||
itemIndex: options.itemIndex || 0,
|
||||
});
|
||||
}
|
||||
|
||||
let buffer: Buffer;
|
||||
if (!options.dataIsBase64) {
|
||||
let valueAsString = value as unknown as string;
|
||||
|
||||
if (typeof value === 'object') {
|
||||
options.mimeType = 'application/json';
|
||||
if (options.format) {
|
||||
valueAsString = JSON.stringify(value, null, 2);
|
||||
} else {
|
||||
valueAsString = JSON.stringify(value);
|
||||
}
|
||||
}
|
||||
|
||||
buffer = iconv.encode(valueAsString, options.encoding || 'utf8', {
|
||||
addBOM: options.addBOM,
|
||||
});
|
||||
} else {
|
||||
buffer = Buffer.from(value as unknown as string, BINARY_ENCODING);
|
||||
}
|
||||
|
||||
const binaryData = await this.helpers.prepareBinaryData(
|
||||
buffer,
|
||||
options.fileName,
|
||||
options.mimeType,
|
||||
);
|
||||
|
||||
if (!binaryData.fileName) {
|
||||
const fileExtension = binaryData.fileExtension ? `.${binaryData.fileExtension}` : '';
|
||||
binaryData.fileName = `file${fileExtension}`;
|
||||
}
|
||||
|
||||
return binaryData;
|
||||
}
|
||||
|
||||
const parseText = (textContent: PdfTextContent) => {
|
||||
let lastY = undefined;
|
||||
const text = [];
|
||||
for (const item of textContent.items) {
|
||||
if ('str' in item) {
|
||||
if (lastY == item.transform[5] || !lastY) {
|
||||
text.push(item.str);
|
||||
} else {
|
||||
text.push(`\n${item.str}`);
|
||||
}
|
||||
lastY = item.transform[5];
|
||||
}
|
||||
}
|
||||
return text.join('');
|
||||
};
|
||||
|
||||
export async function extractDataFromPDF(
|
||||
this: IExecuteFunctions,
|
||||
binaryPropertyName: string,
|
||||
password?: string,
|
||||
maxPages?: number,
|
||||
joinPages = true,
|
||||
itemIndex = 0,
|
||||
) {
|
||||
const binaryData = this.helpers.assertBinaryData(itemIndex, binaryPropertyName);
|
||||
|
||||
let buffer: Buffer;
|
||||
if (binaryData.id) {
|
||||
const stream = await this.helpers.getBinaryStream(binaryData.id);
|
||||
buffer = await this.helpers.binaryToBuffer(stream);
|
||||
} else {
|
||||
buffer = Buffer.from(binaryData.data, BINARY_ENCODING);
|
||||
}
|
||||
|
||||
// Polyfill DOMMatrix for pdfjs-dist in Node.js environments without canvas
|
||||
if (typeof globalThis.DOMMatrix === 'undefined') {
|
||||
const { default: DOMMatrix } = await import('@thednp/dommatrix');
|
||||
globalThis.DOMMatrix = DOMMatrix as unknown as typeof globalThis.DOMMatrix;
|
||||
}
|
||||
|
||||
const { getDocument: readPDF, version: pdfJsVersion } = await import(
|
||||
'pdfjs-dist/legacy/build/pdf.mjs'
|
||||
);
|
||||
const document = await readPDF({
|
||||
password,
|
||||
isEvalSupported: false,
|
||||
data: new Uint8Array(buffer),
|
||||
}).promise;
|
||||
const { info, metadata } = await document
|
||||
.getMetadata()
|
||||
.catch(() => ({ info: null, metadata: null }));
|
||||
|
||||
const pages = [];
|
||||
if (maxPages !== 0) {
|
||||
let pagesToRead = document.numPages;
|
||||
if (maxPages && maxPages < document.numPages) {
|
||||
pagesToRead = maxPages;
|
||||
}
|
||||
for (let i = 1; i <= pagesToRead; i++) {
|
||||
const page = await document.getPage(i);
|
||||
const text = await page.getTextContent().then(parseText);
|
||||
pages.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
const text = joinPages ? pages.join('\n\n') : pages;
|
||||
|
||||
const returnData = {
|
||||
numpages: document.numPages,
|
||||
numrender: document.numPages,
|
||||
info,
|
||||
metadata: (metadata && Object.fromEntries([...metadata])) ?? undefined,
|
||||
text,
|
||||
version: pdfJsVersion,
|
||||
};
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
export function prepareBinariesDataList(data: string | string[] | IBinaryData | IBinaryData[]) {
|
||||
if (Array.isArray(data)) return data;
|
||||
if (typeof data === 'object') return [data];
|
||||
return data.split(',').map((item: string) => item.trim());
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { OperationalError, type Logger } from 'n8n-workflow';
|
||||
|
||||
let instance: ConnectionPoolManager;
|
||||
|
||||
// 5 minutes
|
||||
const ttl = 5 * 60 * 1000;
|
||||
|
||||
// 1 minute
|
||||
const cleanUpInterval = 60 * 1000;
|
||||
|
||||
type RegistrationOptions = {
|
||||
credentials: unknown;
|
||||
nodeType: string;
|
||||
nodeVersion?: string;
|
||||
};
|
||||
|
||||
type GetConnectionOption<Pool> = RegistrationOptions & {
|
||||
/**
|
||||
* When a node requests for a connection pool, but none is available, this
|
||||
* handler is called to create new instance of the pool, which is then cached
|
||||
* and re-used until it goes stale.
|
||||
*/
|
||||
fallBackHandler: (abortController: AbortController) => Promise<Pool>;
|
||||
|
||||
wasUsed: (pool: Pool) => void;
|
||||
};
|
||||
|
||||
type Registration<Pool> = {
|
||||
/** This is an instance of a Connection Pool class, that gets reused across multiple executions */
|
||||
pool: Pool;
|
||||
|
||||
abortController: AbortController;
|
||||
|
||||
wasUsed: (pool: Pool) => void;
|
||||
|
||||
/** We keep this timestamp to check if a pool hasn't been used in a while, and if it needs to be closed */
|
||||
lastUsed: number;
|
||||
};
|
||||
|
||||
export class ConnectionPoolManager {
|
||||
/**
|
||||
* Gets the singleton instance of the ConnectionPoolManager.
|
||||
* Creates a new instance if one doesn't exist.
|
||||
*/
|
||||
static getInstance(logger: Logger): ConnectionPoolManager {
|
||||
if (!instance) {
|
||||
instance = new ConnectionPoolManager(logger);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
private map = new Map<string, Registration<unknown>>();
|
||||
|
||||
/**
|
||||
* Private constructor that initializes the connection pool manager.
|
||||
* Sets up cleanup handlers for process exit and stale connections.
|
||||
*/
|
||||
private constructor(private readonly logger: Logger) {
|
||||
// Close all open pools when the process exits
|
||||
process.on('exit', () => {
|
||||
this.logger.debug('ConnectionPoolManager: Shutting down. Cleaning up all pools');
|
||||
this.purgeConnections();
|
||||
});
|
||||
|
||||
// Regularly close stale pools
|
||||
setInterval(() => this.cleanupStaleConnections(), cleanUpInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique key for connection pool identification.
|
||||
* Hashes the credentials and node information for security.
|
||||
*/
|
||||
private makeKey({ credentials, nodeType, nodeVersion }: RegistrationOptions): string {
|
||||
// The credential contains decrypted secrets, that's why we hash it.
|
||||
return createHash('sha1')
|
||||
.update(
|
||||
JSON.stringify({
|
||||
credentials,
|
||||
nodeType,
|
||||
nodeVersion,
|
||||
}),
|
||||
)
|
||||
.digest('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or creates a connection pool for the given options.
|
||||
* Updates the last used timestamp for existing connections.
|
||||
*/
|
||||
async getConnection<T>(options: GetConnectionOption<T>): Promise<T> {
|
||||
const key = this.makeKey(options);
|
||||
|
||||
let value = this.map.get(key);
|
||||
|
||||
if (value) {
|
||||
value.lastUsed = Date.now();
|
||||
value.wasUsed(value.pool);
|
||||
return value.pool as T;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
value = {
|
||||
pool: await options.fallBackHandler(abortController),
|
||||
abortController,
|
||||
wasUsed: options.wasUsed,
|
||||
} as Registration<unknown>;
|
||||
|
||||
// It's possible that `options.fallBackHandler` already called the abort
|
||||
// function. If that's the case let's not continue.
|
||||
if (abortController.signal.aborted) {
|
||||
throw new OperationalError('Could not create pool. Connection attempt was aborted.', {
|
||||
cause: abortController.signal.reason,
|
||||
});
|
||||
}
|
||||
|
||||
this.map.set(key, { ...value, lastUsed: Date.now() });
|
||||
abortController.signal.addEventListener('abort', async () => {
|
||||
this.logger.debug('ConnectionPoolManager: Got abort signal, cleaning up pool.');
|
||||
this.cleanupConnection(key);
|
||||
});
|
||||
|
||||
return value.pool as T;
|
||||
}
|
||||
|
||||
private cleanupConnection(key: string) {
|
||||
const registration = this.map.get(key);
|
||||
|
||||
if (registration) {
|
||||
this.map.delete(key);
|
||||
registration.abortController.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes and cleans up connection pools that haven't been used within the
|
||||
* TTL.
|
||||
*/
|
||||
private cleanupStaleConnections() {
|
||||
const now = Date.now();
|
||||
for (const [key, { lastUsed }] of this.map.entries()) {
|
||||
if (now - lastUsed > ttl) {
|
||||
this.logger.debug('ConnectionPoolManager: Found stale pool. Cleaning it up.');
|
||||
void this.cleanupConnection(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes and cleans up all existing connection pools.
|
||||
* Connections are closed in the background.
|
||||
*/
|
||||
purgeConnections(): void {
|
||||
for (const key of this.map.keys()) {
|
||||
this.cleanupConnection(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const NODE_RAN_MULTIPLE_TIMES_WARNING =
|
||||
"This node ran multiple times - once for each input item. You can change this by setting 'execute once' in the node settings. <a href='https://docs.n8n.io/flow-logic/looping/#executing-nodes-once' target='_blank'>More Info</a>";
|
||||
|
||||
export const LOCALHOST = '127.0.0.1';
|
||||
|
||||
export const ENABLE_LESS_STRICT_TYPE_VALIDATION =
|
||||
"Try changing the type of comparison. Alternatively you can enable 'Convert types where required'.";
|
||||
@@ -0,0 +1,461 @@
|
||||
import type { INodeProperties, INodePropertyOptions } from 'n8n-workflow';
|
||||
|
||||
export const oldVersionNotice: INodeProperties = {
|
||||
displayName:
|
||||
'<strong>New node version available:</strong> get the latest version with added features from the nodes panel.',
|
||||
name: 'oldVersionNotice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
};
|
||||
|
||||
export const returnAllOrLimit: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Return All',
|
||||
name: 'returnAll',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to return all results or only up to a given limit',
|
||||
},
|
||||
{
|
||||
displayName: 'Limit',
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
displayOptions: {
|
||||
show: {
|
||||
returnAll: [false],
|
||||
},
|
||||
},
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
default: 100,
|
||||
description: 'Max number of results to return',
|
||||
},
|
||||
];
|
||||
|
||||
export const looseTypeValidationProperty: INodeProperties = {
|
||||
displayName: 'Convert types where required',
|
||||
description:
|
||||
'If the type of an expression doesn\'t match the type of the comparison, n8n will try to cast the expression to the required type. E.g. for booleans <code>"false"</code> or <code>0</code> will be cast to <code>false</code>',
|
||||
name: 'looseTypeValidation',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
};
|
||||
|
||||
export const appendAttributionOption: INodeProperties = {
|
||||
displayName: 'Append n8n Attribution',
|
||||
name: 'appendAttribution',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
};
|
||||
|
||||
export const encodeDecodeOptions: INodePropertyOptions[] = [
|
||||
{
|
||||
name: 'armscii8',
|
||||
value: 'armscii8',
|
||||
},
|
||||
{
|
||||
name: 'ascii',
|
||||
value: 'ascii',
|
||||
},
|
||||
{
|
||||
name: 'base64',
|
||||
value: 'base64',
|
||||
},
|
||||
{
|
||||
name: 'big5hkscs',
|
||||
value: 'big5hkscs',
|
||||
},
|
||||
{
|
||||
name: 'binary',
|
||||
value: 'binary',
|
||||
},
|
||||
{
|
||||
name: 'cesu8',
|
||||
value: 'cesu8',
|
||||
},
|
||||
{
|
||||
name: 'cp1046',
|
||||
value: 'cp1046',
|
||||
},
|
||||
{
|
||||
name: 'cp1124',
|
||||
value: 'cp1124',
|
||||
},
|
||||
{
|
||||
name: 'cp1125',
|
||||
value: 'cp1125',
|
||||
},
|
||||
{
|
||||
name: 'cp1129',
|
||||
value: 'cp1129',
|
||||
},
|
||||
{
|
||||
name: 'cp1133',
|
||||
value: 'cp1133',
|
||||
},
|
||||
{
|
||||
name: 'cp1161',
|
||||
value: 'cp1161',
|
||||
},
|
||||
{
|
||||
name: 'cp1162',
|
||||
value: 'cp1162',
|
||||
},
|
||||
{
|
||||
name: 'cp1163',
|
||||
value: 'cp1163',
|
||||
},
|
||||
{
|
||||
name: 'cp437',
|
||||
value: 'cp437',
|
||||
},
|
||||
{
|
||||
name: 'cp720',
|
||||
value: 'cp720',
|
||||
},
|
||||
{
|
||||
name: 'cp737',
|
||||
value: 'cp737',
|
||||
},
|
||||
{
|
||||
name: 'cp775',
|
||||
value: 'cp775',
|
||||
},
|
||||
{
|
||||
name: 'cp808',
|
||||
value: 'cp808',
|
||||
},
|
||||
{
|
||||
name: 'cp850',
|
||||
value: 'cp850',
|
||||
},
|
||||
{
|
||||
name: 'cp852',
|
||||
value: 'cp852',
|
||||
},
|
||||
{
|
||||
name: 'cp855',
|
||||
value: 'cp855',
|
||||
},
|
||||
{
|
||||
name: 'cp856',
|
||||
value: 'cp856',
|
||||
},
|
||||
{
|
||||
name: 'cp857',
|
||||
value: 'cp857',
|
||||
},
|
||||
{
|
||||
name: 'cp858',
|
||||
value: 'cp858',
|
||||
},
|
||||
{
|
||||
name: 'cp860',
|
||||
value: 'cp860',
|
||||
},
|
||||
{
|
||||
name: 'cp861',
|
||||
value: 'cp861',
|
||||
},
|
||||
{
|
||||
name: 'cp862',
|
||||
value: 'cp862',
|
||||
},
|
||||
{
|
||||
name: 'cp863',
|
||||
value: 'cp863',
|
||||
},
|
||||
{
|
||||
name: 'cp864',
|
||||
value: 'cp864',
|
||||
},
|
||||
{
|
||||
name: 'cp865',
|
||||
value: 'cp865',
|
||||
},
|
||||
{
|
||||
name: 'cp866',
|
||||
value: 'cp866',
|
||||
},
|
||||
{
|
||||
name: 'cp869',
|
||||
value: 'cp869',
|
||||
},
|
||||
{
|
||||
name: 'cp922',
|
||||
value: 'cp922',
|
||||
},
|
||||
{
|
||||
name: 'cp936',
|
||||
value: 'cp936',
|
||||
},
|
||||
{
|
||||
name: 'cp949',
|
||||
value: 'cp949',
|
||||
},
|
||||
{
|
||||
name: 'cp950',
|
||||
value: 'cp950',
|
||||
},
|
||||
{
|
||||
name: 'eucjp',
|
||||
value: 'eucjp',
|
||||
},
|
||||
{
|
||||
name: 'gb18030',
|
||||
value: 'gb18030',
|
||||
},
|
||||
{
|
||||
name: 'gbk',
|
||||
value: 'gbk',
|
||||
},
|
||||
{
|
||||
name: 'georgianacademy',
|
||||
value: 'georgianacademy',
|
||||
},
|
||||
{
|
||||
name: 'georgianps',
|
||||
value: 'georgianps',
|
||||
},
|
||||
{
|
||||
name: 'hex',
|
||||
value: 'hex',
|
||||
},
|
||||
{
|
||||
name: 'hproman8',
|
||||
value: 'hproman8',
|
||||
},
|
||||
{
|
||||
name: 'iso646cn',
|
||||
value: 'iso646cn',
|
||||
},
|
||||
{
|
||||
name: 'iso646jp',
|
||||
value: 'iso646jp',
|
||||
},
|
||||
{
|
||||
name: 'iso88591',
|
||||
value: 'iso88591',
|
||||
},
|
||||
{
|
||||
name: 'iso885910',
|
||||
value: 'iso885910',
|
||||
},
|
||||
{
|
||||
name: 'iso885911',
|
||||
value: 'iso885911',
|
||||
},
|
||||
{
|
||||
name: 'iso885913',
|
||||
value: 'iso885913',
|
||||
},
|
||||
{
|
||||
name: 'iso885914',
|
||||
value: 'iso885914',
|
||||
},
|
||||
{
|
||||
name: 'iso885915',
|
||||
value: 'iso885915',
|
||||
},
|
||||
{
|
||||
name: 'iso885916',
|
||||
value: 'iso885916',
|
||||
},
|
||||
{
|
||||
name: 'iso88592',
|
||||
value: 'iso88592',
|
||||
},
|
||||
{
|
||||
name: 'iso88593',
|
||||
value: 'iso88593',
|
||||
},
|
||||
{
|
||||
name: 'iso88594',
|
||||
value: 'iso88594',
|
||||
},
|
||||
{
|
||||
name: 'iso88595',
|
||||
value: 'iso88595',
|
||||
},
|
||||
{
|
||||
name: 'iso88596',
|
||||
value: 'iso88596',
|
||||
},
|
||||
{
|
||||
name: 'iso88597',
|
||||
value: 'iso88597',
|
||||
},
|
||||
{
|
||||
name: 'iso88598',
|
||||
value: 'iso88598',
|
||||
},
|
||||
{
|
||||
name: 'iso88599',
|
||||
value: 'iso88599',
|
||||
},
|
||||
{
|
||||
name: 'koi8r',
|
||||
value: 'koi8r',
|
||||
},
|
||||
{
|
||||
name: 'koi8ru',
|
||||
value: 'koi8ru',
|
||||
},
|
||||
{
|
||||
name: 'koi8t',
|
||||
value: 'koi8t',
|
||||
},
|
||||
{
|
||||
name: 'koi8u',
|
||||
value: 'koi8u',
|
||||
},
|
||||
{
|
||||
name: 'maccenteuro',
|
||||
value: 'maccenteuro',
|
||||
},
|
||||
{
|
||||
name: 'maccroatian',
|
||||
value: 'maccroatian',
|
||||
},
|
||||
{
|
||||
name: 'maccyrillic',
|
||||
value: 'maccyrillic',
|
||||
},
|
||||
{
|
||||
name: 'macgreek',
|
||||
value: 'macgreek',
|
||||
},
|
||||
{
|
||||
name: 'maciceland',
|
||||
value: 'maciceland',
|
||||
},
|
||||
{
|
||||
name: 'macintosh',
|
||||
value: 'macintosh',
|
||||
},
|
||||
{
|
||||
name: 'macroman',
|
||||
value: 'macroman',
|
||||
},
|
||||
{
|
||||
name: 'macromania',
|
||||
value: 'macromania',
|
||||
},
|
||||
{
|
||||
name: 'macthai',
|
||||
value: 'macthai',
|
||||
},
|
||||
{
|
||||
name: 'macturkish',
|
||||
value: 'macturkish',
|
||||
},
|
||||
{
|
||||
name: 'macukraine',
|
||||
value: 'macukraine',
|
||||
},
|
||||
{
|
||||
name: 'mik',
|
||||
value: 'mik',
|
||||
},
|
||||
{
|
||||
name: 'pt154',
|
||||
value: 'pt154',
|
||||
},
|
||||
{
|
||||
name: 'rk1048',
|
||||
value: 'rk1048',
|
||||
},
|
||||
{
|
||||
name: 'shiftjis',
|
||||
value: 'shiftjis',
|
||||
},
|
||||
{
|
||||
name: 'tcvn',
|
||||
value: 'tcvn',
|
||||
},
|
||||
{
|
||||
name: 'tis620',
|
||||
value: 'tis620',
|
||||
},
|
||||
{
|
||||
name: 'ucs2',
|
||||
value: 'ucs2',
|
||||
},
|
||||
{
|
||||
name: 'utf16',
|
||||
value: 'utf16',
|
||||
},
|
||||
{
|
||||
name: 'utf16be',
|
||||
value: 'utf16be',
|
||||
},
|
||||
{
|
||||
name: 'utf32',
|
||||
value: 'utf32',
|
||||
},
|
||||
{
|
||||
name: 'utf32be',
|
||||
value: 'utf32be',
|
||||
},
|
||||
{
|
||||
name: 'utf32le',
|
||||
value: 'utf32le',
|
||||
},
|
||||
{
|
||||
name: 'utf7',
|
||||
value: 'utf7',
|
||||
},
|
||||
{
|
||||
name: 'utf7imap',
|
||||
value: 'utf7imap',
|
||||
},
|
||||
{
|
||||
name: 'utf8',
|
||||
value: 'utf8',
|
||||
},
|
||||
{
|
||||
name: 'viscii',
|
||||
value: 'viscii',
|
||||
},
|
||||
{
|
||||
name: 'windows1250',
|
||||
value: 'windows1250',
|
||||
},
|
||||
{
|
||||
name: 'windows1251',
|
||||
value: 'windows1251',
|
||||
},
|
||||
{
|
||||
name: 'windows1252',
|
||||
value: 'windows1252',
|
||||
},
|
||||
{
|
||||
name: 'windows1253',
|
||||
value: 'windows1253',
|
||||
},
|
||||
{
|
||||
name: 'windows1254',
|
||||
value: 'windows1254',
|
||||
},
|
||||
{
|
||||
name: 'windows1255',
|
||||
value: 'windows1255',
|
||||
},
|
||||
{
|
||||
name: 'windows1256',
|
||||
value: 'windows1256',
|
||||
},
|
||||
{
|
||||
name: 'windows1257',
|
||||
value: 'windows1257',
|
||||
},
|
||||
{
|
||||
name: 'windows1258',
|
||||
value: 'windows1258',
|
||||
},
|
||||
{
|
||||
name: 'windows874',
|
||||
value: 'windows874',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ApplicationError, NodeOperationError, WAIT_INDEFINITELY } from 'n8n-workflow';
|
||||
import type { IExecuteFunctions, IDataObject } from 'n8n-workflow';
|
||||
|
||||
export function configureWaitTillDate(
|
||||
context: IExecuteFunctions,
|
||||
location: 'options' | 'root' = 'options',
|
||||
) {
|
||||
let waitTill = WAIT_INDEFINITELY;
|
||||
let limitOptions: IDataObject = {};
|
||||
|
||||
if (location === 'options') {
|
||||
limitOptions = context.getNodeParameter('options.limitWaitTime.values', 0, {}) as {
|
||||
limitType?: string;
|
||||
resumeAmount?: number;
|
||||
resumeUnit?: string;
|
||||
maxDateAndTime?: string;
|
||||
};
|
||||
} else {
|
||||
const limitWaitTime = context.getNodeParameter('limitWaitTime', 0, false);
|
||||
if (limitWaitTime) {
|
||||
limitOptions.limitType = context.getNodeParameter('limitType', 0, 'afterTimeInterval');
|
||||
|
||||
if (limitOptions.limitType === 'afterTimeInterval') {
|
||||
limitOptions.resumeAmount = context.getNodeParameter('resumeAmount', 0, 1) as number;
|
||||
limitOptions.resumeUnit = context.getNodeParameter('resumeUnit', 0, 'hours');
|
||||
} else {
|
||||
limitOptions.maxDateAndTime = context.getNodeParameter('maxDateAndTime', 0, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(limitOptions).length) {
|
||||
try {
|
||||
if (limitOptions.limitType === 'afterTimeInterval') {
|
||||
let waitAmount = limitOptions.resumeAmount as number;
|
||||
|
||||
if (limitOptions.resumeUnit === 'minutes') {
|
||||
waitAmount *= 60;
|
||||
}
|
||||
if (limitOptions.resumeUnit === 'hours') {
|
||||
waitAmount *= 60 * 60;
|
||||
}
|
||||
if (limitOptions.resumeUnit === 'days') {
|
||||
waitAmount *= 60 * 60 * 24;
|
||||
}
|
||||
|
||||
waitAmount *= 1000;
|
||||
waitTill = new Date(new Date().getTime() + waitAmount);
|
||||
} else {
|
||||
waitTill = new Date(limitOptions.maxDateAndTime as string);
|
||||
}
|
||||
|
||||
if (isNaN(waitTill.getTime())) {
|
||||
throw new ApplicationError('Invalid date format');
|
||||
}
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(context.getNode(), 'Could not configure Limit Wait Time', {
|
||||
description: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return waitTill;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { INodeProperties, IWebhookDescription } from 'n8n-workflow';
|
||||
|
||||
export const sendAndWaitWebhooksDescription: IWebhookDescription[] = [
|
||||
{
|
||||
name: 'default',
|
||||
httpMethod: 'GET',
|
||||
responseMode: 'onReceived',
|
||||
responseData: '',
|
||||
path: '={{ $nodeId }}',
|
||||
restartWebhook: true,
|
||||
isFullPath: true,
|
||||
},
|
||||
{
|
||||
name: 'default',
|
||||
httpMethod: 'POST',
|
||||
responseMode: 'onReceived',
|
||||
responseData: '',
|
||||
path: '={{ $nodeId }}',
|
||||
restartWebhook: true,
|
||||
isFullPath: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const limitWaitTimeProperties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Limit Type',
|
||||
name: 'limitType',
|
||||
type: 'options',
|
||||
default: 'afterTimeInterval',
|
||||
description:
|
||||
'Sets the condition for the execution to resume. Can be a specified date or after some time.',
|
||||
options: [
|
||||
{
|
||||
name: 'After Time Interval',
|
||||
description: 'Waits for a certain amount of time',
|
||||
value: 'afterTimeInterval',
|
||||
},
|
||||
{
|
||||
name: 'At Specified Time',
|
||||
description: 'Waits until the set date and time to continue',
|
||||
value: 'atSpecifiedTime',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Amount',
|
||||
name: 'resumeAmount',
|
||||
type: 'number',
|
||||
displayOptions: {
|
||||
show: {
|
||||
limitType: ['afterTimeInterval'],
|
||||
},
|
||||
},
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
numberPrecision: 2,
|
||||
},
|
||||
default: 1,
|
||||
description: 'The time to wait',
|
||||
},
|
||||
{
|
||||
displayName: 'Unit',
|
||||
name: 'resumeUnit',
|
||||
type: 'options',
|
||||
displayOptions: {
|
||||
show: {
|
||||
limitType: ['afterTimeInterval'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Minutes',
|
||||
value: 'minutes',
|
||||
},
|
||||
{
|
||||
name: 'Hours',
|
||||
value: 'hours',
|
||||
},
|
||||
{
|
||||
name: 'Days',
|
||||
value: 'days',
|
||||
},
|
||||
],
|
||||
default: 'hours',
|
||||
description: 'Unit of the interval value',
|
||||
},
|
||||
{
|
||||
displayName: 'Max Date and Time',
|
||||
name: 'maxDateAndTime',
|
||||
type: 'dateTime',
|
||||
displayOptions: {
|
||||
show: {
|
||||
limitType: ['atSpecifiedTime'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'Continue execution after the specified date and time',
|
||||
},
|
||||
];
|
||||
|
||||
export const limitWaitTimeOption: INodeProperties = {
|
||||
displayName: 'Limit Wait Time',
|
||||
name: 'limitWaitTime',
|
||||
type: 'fixedCollection',
|
||||
description:
|
||||
'Whether to limit the time this node should wait for a user response before execution resumes',
|
||||
default: { values: { limitType: 'afterTimeInterval', resumeAmount: 45, resumeUnit: 'minutes' } },
|
||||
options: [
|
||||
{
|
||||
displayName: 'Values',
|
||||
name: 'values',
|
||||
values: limitWaitTimeProperties,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
export const BUTTON_STYLE_SECONDARY =
|
||||
'display:inline-block; text-decoration:none; background-color:#fff; color:#4a4a4a; padding:12px 24px; font-family: Arial,sans-serif; font-size:14px;font-weight:600; border:1px solid #d1d1d1; border-radius:6px; min-width:120px; margin: 12px 6px 0 6px;';
|
||||
export const BUTTON_STYLE_PRIMARY =
|
||||
'display:inline-block; text-decoration:none; background-color:#ff6d5a; color: #fff; padding:12px 24px; font-family: Arial,sans-serif; font-size:14px;font-weight:600; border-radius:6px; min-width:120px; margin: 12px 2px 0 2px;';
|
||||
|
||||
export const ACTION_RECORDED_PAGE = `
|
||||
<html lang='en'>
|
||||
|
||||
<head>
|
||||
<meta charset='UTF-8' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
|
||||
<link rel='icon' type='image/png' href='https://n8n.io/favicon.ico' />
|
||||
<link
|
||||
href='https://fonts.googleapis.com/css?family=Open+Sans'
|
||||
rel='stylesheet'
|
||||
type='text/css'
|
||||
/>
|
||||
<title>Action recorded</title>
|
||||
<style>
|
||||
*, ::after, ::before { box-sizing: border-box; margin: 0; padding: 0; } body { font-family:
|
||||
Open Sans, sans-serif; font-weight: 400; font-size: 12px; display: flex; flex-direction:
|
||||
column; justify-content: start; background-color: #FBFCFE; } .container { margin: auto;
|
||||
text-align: center; padding-top: 24px; width: 448px; } .card { padding: 24px;
|
||||
background-color: white; border: 1px solid #DBDFE7; border-radius: 8px; box-shadow: 0px 4px
|
||||
16px 0px #634DFF0F; margin-bottom: 16px; } .n8n-link a { color: #7E8186; font-weight: 600;
|
||||
font-size: 12px; text-decoration: none; } .n8n-link svg { display: inline-block;
|
||||
vertical-align: middle; } .header h1 { color: #525356; font-size: 20px; font-weight: 400;
|
||||
padding-bottom: 8px; } .header p { color: #7E8186; font-size: 14px; font-weight: 400; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='container'>
|
||||
<section>
|
||||
<div class='card'>
|
||||
<div class='header'>
|
||||
<h1>Got it, thanks</h1>
|
||||
<p>This page can be closed now</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class='n8n-link'>
|
||||
<a
|
||||
href='https://n8n.io/?utm_source=n8n-internal&utm_medium=send-and-wait'
|
||||
target='_blank'
|
||||
>
|
||||
Automated with
|
||||
<svg
|
||||
width='73'
|
||||
height='20'
|
||||
viewBox='0 0 73 20'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
fill-rule='evenodd'
|
||||
clip-rule='evenodd'
|
||||
d='M40.2373 4C40.2373 6.20915 38.4464 8 36.2373 8C34.3735 8 32.8074 6.72525 32.3633 5H26.7787C25.801 5 24.9666 5.70685 24.8059 6.6712L24.6415 7.6576C24.4854 8.59415 24.0116 9.40925 23.3417 10C24.0116 10.5907 24.4854 11.4058 24.6415 12.3424L24.8059 13.3288C24.9666 14.2931 25.801 15 26.7787 15H28.3633C28.8074 13.2747 30.3735 12 32.2373 12C34.4464 12 36.2373 13.7908 36.2373 16C36.2373 18.2092 34.4464 20 32.2373 20C30.3735 20 28.8074 18.7253 28.3633 17H26.7787C24.8233 17 23.1546 15.5864 22.8331 13.6576L22.6687 12.6712C22.508 11.7069 21.6736 11 20.6959 11H19.0645C18.5652 12.64 17.0406 13.8334 15.2373 13.8334C13.434 13.8334 11.9094 12.64 11.4101 11H9.06449C8.56519 12.64 7.04059 13.8334 5.2373 13.8334C3.02817 13.8334 1.2373 12.0424 1.2373 9.83335C1.2373 7.6242 3.02817 5.83335 5.2373 5.83335C7.16069 5.83335 8.76699 7.19085 9.15039 9H11.3242C11.7076 7.19085 13.3139 5.83335 15.2373 5.83335C17.1607 5.83335 18.767 7.19085 19.1504 9H20.6959C21.6736 9 22.508 8.29315 22.6687 7.3288L22.8331 6.3424C23.1546 4.41365 24.8233 3 26.7787 3H32.3633C32.8074 1.27478 34.3735 0 36.2373 0C38.4464 0 40.2373 1.79086 40.2373 4ZM38.2373 4C38.2373 5.10455 37.3419 6 36.2373 6C35.1327 6 34.2373 5.10455 34.2373 4C34.2373 2.89543 35.1327 2 36.2373 2C37.3419 2 38.2373 2.89543 38.2373 4ZM5.2373 11.8334C6.34189 11.8334 7.23729 10.9379 7.23729 9.83335C7.23729 8.72875 6.34189 7.83335 5.2373 7.83335C4.13273 7.83335 3.2373 8.72875 3.2373 9.83335C3.2373 10.9379 4.13273 11.8334 5.2373 11.8334ZM15.2373 11.8334C16.3419 11.8334 17.2373 10.9379 17.2373 9.83335C17.2373 8.72875 16.3419 7.83335 15.2373 7.83335C14.1327 7.83335 13.2373 8.72875 13.2373 9.83335C13.2373 10.9379 14.1327 11.8334 15.2373 11.8334ZM32.2373 18C33.3419 18 34.2373 17.1045 34.2373 16C34.2373 14.8954 33.3419 14 32.2373 14C31.1327 14 30.2373 14.8954 30.2373 16C30.2373 17.1045 31.1327 18 32.2373 18Z'
|
||||
fill='#EA4B71'
|
||||
></path>
|
||||
<path
|
||||
d='M44.2393 15.0007H46.3277V10.5791C46.3277 9.12704 47.2088 8.49074 48.204 8.49074C49.183 8.49074 49.9498 9.14334 49.9498 10.4812V15.0007H52.038V10.057C52.038 7.91969 50.798 6.67969 48.8567 6.67969C47.633 6.67969 46.9477 7.16914 46.4582 7.80544H46.3277L46.1482 6.84284H44.2393V15.0007Z'
|
||||
fill='#101330'
|
||||
></path>
|
||||
<path
|
||||
d='M60.0318 9.50205V9.40415C60.7498 9.0452 61.4678 8.4252 61.4678 7.20155C61.4678 5.43945 60.0153 4.37891 58.0088 4.37891C55.9528 4.37891 54.4843 5.5047 54.4843 7.23415C54.4843 8.4089 55.1698 9.0452 55.9203 9.40415V9.50205C55.0883 9.79575 54.0928 10.6768 54.0928 12.1452C54.0928 13.9237 55.5613 15.1637 57.9923 15.1637C60.4233 15.1637 61.8428 13.9237 61.8428 12.1452C61.8428 10.6768 60.8638 9.81205 60.0318 9.50205ZM57.9923 5.87995C58.8083 5.87995 59.4118 6.40205 59.4118 7.2831C59.4118 8.16415 58.7918 8.6863 57.9923 8.6863C57.1928 8.6863 56.5238 8.16415 56.5238 7.2831C56.5238 6.38575 57.1603 5.87995 57.9923 5.87995ZM57.9923 13.5974C57.0458 13.5974 56.2793 12.9937 56.2793 11.9658C56.2793 11.0358 56.9153 10.3342 57.9758 10.3342C59.0203 10.3342 59.6568 11.0195 59.6568 11.9984C59.6568 12.9937 58.9223 13.5974 57.9923 13.5974Z'
|
||||
fill='#101330'
|
||||
></path>
|
||||
<path
|
||||
d='M63.9639 15.0007H66.0524V10.5791C66.0524 9.12704 66.9334 8.49074 67.9289 8.49074C68.9079 8.49074 69.6744 9.14334 69.6744 10.4812V15.0007H71.7629V10.057C71.7629 7.91969 70.5229 6.67969 68.5814 6.67969C67.3579 6.67969 66.6724 7.16914 66.1829 7.80544H66.0524L65.8729 6.84284H63.9639V15.0007Z'
|
||||
fill='#101330'
|
||||
></path>
|
||||
</svg>
|
||||
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>`;
|
||||
|
||||
export function createEmailBodyWithN8nAttribution(
|
||||
message: string,
|
||||
buttons: string,
|
||||
instanceId?: string,
|
||||
) {
|
||||
const utm_campaign = instanceId ? `&utm_campaign=${instanceId}` : '';
|
||||
const n8nWebsiteLink = `https://n8n.io/?utm_source=n8n-internal&utm_medium=send-and-wait${utm_campaign}`;
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang='en'>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>My form</title>
|
||||
</head>
|
||||
|
||||
<body
|
||||
style="font-family: Arial, sans-serif; font-size: 12px; background-color: #fbfcfe; margin: 0; padding: 0;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0"
|
||||
style="background-color:#fbfcfe; border: 1px solid #dbdfe7; border-radius: 8px;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 24px 0;">
|
||||
<table width="448" cellpadding="0" cellspacing="0" border="0"
|
||||
style="width: 100%; max-width: 448px; background-color: #ffffff; border: 1px solid #dbdfe7; border-radius: 8px; padding: 24px; box-shadow: 0px 4px 16px rgba(99, 77, 255, 0.06);">
|
||||
<tr>
|
||||
<td
|
||||
style="text-align: center; padding-top: 8px; font-family: Arial, sans-serif; font-size: 14px; color: #7e8186;">
|
||||
<p style="white-space: pre-line;">${message}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding-top: 12px;">
|
||||
${buttons}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Divider -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom: 24px;">
|
||||
<tr>
|
||||
<td style="border-top: 0px solid #dbdfe7;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Footer -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0"
|
||||
style="text-align: center; color: #7e8186; font-family: Arial, sans-serif; font-size: 12px;">
|
||||
<tr>
|
||||
<td>
|
||||
<a href=${n8nWebsiteLink}
|
||||
target="_blank" style="color: #7e8186; text-decoration: none;">Automated with
|
||||
n8n</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
export function createEmailBodyWithoutN8nAttribution(message: string, buttons: string) {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang='en'>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>My form</title>
|
||||
</head>
|
||||
|
||||
<body
|
||||
style="font-family: Arial, sans-serif; font-size: 12px; background-color: #fbfcfe; margin: 0; padding: 0;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0"
|
||||
style="background-color:#fbfcfe; border: 1px solid #dbdfe7; border-radius: 8px;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 24px 0;">
|
||||
<table width="448" cellpadding="0" cellspacing="0" border="0"
|
||||
style="width: 100%; max-width: 448px; background-color: #ffffff; border: 1px solid #dbdfe7; border-radius: 8px; padding: 24px; box-shadow: 0px 4px 16px rgba(99, 77, 255, 0.06);">
|
||||
<tr>
|
||||
<td
|
||||
style="text-align: center; padding-top: 8px; font-family: Arial, sans-serif; font-size: 14px; color: #7e8186;">
|
||||
<p style="white-space: pre-line;">${message}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding-top: 12px;">
|
||||
${buttons}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Divider -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom: 24px;">
|
||||
<tr>
|
||||
<td style="border-top: 0px solid #dbdfe7;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
|
||||
export interface IEmail {
|
||||
from?: string;
|
||||
to?: string;
|
||||
cc?: string;
|
||||
bcc?: string;
|
||||
replyTo?: string;
|
||||
inReplyTo?: string;
|
||||
reference?: string;
|
||||
references?: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
htmlBody?: string;
|
||||
attachments?: IDataObject[];
|
||||
}
|
||||
@@ -0,0 +1,704 @@
|
||||
import { type MockProxy, mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeProperties,
|
||||
IWebhookFunctions,
|
||||
IWorkflowSettings,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError, WAIT_INDEFINITELY } from 'n8n-workflow';
|
||||
|
||||
import { configureWaitTillDate } from '../configureWaitTillDate.util';
|
||||
import {
|
||||
getSendAndWaitProperties,
|
||||
getSendAndWaitConfig,
|
||||
createEmail,
|
||||
sendAndWaitWebhook,
|
||||
} from '../utils';
|
||||
|
||||
describe('Send and Wait utils tests', () => {
|
||||
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
|
||||
let mockWebhookFunctions: MockProxy<IWebhookFunctions>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
mockWebhookFunctions = mock<IWebhookFunctions>();
|
||||
mockWebhookFunctions.getWorkflowSettings.mockReturnValue(mock<IWorkflowSettings>({}));
|
||||
});
|
||||
|
||||
describe('getSendAndWaitProperties', () => {
|
||||
it('should return properties with correct display options', () => {
|
||||
const targetProperties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Test Property',
|
||||
name: 'testProperty',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
const extraOptions: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Extra Property',
|
||||
name: 'extraProperty',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
|
||||
const result = getSendAndWaitProperties(targetProperties, undefined, undefined, {
|
||||
extraOptions,
|
||||
});
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'options',
|
||||
options: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'extraProperty',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include extra options when provided', () => {
|
||||
const targetProperties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Test Property',
|
||||
name: 'testProperty',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
const extraOptions: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Extra Property',
|
||||
name: 'extraProperty',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
const result = getSendAndWaitProperties(targetProperties, undefined, undefined, {
|
||||
extraOptions,
|
||||
});
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['message'],
|
||||
operation: ['sendAndWait'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSendAndWaitConfig', () => {
|
||||
it('should return correct config for single approval', () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
|
||||
const params: { [key: string]: any } = {
|
||||
message: 'Test message',
|
||||
subject: 'Test subject',
|
||||
'approvalOptions.values': {
|
||||
approvalType: 'single',
|
||||
approveLabel: 'Approve',
|
||||
buttonApprovalStyle: 'primary',
|
||||
},
|
||||
};
|
||||
return params[parameterName];
|
||||
});
|
||||
|
||||
mockExecuteFunctions.getSignedResumeUrl.mockReturnValue(
|
||||
'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
|
||||
);
|
||||
const config = getSendAndWaitConfig(mockExecuteFunctions);
|
||||
|
||||
expect(config).toEqual({
|
||||
appendAttribution: undefined,
|
||||
title: 'Test subject',
|
||||
message: 'Test message',
|
||||
options: [
|
||||
{
|
||||
label: 'Approve',
|
||||
style: 'primary',
|
||||
url: 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return correct config for double approval', () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
|
||||
const params: { [key: string]: any } = {
|
||||
message: 'Test message',
|
||||
subject: 'Test subject',
|
||||
'approvalOptions.values': {
|
||||
approvalType: 'double',
|
||||
approveLabel: 'Approve',
|
||||
buttonApprovalStyle: 'primary',
|
||||
disapproveLabel: 'Reject',
|
||||
buttonDisapprovalStyle: 'secondary',
|
||||
},
|
||||
};
|
||||
return params[parameterName];
|
||||
});
|
||||
|
||||
mockExecuteFunctions.getSignedResumeUrl.mockReturnValueOnce(
|
||||
'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
|
||||
);
|
||||
mockExecuteFunctions.getSignedResumeUrl.mockReturnValueOnce(
|
||||
'http://localhost/waiting-webhook/nodeID?approved=false&signature=abc',
|
||||
);
|
||||
|
||||
const config = getSendAndWaitConfig(mockExecuteFunctions);
|
||||
|
||||
expect(config.options).toHaveLength(2);
|
||||
expect(config.options).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
label: 'Reject',
|
||||
style: 'secondary',
|
||||
url: 'http://localhost/waiting-webhook/nodeID?approved=false&signature=abc',
|
||||
},
|
||||
{
|
||||
label: 'Approve',
|
||||
style: 'primary',
|
||||
url: 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEmail', () => {
|
||||
beforeEach(() => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
|
||||
const params: { [key: string]: any } = {
|
||||
sendTo: 'test@example.com',
|
||||
message: 'Test message',
|
||||
subject: 'Test subject',
|
||||
'approvalOptions.values': {
|
||||
approvalType: 'single',
|
||||
approveLabel: 'Approve',
|
||||
buttonApprovalStyle: 'primary',
|
||||
},
|
||||
};
|
||||
return params[parameterName];
|
||||
});
|
||||
|
||||
mockExecuteFunctions.getSignedResumeUrl.mockReturnValue('http://localhost/testNodeId');
|
||||
});
|
||||
|
||||
it('should create a valid email object', () => {
|
||||
const email = createEmail(mockExecuteFunctions);
|
||||
|
||||
expect(email).toEqual({
|
||||
to: 'test@example.com',
|
||||
subject: 'Test subject',
|
||||
body: '',
|
||||
htmlBody: expect.stringContaining('Test message'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw NodeOperationError for invalid email address', () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
|
||||
const params: { [key: string]: any } = {
|
||||
sendTo: 'invalid@@email.com',
|
||||
message: 'Test message',
|
||||
subject: 'Test subject',
|
||||
'approvalOptions.values': {
|
||||
approvalType: 'single',
|
||||
},
|
||||
};
|
||||
return params[parameterName];
|
||||
});
|
||||
|
||||
expect(() => createEmail(mockExecuteFunctions)).toThrow(NodeOperationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendAndWaitWebhook', () => {
|
||||
it('should handle approved webhook', async () => {
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue({
|
||||
query: { approved: 'true' },
|
||||
} as any);
|
||||
|
||||
const result = await sendAndWaitWebhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toEqual({
|
||||
webhookResponse: expect.any(String),
|
||||
workflowData: [[{ json: { data: { approved: true } } }]],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle disapproved webhook', async () => {
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue({
|
||||
query: { approved: 'false' },
|
||||
} as any);
|
||||
|
||||
const result = await sendAndWaitWebhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toEqual({
|
||||
webhookResponse: expect.any(String),
|
||||
workflowData: [[{ json: { data: { approved: false } } }]],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle freeText GET webhook', async () => {
|
||||
const mockRender = jest.fn();
|
||||
const mockSetHeader = jest.fn();
|
||||
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue({
|
||||
method: 'GET',
|
||||
} as any);
|
||||
|
||||
mockWebhookFunctions.getResponseObject.mockReturnValue({
|
||||
render: mockRender,
|
||||
setHeader: mockSetHeader,
|
||||
} as any);
|
||||
|
||||
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
|
||||
const params: { [key: string]: any } = {
|
||||
responseType: 'freeText',
|
||||
message: 'Test message',
|
||||
options: {},
|
||||
};
|
||||
return params[parameterName];
|
||||
});
|
||||
|
||||
const result = await sendAndWaitWebhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toEqual({
|
||||
noWebhookResponse: true,
|
||||
});
|
||||
|
||||
expect(mockSetHeader).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(mockRender).toHaveBeenCalledWith('form-trigger', {
|
||||
testRun: false,
|
||||
formTitle: '',
|
||||
formDescription: 'Test message',
|
||||
formDescriptionMetadata: 'Test message',
|
||||
formSubmittedHeader: 'Got it, thanks',
|
||||
formSubmittedText: 'This page can be closed now',
|
||||
n8nWebsiteLink: 'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger',
|
||||
formFields: [
|
||||
{
|
||||
id: 'field-0',
|
||||
errorId: 'error-field-0',
|
||||
label: 'Response',
|
||||
inputRequired: 'form-required',
|
||||
defaultValue: '',
|
||||
isTextarea: true,
|
||||
},
|
||||
],
|
||||
appendAttribution: true,
|
||||
buttonLabel: 'Submit',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle freeText POST webhook', async () => {
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue({
|
||||
method: 'POST',
|
||||
} as any);
|
||||
|
||||
mockWebhookFunctions.getBodyData.mockReturnValue({
|
||||
data: {
|
||||
'field-0': 'test value',
|
||||
},
|
||||
} as any);
|
||||
|
||||
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
|
||||
const params: { [key: string]: any } = {
|
||||
responseType: 'freeText',
|
||||
};
|
||||
return params[parameterName];
|
||||
});
|
||||
|
||||
const result = await sendAndWaitWebhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(result.workflowData).toEqual([[{ json: { data: { text: 'test value' } } }]]);
|
||||
});
|
||||
|
||||
it('should handle customForm GET webhook', async () => {
|
||||
const mockRender = jest.fn();
|
||||
const mockSetHeader = jest.fn();
|
||||
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue({
|
||||
method: 'GET',
|
||||
} as any);
|
||||
|
||||
mockWebhookFunctions.getResponseObject.mockReturnValue({
|
||||
render: mockRender,
|
||||
setHeader: mockSetHeader,
|
||||
} as any);
|
||||
|
||||
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
|
||||
const params: { [key: string]: any } = {
|
||||
responseType: 'customForm',
|
||||
message: 'Test message',
|
||||
defineForm: 'fields',
|
||||
'formFields.values': [{ label: 'Field 1', fieldType: 'text', requiredField: true }],
|
||||
options: {
|
||||
responseFormTitle: 'Test title',
|
||||
responseFormDescription: 'Test description',
|
||||
responseFormButtonLabel: 'Test button',
|
||||
responseFormCustomCss: 'body { background-color: red; }',
|
||||
},
|
||||
};
|
||||
return params[parameterName];
|
||||
});
|
||||
|
||||
const result = await sendAndWaitWebhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toEqual({
|
||||
noWebhookResponse: true,
|
||||
});
|
||||
|
||||
expect(mockSetHeader).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(mockRender).toHaveBeenCalledWith('form-trigger', {
|
||||
testRun: false,
|
||||
formTitle: 'Test title',
|
||||
formDescription: 'Test description',
|
||||
formDescriptionMetadata: 'Test description',
|
||||
formSubmittedHeader: 'Got it, thanks',
|
||||
formSubmittedText: 'This page can be closed now',
|
||||
n8nWebsiteLink: 'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger',
|
||||
formFields: [
|
||||
{
|
||||
id: 'field-0',
|
||||
errorId: 'error-field-0',
|
||||
inputRequired: 'form-required',
|
||||
defaultValue: '',
|
||||
isInput: true,
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
appendAttribution: true,
|
||||
buttonLabel: 'Test button',
|
||||
dangerousCustomCss: 'body { background-color: red; }',
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve expressions in HTML fields for customForm GET webhook', async () => {
|
||||
const mockRender = jest.fn();
|
||||
const mockSetHeader = jest.fn();
|
||||
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue({
|
||||
method: 'GET',
|
||||
} as any);
|
||||
|
||||
mockWebhookFunctions.getResponseObject.mockReturnValue({
|
||||
render: mockRender,
|
||||
setHeader: mockSetHeader,
|
||||
} as any);
|
||||
|
||||
// Mock evaluateExpression to resolve the expression
|
||||
mockWebhookFunctions.evaluateExpression.mockImplementation((expression) => {
|
||||
if (expression === '{{ $json.videoUrl }}') {
|
||||
return 'https://example.com/video.mp4';
|
||||
}
|
||||
return expression;
|
||||
});
|
||||
|
||||
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
|
||||
const params: { [key: string]: any } = {
|
||||
responseType: 'customForm',
|
||||
message: 'Test message',
|
||||
defineForm: 'fields',
|
||||
'formFields.values': [
|
||||
{
|
||||
fieldLabel: 'Custom HTML',
|
||||
fieldType: 'html',
|
||||
// Use <source> tag inside <video> since sanitizeHtml allows src on source, not video
|
||||
html: '<video controls><source src="{{ $json.videoUrl }}" type="video/mp4" /></video>',
|
||||
},
|
||||
],
|
||||
options: {},
|
||||
};
|
||||
return params[parameterName];
|
||||
});
|
||||
|
||||
await sendAndWaitWebhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(mockRender).toHaveBeenCalledWith(
|
||||
'form-trigger',
|
||||
expect.objectContaining({
|
||||
formFields: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
html: '<video controls><source src="https://example.com/video.mp4" type="video/mp4"></source></video>',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle customForm POST webhook', async () => {
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue({
|
||||
method: 'POST',
|
||||
contentType: 'multipart/form-data',
|
||||
} as any);
|
||||
mockWebhookFunctions.getNode.mockReturnValue({} as any);
|
||||
|
||||
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
|
||||
const params: { [key: string]: any } = {
|
||||
responseType: 'customForm',
|
||||
defineForm: 'fields',
|
||||
'formFields.values': [
|
||||
{
|
||||
fieldLabel: 'test 1',
|
||||
fieldType: 'text',
|
||||
},
|
||||
],
|
||||
};
|
||||
return params[parameterName];
|
||||
});
|
||||
|
||||
mockWebhookFunctions.getBodyData.mockReturnValue({
|
||||
data: {
|
||||
'field-0': 'test value',
|
||||
},
|
||||
} as any);
|
||||
|
||||
const result = await sendAndWaitWebhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(result.workflowData).toEqual([[{ json: { data: { 'test 1': 'test value' } } }]]);
|
||||
});
|
||||
|
||||
it('should return noWebhookResponse if method GET and user-agent is bot', async () => {
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue({
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'user-agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
|
||||
},
|
||||
query: { approved: 'false' },
|
||||
} as any);
|
||||
|
||||
const send = jest.fn();
|
||||
|
||||
mockWebhookFunctions.getResponseObject.mockReturnValue({
|
||||
send,
|
||||
} as any);
|
||||
|
||||
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
|
||||
const params: { [key: string]: any } = {
|
||||
responseType: 'approval',
|
||||
};
|
||||
return params[parameterName];
|
||||
});
|
||||
|
||||
const result = await sendAndWaitWebhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(send).toHaveBeenCalledWith('');
|
||||
expect(result).toEqual({ noWebhookResponse: true });
|
||||
});
|
||||
|
||||
it('should return noWebhookResponse if user-agent is Microsoft Teams link preview service (SkypeSpaces)', async () => {
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue({
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'user-agent': 'SkypeSpaces/1.0a$*+',
|
||||
},
|
||||
query: { approved: 'true' },
|
||||
} as any);
|
||||
|
||||
const send = jest.fn();
|
||||
|
||||
mockWebhookFunctions.getResponseObject.mockReturnValue({
|
||||
send,
|
||||
} as any);
|
||||
|
||||
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
|
||||
const params: { [key: string]: any } = {
|
||||
responseType: 'approval',
|
||||
};
|
||||
return params[parameterName];
|
||||
});
|
||||
|
||||
const result = await sendAndWaitWebhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(send).toHaveBeenCalledWith('');
|
||||
expect(result).toEqual({ noWebhookResponse: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('configureWaitTillDate', () => {
|
||||
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return WAIT_INDEFINITELY if limitWaitTime is empty', () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({});
|
||||
const result = configureWaitTillDate(mockExecuteFunctions);
|
||||
expect(result).toBe(WAIT_INDEFINITELY);
|
||||
});
|
||||
|
||||
it('should calculate future date correctly for afterTimeInterval with minutes', () => {
|
||||
const resumeAmount = 5;
|
||||
const resumeUnit = 'minutes';
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
|
||||
limitType: 'afterTimeInterval',
|
||||
resumeAmount,
|
||||
resumeUnit,
|
||||
});
|
||||
|
||||
const result = configureWaitTillDate(mockExecuteFunctions);
|
||||
const expectedDate = new Date(new Date().getTime() + 5 * 60 * 1000);
|
||||
expect(result.getTime()).toBeCloseTo(expectedDate.getTime(), -2); // Allowing 100ms difference
|
||||
});
|
||||
|
||||
it('should calculate future date correctly for afterTimeInterval with hours', () => {
|
||||
const resumeAmount = 2;
|
||||
const resumeUnit = 'hours';
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
|
||||
limitType: 'afterTimeInterval',
|
||||
resumeAmount,
|
||||
resumeUnit,
|
||||
});
|
||||
|
||||
const result = configureWaitTillDate(mockExecuteFunctions);
|
||||
const expectedDate = new Date(new Date().getTime() + 2 * 60 * 60 * 1000);
|
||||
expect(result.getTime()).toBeCloseTo(expectedDate.getTime(), -2);
|
||||
});
|
||||
|
||||
it('should calculate future date correctly for afterTimeInterval with days', () => {
|
||||
const resumeAmount = 1;
|
||||
const resumeUnit = 'days';
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
|
||||
limitType: 'afterTimeInterval',
|
||||
resumeAmount,
|
||||
resumeUnit,
|
||||
});
|
||||
|
||||
const result = configureWaitTillDate(mockExecuteFunctions);
|
||||
const expectedDate = new Date(new Date().getTime() + 1 * 24 * 60 * 60 * 1000);
|
||||
expect(result.getTime()).toBeCloseTo(expectedDate.getTime(), -2);
|
||||
});
|
||||
|
||||
it('should return the specified maxDateAndTime for maxDateAndTime limitType', () => {
|
||||
const maxDateAndTime = '2023-12-31T23:59:59Z';
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
|
||||
limitType: 'maxDateAndTime',
|
||||
maxDateAndTime,
|
||||
});
|
||||
|
||||
const result = configureWaitTillDate(mockExecuteFunctions);
|
||||
expect(result).toEqual(new Date(maxDateAndTime));
|
||||
});
|
||||
|
||||
it('should throw NodeOperationError for invalid maxDateAndTime format', () => {
|
||||
const invalidMaxDateAndTime = 'invalid-date';
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValue({
|
||||
limitType: 'maxDateAndTime',
|
||||
maxDateAndTime: invalidMaxDateAndTime,
|
||||
});
|
||||
|
||||
expect(() => configureWaitTillDate(mockExecuteFunctions)).toThrow(NodeOperationError);
|
||||
expect(() => configureWaitTillDate(mockExecuteFunctions)).toThrow(
|
||||
'Could not configure Limit Wait Time',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NodeOperationError for invalid resumeAmount or resumeUnit', () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValue({
|
||||
limitType: 'afterTimeInterval',
|
||||
resumeAmount: 'invalid',
|
||||
resumeUnit: 'minutes',
|
||||
});
|
||||
|
||||
expect(() => configureWaitTillDate(mockExecuteFunctions)).toThrow(NodeOperationError);
|
||||
expect(() => configureWaitTillDate(mockExecuteFunctions)).toThrow(
|
||||
'Could not configure Limit Wait Time',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return WAIT_INDEFINITELY when limitWaitTime is false', () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(false);
|
||||
const result = configureWaitTillDate(mockExecuteFunctions, 'root');
|
||||
expect(result).toBe(WAIT_INDEFINITELY);
|
||||
});
|
||||
|
||||
it('should calculate minutes correctly in root location', () => {
|
||||
mockExecuteFunctions.getNodeParameter
|
||||
.mockReturnValueOnce(true) // limitWaitTime
|
||||
.mockReturnValueOnce('afterTimeInterval') // limitType
|
||||
.mockReturnValueOnce(15) // resumeAmount
|
||||
.mockReturnValueOnce('minutes'); // resumeUnit
|
||||
|
||||
const result = configureWaitTillDate(mockExecuteFunctions, 'root');
|
||||
const expectedDate = new Date(new Date().getTime() + 15 * 60 * 1000);
|
||||
expect(result.getTime()).toBeCloseTo(expectedDate.getTime(), -2);
|
||||
});
|
||||
|
||||
it('should calculate hours correctly in root location', () => {
|
||||
mockExecuteFunctions.getNodeParameter
|
||||
.mockReturnValueOnce(true)
|
||||
.mockReturnValueOnce('afterTimeInterval')
|
||||
.mockReturnValueOnce(3)
|
||||
.mockReturnValueOnce('hours');
|
||||
|
||||
const result = configureWaitTillDate(mockExecuteFunctions, 'root');
|
||||
const expectedDate = new Date(new Date().getTime() + 3 * 60 * 60 * 1000);
|
||||
expect(result.getTime()).toBeCloseTo(expectedDate.getTime(), -2);
|
||||
});
|
||||
|
||||
it('should calculate days correctly in root location', () => {
|
||||
mockExecuteFunctions.getNodeParameter
|
||||
.mockReturnValueOnce(true)
|
||||
.mockReturnValueOnce('afterTimeInterval')
|
||||
.mockReturnValueOnce(5)
|
||||
.mockReturnValueOnce('days');
|
||||
|
||||
const result = configureWaitTillDate(mockExecuteFunctions, 'root');
|
||||
const expectedDate = new Date(new Date().getTime() + 5 * 24 * 60 * 60 * 1000);
|
||||
expect(result.getTime()).toBeCloseTo(expectedDate.getTime(), -2);
|
||||
});
|
||||
|
||||
it('should handle maxDateAndTime in root location', () => {
|
||||
const maxDateAndTime = '2024-12-31T23:59:59Z';
|
||||
mockExecuteFunctions.getNodeParameter
|
||||
.mockReturnValueOnce(true)
|
||||
.mockReturnValueOnce('maxDateAndTime')
|
||||
.mockReturnValueOnce(maxDateAndTime);
|
||||
|
||||
const result = configureWaitTillDate(mockExecuteFunctions, 'root');
|
||||
expect(result).toEqual(new Date(maxDateAndTime));
|
||||
});
|
||||
|
||||
it('should throw error for invalid date in root location', () => {
|
||||
mockExecuteFunctions.getNodeParameter
|
||||
.mockReturnValueOnce(true)
|
||||
.mockReturnValueOnce('maxDateAndTime')
|
||||
.mockReturnValueOnce('not-a-valid-date');
|
||||
|
||||
expect(() => configureWaitTillDate(mockExecuteFunctions, 'root')).toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should throw error for invalid resumeAmount in root location', () => {
|
||||
mockExecuteFunctions.getNodeParameter
|
||||
.mockReturnValueOnce(true)
|
||||
.mockReturnValueOnce('afterTimeInterval')
|
||||
.mockReturnValueOnce('not-a-number')
|
||||
.mockReturnValueOnce('minutes');
|
||||
|
||||
expect(() => configureWaitTillDate(mockExecuteFunctions, 'root')).toThrow(NodeOperationError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,579 @@
|
||||
import isbot from 'isbot';
|
||||
import { getWebhookSandboxCSP } from 'n8n-core';
|
||||
import type {
|
||||
FormFieldsParameter,
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeProperties,
|
||||
IWebhookFunctions,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError, SEND_AND_WAIT_OPERATION, updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import { cssVariables } from '../../nodes/Form/cssVariables';
|
||||
import { formFieldsProperties } from '../../nodes/Form/Form.node';
|
||||
import {
|
||||
parseFormFields,
|
||||
prepareFormData,
|
||||
prepareFormFields,
|
||||
prepareFormReturnItem,
|
||||
} from '../../nodes/Form/utils/utils';
|
||||
import { escapeHtml } from '../utilities';
|
||||
import { limitWaitTimeOption } from './descriptions';
|
||||
import {
|
||||
ACTION_RECORDED_PAGE,
|
||||
BUTTON_STYLE_PRIMARY,
|
||||
BUTTON_STYLE_SECONDARY,
|
||||
createEmailBodyWithN8nAttribution,
|
||||
createEmailBodyWithoutN8nAttribution,
|
||||
} from './email-templates';
|
||||
import type { IEmail } from './interfaces';
|
||||
|
||||
export type SendAndWaitConfig = {
|
||||
title: string;
|
||||
message: string;
|
||||
options: Array<{ label: string; url: string; style: string }>;
|
||||
appendAttribution?: boolean;
|
||||
};
|
||||
|
||||
type FormResponseTypeOptions = {
|
||||
messageButtonLabel?: string;
|
||||
responseFormTitle?: string;
|
||||
responseFormDescription?: string;
|
||||
responseFormButtonLabel?: string;
|
||||
responseFormCustomCss?: string;
|
||||
};
|
||||
|
||||
const INPUT_FIELD_IDENTIFIER = 'field-0';
|
||||
|
||||
const appendAttributionOption: INodeProperties = {
|
||||
displayName: 'Append n8n Attribution',
|
||||
name: 'appendAttribution',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether to include the phrase "This message was sent automatically with n8n" to the end of the message',
|
||||
};
|
||||
|
||||
// Operation Properties ----------------------------------------------------------
|
||||
export function getSendAndWaitProperties(
|
||||
targetProperties: INodeProperties[],
|
||||
resource: string | null = 'message',
|
||||
additionalProperties: INodeProperties[] = [],
|
||||
options?: {
|
||||
noButtonStyle?: boolean;
|
||||
defaultApproveLabel?: string;
|
||||
defaultDisapproveLabel?: string;
|
||||
extraOptions?: INodeProperties[];
|
||||
},
|
||||
): INodeProperties[] {
|
||||
const buttonStyle: INodeProperties = {
|
||||
displayName: 'Button Style',
|
||||
name: 'buttonStyle',
|
||||
type: 'options',
|
||||
default: 'primary',
|
||||
options: [
|
||||
{
|
||||
name: 'Primary',
|
||||
value: 'primary',
|
||||
},
|
||||
{
|
||||
name: 'Secondary',
|
||||
value: 'secondary',
|
||||
},
|
||||
],
|
||||
};
|
||||
const approvalOptionsValues = [
|
||||
{
|
||||
displayName: 'Type of Approval',
|
||||
name: 'approvalType',
|
||||
type: 'options',
|
||||
placeholder: 'Add option',
|
||||
default: 'single',
|
||||
options: [
|
||||
{
|
||||
name: 'Approve Only',
|
||||
value: 'single',
|
||||
},
|
||||
{
|
||||
name: 'Approve and Disapprove',
|
||||
value: 'double',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Approve Button Label',
|
||||
name: 'approveLabel',
|
||||
type: 'string',
|
||||
default: options?.defaultApproveLabel || 'Approve',
|
||||
displayOptions: {
|
||||
show: {
|
||||
approvalType: ['single', 'double'],
|
||||
},
|
||||
},
|
||||
},
|
||||
...[
|
||||
options?.noButtonStyle
|
||||
? ({} as INodeProperties)
|
||||
: {
|
||||
...buttonStyle,
|
||||
displayName: 'Approve Button Style',
|
||||
name: 'buttonApprovalStyle',
|
||||
displayOptions: {
|
||||
show: {
|
||||
approvalType: ['single', 'double'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
displayName: 'Disapprove Button Label',
|
||||
name: 'disapproveLabel',
|
||||
type: 'string',
|
||||
default: options?.defaultDisapproveLabel || 'Decline',
|
||||
displayOptions: {
|
||||
show: {
|
||||
approvalType: ['double'],
|
||||
},
|
||||
},
|
||||
},
|
||||
...[
|
||||
options?.noButtonStyle
|
||||
? ({} as INodeProperties)
|
||||
: {
|
||||
...buttonStyle,
|
||||
displayName: 'Disapprove Button Style',
|
||||
name: 'buttonDisapprovalStyle',
|
||||
default: 'secondary',
|
||||
displayOptions: {
|
||||
show: {
|
||||
approvalType: ['double'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
].filter((p) => Object.keys(p).length) as INodeProperties[];
|
||||
|
||||
const sendAndWait: INodeProperties[] = [
|
||||
...targetProperties,
|
||||
{
|
||||
displayName: 'Subject',
|
||||
name: 'subject',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'e.g. Approval required',
|
||||
},
|
||||
{
|
||||
displayName: 'Message',
|
||||
name: 'message',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
typeOptions: {
|
||||
rows: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Response Type',
|
||||
name: 'responseType',
|
||||
type: 'options',
|
||||
default: 'approval',
|
||||
options: [
|
||||
{
|
||||
name: 'Approval',
|
||||
value: 'approval',
|
||||
description: 'User can approve/disapprove from within the message',
|
||||
},
|
||||
{
|
||||
name: 'Free Text',
|
||||
value: 'freeText',
|
||||
description: 'User can submit a response via a form',
|
||||
},
|
||||
{
|
||||
name: 'Custom Form',
|
||||
value: 'customForm',
|
||||
description: 'User can submit a response via a custom form',
|
||||
},
|
||||
],
|
||||
},
|
||||
...updateDisplayOptions(
|
||||
{
|
||||
show: {
|
||||
responseType: ['customForm'],
|
||||
},
|
||||
},
|
||||
formFieldsProperties,
|
||||
),
|
||||
|
||||
{
|
||||
displayName: 'Approval Options',
|
||||
name: 'approvalOptions',
|
||||
type: 'fixedCollection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Values',
|
||||
name: 'values',
|
||||
values: approvalOptionsValues,
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
responseType: ['approval'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
options: [limitWaitTimeOption, appendAttributionOption, ...(options?.extraOptions ?? [])],
|
||||
displayOptions: {
|
||||
show: {
|
||||
responseType: ['approval'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Message Button Label',
|
||||
name: 'messageButtonLabel',
|
||||
type: 'string',
|
||||
default: 'Respond',
|
||||
},
|
||||
{
|
||||
displayName: 'Response Form Title',
|
||||
name: 'responseFormTitle',
|
||||
description: 'Title of the form that the user can access to provide their response',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Response Form Description',
|
||||
name: 'responseFormDescription',
|
||||
description: 'Description of the form that the user can access to provide their response',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Response Form Button Label',
|
||||
name: 'responseFormButtonLabel',
|
||||
type: 'string',
|
||||
default: 'Submit',
|
||||
},
|
||||
{
|
||||
displayName: 'Response Form Custom Styling',
|
||||
name: 'responseFormCustomCss',
|
||||
type: 'string',
|
||||
typeOptions: {
|
||||
rows: 10,
|
||||
editor: 'cssEditor',
|
||||
},
|
||||
default: cssVariables.trim(),
|
||||
description: 'Override default styling of the response form with CSS',
|
||||
},
|
||||
limitWaitTimeOption,
|
||||
appendAttributionOption,
|
||||
...(options?.extraOptions ?? []),
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
responseType: ['freeText', 'customForm'],
|
||||
},
|
||||
},
|
||||
},
|
||||
...additionalProperties,
|
||||
];
|
||||
|
||||
return updateDisplayOptions(
|
||||
{
|
||||
show: {
|
||||
...(resource ? { resource: [resource] } : {}),
|
||||
operation: [SEND_AND_WAIT_OPERATION],
|
||||
},
|
||||
},
|
||||
sendAndWait,
|
||||
);
|
||||
}
|
||||
|
||||
// Webhook Function --------------------------------------------------------------
|
||||
const getFormResponseCustomizations = (context: IWebhookFunctions) => {
|
||||
const message = context.getNodeParameter('message', '') as string;
|
||||
const options = context.getNodeParameter('options', {}) as FormResponseTypeOptions;
|
||||
|
||||
let formTitle = '';
|
||||
if (options.responseFormTitle) {
|
||||
formTitle = options.responseFormTitle;
|
||||
}
|
||||
|
||||
let formDescription = message;
|
||||
if (options.responseFormDescription) {
|
||||
formDescription = options.responseFormDescription;
|
||||
}
|
||||
formDescription = formDescription.replace(/\\n/g, '\n').replace(/<br>/g, '\n');
|
||||
|
||||
let buttonLabel = 'Submit';
|
||||
if (options.responseFormButtonLabel) {
|
||||
buttonLabel = options.responseFormButtonLabel;
|
||||
}
|
||||
|
||||
return {
|
||||
formTitle,
|
||||
formDescription,
|
||||
buttonLabel,
|
||||
customCss: options.responseFormCustomCss,
|
||||
};
|
||||
};
|
||||
|
||||
export async function sendAndWaitWebhook(this: IWebhookFunctions) {
|
||||
const method = this.getRequestObject().method;
|
||||
const res = this.getResponseObject();
|
||||
const req = this.getRequestObject();
|
||||
|
||||
const responseType = this.getNodeParameter('responseType', 'approval') as
|
||||
| 'approval'
|
||||
| 'freeText'
|
||||
| 'customForm';
|
||||
|
||||
if (
|
||||
responseType === 'approval' &&
|
||||
(isbot(req.headers['user-agent']) ||
|
||||
// Microsoft Teams link preview service (SkypeSpaces) automatically fetches
|
||||
// URLs in chat messages for rich previews, which would trigger the approval
|
||||
req.headers['user-agent']?.includes('SkypeSpaces'))
|
||||
) {
|
||||
res.send('');
|
||||
return { noWebhookResponse: true };
|
||||
}
|
||||
|
||||
if (responseType === 'freeText') {
|
||||
if (method === 'GET') {
|
||||
const { formTitle, formDescription, buttonLabel, customCss } =
|
||||
getFormResponseCustomizations(this);
|
||||
|
||||
const data = prepareFormData({
|
||||
formTitle,
|
||||
formDescription,
|
||||
formSubmittedHeader: 'Got it, thanks',
|
||||
formSubmittedText: 'This page can be closed now',
|
||||
buttonLabel,
|
||||
redirectUrl: undefined,
|
||||
formFields: [
|
||||
{
|
||||
fieldLabel: 'Response',
|
||||
fieldType: 'textarea',
|
||||
requiredField: true,
|
||||
},
|
||||
],
|
||||
testRun: false,
|
||||
query: {},
|
||||
customCss,
|
||||
});
|
||||
|
||||
res.setHeader('Content-Security-Policy', getWebhookSandboxCSP());
|
||||
res.render('form-trigger', data);
|
||||
|
||||
return {
|
||||
noWebhookResponse: true,
|
||||
};
|
||||
}
|
||||
if (method === 'POST') {
|
||||
const data = this.getBodyData().data as IDataObject;
|
||||
|
||||
return {
|
||||
webhookResponse: ACTION_RECORDED_PAGE,
|
||||
workflowData: [[{ json: { data: { text: data[INPUT_FIELD_IDENTIFIER] } } }]],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (responseType === 'customForm') {
|
||||
const defineForm = this.getNodeParameter('defineForm', 'fields') as 'fields' | 'json';
|
||||
let fields: FormFieldsParameter = [];
|
||||
|
||||
if (defineForm === 'json') {
|
||||
fields = parseFormFields(this, {
|
||||
defineForm: 'json',
|
||||
fieldsParameterName: 'jsonOutput',
|
||||
});
|
||||
} else {
|
||||
fields = parseFormFields(this, {
|
||||
defineForm: 'fields',
|
||||
fieldsParameterName: 'formFields.values',
|
||||
});
|
||||
}
|
||||
|
||||
if (method === 'GET') {
|
||||
const { formTitle, formDescription, buttonLabel, customCss } =
|
||||
getFormResponseCustomizations(this);
|
||||
|
||||
fields = prepareFormFields(fields);
|
||||
|
||||
const data = prepareFormData({
|
||||
formTitle,
|
||||
formDescription,
|
||||
formSubmittedHeader: 'Got it, thanks',
|
||||
formSubmittedText: 'This page can be closed now',
|
||||
buttonLabel,
|
||||
redirectUrl: undefined,
|
||||
formFields: fields,
|
||||
testRun: false,
|
||||
query: {},
|
||||
customCss,
|
||||
});
|
||||
|
||||
res.setHeader('Content-Security-Policy', getWebhookSandboxCSP());
|
||||
res.render('form-trigger', data);
|
||||
|
||||
return {
|
||||
noWebhookResponse: true,
|
||||
};
|
||||
}
|
||||
if (method === 'POST') {
|
||||
const returnItem = await prepareFormReturnItem(this, fields, 'production', true);
|
||||
const json = returnItem.json;
|
||||
|
||||
delete json.submittedAt;
|
||||
delete json.formMode;
|
||||
|
||||
returnItem.json = { data: json };
|
||||
|
||||
return {
|
||||
webhookResponse: ACTION_RECORDED_PAGE,
|
||||
workflowData: [[returnItem]],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const query = req.query as { approved: 'false' | 'true' };
|
||||
const approved = query.approved === 'true';
|
||||
return {
|
||||
webhookResponse: ACTION_RECORDED_PAGE,
|
||||
workflowData: [[{ json: { data: { approved } } }]],
|
||||
};
|
||||
}
|
||||
|
||||
// Send and Wait Config -----------------------------------------------------------
|
||||
export function getSendAndWaitConfig(context: IExecuteFunctions): SendAndWaitConfig {
|
||||
const message = escapeHtml((context.getNodeParameter('message', 0, '') as string).trim())
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/<br>/g, '\n');
|
||||
const subject = escapeHtml(context.getNodeParameter('subject', 0, '') as string);
|
||||
const approvalOptions = context.getNodeParameter('approvalOptions.values', 0, {}) as {
|
||||
approvalType?: 'single' | 'double';
|
||||
approveLabel?: string;
|
||||
buttonApprovalStyle?: string;
|
||||
disapproveLabel?: string;
|
||||
buttonDisapprovalStyle?: string;
|
||||
};
|
||||
|
||||
const options = context.getNodeParameter('options', 0, {});
|
||||
|
||||
const config: SendAndWaitConfig = {
|
||||
title: subject,
|
||||
message,
|
||||
options: [],
|
||||
appendAttribution: options?.appendAttribution as boolean,
|
||||
};
|
||||
|
||||
const responseType = context.getNodeParameter('responseType', 0, 'approval') as string;
|
||||
|
||||
context.setSignatureValidationRequired();
|
||||
const approvedSignedResumeUrl = context.getSignedResumeUrl({ approved: 'true' });
|
||||
|
||||
if (responseType === 'freeText' || responseType === 'customForm') {
|
||||
const label = context.getNodeParameter('options.messageButtonLabel', 0, 'Respond') as string;
|
||||
config.options.push({
|
||||
label,
|
||||
url: approvedSignedResumeUrl,
|
||||
style: 'primary',
|
||||
});
|
||||
} else if (approvalOptions.approvalType === 'double') {
|
||||
const approveLabel = escapeHtml(approvalOptions.approveLabel || 'Approve');
|
||||
const buttonApprovalStyle = approvalOptions.buttonApprovalStyle || 'primary';
|
||||
const disapproveLabel = escapeHtml(approvalOptions.disapproveLabel || 'Disapprove');
|
||||
const buttonDisapprovalStyle = approvalOptions.buttonDisapprovalStyle || 'secondary';
|
||||
const disapprovedSignedResumeUrl = context.getSignedResumeUrl({ approved: 'false' });
|
||||
|
||||
config.options.push({
|
||||
label: disapproveLabel,
|
||||
url: disapprovedSignedResumeUrl,
|
||||
style: buttonDisapprovalStyle,
|
||||
});
|
||||
config.options.push({
|
||||
label: approveLabel,
|
||||
url: approvedSignedResumeUrl,
|
||||
style: buttonApprovalStyle,
|
||||
});
|
||||
} else {
|
||||
const label = escapeHtml(approvalOptions.approveLabel || 'Approve');
|
||||
const style = approvalOptions.buttonApprovalStyle || 'primary';
|
||||
config.options.push({
|
||||
label,
|
||||
url: approvedSignedResumeUrl,
|
||||
style,
|
||||
});
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export function createButton(url: string, label: string, style: string) {
|
||||
let buttonStyle = BUTTON_STYLE_PRIMARY;
|
||||
if (style === 'secondary') {
|
||||
buttonStyle = BUTTON_STYLE_SECONDARY;
|
||||
}
|
||||
return `<a href="${url}" target="_blank" style="${buttonStyle}">${label}</a>`;
|
||||
}
|
||||
|
||||
export function createEmail(context: IExecuteFunctions) {
|
||||
const to = (context.getNodeParameter('sendTo', 0, '') as string).trim();
|
||||
const config = getSendAndWaitConfig(context);
|
||||
|
||||
if (to.indexOf('@') === -1 || (to.match(/@/g) || []).length > 1) {
|
||||
const description = `The email address '${to}' in the 'To' field isn't valid or contains multiple addresses. Please provide only a single email address.`;
|
||||
throw new NodeOperationError(context.getNode(), 'Invalid email address', {
|
||||
description,
|
||||
itemIndex: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const buttons: string[] = [];
|
||||
for (const option of config.options) {
|
||||
buttons.push(createButton(option.url, option.label, option.style));
|
||||
}
|
||||
let emailBody: string;
|
||||
if (config.appendAttribution !== false) {
|
||||
const instanceId = context.getInstanceId();
|
||||
emailBody = createEmailBodyWithN8nAttribution(config.message, buttons.join('\n'), instanceId);
|
||||
} else {
|
||||
emailBody = createEmailBodyWithoutN8nAttribution(config.message, buttons.join('\n'));
|
||||
}
|
||||
|
||||
const email: IEmail = {
|
||||
to,
|
||||
subject: config.title,
|
||||
body: '',
|
||||
htmlBody: emailBody,
|
||||
};
|
||||
|
||||
return email;
|
||||
}
|
||||
|
||||
const sendAndWaitWaitingTooltip = (parameters: { operation: string }) => {
|
||||
if (parameters?.operation === 'sendAndWait') {
|
||||
return "Execution will continue after the user's response";
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
export const SEND_AND_WAIT_WAITING_TOOLTIP = `={{ (${sendAndWaitWaitingTooltip})($parameter) }}`;
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export const sshTunnelProperties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'SSH Tunnel',
|
||||
name: 'sshTunnel',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
displayName: 'SSH Authenticate with',
|
||||
name: 'sshAuthenticateWith',
|
||||
type: 'options',
|
||||
default: 'password',
|
||||
options: [
|
||||
{
|
||||
name: 'Password',
|
||||
value: 'password',
|
||||
},
|
||||
{
|
||||
name: 'Private Key',
|
||||
value: 'privateKey',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
sshTunnel: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'SSH Host',
|
||||
name: 'sshHost',
|
||||
type: 'string',
|
||||
default: 'localhost',
|
||||
displayOptions: {
|
||||
show: {
|
||||
sshTunnel: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'SSH Port',
|
||||
name: 'sshPort',
|
||||
type: 'number',
|
||||
default: 22,
|
||||
displayOptions: {
|
||||
show: {
|
||||
sshTunnel: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'SSH User',
|
||||
name: 'sshUser',
|
||||
type: 'string',
|
||||
default: 'root',
|
||||
displayOptions: {
|
||||
show: {
|
||||
sshTunnel: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'SSH Password',
|
||||
name: 'sshPassword',
|
||||
type: 'string',
|
||||
typeOptions: {
|
||||
password: true,
|
||||
},
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
sshTunnel: [true],
|
||||
sshAuthenticateWith: ['password'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Private Key',
|
||||
name: 'privateKey', // TODO: Rename to sshPrivateKey
|
||||
type: 'string',
|
||||
typeOptions: {
|
||||
rows: 4,
|
||||
password: true,
|
||||
},
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
sshTunnel: [true],
|
||||
sshAuthenticateWith: ['privateKey'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Passphrase',
|
||||
name: 'passphrase', // TODO: Rename to sshPassphrase
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'Passphrase used to create the key, if no passphrase was used leave empty',
|
||||
displayOptions: {
|
||||
show: {
|
||||
sshTunnel: [true],
|
||||
sshAuthenticateWith: ['privateKey'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,536 @@
|
||||
import get from 'lodash/get';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import isNull from 'lodash/isNull';
|
||||
import isObject from 'lodash/isObject';
|
||||
import merge from 'lodash/merge';
|
||||
import reduce from 'lodash/reduce';
|
||||
import type {
|
||||
IDataObject,
|
||||
IDisplayOptions,
|
||||
IExecuteFunctions,
|
||||
INode,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
IPairedItemData,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
ApplicationError,
|
||||
jsonParse,
|
||||
MYSQL_NODE_TYPE,
|
||||
POSTGRES_NODE_TYPE,
|
||||
randomInt,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
/**
|
||||
* Creates an array of elements split into groups the length of `size`.
|
||||
* If `array` can't be split evenly, the final chunk will be the remaining
|
||||
* elements.
|
||||
*
|
||||
* @param {Array} array The array to process.
|
||||
* @param {number} [size=1] The length of each chunk
|
||||
* @example
|
||||
*
|
||||
* chunk(['a', 'b', 'c', 'd'], 2)
|
||||
* // => [['a', 'b'], ['c', 'd']]
|
||||
*
|
||||
* chunk(['a', 'b', 'c', 'd'], 3)
|
||||
* // => [['a', 'b', 'c'], ['d']]
|
||||
*/
|
||||
|
||||
export function chunk<T>(array: T[], size = 1) {
|
||||
const length = array === null ? 0 : array.length;
|
||||
if (!length || size < 1) {
|
||||
return [];
|
||||
}
|
||||
let index = 0;
|
||||
let resIndex = 0;
|
||||
const result = new Array(Math.ceil(length / size));
|
||||
|
||||
while (index < length) {
|
||||
result[resIndex++] = array.slice(index, (index += size));
|
||||
}
|
||||
return result as T[][];
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffles an array in place using the Fisher-Yates shuffle algorithm
|
||||
* @param {Array} array The array to shuffle.
|
||||
*/
|
||||
export const shuffleArray = <T>(array: T[]): void => {
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = randomInt(i + 1);
|
||||
[array[i], array[j]] = [array[j], array[i]];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Flattens an object with deep data
|
||||
* @param {IDataObject} data The object to flatten
|
||||
* @param {string[]} prefix The prefix to add to each key in the returned flat object
|
||||
*/
|
||||
export const flattenKeys = (obj: IDataObject, prefix: string[] = []): IDataObject => {
|
||||
return !isObject(obj)
|
||||
? { [prefix.join('.')]: obj }
|
||||
: reduce(
|
||||
obj,
|
||||
(cum, next, key) => merge(cum, flattenKeys(next as IDataObject, [...prefix, key])),
|
||||
{},
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Takes a multidimensional array and converts it to a one-dimensional array.
|
||||
*
|
||||
* @param {Array} nestedArray The array to be flattened.
|
||||
* @example
|
||||
*
|
||||
* flatten([['a', 'b'], ['c', 'd']])
|
||||
* // => ['a', 'b', 'c', 'd']
|
||||
*
|
||||
*/
|
||||
|
||||
export function flatten<T>(nestedArray: T[][]) {
|
||||
const result = [];
|
||||
|
||||
(function loop(array: T[] | T[][]) {
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
if (Array.isArray(array[i])) {
|
||||
loop(array[i] as T[]);
|
||||
} else {
|
||||
result.push(array[i]);
|
||||
}
|
||||
}
|
||||
})(nestedArray);
|
||||
|
||||
//TODO: check logic in MicrosoftSql.node.ts
|
||||
|
||||
return result as any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the values of specified keys in two objects.
|
||||
*
|
||||
* @param {T} obj1 - The first object to compare.
|
||||
* @param {T} obj2 - The second object to compare.
|
||||
* @param {string[]} keys - An array of keys to compare.
|
||||
* @param {boolean} disableDotNotation - Whether to use dot notation to access nested properties.
|
||||
* @returns {boolean} - Whether the values of the specified keys are equal in both objects.
|
||||
*/
|
||||
export const compareItems = <T extends { json: Record<string, unknown> }>(
|
||||
obj1: T,
|
||||
obj2: T,
|
||||
keys: string[],
|
||||
disableDotNotation: boolean = false,
|
||||
): boolean => {
|
||||
let result = true;
|
||||
for (const key of keys) {
|
||||
if (!disableDotNotation) {
|
||||
if (!isEqual(get(obj1.json, key), get(obj2.json, key))) {
|
||||
result = false;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if (!isEqual(obj1.json[key], obj2.json[key])) {
|
||||
result = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export function updateDisplayOptions(
|
||||
displayOptions: IDisplayOptions,
|
||||
properties: INodeProperties[],
|
||||
) {
|
||||
return properties.map((nodeProperty) => {
|
||||
return {
|
||||
...nodeProperty,
|
||||
displayOptions: merge({}, nodeProperty.displayOptions, displayOptions),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function processJsonInput<T>(jsonData: T, inputName?: string) {
|
||||
let values;
|
||||
const input = inputName ? `'${inputName}' ` : '';
|
||||
|
||||
if (typeof jsonData === 'string') {
|
||||
try {
|
||||
values = jsonParse(jsonData);
|
||||
} catch (error) {
|
||||
throw new ApplicationError(`Input ${input} must contain a valid JSON`, { level: 'warning' });
|
||||
}
|
||||
} else if (typeof jsonData === 'object') {
|
||||
values = jsonData;
|
||||
} else {
|
||||
throw new ApplicationError(`Input ${input} must contain a valid JSON`, { level: 'warning' });
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
function isFalsy<T>(value: T) {
|
||||
if (isNull(value)) return true;
|
||||
if (typeof value === 'string' && value === '') return true;
|
||||
if (Array.isArray(value) && value.length === 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const parseStringAndCompareToObject = (str: string, arr: IDataObject) => {
|
||||
try {
|
||||
const parsedArray = jsonParse(str);
|
||||
return isEqual(parsedArray, arr);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const fuzzyCompare = (useFuzzyCompare: boolean, compareVersion = 1) => {
|
||||
if (!useFuzzyCompare) {
|
||||
//Fuzzy compare is false we do strict comparison
|
||||
return <T, U>(item1: T, item2: U) => isEqual(item1, item2);
|
||||
}
|
||||
|
||||
return <T, U>(item1: T, item2: U) => {
|
||||
//Both types are the same, so we do strict comparison
|
||||
if (!isNull(item1) && !isNull(item2) && typeof item1 === typeof item2) {
|
||||
return isEqual(item1, item2);
|
||||
}
|
||||
|
||||
if (compareVersion >= 2) {
|
||||
//Null, 0 and "0" treated as equal
|
||||
if (isNull(item1) && (isNull(item2) || item2 === 0 || item2 === '0')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isNull(item2) && (isNull(item1) || item1 === 0 || item1 === '0')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
//Null, empty strings, empty arrays all treated as the same
|
||||
if (isFalsy(item1) && isFalsy(item2)) return true;
|
||||
|
||||
//When a field is missing in one branch and isFalsy() in another, treat them as matching
|
||||
if (isFalsy(item1) && item2 === undefined) return true;
|
||||
if (item1 === undefined && isFalsy(item2)) return true;
|
||||
|
||||
//Compare numbers and strings representing that number
|
||||
if (typeof item1 === 'number' && typeof item2 === 'string') {
|
||||
return item1.toString() === item2;
|
||||
}
|
||||
|
||||
if (typeof item1 === 'string' && typeof item2 === 'number') {
|
||||
return item1 === item2.toString();
|
||||
}
|
||||
|
||||
//Compare objects/arrays and their stringified version
|
||||
if (!isNull(item1) && typeof item1 === 'object' && typeof item2 === 'string') {
|
||||
return parseStringAndCompareToObject(item2, item1 as IDataObject);
|
||||
}
|
||||
|
||||
if (!isNull(item2) && typeof item1 === 'string' && typeof item2 === 'object') {
|
||||
return parseStringAndCompareToObject(item1, item2 as IDataObject);
|
||||
}
|
||||
|
||||
//Compare booleans and strings representing the boolean (’true’, ‘True’, ‘TRUE’)
|
||||
if (typeof item1 === 'boolean' && typeof item2 === 'string') {
|
||||
if (item1 === true && item2.toLocaleLowerCase() === 'true') return true;
|
||||
if (item1 === false && item2.toLocaleLowerCase() === 'false') return true;
|
||||
}
|
||||
|
||||
if (typeof item2 === 'boolean' && typeof item1 === 'string') {
|
||||
if (item2 === true && item1.toLocaleLowerCase() === 'true') return true;
|
||||
if (item2 === false && item1.toLocaleLowerCase() === 'false') return true;
|
||||
}
|
||||
|
||||
//Compare booleans and the numbers/string 0 and 1
|
||||
if (typeof item1 === 'boolean' && typeof item2 === 'number') {
|
||||
if (item1 === true && item2 === 1) return true;
|
||||
if (item1 === false && item2 === 0) return true;
|
||||
}
|
||||
|
||||
if (typeof item2 === 'boolean' && typeof item1 === 'number') {
|
||||
if (item2 === true && item1 === 1) return true;
|
||||
if (item2 === false && item1 === 0) return true;
|
||||
}
|
||||
|
||||
if (typeof item1 === 'boolean' && typeof item2 === 'string') {
|
||||
if (item1 === true && item2 === '1') return true;
|
||||
if (item1 === false && item2 === '0') return true;
|
||||
}
|
||||
|
||||
if (typeof item2 === 'boolean' && typeof item1 === 'string') {
|
||||
if (item2 === true && item1 === '1') return true;
|
||||
if (item2 === false && item1 === '0') return true;
|
||||
}
|
||||
|
||||
return isEqual(item1, item2);
|
||||
};
|
||||
};
|
||||
|
||||
export function wrapData(data: IDataObject | IDataObject[]): INodeExecutionData[] {
|
||||
if (!Array.isArray(data)) {
|
||||
return [{ json: data }];
|
||||
}
|
||||
return data.map((item) => ({
|
||||
json: item,
|
||||
}));
|
||||
}
|
||||
|
||||
export const keysToLowercase = <T>(headers: T) => {
|
||||
if (typeof headers !== 'object' || Array.isArray(headers) || headers === null) return headers;
|
||||
return Object.entries(headers).reduce((acc, [key, value]) => {
|
||||
acc[key.toLowerCase()] = value as IDataObject;
|
||||
return acc;
|
||||
}, {} as IDataObject);
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a private key by removing unnecessary whitespace and adding line breaks.
|
||||
* @param privateKey - The private key to format.
|
||||
* @returns The formatted private key.
|
||||
*/
|
||||
export function formatPrivateKey(privateKey: string, keyIsPublic = false): string {
|
||||
let regex = /(PRIVATE KEY|CERTIFICATE)/;
|
||||
if (keyIsPublic) {
|
||||
regex = /(PUBLIC KEY)/;
|
||||
}
|
||||
if (!privateKey || /\n/.test(privateKey)) {
|
||||
return privateKey;
|
||||
}
|
||||
let formattedPrivateKey = '';
|
||||
const parts = privateKey.split('-----').filter((item) => item !== '');
|
||||
parts.forEach((part) => {
|
||||
if (regex.test(part)) {
|
||||
formattedPrivateKey += `-----${part}-----`;
|
||||
} else {
|
||||
const passRegex = /Proc-Type|DEK-Info/;
|
||||
if (passRegex.test(part)) {
|
||||
part = part.replace(/:\s+/g, ':');
|
||||
formattedPrivateKey += part.replace(/\\n/g, '\n').replace(/\s+/g, '\n');
|
||||
} else {
|
||||
formattedPrivateKey += part.replace(/\\n/g, '\n').replace(/\s+/g, '\n');
|
||||
}
|
||||
}
|
||||
});
|
||||
return formattedPrivateKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* @TECH_DEBT Explore replacing with handlebars
|
||||
*/
|
||||
export function getResolvables(expression: string) {
|
||||
if (!expression) return [];
|
||||
|
||||
const resolvables = [];
|
||||
const resolvableRegex = /({{[\s\S]*?}})/g;
|
||||
|
||||
let match;
|
||||
|
||||
while ((match = resolvableRegex.exec(expression)) !== null) {
|
||||
if (match[1]) {
|
||||
resolvables.push(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return resolvables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens an object with deep data
|
||||
*
|
||||
* @param {IDataObject} data The object to flatten
|
||||
*/
|
||||
export function flattenObject(data: IDataObject) {
|
||||
const returnData: IDataObject = {};
|
||||
for (const key1 of Object.keys(data)) {
|
||||
if (data[key1] !== null && typeof data[key1] === 'object') {
|
||||
if (data[key1] instanceof Date) {
|
||||
returnData[key1] = data[key1]?.toString();
|
||||
continue;
|
||||
}
|
||||
const flatObject = flattenObject(data[key1] as IDataObject);
|
||||
for (const key2 in flatObject) {
|
||||
if (flatObject[key2] === undefined) {
|
||||
continue;
|
||||
}
|
||||
returnData[`${key1}.${key2}`] = flatObject[key2];
|
||||
}
|
||||
} else {
|
||||
returnData[key1] = data[key1];
|
||||
}
|
||||
}
|
||||
return returnData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capitalizes the first letter of a string
|
||||
*
|
||||
* @param {string} string The string to capitalize
|
||||
*/
|
||||
export function capitalize(str: string): string {
|
||||
if (!str) return str;
|
||||
|
||||
const chars = str.split('');
|
||||
chars[0] = chars[0].toUpperCase();
|
||||
|
||||
return chars.join('');
|
||||
}
|
||||
|
||||
export function generatePairedItemData(length: number): IPairedItemData[] {
|
||||
return Array.from({ length }, (_, item) => ({
|
||||
item,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Output Paired Item Data Array
|
||||
*
|
||||
* @param {number | IPairedItemData | IPairedItemData[] | undefined} pairedItem
|
||||
*/
|
||||
export function preparePairedItemDataArray(
|
||||
pairedItem: number | IPairedItemData | IPairedItemData[] | undefined,
|
||||
): IPairedItemData[] {
|
||||
if (pairedItem === undefined) return [];
|
||||
if (typeof pairedItem === 'number') return [{ item: pairedItem }];
|
||||
if (Array.isArray(pairedItem)) return pairedItem;
|
||||
return [pairedItem];
|
||||
}
|
||||
|
||||
export const sanitizeDataPathKey = (item: IDataObject, key: string) => {
|
||||
if (item[key] !== undefined) {
|
||||
return key;
|
||||
}
|
||||
|
||||
if (
|
||||
(key.startsWith("['") && key.endsWith("']")) ||
|
||||
(key.startsWith('["') && key.endsWith('"]'))
|
||||
) {
|
||||
key = key.slice(2, -2);
|
||||
if (item[key] !== undefined) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
/**
|
||||
* Escape HTML
|
||||
*
|
||||
* @param {string} text The text to escape
|
||||
*/
|
||||
export function escapeHtml(text: string): string {
|
||||
if (!text) return '';
|
||||
return text.replace(/&|<|>|'|"/g, (match) => {
|
||||
switch (match) {
|
||||
case '&':
|
||||
return '&';
|
||||
case '<':
|
||||
return '<';
|
||||
case '>':
|
||||
return '>';
|
||||
case ''':
|
||||
return "'";
|
||||
case '"':
|
||||
return '"';
|
||||
default:
|
||||
return match;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts each item json's keys by a priority list
|
||||
*
|
||||
* @param {INodeExecutionData[]} data The array of items which keys will be sorted
|
||||
* @param {string[]} priorityList The priority list, keys of item.json will be sorted in this order first then alphabetically
|
||||
*/
|
||||
export function sortItemKeysByPriorityList(data: INodeExecutionData[], priorityList: string[]) {
|
||||
return data.map((item) => {
|
||||
const itemKeys = Object.keys(item.json);
|
||||
|
||||
const updatedKeysOrder = itemKeys.sort((a, b) => {
|
||||
const indexA = priorityList.indexOf(a);
|
||||
const indexB = priorityList.indexOf(b);
|
||||
|
||||
if (indexA !== -1 && indexB !== -1) {
|
||||
return indexA - indexB;
|
||||
} else if (indexA !== -1) {
|
||||
return -1;
|
||||
} else if (indexB !== -1) {
|
||||
return 1;
|
||||
}
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
const updatedItem: IDataObject = {};
|
||||
for (const key of updatedKeysOrder) {
|
||||
updatedItem[key] = item.json[key];
|
||||
}
|
||||
|
||||
item.json = updatedItem;
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
export function createUtmCampaignLink(nodeType: string, instanceId?: string) {
|
||||
return `https://n8n.io/?utm_source=n8n-internal&utm_medium=powered_by&utm_campaign=${encodeURIComponent(
|
||||
nodeType,
|
||||
)}${instanceId ? '_' + instanceId : ''}`;
|
||||
}
|
||||
|
||||
export const removeTrailingSlash = (url: string) => {
|
||||
if (url.endsWith('/')) {
|
||||
return url.slice(0, -1);
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
export function addExecutionHints(
|
||||
context: IExecuteFunctions,
|
||||
node: INode,
|
||||
items: INodeExecutionData[],
|
||||
operation: string,
|
||||
executeOnce: boolean | undefined,
|
||||
) {
|
||||
if (
|
||||
(node.type === POSTGRES_NODE_TYPE || node.type === MYSQL_NODE_TYPE) &&
|
||||
operation === 'select' &&
|
||||
items.length > 1 &&
|
||||
!executeOnce
|
||||
) {
|
||||
context.addExecutionHints({
|
||||
message: `This node ran ${items.length} times, once for each input item. To run for the first item only, enable 'execute once' in the node settings`,
|
||||
location: 'outputPane',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
node.type === POSTGRES_NODE_TYPE &&
|
||||
operation === 'executeQuery' &&
|
||||
items.length > 1 &&
|
||||
(context.getNodeParameter('options.queryBatching', 0, 'single') as string) === 'single' &&
|
||||
(context.getNodeParameter('query', 0, '') as string).toLowerCase().startsWith('insert')
|
||||
) {
|
||||
context.addExecutionHints({
|
||||
message:
|
||||
"Inserts were batched for performance. If you need to preserve item matching, consider changing 'Query batching' to 'Independent' in the options.",
|
||||
location: 'outputPane',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
node.type === MYSQL_NODE_TYPE &&
|
||||
operation === 'executeQuery' &&
|
||||
(context.getNodeParameter('options.queryBatching', 0, 'single') as string) === 'single' &&
|
||||
(context.getNodeParameter('query', 0, '') as string).toLowerCase().startsWith('insert')
|
||||
) {
|
||||
context.addExecutionHints({
|
||||
message:
|
||||
"Inserts were batched for performance. If you need to preserve item matching, consider changing 'Query batching' to 'Independent' in the options.",
|
||||
location: 'outputPane',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { timingSafeEqual } from 'crypto';
|
||||
|
||||
/**
|
||||
* Maximum allowed age for a webhook request timestamp (5 minutes).
|
||||
* Requests older than this are considered potential replay attacks.
|
||||
*/
|
||||
const MAX_TIMESTAMP_AGE_SECONDS = 300;
|
||||
|
||||
export interface VerifySignatureOptions {
|
||||
/**
|
||||
* Returns the expected signature/secret. For HMAC, compute using the same algorithm.
|
||||
* Return `null` if signature cannot be computed (missing secret/body).
|
||||
*/
|
||||
getExpectedSignature: () => string | null;
|
||||
/**
|
||||
* If true, skip validation when `getExpectedSignature()` returns `null`.
|
||||
* Use for backward compatibility with unsigned webhooks.
|
||||
* @default false
|
||||
*/
|
||||
skipIfNoExpectedSignature?: boolean;
|
||||
/**
|
||||
* Returns the actual signature from request headers, or `null` if not present.
|
||||
*/
|
||||
getActualSignature: () => string | null;
|
||||
/**
|
||||
* Optional. Returns timestamp from request (seconds or milliseconds, auto-converted).
|
||||
* Enables replay attack prevention (default: 5 minute window).
|
||||
*/
|
||||
getTimestamp?: () => number | string | null;
|
||||
/**
|
||||
* If true, skip timestamp validation when `getTimestamp()` returns `null`.
|
||||
* @default false
|
||||
*/
|
||||
skipIfNoTimestamp?: boolean;
|
||||
/**
|
||||
* Maximum allowed timestamp age in seconds.
|
||||
* @default 300 (5 minutes)
|
||||
*/
|
||||
maxTimestampAgeSeconds?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies webhook signatures and prevents replay attacks.
|
||||
*
|
||||
* Features:
|
||||
* - Signature verification using constant-time comparison (prevents timing attacks)
|
||||
* - Optional timestamp validation (prevents replay attacks)
|
||||
* - Supports HMAC-based and simple secret comparison patterns
|
||||
*
|
||||
* @param options - Configuration options
|
||||
* @returns `true` if valid, `false` otherwise. Never throws.
|
||||
*
|
||||
* @example
|
||||
* verifySignature({
|
||||
* getExpectedSignature: () => {
|
||||
* const hmac = createHmac('sha256', secret);
|
||||
* hmac.update(rawBody);
|
||||
* return `sha256=${hmac.digest('base64')}`;
|
||||
* },
|
||||
* getActualSignature: () => req.header('x-signature'),
|
||||
* getTimestamp: () => req.header('x-timestamp'),
|
||||
* });
|
||||
*/
|
||||
export function verifySignature(options: VerifySignatureOptions): boolean {
|
||||
const { getExpectedSignature, getActualSignature, getTimestamp, maxTimestampAgeSeconds } =
|
||||
options;
|
||||
try {
|
||||
// Validate timestamp if provided (replay attack prevention)
|
||||
if (getTimestamp) {
|
||||
const timestamp = getTimestamp();
|
||||
const shouldSkip = options.skipIfNoTimestamp && timestamp === null;
|
||||
if (!shouldSkip && !isTimestampValid(timestamp, maxTimestampAgeSeconds)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Get expected signature
|
||||
const expectedSignature = getExpectedSignature();
|
||||
if (!expectedSignature || typeof expectedSignature !== 'string') {
|
||||
if (options.skipIfNoExpectedSignature) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get actual signature
|
||||
const actualSignature = getActualSignature();
|
||||
if (!actualSignature || typeof actualSignature !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedBuffer = Buffer.from(expectedSignature);
|
||||
const actualBuffer = Buffer.from(actualSignature);
|
||||
|
||||
// Perform constant-time comparison to prevent timing attacks
|
||||
return (
|
||||
expectedBuffer.length === actualBuffer.length && timingSafeEqual(expectedBuffer, actualBuffer)
|
||||
);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates timestamp is within acceptable window (auto-detects seconds/milliseconds).
|
||||
*/
|
||||
function isTimestampValid(
|
||||
timestamp: number | string | null,
|
||||
maxTimestampAgeSeconds?: number,
|
||||
): boolean {
|
||||
if (timestamp === null) {
|
||||
return false;
|
||||
}
|
||||
const timestampNum =
|
||||
typeof timestamp === 'string' ? parseInt(timestamp, 10) : Math.floor(timestamp);
|
||||
if (isNaN(timestampNum)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Convert to seconds if timestamp is in milliseconds
|
||||
const timestampSec = timestampNum > 1e10 ? Math.floor(timestampNum / 1000) : timestampNum;
|
||||
const currentTimeSec = Math.floor(Date.now() / 1000);
|
||||
const maxAge = maxTimestampAgeSeconds ?? MAX_TIMESTAMP_AGE_SECONDS;
|
||||
const age = Math.abs(currentTimeSec - timestampSec);
|
||||
return age <= maxAge;
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
import {
|
||||
createRunExecutionData,
|
||||
type INodeExecutionData,
|
||||
type IPairedItemData,
|
||||
type ISourceData,
|
||||
type ITaskData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { previousTaskData, findPairedItemThroughWorkflowData } from './workflow-backtracking';
|
||||
|
||||
describe('backtracking.ts', () => {
|
||||
describe('previousTaskData', () => {
|
||||
it('should return undefined when source is empty', () => {
|
||||
const runData = {};
|
||||
const currentRunData: ITaskData = {
|
||||
source: [],
|
||||
data: { main: [[]] },
|
||||
executionTime: 0,
|
||||
executionStatus: 'success',
|
||||
executionIndex: 0,
|
||||
startTime: 0,
|
||||
};
|
||||
|
||||
const result = previousTaskData(runData, currentRunData);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when source is undefined', () => {
|
||||
const runData = {};
|
||||
const currentRunData: ITaskData = {
|
||||
data: { main: [[]] },
|
||||
executionTime: 0,
|
||||
executionStatus: 'success',
|
||||
executionIndex: 0,
|
||||
startTime: 0,
|
||||
} as unknown as ITaskData; // Type assertion to match the expected type
|
||||
|
||||
const result = previousTaskData(runData, currentRunData);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when previousNode is undefined', () => {
|
||||
const runData = {};
|
||||
const currentRunData: ITaskData = {
|
||||
source: [{} as unknown as ISourceData],
|
||||
data: { main: [[]] },
|
||||
executionTime: 0,
|
||||
executionStatus: 'success',
|
||||
executionIndex: 0,
|
||||
startTime: 0,
|
||||
};
|
||||
|
||||
const result = previousTaskData(runData, currentRunData);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when run data for previousNode does not exist', () => {
|
||||
const runData = {};
|
||||
const currentRunData: ITaskData = {
|
||||
source: [{ previousNode: 'node1' }],
|
||||
data: { main: [[]] },
|
||||
executionTime: 0,
|
||||
executionStatus: 'success',
|
||||
executionIndex: 0,
|
||||
startTime: 0,
|
||||
};
|
||||
|
||||
const result = previousTaskData(runData, currentRunData);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when run data for previousNode is empty', () => {
|
||||
const runData = {
|
||||
node1: [],
|
||||
};
|
||||
const currentRunData: ITaskData = {
|
||||
source: [{ previousNode: 'node1' }],
|
||||
data: { main: [[]] },
|
||||
executionTime: 0,
|
||||
executionStatus: 'success',
|
||||
executionIndex: 0,
|
||||
startTime: 0,
|
||||
};
|
||||
|
||||
const result = previousTaskData(runData, currentRunData);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the correct task data from previousNode', () => {
|
||||
const expectedTaskData: ITaskData = {
|
||||
data: { main: [[{ json: { test: 'value' } }]] },
|
||||
executionTime: 100,
|
||||
executionStatus: 'success',
|
||||
executionIndex: 0,
|
||||
startTime: 1000,
|
||||
} as unknown as ITaskData;
|
||||
|
||||
const runData = {
|
||||
node1: [expectedTaskData],
|
||||
};
|
||||
const currentRunData: ITaskData = {
|
||||
source: [{ previousNode: 'node1' }],
|
||||
data: { main: [[]] },
|
||||
executionTime: 0,
|
||||
executionStatus: 'success',
|
||||
executionIndex: 0,
|
||||
startTime: 0,
|
||||
};
|
||||
|
||||
const result = previousTaskData(runData, currentRunData);
|
||||
|
||||
expect(result).toBe(expectedTaskData);
|
||||
});
|
||||
|
||||
it('should return correct task data using previousNodeRun index', () => {
|
||||
const taskData1: ITaskData = {
|
||||
data: { main: [[{ json: { run: 1 } }]] },
|
||||
executionTime: 100,
|
||||
executionStatus: 'success',
|
||||
executionIndex: 0,
|
||||
startTime: 1000,
|
||||
} as unknown as ITaskData;
|
||||
|
||||
const taskData2: ITaskData = {
|
||||
data: { main: [[{ json: { run: 2 } }]] },
|
||||
executionTime: 200,
|
||||
executionStatus: 'success',
|
||||
executionIndex: 0,
|
||||
startTime: 2000,
|
||||
} as unknown as ITaskData;
|
||||
|
||||
const runData = {
|
||||
node1: [taskData1, taskData2],
|
||||
};
|
||||
const currentRunData: ITaskData = {
|
||||
source: [{ previousNode: 'node1', previousNodeRun: 1 }],
|
||||
data: { main: [[]] },
|
||||
executionTime: 0,
|
||||
executionStatus: 'success',
|
||||
executionIndex: 0,
|
||||
startTime: 0,
|
||||
};
|
||||
|
||||
const result = previousTaskData(runData, currentRunData);
|
||||
|
||||
expect(result).toBe(taskData2);
|
||||
});
|
||||
|
||||
it('should default to index 0 when previousNodeRun is undefined', () => {
|
||||
const taskData1: ITaskData = {
|
||||
data: { main: [[{ json: { run: 1 } }]] },
|
||||
executionTime: 100,
|
||||
executionStatus: 'success',
|
||||
executionIndex: 0,
|
||||
startTime: 1000,
|
||||
} as unknown as ITaskData;
|
||||
|
||||
const taskData2: ITaskData = {
|
||||
data: { main: [[{ json: { run: 2 } }]] },
|
||||
executionTime: 200,
|
||||
executionStatus: 'success',
|
||||
executionIndex: 0,
|
||||
startTime: 2000,
|
||||
} as unknown as ITaskData;
|
||||
|
||||
const runData = {
|
||||
node1: [taskData1, taskData2],
|
||||
};
|
||||
const currentRunData: ITaskData = {
|
||||
source: [{ previousNode: 'node1' }],
|
||||
data: { main: [[]] },
|
||||
executionTime: 0,
|
||||
executionStatus: 'success',
|
||||
executionIndex: 0,
|
||||
startTime: 0,
|
||||
};
|
||||
|
||||
const result = previousTaskData(runData, currentRunData);
|
||||
|
||||
expect(result).toBe(taskData1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPairedItemThroughWorkflowData', () => {
|
||||
it('should return undefined when lastNodeExecuted is undefined', () => {
|
||||
const workflowRunData = createRunExecutionData({
|
||||
resultData: {
|
||||
runData: {},
|
||||
lastNodeExecuted: undefined,
|
||||
},
|
||||
});
|
||||
const item: INodeExecutionData = {
|
||||
json: { test: 'value' },
|
||||
pairedItem: { item: 0 },
|
||||
};
|
||||
|
||||
const result = findPairedItemThroughWorkflowData(workflowRunData, item, 0);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when no run data exists for lastNodeExecuted', () => {
|
||||
const workflowRunData = createRunExecutionData({
|
||||
resultData: {
|
||||
runData: {},
|
||||
lastNodeExecuted: 'node1',
|
||||
},
|
||||
});
|
||||
const item: INodeExecutionData = {
|
||||
json: { test: 'value' },
|
||||
pairedItem: { item: 0 },
|
||||
};
|
||||
|
||||
const result = findPairedItemThroughWorkflowData(workflowRunData, item, 0);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when run data is empty', () => {
|
||||
const workflowRunData = createRunExecutionData({
|
||||
resultData: {
|
||||
runData: {
|
||||
node1: [],
|
||||
},
|
||||
lastNodeExecuted: 'node1',
|
||||
},
|
||||
});
|
||||
const item: INodeExecutionData = {
|
||||
json: { test: 'value' },
|
||||
pairedItem: { item: 0 },
|
||||
};
|
||||
|
||||
const result = findPairedItemThroughWorkflowData(workflowRunData, item, 0);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when task data is undefined', () => {
|
||||
const workflowRunData = createRunExecutionData({
|
||||
resultData: {
|
||||
runData: {
|
||||
node1: [undefined as any],
|
||||
},
|
||||
lastNodeExecuted: 'node1',
|
||||
},
|
||||
});
|
||||
const item: INodeExecutionData = {
|
||||
json: { test: 'value' },
|
||||
pairedItem: { item: 0 },
|
||||
};
|
||||
|
||||
const result = findPairedItemThroughWorkflowData(workflowRunData, item, 0);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return paired item when no previous task data exists', () => {
|
||||
const expectedPairedItem: IPairedItemData = { item: 0 };
|
||||
const workflowRunData = createRunExecutionData({
|
||||
resultData: {
|
||||
runData: {
|
||||
node1: [
|
||||
{
|
||||
data: { main: [[]] },
|
||||
executionTime: 0,
|
||||
executionStatus: 'success',
|
||||
startTime: 0,
|
||||
} as unknown as ITaskData,
|
||||
],
|
||||
},
|
||||
lastNodeExecuted: 'node1',
|
||||
},
|
||||
});
|
||||
const item: INodeExecutionData = {
|
||||
json: { test: 'value' },
|
||||
pairedItem: expectedPairedItem,
|
||||
};
|
||||
|
||||
const result = findPairedItemThroughWorkflowData(workflowRunData, item, 0);
|
||||
|
||||
expect(result).toBe(expectedPairedItem);
|
||||
});
|
||||
|
||||
it('should backtrack through workflow data with simple paired item', () => {
|
||||
const finalPairedItem: IPairedItemData = { item: 5 };
|
||||
const item: INodeExecutionData = {
|
||||
json: { test: 'value' },
|
||||
pairedItem: { item: 0 },
|
||||
};
|
||||
const workflowRunData = createRunExecutionData({
|
||||
resultData: {
|
||||
runData: {
|
||||
node1: [
|
||||
{
|
||||
source: [{ previousNode: 'node2' }],
|
||||
data: { main: [[item]] },
|
||||
executionTime: 100,
|
||||
executionStatus: 'success',
|
||||
executionIndex: 0,
|
||||
startTime: 1000,
|
||||
},
|
||||
],
|
||||
node2: [
|
||||
{
|
||||
data: { main: [[{ json: { value: 2 }, pairedItem: finalPairedItem }]] },
|
||||
executionTime: 200,
|
||||
executionStatus: 'success',
|
||||
executionIndex: 0,
|
||||
startTime: 2000,
|
||||
} as unknown as ITaskData,
|
||||
],
|
||||
},
|
||||
lastNodeExecuted: 'node1',
|
||||
},
|
||||
});
|
||||
|
||||
const result = findPairedItemThroughWorkflowData(workflowRunData, item, 0);
|
||||
|
||||
expect(result).toBe(finalPairedItem);
|
||||
});
|
||||
|
||||
it('should backtrack through workflow data with object paired item', () => {
|
||||
const finalPairedItem: IPairedItemData = { item: 3, input: 1 };
|
||||
const item: INodeExecutionData = {
|
||||
json: { test: 'value' },
|
||||
pairedItem: { item: 0, input: 1 },
|
||||
};
|
||||
const workflowRunData = createRunExecutionData({
|
||||
resultData: {
|
||||
runData: {
|
||||
node1: [
|
||||
{
|
||||
source: [{ previousNode: 'node2' }],
|
||||
data: { main: [[item]] },
|
||||
executionTime: 100,
|
||||
executionStatus: 'success',
|
||||
startTime: 1000,
|
||||
} as unknown as ITaskData,
|
||||
],
|
||||
node2: [
|
||||
{
|
||||
data: { main: [[], [{ json: { value: 2 }, pairedItem: finalPairedItem }]] },
|
||||
executionTime: 200,
|
||||
executionStatus: 'success',
|
||||
startTime: 2000,
|
||||
} as unknown as ITaskData,
|
||||
],
|
||||
},
|
||||
lastNodeExecuted: 'node1',
|
||||
},
|
||||
});
|
||||
|
||||
const result = findPairedItemThroughWorkflowData(workflowRunData, item, 0);
|
||||
|
||||
expect(result).toBe(finalPairedItem);
|
||||
});
|
||||
|
||||
it('should use itemIndex parameter when paired item is numeric', () => {
|
||||
const finalPairedItem: IPairedItemData = { item: 7 };
|
||||
|
||||
const item: INodeExecutionData = {
|
||||
json: { test: 'value' },
|
||||
pairedItem: 2, // Numeric paired item
|
||||
};
|
||||
|
||||
const workflowRunData = createRunExecutionData({
|
||||
resultData: {
|
||||
runData: {
|
||||
node1: [
|
||||
{
|
||||
source: [{ previousNode: 'node2' }],
|
||||
data: { main: [[item]] },
|
||||
executionTime: 100,
|
||||
executionStatus: 'success',
|
||||
startTime: 1000,
|
||||
} as unknown as ITaskData,
|
||||
],
|
||||
node2: [
|
||||
{
|
||||
data: {
|
||||
main: [
|
||||
[
|
||||
{ json: {} },
|
||||
{ json: {} },
|
||||
{ json: { value: 2 }, pairedItem: finalPairedItem },
|
||||
],
|
||||
],
|
||||
},
|
||||
executionTime: 200,
|
||||
executionStatus: 'success',
|
||||
startTime: 2000,
|
||||
} as unknown as ITaskData,
|
||||
],
|
||||
},
|
||||
lastNodeExecuted: 'node1',
|
||||
},
|
||||
});
|
||||
|
||||
const result = findPairedItemThroughWorkflowData(workflowRunData, item, 5);
|
||||
|
||||
expect(result).toBe(finalPairedItem);
|
||||
});
|
||||
|
||||
it('should handle multiple levels of backtracking', () => {
|
||||
const finalPairedItem: IPairedItemData = { item: 10 };
|
||||
const workflowRunData = createRunExecutionData({
|
||||
resultData: {
|
||||
runData: {
|
||||
node1: [
|
||||
{
|
||||
source: [{ previousNode: 'node2' }],
|
||||
data: { main: [[{ json: { value: 1 }, pairedItem: { item: 0 } }]] },
|
||||
executionTime: 100,
|
||||
executionStatus: 'success',
|
||||
startTime: 1000,
|
||||
} as unknown as ITaskData,
|
||||
],
|
||||
node2: [
|
||||
{
|
||||
source: [{ previousNode: 'node3' }],
|
||||
data: { main: [[{ json: { value: 2 }, pairedItem: { item: 1 } }]] },
|
||||
executionTime: 200,
|
||||
executionStatus: 'success',
|
||||
startTime: 2000,
|
||||
} as unknown as ITaskData,
|
||||
],
|
||||
node3: [
|
||||
{
|
||||
data: { main: [[null, { json: { value: 3 }, pairedItem: finalPairedItem }]] },
|
||||
executionTime: 300,
|
||||
executionStatus: 'success',
|
||||
startTime: 3000,
|
||||
} as unknown as ITaskData,
|
||||
],
|
||||
},
|
||||
lastNodeExecuted: 'node1',
|
||||
},
|
||||
});
|
||||
const item: INodeExecutionData = {
|
||||
json: { test: 'value' },
|
||||
pairedItem: { item: 0 },
|
||||
};
|
||||
|
||||
const result = findPairedItemThroughWorkflowData(workflowRunData, item, 0);
|
||||
|
||||
expect(result).toBe(finalPairedItem);
|
||||
});
|
||||
|
||||
it('should use last run data when multiple runs exist', () => {
|
||||
const finalPairedItem: IPairedItemData = { item: 15 };
|
||||
const workflowRunData = createRunExecutionData({
|
||||
resultData: {
|
||||
runData: {
|
||||
node1: [
|
||||
{
|
||||
source: [{ previousNode: 'node2' }],
|
||||
data: { main: [[{ json: { value: 1 }, pairedItem: { item: 0 } }]] },
|
||||
executionTime: 100,
|
||||
executionStatus: 'success',
|
||||
startTime: 1000,
|
||||
} as unknown as ITaskData,
|
||||
{
|
||||
source: [{ previousNode: 'node2' }],
|
||||
data: { main: [[{ json: { value: 2 }, pairedItem: { item: 0 } }]] },
|
||||
executionTime: 150,
|
||||
executionStatus: 'success',
|
||||
startTime: 1500,
|
||||
} as unknown as ITaskData,
|
||||
],
|
||||
node2: [
|
||||
{
|
||||
data: { main: [[{ json: { value: 3 }, pairedItem: finalPairedItem }]] },
|
||||
executionTime: 200,
|
||||
executionStatus: 'success',
|
||||
startTime: 2000,
|
||||
} as unknown as ITaskData,
|
||||
],
|
||||
},
|
||||
lastNodeExecuted: 'node1',
|
||||
},
|
||||
});
|
||||
const item: INodeExecutionData = {
|
||||
json: { test: 'value' },
|
||||
pairedItem: { item: 0 },
|
||||
};
|
||||
|
||||
const result = findPairedItemThroughWorkflowData(workflowRunData, item, 0);
|
||||
|
||||
expect(result).toBe(finalPairedItem);
|
||||
});
|
||||
|
||||
it('should handle missing nodeInformationArray gracefully', () => {
|
||||
const workflowRunData = createRunExecutionData({
|
||||
resultData: {
|
||||
runData: {
|
||||
node1: [
|
||||
{
|
||||
source: [{ previousNode: 'node2' }],
|
||||
data: {},
|
||||
executionTime: 100,
|
||||
executionStatus: 'success',
|
||||
startTime: 1000,
|
||||
} as unknown as ITaskData,
|
||||
],
|
||||
node2: [
|
||||
{
|
||||
data: { main: [[]] },
|
||||
executionTime: 200,
|
||||
executionStatus: 'success',
|
||||
startTime: 2000,
|
||||
} as unknown as ITaskData,
|
||||
],
|
||||
},
|
||||
lastNodeExecuted: 'node1',
|
||||
},
|
||||
});
|
||||
const item: INodeExecutionData = {
|
||||
json: { test: 'value' },
|
||||
pairedItem: { item: 0 },
|
||||
};
|
||||
|
||||
const result = findPairedItemThroughWorkflowData(workflowRunData, item, 0);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import type {
|
||||
INodeExecutionData,
|
||||
IPairedItemData,
|
||||
IRunExecutionData,
|
||||
ITaskData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
/*
|
||||
* These functions do not cover all possible edge cases for backtracking through workflow run data.
|
||||
* They are designed to work for a simple and linear workflow execution.
|
||||
* If the workflow has branches or complex execution paths, additional logic may be needed.
|
||||
* We should follow up on this and improve the logic in the future.
|
||||
*/
|
||||
|
||||
/*
|
||||
* If we cannot backtrack correctly, we return undefined to fallback to the current paired item behavior
|
||||
* failing in these functions will cause the parent workflow to fail
|
||||
*/
|
||||
|
||||
/**
|
||||
* This function retrieves the previous task data for a given task in the workflow run data.
|
||||
* Until there is no more source set
|
||||
*/
|
||||
export function previousTaskData(
|
||||
runData: IRunExecutionData['resultData']['runData'],
|
||||
currentRunData: ITaskData,
|
||||
): ITaskData | undefined {
|
||||
const nextNodeName = currentRunData.source?.[0]?.previousNode;
|
||||
if (!nextNodeName) {
|
||||
return undefined; // No next node
|
||||
}
|
||||
|
||||
const nextRunData = runData[nextNodeName];
|
||||
if (!nextRunData || nextRunData.length === 0) {
|
||||
// We don't expect this case to happen in practice, but if for some reason it happens, we fallback to undefined
|
||||
return undefined; // No run data for the next node
|
||||
}
|
||||
|
||||
const nextRunIndex = currentRunData.source?.[0]?.previousNodeRun ?? 0;
|
||||
|
||||
return nextRunData[nextRunIndex]; // Return the first run data for the next node
|
||||
}
|
||||
|
||||
export function findPairedItemThroughWorkflowData(
|
||||
workflowRunData: IRunExecutionData,
|
||||
item: INodeExecutionData,
|
||||
itemIndex: number,
|
||||
): IPairedItemData | IPairedItemData[] | number | undefined {
|
||||
// The provided item is already the item of the last node executed in this workflow run
|
||||
// So the item.pairedItem is the paired item of the last node executed and is therefore referencing
|
||||
// a node in the previous task data
|
||||
|
||||
const currentNodeName = workflowRunData.resultData.lastNodeExecuted;
|
||||
if (!currentNodeName) {
|
||||
// If no node name is available, then we don't know where to start backtracking
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// This is the run data of the last node executed in the workflow run
|
||||
const runData = workflowRunData.resultData.runData[currentNodeName];
|
||||
|
||||
if (!runData) {
|
||||
// No run data available for the last node executed
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Since we are backtracking through the workflow, we start with the last run data
|
||||
const runIndex = runData.length - 1;
|
||||
|
||||
const taskData = runData[runIndex];
|
||||
|
||||
if (!taskData) {
|
||||
// If no run data is available, then the workflow did not run at all
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Now we are getting the second last task data, because our initial pairedItem points to this.
|
||||
let runDataItem = previousTaskData(workflowRunData.resultData.runData, taskData);
|
||||
|
||||
let pairedItem = item.pairedItem;
|
||||
|
||||
// move the runDataItem to the previous node in the in the workflow execution data
|
||||
// and find the paired item of the current item in the previous task data
|
||||
// We do this until we reach the first task data of the workflow run
|
||||
|
||||
while (runDataItem !== undefined) {
|
||||
// We find the output items for the current run data item
|
||||
const nodeInformationArray = runDataItem.data?.['main'];
|
||||
|
||||
// We find and fallback to 0 for the input index and item index
|
||||
// The input index is the run the node was executed in case it was executed multiple times
|
||||
// The item index is the index of the paired item we are looking for
|
||||
let inputIndex = 0;
|
||||
let nodeIndex = itemIndex;
|
||||
if (typeof pairedItem === 'object') {
|
||||
inputIndex = (pairedItem as IPairedItemData).input ?? 0;
|
||||
nodeIndex = (pairedItem as IPairedItemData).item ?? itemIndex;
|
||||
} else if (typeof pairedItem === 'number') {
|
||||
// If the paired item is a number, we use it as the node index
|
||||
nodeIndex = pairedItem;
|
||||
// and fallback to 0 for the input index
|
||||
inputIndex = 0;
|
||||
}
|
||||
|
||||
// We found the paired item of the current run data item, this points to the node in the previous task data
|
||||
pairedItem = nodeInformationArray?.[inputIndex]?.[nodeIndex]?.pairedItem;
|
||||
|
||||
// We move the runDataItem to the previous task data
|
||||
runDataItem = previousTaskData(workflowRunData.resultData.runData, runDataItem);
|
||||
}
|
||||
|
||||
// This is the paired item that was in the first task data when the workflow was executed
|
||||
return pairedItem;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
These files contain reusable logic for workflow inputs mapping used in these nodes:
|
||||
|
||||
- n8n-nodes-base.executeWorkflow
|
||||
- n8n-nodes-base.executeWorkflowTrigger
|
||||
- @n8n/n8n-nodes-langchain.toolWorkflow
|
||||
@@ -0,0 +1,202 @@
|
||||
import { json as generateSchemaFromExample, type SchemaObject } from 'generate-schema';
|
||||
import type { JSONSchema7 } from 'json-schema';
|
||||
import pickBy from 'lodash/pickBy';
|
||||
import type {
|
||||
FieldValueOption,
|
||||
FieldType,
|
||||
IWorkflowNodeContext,
|
||||
INodeExecutionData,
|
||||
IDataObject,
|
||||
ResourceMapperField,
|
||||
ILocalLoadOptionsFunctions,
|
||||
WorkflowInputsData,
|
||||
IExecuteFunctions,
|
||||
ISupplyDataFunctions,
|
||||
} from 'n8n-workflow';
|
||||
import { jsonParse, NodeOperationError, EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
JSON_EXAMPLE,
|
||||
INPUT_SOURCE,
|
||||
WORKFLOW_INPUTS,
|
||||
VALUES,
|
||||
TYPE_OPTIONS,
|
||||
PASSTHROUGH,
|
||||
} from './constants';
|
||||
|
||||
const SUPPORTED_TYPES = TYPE_OPTIONS.map((x) => x.value);
|
||||
|
||||
function parseJsonSchema(schema: JSONSchema7): FieldValueOption[] | string {
|
||||
if (schema.type !== 'object') {
|
||||
if (schema.type === undefined) {
|
||||
return 'Invalid JSON schema. Missing key `type` in schema';
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.type)) {
|
||||
return `Invalid JSON schema type. Only object type is supported, but got an array of types: ${schema.type.join(', ')}`;
|
||||
}
|
||||
|
||||
return `Invalid JSON schema type. Only object type is supported, but got ${schema.type}`;
|
||||
}
|
||||
|
||||
if (!schema?.properties) {
|
||||
return 'Invalid JSON schema. Missing key `properties` in schema';
|
||||
}
|
||||
|
||||
if (typeof schema.properties !== 'object') {
|
||||
return 'Invalid JSON schema. Key `properties` is not an object';
|
||||
}
|
||||
|
||||
const result: FieldValueOption[] = [];
|
||||
for (const [name, v] of Object.entries(schema.properties)) {
|
||||
if (typeof v !== 'object') {
|
||||
return `Invalid JSON schema. Value for property '${name}' is not an object`;
|
||||
}
|
||||
|
||||
const type = v?.type;
|
||||
|
||||
if (type === 'null') {
|
||||
result.push({ name, type: 'any' });
|
||||
} else if (Array.isArray(type)) {
|
||||
// Schema allows an array of types, but we don't
|
||||
return `Invalid JSON schema. Array of types for property '${name}' is not supported by n8n. Either provide a single type or use type 'any' to allow any type`;
|
||||
} else if (typeof type !== 'string') {
|
||||
return `Invalid JSON schema. Unexpected non-string type ${type} for property '${name}'`;
|
||||
} else if (!SUPPORTED_TYPES.includes(type as never)) {
|
||||
return `Invalid JSON schema. Unsupported type ${type} for property '${name}'. Supported types are ${JSON.stringify(SUPPORTED_TYPES, null, 1)}`;
|
||||
} else {
|
||||
result.push({ name, type: type as FieldType });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseJsonExample(context: IWorkflowNodeContext): JSONSchema7 {
|
||||
const jsonString = context.getNodeParameter(JSON_EXAMPLE, 0, '') as string;
|
||||
const json = jsonParse<SchemaObject>(jsonString);
|
||||
|
||||
return generateSchemaFromExample(json) as JSONSchema7;
|
||||
}
|
||||
|
||||
export function getFieldEntries(context: IWorkflowNodeContext): {
|
||||
dataMode: WorkflowInputsData['dataMode'];
|
||||
fields: FieldValueOption[];
|
||||
subworkflowInfo?: WorkflowInputsData['subworkflowInfo'];
|
||||
} {
|
||||
const inputSource = context.getNodeParameter(INPUT_SOURCE, 0, PASSTHROUGH);
|
||||
let result: FieldValueOption[] | string = 'Internal Error: Invalid input source';
|
||||
try {
|
||||
if (inputSource === WORKFLOW_INPUTS) {
|
||||
result = context.getNodeParameter(
|
||||
`${WORKFLOW_INPUTS}.${VALUES}`,
|
||||
0,
|
||||
[],
|
||||
) as FieldValueOption[];
|
||||
} else if (inputSource === JSON_EXAMPLE) {
|
||||
const schema = parseJsonExample(context);
|
||||
result = parseJsonSchema(schema);
|
||||
} else if (inputSource === PASSTHROUGH) {
|
||||
result = [];
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
result =
|
||||
e && typeof e === 'object' && 'message' in e && typeof e.message === 'string'
|
||||
? e.message
|
||||
: `Unknown error occurred: ${JSON.stringify(e)}`;
|
||||
}
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
const dataMode = String(inputSource);
|
||||
const workflow = context.getWorkflow();
|
||||
const node = context.getNode();
|
||||
return {
|
||||
fields: result,
|
||||
dataMode,
|
||||
subworkflowInfo: { workflowId: workflow.id, triggerId: node.id },
|
||||
};
|
||||
}
|
||||
throw new NodeOperationError(context.getNode(), result);
|
||||
}
|
||||
|
||||
export function getWorkflowInputValues(
|
||||
this: IExecuteFunctions | ISupplyDataFunctions,
|
||||
): INodeExecutionData[] {
|
||||
const inputData = this.getInputData();
|
||||
|
||||
return inputData.map(({ json, binary }, itemIndex) => {
|
||||
const itemFieldValues = this.getNodeParameter(
|
||||
'workflowInputs.value',
|
||||
itemIndex,
|
||||
{},
|
||||
) as IDataObject;
|
||||
|
||||
return {
|
||||
json: {
|
||||
...json,
|
||||
...itemFieldValues,
|
||||
},
|
||||
index: itemIndex,
|
||||
pairedItem: {
|
||||
item: itemIndex,
|
||||
},
|
||||
binary,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getCurrentWorkflowInputData(this: IExecuteFunctions | ISupplyDataFunctions) {
|
||||
const inputData: INodeExecutionData[] = getWorkflowInputValues.call(this);
|
||||
|
||||
const schema = this.getNodeParameter('workflowInputs.schema', 0, []) as ResourceMapperField[];
|
||||
|
||||
if (schema.length === 0) {
|
||||
return inputData;
|
||||
} else {
|
||||
const removedKeys = new Set(schema.filter((x) => x.removed).map((x) => x.displayName));
|
||||
|
||||
const filteredInputData: INodeExecutionData[] = inputData.map(({ json, binary }, index) => ({
|
||||
index,
|
||||
pairedItem: { item: index },
|
||||
json: pickBy(json, (_v, key) => !removedKeys.has(key)),
|
||||
binary,
|
||||
}));
|
||||
|
||||
return filteredInputData;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadWorkflowInputMappings(
|
||||
this: ILocalLoadOptionsFunctions,
|
||||
): Promise<WorkflowInputsData> {
|
||||
const nodeLoadContext = await this.getWorkflowNodeContext(
|
||||
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
||||
true,
|
||||
);
|
||||
let fields: ResourceMapperField[] = [];
|
||||
let dataMode: string = PASSTHROUGH;
|
||||
let subworkflowInfo: { workflowId?: string; triggerId?: string } | undefined;
|
||||
|
||||
if (nodeLoadContext) {
|
||||
const fieldValues = getFieldEntries(nodeLoadContext);
|
||||
dataMode = fieldValues.dataMode;
|
||||
subworkflowInfo = fieldValues.subworkflowInfo;
|
||||
|
||||
fields = fieldValues.fields.map((currentWorkflowInput) => {
|
||||
const field: ResourceMapperField = {
|
||||
id: currentWorkflowInput.name,
|
||||
displayName: currentWorkflowInput.name,
|
||||
required: false,
|
||||
defaultMatch: false,
|
||||
display: true,
|
||||
canBeUsedToMatch: true,
|
||||
};
|
||||
|
||||
if (currentWorkflowInput.type !== 'any') {
|
||||
field.type = currentWorkflowInput.type;
|
||||
}
|
||||
|
||||
return field;
|
||||
});
|
||||
}
|
||||
return { fields, dataMode, subworkflowInfo };
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { ISupplyDataFunctions } from 'n8n-workflow';
|
||||
|
||||
import { getWorkflowInputValues } from '../GenericFunctions';
|
||||
|
||||
describe('getWorkflowInputValues', () => {
|
||||
const supplyDataFunctions = mock<ISupplyDataFunctions>();
|
||||
|
||||
it('should correctly map the binary property', () => {
|
||||
supplyDataFunctions.getInputData.mockReturnValue([
|
||||
{
|
||||
json: { key1: 'value1' },
|
||||
binary: { file1: { data: 'binaryData1', mimeType: 'image/png' } },
|
||||
},
|
||||
{
|
||||
json: { key2: 'value2' },
|
||||
binary: { file2: { data: 'binaryData2', mimeType: 'image/jpeg' } },
|
||||
},
|
||||
]);
|
||||
|
||||
supplyDataFunctions.getNodeParameter
|
||||
.calledWith('workflowInputs.value', 0)
|
||||
.mockReturnValueOnce({ additionalKey1: 'additionalValue1' });
|
||||
supplyDataFunctions.getNodeParameter
|
||||
.calledWith('workflowInputs.value', 1)
|
||||
.mockReturnValueOnce({ additionalKey2: 'additionalValue2' });
|
||||
|
||||
const result = getWorkflowInputValues.call(supplyDataFunctions);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
key1: 'value1',
|
||||
additionalKey1: 'additionalValue1',
|
||||
},
|
||||
binary: { file1: { data: 'binaryData1', mimeType: 'image/png' } },
|
||||
index: 0,
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
{
|
||||
json: {
|
||||
key2: 'value2',
|
||||
additionalKey2: 'additionalValue2',
|
||||
},
|
||||
binary: { file2: { data: 'binaryData2', mimeType: 'image/jpeg' } },
|
||||
index: 1,
|
||||
pairedItem: { item: 1 },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(supplyDataFunctions.getInputData).toHaveBeenCalled();
|
||||
expect(supplyDataFunctions.getNodeParameter).toHaveBeenCalledWith(
|
||||
'workflowInputs.value',
|
||||
0,
|
||||
{},
|
||||
);
|
||||
expect(supplyDataFunctions.getNodeParameter).toHaveBeenCalledWith(
|
||||
'workflowInputs.value',
|
||||
1,
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FieldType } from 'n8n-workflow';
|
||||
|
||||
export const INPUT_SOURCE = 'inputSource';
|
||||
export const WORKFLOW_INPUTS = 'workflowInputs';
|
||||
export const VALUES = 'values';
|
||||
export const JSON_EXAMPLE = 'jsonExample';
|
||||
export const PASSTHROUGH = 'passthrough';
|
||||
export const TYPE_OPTIONS: Array<{ name: string; value: FieldType | 'any' }> = [
|
||||
{
|
||||
name: 'Allow Any Type',
|
||||
value: 'any',
|
||||
},
|
||||
{
|
||||
name: 'String',
|
||||
value: 'string',
|
||||
},
|
||||
{
|
||||
name: 'Number',
|
||||
value: 'number',
|
||||
},
|
||||
{
|
||||
name: 'Boolean',
|
||||
value: 'boolean',
|
||||
},
|
||||
{
|
||||
name: 'Array',
|
||||
value: 'array',
|
||||
},
|
||||
{
|
||||
name: 'Object',
|
||||
value: 'object',
|
||||
},
|
||||
// Intentional omission of `dateTime`, `time`, `string-alphanumeric`, `form-fields`, `jwt` and `url`
|
||||
];
|
||||
|
||||
export const FALLBACK_DEFAULT_VALUE = null;
|
||||
Reference in New Issue
Block a user