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:
@@ -0,0 +1,191 @@
|
||||
import { Container } from '@n8n/di';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { CredentialInformation } from 'n8n-workflow';
|
||||
import { AssertionError } from 'node:assert';
|
||||
|
||||
import { CREDENTIAL_ERRORS } from '@/constants';
|
||||
import { Cipher } from '@/encryption/cipher';
|
||||
import type { InstanceSettings } from '@/instance-settings';
|
||||
|
||||
import { Credentials } from '../credentials';
|
||||
|
||||
describe('Credentials', () => {
|
||||
const nodeCredentials = { id: '123', name: 'Test Credential' };
|
||||
const credentialType = 'testApi';
|
||||
|
||||
const cipher = new Cipher(mock<InstanceSettings>({ encryptionKey: 'password' }));
|
||||
Container.set(Cipher, cipher);
|
||||
|
||||
const setDataKey = (credentials: Credentials, key: string, data: CredentialInformation) => {
|
||||
let fullData;
|
||||
try {
|
||||
fullData = credentials.getData();
|
||||
} catch (e) {
|
||||
fullData = {};
|
||||
}
|
||||
fullData[key] = data;
|
||||
return credentials.setData(fullData);
|
||||
};
|
||||
|
||||
describe('without nodeType set', () => {
|
||||
test('should be able to set and read key data without initial data set', () => {
|
||||
const credentials = new Credentials(nodeCredentials, credentialType);
|
||||
|
||||
const key = 'key1';
|
||||
const newData = 1234;
|
||||
|
||||
setDataKey(credentials, key, newData);
|
||||
|
||||
expect(credentials.getData()[key]).toEqual(newData);
|
||||
});
|
||||
|
||||
test('should be able to set and read key data with initial data set', () => {
|
||||
const key = 'key2';
|
||||
|
||||
// Saved under "key1"
|
||||
const initialData = 4321;
|
||||
const initialDataEncoded = 'U2FsdGVkX1+0baznXt+Ag/ub8A2kHLyoLxn/rR9h4XQ=';
|
||||
|
||||
const credentials = new Credentials(nodeCredentials, credentialType, initialDataEncoded);
|
||||
|
||||
const newData = 1234;
|
||||
|
||||
// Set and read new data
|
||||
setDataKey(credentials, key, newData);
|
||||
expect(credentials.getData()[key]).toEqual(newData);
|
||||
|
||||
// Read the data which got provided encrypted on init
|
||||
expect(credentials.getData().key1).toEqual(initialData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getData', () => {
|
||||
test('should throw an error when data is missing', () => {
|
||||
const credentials = new Credentials(nodeCredentials, credentialType);
|
||||
credentials.data = undefined;
|
||||
|
||||
expect(() => credentials.getData()).toThrow(CREDENTIAL_ERRORS.NO_DATA);
|
||||
});
|
||||
|
||||
test('should throw an error when decryption fails', () => {
|
||||
const credentials = new Credentials(nodeCredentials, credentialType);
|
||||
credentials.data = '{"key": "already-decrypted-credentials-data" }';
|
||||
|
||||
expect(() => credentials.getData()).toThrow(CREDENTIAL_ERRORS.DECRYPTION_FAILED);
|
||||
|
||||
try {
|
||||
credentials.getData();
|
||||
} catch (error) {
|
||||
expect(error.constructor.name).toBe('CredentialDataError');
|
||||
expect(error.extra).toEqual({ ...nodeCredentials, type: credentialType });
|
||||
expect((error.cause.code as string).startsWith('ERR_OSSL_')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('should throw an error when JSON parsing fails', () => {
|
||||
const credentials = new Credentials(nodeCredentials, credentialType);
|
||||
credentials.data = cipher.encrypt('invalid-json-string');
|
||||
|
||||
expect(() => credentials.getData()).toThrow(CREDENTIAL_ERRORS.INVALID_JSON);
|
||||
|
||||
try {
|
||||
credentials.getData();
|
||||
} catch (error) {
|
||||
expect(error.constructor.name).toBe('CredentialDataError');
|
||||
expect(error.extra).toEqual({ ...nodeCredentials, type: credentialType });
|
||||
expect(error.cause).toBeInstanceOf(SyntaxError);
|
||||
expect(error.cause.message).toMatch('Unexpected token ');
|
||||
}
|
||||
});
|
||||
|
||||
test('should successfully decrypt and parse valid JSON credentials', () => {
|
||||
const credentials = new Credentials(nodeCredentials, credentialType);
|
||||
credentials.setData({ username: 'testuser', password: 'testpass' });
|
||||
|
||||
const decryptedData = credentials.getData();
|
||||
expect(decryptedData.username).toBe('testuser');
|
||||
expect(decryptedData.password).toBe('testpass');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setData', () => {
|
||||
test.each<{}>([[123], [null], [undefined]])(
|
||||
'should throw an AssertionError when data is %s',
|
||||
(data) => {
|
||||
const credentials = new Credentials<{}>(nodeCredentials, credentialType);
|
||||
|
||||
expect(() => credentials.setData(data)).toThrow(AssertionError);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('updateData', () => {
|
||||
const nodeCredentials = { id: '123', name: 'Test Credential' };
|
||||
const credentialType = 'testApi';
|
||||
|
||||
test('should update existing data', () => {
|
||||
const credentials = new Credentials(
|
||||
nodeCredentials,
|
||||
credentialType,
|
||||
cipher.encrypt({
|
||||
username: 'olduser',
|
||||
password: 'oldpass',
|
||||
apiKey: 'oldkey',
|
||||
}),
|
||||
);
|
||||
|
||||
credentials.updateData({ username: 'newuser', password: 'newpass' });
|
||||
|
||||
expect(credentials.getData()).toEqual({
|
||||
username: 'newuser',
|
||||
password: 'newpass',
|
||||
apiKey: 'oldkey',
|
||||
});
|
||||
});
|
||||
|
||||
test('should delete specified keys', () => {
|
||||
const credentials = new Credentials(
|
||||
nodeCredentials,
|
||||
credentialType,
|
||||
cipher.encrypt({
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
apiKey: 'testkey',
|
||||
}),
|
||||
);
|
||||
|
||||
credentials.updateData({}, ['username', 'apiKey']);
|
||||
|
||||
expect(credentials.getData()).toEqual({
|
||||
password: 'testpass',
|
||||
});
|
||||
});
|
||||
|
||||
test('should update and delete keys in same operation', () => {
|
||||
const credentials = new Credentials(
|
||||
nodeCredentials,
|
||||
credentialType,
|
||||
cipher.encrypt({
|
||||
username: 'olduser',
|
||||
password: 'oldpass',
|
||||
apiKey: 'oldkey',
|
||||
}),
|
||||
);
|
||||
|
||||
credentials.updateData({ username: 'newuser' }, ['apiKey']);
|
||||
|
||||
expect(credentials.getData()).toEqual({
|
||||
username: 'newuser',
|
||||
password: 'oldpass',
|
||||
});
|
||||
});
|
||||
|
||||
test('should throw an error if no data was previously set', () => {
|
||||
const credentials = new Credentials(nodeCredentials, credentialType);
|
||||
|
||||
expect(() => {
|
||||
credentials.updateData({ username: 'newuser' });
|
||||
}).toThrow(CREDENTIAL_ERRORS.NO_DATA);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { SecurityConfig } from '@n8n/config';
|
||||
import { Container } from '@n8n/di';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import { isWebhookHtmlSandboxingDisabled, getWebhookSandboxCSP } from '@/html-sandbox';
|
||||
|
||||
const securityConfig = mock<SecurityConfig>();
|
||||
|
||||
describe('isWebhookHtmlSandboxingDisabled', () => {
|
||||
beforeAll(() => {
|
||||
jest.spyOn(Container, 'get').mockReturnValue(securityConfig);
|
||||
});
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return false when sandboxing is enabled', () => {
|
||||
securityConfig.disableWebhookHtmlSandboxing = false;
|
||||
expect(isWebhookHtmlSandboxingDisabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when sandboxing is disabled', () => {
|
||||
securityConfig.disableWebhookHtmlSandboxing = true;
|
||||
expect(isWebhookHtmlSandboxingDisabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWebhookSandboxCSP', () => {
|
||||
it('should return correct CSP sandbox directive', () => {
|
||||
const csp = getWebhookSandboxCSP();
|
||||
expect(csp).toBe(
|
||||
'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',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not include allow-same-origin', () => {
|
||||
const csp = getWebhookSandboxCSP();
|
||||
expect(csp).not.toContain('allow-same-origin');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import http from 'http';
|
||||
import https from 'https';
|
||||
import type { AddressInfo } from 'net';
|
||||
import nock from 'nock';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import { installGlobalProxyAgent, uninstallGlobalProxyAgent } from '../http-proxy';
|
||||
|
||||
interface TestResponse {
|
||||
message: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface ProxyRequest {
|
||||
method: string;
|
||||
url: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
async function createMockProxyServer() {
|
||||
const capturedRequests: ProxyRequest[] = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
capturedRequests.push({
|
||||
method: req.method ?? 'GET',
|
||||
url: req.url ?? '',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ message: 'proxied', timestamp: Date.now() }));
|
||||
});
|
||||
|
||||
server.on('connect', (req, clientSocket) => {
|
||||
capturedRequests.push({
|
||||
method: 'CONNECT',
|
||||
url: req.url ?? '',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
||||
clientSocket.end();
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
server.on('error', reject);
|
||||
});
|
||||
|
||||
const address = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
server,
|
||||
port: address.port,
|
||||
url: `http://127.0.0.1:${address.port}`,
|
||||
capturedRequests,
|
||||
clearRequests: () => (capturedRequests.length = 0),
|
||||
};
|
||||
}
|
||||
|
||||
async function makeRequest(url: string): Promise<TestResponse> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const urlObj = new URL(url);
|
||||
const httpModule = urlObj.protocol === 'https:' ? https : http;
|
||||
|
||||
const req = httpModule.get(url, { timeout: 5000 }, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('Request timeout'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('HTTP Proxy Tests', () => {
|
||||
let proxyServer: Awaited<ReturnType<typeof createMockProxyServer>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
proxyServer = await createMockProxyServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await promisify(proxyServer.server.close.bind(proxyServer.server))();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.HTTP_PROXY;
|
||||
delete process.env.HTTPS_PROXY;
|
||||
delete process.env.NO_PROXY;
|
||||
delete process.env.ALL_PROXY;
|
||||
nock.cleanAll();
|
||||
nock.restore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
uninstallGlobalProxyAgent();
|
||||
proxyServer.clearRequests();
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: 'should use HTTP_PROXY for HTTP requests',
|
||||
env: { HTTP_PROXY: true },
|
||||
targetUrl: 'http://api.example.com:8080/test',
|
||||
expectProxied: true,
|
||||
},
|
||||
{
|
||||
name: 'should ignore HTTPS_PROXY for HTTP requests',
|
||||
env: { HTTPS_PROXY: true },
|
||||
targetUrl: 'http://api.example.com:8080/test',
|
||||
expectProxied: false,
|
||||
},
|
||||
{
|
||||
name: 'should use ALL_PROXY when specific proxy not set',
|
||||
env: { ALL_PROXY: true },
|
||||
targetUrl: 'http://api.example.com:8080/test',
|
||||
expectProxied: true,
|
||||
},
|
||||
{
|
||||
name: 'should prefer HTTP_PROXY over ALL_PROXY',
|
||||
env: { HTTP_PROXY: true, ALL_PROXY: 'http://unused:8080' },
|
||||
targetUrl: 'http://api.example.com:8080/test',
|
||||
expectProxied: true,
|
||||
},
|
||||
{
|
||||
name: 'should make direct requests when no proxy configured',
|
||||
env: {},
|
||||
targetUrl: 'http://api.example.com:8080/test',
|
||||
expectProxied: false,
|
||||
},
|
||||
{
|
||||
name: 'should bypass proxy for exact hostname match',
|
||||
env: { HTTP_PROXY: true, NO_PROXY: 'api.example.com' },
|
||||
targetUrl: 'http://api.example.com:8080/test',
|
||||
expectProxied: false,
|
||||
},
|
||||
{
|
||||
name: 'should bypass proxy for exact IP match',
|
||||
env: { HTTP_PROXY: true, NO_PROXY: '192.168.1.100' },
|
||||
targetUrl: 'http://192.168.1.100:8080/test',
|
||||
expectProxied: false,
|
||||
},
|
||||
{
|
||||
name: 'should proxy when hostname not in NO_PROXY',
|
||||
env: { HTTP_PROXY: true, NO_PROXY: 'example.com' },
|
||||
targetUrl: 'http://test.local:8080/api',
|
||||
expectProxied: true,
|
||||
},
|
||||
{
|
||||
name: 'should bypass proxy for wildcard subdomain patterns',
|
||||
env: { HTTP_PROXY: true, NO_PROXY: '*.local' },
|
||||
targetUrl: 'http://app.local:8080/api',
|
||||
expectProxied: false,
|
||||
},
|
||||
{
|
||||
name: 'should bypass proxy for nested wildcard patterns',
|
||||
env: { HTTP_PROXY: true, NO_PROXY: '*.example.com' },
|
||||
targetUrl: 'http://api.example.com:8080/data',
|
||||
expectProxied: false,
|
||||
},
|
||||
{
|
||||
name: 'should proxy when wildcard does not match',
|
||||
env: { HTTP_PROXY: true, NO_PROXY: '*.example.com' },
|
||||
targetUrl: 'http://test.local:8080/api',
|
||||
expectProxied: true,
|
||||
},
|
||||
{
|
||||
name: 'should handle multiple NO_PROXY patterns - match first',
|
||||
env: { HTTP_PROXY: true, NO_PROXY: 'localhost,*.local,example.com' },
|
||||
targetUrl: 'http://localhost:8080/test',
|
||||
expectProxied: false,
|
||||
},
|
||||
{
|
||||
name: 'should handle multiple NO_PROXY patterns - match middle',
|
||||
env: { HTTP_PROXY: true, NO_PROXY: 'localhost,*.local,example.com' },
|
||||
targetUrl: 'http://app.local:8080/api',
|
||||
expectProxied: false,
|
||||
},
|
||||
{
|
||||
name: 'should handle multiple NO_PROXY patterns - match last',
|
||||
env: { HTTP_PROXY: true, NO_PROXY: 'localhost,*.local,example.com' },
|
||||
targetUrl: 'http://example.com:8080/data',
|
||||
expectProxied: false,
|
||||
},
|
||||
{
|
||||
name: 'should proxy when none of multiple patterns match',
|
||||
env: { HTTP_PROXY: true, NO_PROXY: 'localhost,*.example.com,test.org' },
|
||||
targetUrl: 'http://app.local:8080/api',
|
||||
expectProxied: true,
|
||||
},
|
||||
{
|
||||
name: 'should respect NO_PROXY with ALL_PROXY',
|
||||
env: { ALL_PROXY: true, NO_PROXY: '*.example.com' },
|
||||
targetUrl: 'http://api.example.com:8080/test',
|
||||
expectProxied: false,
|
||||
},
|
||||
{
|
||||
name: 'should proxy when target not in NO_PROXY list',
|
||||
env: { HTTP_PROXY: true, NO_PROXY: 'localhost,*.internal' },
|
||||
targetUrl: 'http://api.example.com:8080/test',
|
||||
expectProxied: true,
|
||||
},
|
||||
])('$name', async ({ env, targetUrl, expectProxied }) => {
|
||||
if (env.HTTP_PROXY) process.env.HTTP_PROXY = proxyServer.url;
|
||||
if (env.HTTPS_PROXY) process.env.HTTPS_PROXY = proxyServer.url;
|
||||
if (env.ALL_PROXY === true) process.env.ALL_PROXY = proxyServer.url;
|
||||
if (env.ALL_PROXY && env.ALL_PROXY !== true) process.env.ALL_PROXY = env.ALL_PROXY;
|
||||
if (env.NO_PROXY) process.env.NO_PROXY = env.NO_PROXY;
|
||||
|
||||
installGlobalProxyAgent();
|
||||
|
||||
let scope: nock.Scope | undefined;
|
||||
if (!expectProxied) {
|
||||
scope = setupDirectRequestMock(targetUrl);
|
||||
}
|
||||
|
||||
const response = await makeRequest(targetUrl);
|
||||
|
||||
if (expectProxied) {
|
||||
expectProxiedResponse(response);
|
||||
} else {
|
||||
expectDirectResponse(response, scope);
|
||||
}
|
||||
});
|
||||
|
||||
function setupDirectRequestMock(targetUrl: string) {
|
||||
if (!nock.isActive()) nock.activate();
|
||||
const url = new URL(targetUrl);
|
||||
return nock(`${url.protocol}//${url.host}`)
|
||||
.get(url.pathname)
|
||||
.reply(200, { message: 'direct', timestamp: Date.now() });
|
||||
}
|
||||
|
||||
function expectProxiedResponse(response: TestResponse) {
|
||||
expect(response.message).toBe('proxied');
|
||||
expect(proxyServer.capturedRequests.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
function expectDirectResponse(response: TestResponse, scope?: nock.Scope) {
|
||||
expect(response.message).toBe('direct');
|
||||
expect(proxyServer.capturedRequests).toHaveLength(0);
|
||||
expect(scope?.isDone()).toBe(true);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
/* eslint-disable @typescript-eslint/unbound-method */
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { Container } from '@n8n/di';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { existsSync, renameSync } from 'node:fs';
|
||||
|
||||
import { InstanceSettings } from '@/instance-settings';
|
||||
import { mockInstance } from '@test/utils';
|
||||
|
||||
import { StoragePathError } from '../storage-path-conflict.error';
|
||||
import { StorageConfig } from '../storage.config';
|
||||
|
||||
jest.mock('node:fs', () => ({
|
||||
existsSync: jest.fn(),
|
||||
renameSync: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('StorageConfig', () => {
|
||||
const n8nFolder = '~/.n8n';
|
||||
let markFsStorageMigrated: jest.Mock;
|
||||
let logger: Logger;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = {};
|
||||
jest.resetAllMocks();
|
||||
Container.reset();
|
||||
markFsStorageMigrated = jest.fn();
|
||||
mockInstance(InstanceSettings, {
|
||||
n8nFolder,
|
||||
fsStorageMigrated: false,
|
||||
markFsStorageMigrated,
|
||||
});
|
||||
logger = mock<Logger>();
|
||||
Container.set(Logger, logger);
|
||||
(existsSync as jest.Mock).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('should use default values when no env variables are defined', () => {
|
||||
const config = Container.get(StorageConfig);
|
||||
|
||||
expect(config.mode).toBe('database');
|
||||
expect(config.storagePath).toBe('~/.n8n/storage');
|
||||
});
|
||||
|
||||
it('should set mode to filesystem when N8N_EXECUTION_DATA_STORAGE_MODE is filesystem', () => {
|
||||
process.env.N8N_EXECUTION_DATA_STORAGE_MODE = 'filesystem';
|
||||
|
||||
const config = Container.get(StorageConfig);
|
||||
|
||||
expect(config.mode).toBe('filesystem');
|
||||
});
|
||||
|
||||
it('should override default path when N8N_STORAGE_PATH is set', () => {
|
||||
process.env.N8N_STORAGE_PATH = '/custom/storage/path';
|
||||
|
||||
const config = Container.get(StorageConfig);
|
||||
|
||||
expect(config.storagePath).toBe('/custom/storage/path');
|
||||
});
|
||||
|
||||
it('should throw error when N8N_STORAGE_PATH and N8N_BINARY_DATA_STORAGE_PATH are set to different values', () => {
|
||||
process.env.N8N_STORAGE_PATH = '/path/one';
|
||||
process.env.N8N_BINARY_DATA_STORAGE_PATH = '/path/two';
|
||||
|
||||
expect(() => Container.get(StorageConfig)).toThrow(StoragePathError);
|
||||
});
|
||||
|
||||
it('should not throw error when N8N_STORAGE_PATH and N8N_BINARY_DATA_STORAGE_PATH are set to the same value', () => {
|
||||
process.env.N8N_STORAGE_PATH = '/same/path';
|
||||
process.env.N8N_BINARY_DATA_STORAGE_PATH = '/same/path';
|
||||
|
||||
const config = Container.get(StorageConfig);
|
||||
|
||||
expect(config.storagePath).toBe('/same/path');
|
||||
});
|
||||
|
||||
it('should fall back to default for invalid mode value', () => {
|
||||
process.env.N8N_EXECUTION_DATA_STORAGE_MODE = 'invalid-mode';
|
||||
console.warn = jest.fn();
|
||||
|
||||
const config = Container.get(StorageConfig);
|
||||
|
||||
expect(config.mode).toBe('database');
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Invalid value for N8N_EXECUTION_DATA_STORAGE_MODE'),
|
||||
);
|
||||
});
|
||||
|
||||
describe('storage dir migration', () => {
|
||||
it('should log deprecation warning and use old path when old path exists but migration not enabled', () => {
|
||||
(existsSync as jest.Mock).mockReturnValueOnce(true); // old path exists
|
||||
|
||||
const config = Container.get(StorageConfig);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Deprecation warning'));
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('N8N_MIGRATE_FS_STORAGE_PATH=true'),
|
||||
);
|
||||
expect(config.storagePath).toBe('~/.n8n/binaryData');
|
||||
expect(renameSync).not.toHaveBeenCalled();
|
||||
expect(markFsStorageMigrated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should proceed when old path exists and migration is enabled', () => {
|
||||
process.env.N8N_MIGRATE_FS_STORAGE_PATH = 'true';
|
||||
(existsSync as jest.Mock)
|
||||
.mockReturnValueOnce(true) // old path exists
|
||||
.mockReturnValueOnce(false); // new path does not exist
|
||||
|
||||
Container.get(StorageConfig);
|
||||
|
||||
expect(renameSync).toHaveBeenCalledWith('~/.n8n/binaryData', '~/.n8n/storage');
|
||||
expect(markFsStorageMigrated).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip if already migrated', () => {
|
||||
mockInstance(InstanceSettings, {
|
||||
n8nFolder,
|
||||
fsStorageMigrated: true,
|
||||
markFsStorageMigrated,
|
||||
});
|
||||
|
||||
Container.get(StorageConfig);
|
||||
|
||||
expect(renameSync).not.toHaveBeenCalled();
|
||||
expect(markFsStorageMigrated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip if `N8N_STORAGE_PATH` is set', () => {
|
||||
process.env.N8N_STORAGE_PATH = '/custom/path';
|
||||
|
||||
Container.get(StorageConfig);
|
||||
|
||||
expect(renameSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip if `N8N_BINARY_DATA_STORAGE_PATH` is set', () => {
|
||||
process.env.N8N_BINARY_DATA_STORAGE_PATH = '/custom/path';
|
||||
|
||||
Container.get(StorageConfig);
|
||||
|
||||
expect(renameSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip if `binaryData` does not exist', () => {
|
||||
(existsSync as jest.Mock).mockReturnValueOnce(false); // old path does not exist
|
||||
|
||||
Container.get(StorageConfig);
|
||||
|
||||
expect(renameSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should error if `storage` already exists when migration is enabled', () => {
|
||||
process.env.N8N_MIGRATE_FS_STORAGE_PATH = 'true';
|
||||
(existsSync as jest.Mock)
|
||||
.mockReturnValueOnce(true) // old path exists
|
||||
.mockReturnValueOnce(true); // new path also exists
|
||||
|
||||
expect(() => Container.get(StorageConfig)).toThrow(StoragePathError);
|
||||
expect(renameSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['ENOENT', 'EEXIST'])('should ignore `%s` error', (code) => {
|
||||
process.env.N8N_MIGRATE_FS_STORAGE_PATH = 'true';
|
||||
(existsSync as jest.Mock).mockReturnValueOnce(true).mockReturnValueOnce(false);
|
||||
(renameSync as jest.Mock).mockImplementation(() => {
|
||||
throw Object.assign(new Error(code), { code });
|
||||
});
|
||||
|
||||
expect(() => Container.get(StorageConfig)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should rethrow other errors', () => {
|
||||
process.env.N8N_MIGRATE_FS_STORAGE_PATH = 'true';
|
||||
(existsSync as jest.Mock)
|
||||
.mockReturnValueOnce(true) // old path exists
|
||||
.mockReturnValueOnce(false); // new path does not exist
|
||||
const otherError = Object.assign(new Error('EACCES'), { code: 'EACCES' });
|
||||
(renameSync as jest.Mock).mockImplementation(() => {
|
||||
throw otherError;
|
||||
});
|
||||
|
||||
expect(() => Container.get(StorageConfig)).toThrow(otherError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { sign, JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
|
||||
import type { IBinaryData } from 'n8n-workflow';
|
||||
|
||||
import type { ErrorReporter } from '@/errors';
|
||||
|
||||
import type { BinaryDataConfig } from '../binary-data.config';
|
||||
import { BinaryDataService } from '../binary-data.service';
|
||||
|
||||
const now = new Date('2025-01-01T01:23:45.678Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
describe('BinaryDataService', () => {
|
||||
const signingSecret = 'test-signing-secret';
|
||||
const config = mock<BinaryDataConfig>({ signingSecret });
|
||||
const logger = mock<Logger>();
|
||||
const errorReporter = mock<ErrorReporter>();
|
||||
const binaryData = mock<IBinaryData>({ id: 'filesystem:id_123' });
|
||||
const validToken = sign({ id: binaryData.id }, signingSecret, { expiresIn: '1 day' });
|
||||
|
||||
let service: BinaryDataService;
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
config.signingSecret = signingSecret;
|
||||
service = new BinaryDataService(config, errorReporter, logger);
|
||||
});
|
||||
|
||||
describe('createSignedToken', () => {
|
||||
it('should throw for binary-data without an id', () => {
|
||||
const binaryData = mock<IBinaryData>({ id: undefined });
|
||||
|
||||
expect(() => service.createSignedToken(binaryData)).toThrow();
|
||||
});
|
||||
|
||||
it('should create a signed token for valid binary-data', () => {
|
||||
const token = service.createSignedToken(binaryData);
|
||||
|
||||
expect(token).toBe(validToken);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSignedToken', () => {
|
||||
const invalidToken = sign({ id: binaryData.id }, 'fake-secret');
|
||||
const expiredToken = sign({ id: binaryData.id }, signingSecret, { expiresIn: '-1 day' });
|
||||
|
||||
it('should throw on invalid tokens', () => {
|
||||
expect(() => service.validateSignedToken(invalidToken)).toThrow(JsonWebTokenError);
|
||||
});
|
||||
|
||||
it('should throw on expired tokens', () => {
|
||||
expect(() => service.validateSignedToken(expiredToken)).toThrow(TokenExpiredError);
|
||||
});
|
||||
|
||||
it('should return binary-data id on valid tokens', () => {
|
||||
const result = service.validateSignedToken(validToken);
|
||||
expect(result).toBe(binaryData.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Container } from '@n8n/di';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
import { InstanceSettings } from '@/instance-settings';
|
||||
import { mockInstance } from '@test/utils';
|
||||
|
||||
import { BinaryDataConfig } from '../binary-data.config';
|
||||
|
||||
jest.mock('node:fs', () => ({
|
||||
existsSync: jest.fn().mockReturnValue(false),
|
||||
renameSync: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('BinaryDataConfig', () => {
|
||||
const n8nFolder = '/test/n8n';
|
||||
const encryptionKey = 'test-encryption-key';
|
||||
console.warn = jest.fn().mockImplementation(() => {});
|
||||
|
||||
const now = new Date('2025-01-01T01:23:45.678Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = {};
|
||||
jest.resetAllMocks();
|
||||
Container.reset();
|
||||
mockInstance(InstanceSettings, { encryptionKey, n8nFolder });
|
||||
(existsSync as jest.Mock).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('should use default values when no env variables are defined', () => {
|
||||
const config = Container.get(BinaryDataConfig);
|
||||
|
||||
expect(config.availableModes).toEqual(['filesystem', 's3', 'database']);
|
||||
expect(config.mode).toBe('filesystem');
|
||||
expect(config.localStoragePath).toBe('/test/n8n/storage');
|
||||
});
|
||||
|
||||
it('should use values from env variables when defined', () => {
|
||||
process.env.N8N_DEFAULT_BINARY_DATA_MODE = 's3';
|
||||
process.env.N8N_BINARY_DATA_STORAGE_PATH = '/custom/storage/path';
|
||||
process.env.N8N_BINARY_DATA_SIGNING_SECRET = 'super-secret';
|
||||
|
||||
const config = Container.get(BinaryDataConfig);
|
||||
|
||||
expect(config.mode).toEqual('s3');
|
||||
expect(config.availableModes).toEqual(['filesystem', 's3', 'database']);
|
||||
expect(config.localStoragePath).toEqual('/custom/storage/path');
|
||||
expect(config.signingSecret).toBe('super-secret');
|
||||
});
|
||||
|
||||
it('should derive the signing secret from the encryption-key, when none is passed in', () => {
|
||||
const config = Container.get(BinaryDataConfig);
|
||||
|
||||
expect(config.signingSecret).toBe('96eHYcXMF6J1Pn6dhdkOEt6H2BMa6kR5oR0ce7llWyA=');
|
||||
});
|
||||
|
||||
it('should fallback to filesystem for invalid mode', () => {
|
||||
process.env.N8N_DEFAULT_BINARY_DATA_MODE = 'invalid-mode';
|
||||
|
||||
const config = Container.get(BinaryDataConfig);
|
||||
|
||||
expect(config.mode).toEqual('filesystem');
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Invalid value for N8N_DEFAULT_BINARY_DATA_MODE'),
|
||||
);
|
||||
});
|
||||
|
||||
describe('dbMaxFileSize', () => {
|
||||
it('should coerce string env variable to number', () => {
|
||||
process.env.N8N_BINARY_DATA_DATABASE_MAX_FILE_SIZE = '1024';
|
||||
|
||||
const config = Container.get(BinaryDataConfig);
|
||||
|
||||
expect(config.dbMaxFileSize).toBe(1024);
|
||||
});
|
||||
|
||||
it('should use default value when env variable is not set', () => {
|
||||
const config = Container.get(BinaryDataConfig);
|
||||
|
||||
expect(config.dbMaxFileSize).toBe(512);
|
||||
});
|
||||
|
||||
it('should fallback to default for invalid value', () => {
|
||||
process.env.N8N_BINARY_DATA_DATABASE_MAX_FILE_SIZE = 'not-a-number';
|
||||
|
||||
const config = Container.get(BinaryDataConfig);
|
||||
|
||||
expect(config.dbMaxFileSize).toBe(512);
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Invalid value for N8N_BINARY_DATA_DATABASE_MAX_FILE_SIZE'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback to default when value exceeds maximum', () => {
|
||||
process.env.N8N_BINARY_DATA_DATABASE_MAX_FILE_SIZE = '2048';
|
||||
|
||||
const config = Container.get(BinaryDataConfig);
|
||||
|
||||
expect(config.dbMaxFileSize).toBe(512);
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Invalid value for N8N_BINARY_DATA_DATABASE_MAX_FILE_SIZE'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { FileSystemManager } from '@/binary-data/file-system.manager';
|
||||
import type { ErrorReporter } from '@/errors';
|
||||
import { toFileId, toStream } from '@test/utils';
|
||||
|
||||
import type { BinaryData } from '../types';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('fs/promises');
|
||||
|
||||
const storagePath = tmpdir();
|
||||
const errorReporter = mock<ErrorReporter>();
|
||||
|
||||
const fsManager = new FileSystemManager(storagePath, errorReporter);
|
||||
|
||||
const toFullFilePath = (fileId: string) => path.join(storagePath, fileId);
|
||||
|
||||
const workflowId = 'ObogjVbqpNOQpiyV';
|
||||
const executionId = '999';
|
||||
const fileUuid = '71f6209b-5d48-41a2-a224-80d529d8bb32';
|
||||
const fileId = toFileId(workflowId, executionId, fileUuid);
|
||||
|
||||
const otherWorkflowId = 'FHio8ftV6SrCAfPJ';
|
||||
const otherExecutionId = '888';
|
||||
const otherFileUuid = '71f6209b-5d48-41a2-a224-80d529d8bb33';
|
||||
const otherFileId = toFileId(otherWorkflowId, otherExecutionId, otherFileUuid);
|
||||
|
||||
const mockBuffer = Buffer.from('Test data');
|
||||
const mockStream = toStream(mockBuffer);
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('store()', () => {
|
||||
it('should store a buffer', async () => {
|
||||
const metadata = { mimeType: 'text/plain' };
|
||||
|
||||
const result = await fsManager.store(
|
||||
{ type: 'execution', workflowId, executionId },
|
||||
mockBuffer,
|
||||
metadata,
|
||||
);
|
||||
|
||||
expect(result.fileSize).toBe(mockBuffer.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPath()', () => {
|
||||
it('should return a path', async () => {
|
||||
const filePath = fsManager.getPath(fileId);
|
||||
|
||||
expect(filePath).toBe(toFullFilePath(fileId));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAsBuffer()', () => {
|
||||
it('should return a buffer', async () => {
|
||||
fsp.readFile = jest.fn().mockResolvedValue(mockBuffer);
|
||||
fsp.access = jest.fn().mockImplementation(async () => {});
|
||||
|
||||
const result = await fsManager.getAsBuffer(fileId);
|
||||
|
||||
expect(Buffer.isBuffer(result)).toBe(true);
|
||||
expect(fsp.readFile).toHaveBeenCalledWith(toFullFilePath(fileId));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAsStream()', () => {
|
||||
it('should return a stream', async () => {
|
||||
fs.createReadStream = jest.fn().mockReturnValue(mockStream);
|
||||
fsp.access = jest.fn().mockImplementation(async () => {});
|
||||
|
||||
const stream = await fsManager.getAsStream(fileId);
|
||||
|
||||
expect(stream).toBeInstanceOf(Readable);
|
||||
expect(fs.createReadStream).toHaveBeenCalledWith(toFullFilePath(fileId), {
|
||||
highWaterMark: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMetadata()', () => {
|
||||
it('should return metadata', async () => {
|
||||
const mimeType = 'text/plain';
|
||||
const fileName = 'file.txt';
|
||||
|
||||
fsp.readFile = jest.fn().mockResolvedValue(
|
||||
JSON.stringify({
|
||||
fileSize: 1,
|
||||
mimeType,
|
||||
fileName,
|
||||
}),
|
||||
);
|
||||
|
||||
const metadata = await fsManager.getMetadata(fileId);
|
||||
|
||||
expect(metadata).toEqual(expect.objectContaining({ fileSize: 1, mimeType, fileName }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyByFileId()', () => {
|
||||
it('should copy by file ID and return the file ID', async () => {
|
||||
fsp.copyFile = jest.fn().mockResolvedValue(undefined);
|
||||
fsp.writeFile = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
// @ts-expect-error - private method
|
||||
jest.spyOn(fsManager, 'toFileId').mockReturnValue(otherFileId);
|
||||
|
||||
const targetFileId = await fsManager.copyByFileId(
|
||||
{ type: 'execution', workflowId, executionId },
|
||||
fileId,
|
||||
);
|
||||
|
||||
const sourcePath = toFullFilePath(fileId);
|
||||
const targetPath = toFullFilePath(targetFileId);
|
||||
|
||||
expect(fsp.copyFile).toHaveBeenCalledWith(sourcePath, targetPath);
|
||||
|
||||
// Make sure metadata file was written
|
||||
expect(fsp.writeFile).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyByFilePath()', () => {
|
||||
test('should copy by file path and return the file ID and size', async () => {
|
||||
const sourceFilePath = tmpdir();
|
||||
const metadata = { mimeType: 'text/plain' };
|
||||
|
||||
// @ts-expect-error - private method
|
||||
jest.spyOn(fsManager, 'toFileId').mockReturnValue(otherFileId);
|
||||
|
||||
// @ts-expect-error - private method
|
||||
jest.spyOn(fsManager, 'getSize').mockReturnValue(mockBuffer.length);
|
||||
|
||||
const targetPath = toFullFilePath(otherFileId);
|
||||
|
||||
fsp.cp = jest.fn().mockResolvedValue(undefined);
|
||||
fsp.writeFile = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const result = await fsManager.copyByFilePath(
|
||||
{ type: 'execution', workflowId, executionId },
|
||||
sourceFilePath,
|
||||
metadata,
|
||||
);
|
||||
|
||||
expect(fsp.cp).toHaveBeenCalledWith(sourceFilePath, targetPath);
|
||||
expect(fsp.writeFile).toHaveBeenCalledWith(
|
||||
`${toFullFilePath(otherFileId)}.metadata`,
|
||||
JSON.stringify({ ...metadata, fileSize: mockBuffer.length }),
|
||||
{ encoding: 'utf-8' },
|
||||
);
|
||||
expect(result.fileSize).toBe(mockBuffer.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteMany()', () => {
|
||||
const rmOptions = {
|
||||
force: true,
|
||||
recursive: true,
|
||||
};
|
||||
|
||||
it('should delete many files by workflow ID and execution ID', async () => {
|
||||
const ids: BinaryData.FileLocation[] = [
|
||||
{ type: 'execution', workflowId, executionId },
|
||||
{ type: 'execution', workflowId: otherWorkflowId, executionId: otherExecutionId },
|
||||
];
|
||||
|
||||
fsp.rm = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const promise = fsManager.deleteMany(ids);
|
||||
|
||||
await expect(promise).resolves.not.toThrow();
|
||||
|
||||
expect(fsp.rm).toHaveBeenCalledTimes(2);
|
||||
expect(fsp.rm).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
`${storagePath}/workflows/${workflowId}/executions/${executionId}`,
|
||||
rmOptions,
|
||||
);
|
||||
expect(fsp.rm).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
`${storagePath}/workflows/${otherWorkflowId}/executions/${otherExecutionId}`,
|
||||
rmOptions,
|
||||
);
|
||||
});
|
||||
|
||||
it('should suppress error on non-existing filepath', async () => {
|
||||
const ids: BinaryData.FileLocation[] = [
|
||||
{ type: 'execution', workflowId: 'does-not-exist', executionId: 'does-not-exist' },
|
||||
];
|
||||
|
||||
fsp.rm = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const promise = fsManager.deleteMany(ids);
|
||||
|
||||
await expect(promise).resolves.not.toThrow();
|
||||
|
||||
expect(fsp.rm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rename()', () => {
|
||||
it('should rename a file', async () => {
|
||||
fsp.rename = jest.fn().mockResolvedValue(undefined);
|
||||
fsp.rm = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const promise = fsManager.rename(fileId, otherFileId);
|
||||
|
||||
const oldPath = toFullFilePath(fileId);
|
||||
const newPath = toFullFilePath(otherFileId);
|
||||
|
||||
await expect(promise).resolves.not.toThrow();
|
||||
|
||||
expect(fsp.rename).toHaveBeenCalledTimes(2);
|
||||
expect(fsp.rename).toHaveBeenCalledWith(oldPath, newPath);
|
||||
expect(fsp.rename).toHaveBeenCalledWith(`${oldPath}.metadata`, `${newPath}.metadata`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import fs from 'node:fs/promises';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { ObjectStoreService } from '@/binary-data/object-store/object-store.service.ee';
|
||||
import type { MetadataResponseHeaders } from '@/binary-data/object-store/types';
|
||||
import { ObjectStoreManager } from '@/binary-data/object-store.manager';
|
||||
import { mockInstance, toFileId, toStream } from '@test/utils';
|
||||
|
||||
jest.mock('fs/promises');
|
||||
|
||||
const objectStoreService = mockInstance(ObjectStoreService);
|
||||
const objectStoreManager = new ObjectStoreManager(objectStoreService);
|
||||
|
||||
const workflowId = 'ObogjVbqpNOQpiyV';
|
||||
const executionId = '999';
|
||||
const fileUuid = '71f6209b-5d48-41a2-a224-80d529d8bb32';
|
||||
const fileId = toFileId(workflowId, executionId, fileUuid);
|
||||
const prefix = `workflows/${workflowId}/executions/${executionId}/binary_data/`;
|
||||
|
||||
const otherWorkflowId = 'FHio8ftV6SrCAfPJ';
|
||||
const otherExecutionId = '888';
|
||||
const otherFileUuid = '71f6209b-5d48-41a2-a224-80d529d8bb33';
|
||||
const otherFileId = toFileId(otherWorkflowId, otherExecutionId, otherFileUuid);
|
||||
|
||||
const mockBuffer = Buffer.from('Test data');
|
||||
const mockStream = toStream(mockBuffer);
|
||||
|
||||
beforeAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('store()', () => {
|
||||
it('should store a buffer', async () => {
|
||||
const metadata = { mimeType: 'text/plain' };
|
||||
|
||||
const result = await objectStoreManager.store(
|
||||
{ type: 'execution', workflowId, executionId },
|
||||
mockBuffer,
|
||||
metadata,
|
||||
);
|
||||
|
||||
expect(result.fileId.startsWith(prefix)).toBe(true);
|
||||
expect(result.fileSize).toBe(mockBuffer.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPath()', () => {
|
||||
it('should return a path', async () => {
|
||||
const path = objectStoreManager.getPath(fileId);
|
||||
|
||||
expect(path).toBe(fileId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAsBuffer()', () => {
|
||||
it('should return a buffer', async () => {
|
||||
// @ts-expect-error Overload signature seemingly causing the return type to be misinferred
|
||||
objectStoreService.get.mockResolvedValue(mockBuffer);
|
||||
|
||||
const result = await objectStoreManager.getAsBuffer(fileId);
|
||||
|
||||
expect(Buffer.isBuffer(result)).toBe(true);
|
||||
expect(objectStoreService.get).toHaveBeenCalledWith(fileId, { mode: 'buffer' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAsStream()', () => {
|
||||
it('should return a stream', async () => {
|
||||
objectStoreService.get.mockResolvedValue(mockStream);
|
||||
|
||||
const stream = await objectStoreManager.getAsStream(fileId);
|
||||
|
||||
expect(stream).toBeInstanceOf(Readable);
|
||||
expect(objectStoreService.get).toHaveBeenCalledWith(fileId, { mode: 'stream' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMetadata()', () => {
|
||||
it('should return metadata', async () => {
|
||||
const mimeType = 'text/plain';
|
||||
const fileName = 'file.txt';
|
||||
|
||||
objectStoreService.getMetadata.mockResolvedValue(
|
||||
mock<MetadataResponseHeaders>({
|
||||
'content-length': '1',
|
||||
'content-type': mimeType,
|
||||
'x-amz-meta-filename': fileName,
|
||||
}),
|
||||
);
|
||||
|
||||
const metadata = await objectStoreManager.getMetadata(fileId);
|
||||
|
||||
expect(metadata).toEqual(expect.objectContaining({ fileSize: 1, mimeType, fileName }));
|
||||
expect(objectStoreService.getMetadata).toHaveBeenCalledWith(fileId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyByFileId()', () => {
|
||||
it('should copy by file ID and return the file ID', async () => {
|
||||
const targetFileId = await objectStoreManager.copyByFileId(
|
||||
{ type: 'execution', workflowId, executionId },
|
||||
fileId,
|
||||
);
|
||||
|
||||
expect(targetFileId.startsWith(prefix)).toBe(true);
|
||||
expect(objectStoreService.get).toHaveBeenCalledWith(fileId, { mode: 'buffer' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyByFilePath()', () => {
|
||||
test('should copy by file path and return the file ID and size', async () => {
|
||||
const sourceFilePath = 'path/to/file/in/filesystem';
|
||||
const metadata = { mimeType: 'text/plain' };
|
||||
|
||||
fs.readFile = jest.fn().mockResolvedValue(mockBuffer);
|
||||
|
||||
const result = await objectStoreManager.copyByFilePath(
|
||||
{ type: 'execution', workflowId, executionId },
|
||||
sourceFilePath,
|
||||
metadata,
|
||||
);
|
||||
|
||||
expect(result.fileId.startsWith(prefix)).toBe(true);
|
||||
expect(fs.readFile).toHaveBeenCalledWith(sourceFilePath);
|
||||
expect(result.fileSize).toBe(mockBuffer.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rename()', () => {
|
||||
it('should rename a file', async () => {
|
||||
const promise = objectStoreManager.rename(fileId, otherFileId);
|
||||
|
||||
await expect(promise).resolves.not.toThrow();
|
||||
|
||||
expect(objectStoreService.get).toHaveBeenCalledWith(fileId, { mode: 'buffer' });
|
||||
expect(objectStoreService.getMetadata).toHaveBeenCalledWith(fileId);
|
||||
expect(objectStoreService.deleteOne).toHaveBeenCalledWith(fileId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
import { Readable } from 'node:stream';
|
||||
import { createGunzip } from 'node:zlib';
|
||||
|
||||
import { binaryToBuffer } from '@/binary-data/utils';
|
||||
|
||||
describe('BinaryData/utils', () => {
|
||||
describe('binaryToBuffer', () => {
|
||||
it('should handle buffer objects', async () => {
|
||||
const body = Buffer.from('test');
|
||||
expect((await binaryToBuffer(body)).toString()).toEqual('test');
|
||||
});
|
||||
|
||||
it('should handle valid uncompressed Readable streams', async () => {
|
||||
const body = Readable.from(Buffer.from('test'));
|
||||
expect((await binaryToBuffer(body)).toString()).toEqual('test');
|
||||
});
|
||||
|
||||
it('should handle valid compressed Readable streams', async () => {
|
||||
const gunzip = createGunzip();
|
||||
const body = Readable.from(
|
||||
Buffer.from('1f8b08000000000000032b492d2e01000c7e7fd804000000', 'hex'),
|
||||
).pipe(gunzip);
|
||||
expect((await binaryToBuffer(body)).toString()).toEqual('test');
|
||||
});
|
||||
|
||||
it('should throw on invalid compressed Readable streams', async () => {
|
||||
const gunzip = createGunzip();
|
||||
const body = Readable.from(Buffer.from('0001f8b080000000000000000', 'hex')).pipe(gunzip);
|
||||
await expect(binaryToBuffer(body)).rejects.toThrow(
|
||||
new UnexpectedError('Failed to decompress response'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Config, Env, ExecutionsConfig } from '@n8n/config';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { InstanceSettings } from '@/instance-settings';
|
||||
import { StorageConfig } from '@/storage.config';
|
||||
|
||||
export const BINARY_DATA_MODES = ['default', 'filesystem', 's3', 'database'] as const;
|
||||
|
||||
const binaryDataModesSchema = z.enum(BINARY_DATA_MODES);
|
||||
|
||||
const availableModesSchema = z
|
||||
.string()
|
||||
.transform((value) => value.split(','))
|
||||
.pipe(binaryDataModesSchema.array());
|
||||
|
||||
const dbMaxFileSizeSchema = z.coerce
|
||||
.number()
|
||||
.max(1024, 'Binary data max file size in `database` mode cannot exceed 1024 MiB'); // because of Postgres BYTEA hard limit
|
||||
|
||||
@Config
|
||||
export class BinaryDataConfig {
|
||||
/** Available modes of binary data storage, as comma separated strings. */
|
||||
availableModes: z.infer<typeof availableModesSchema> = ['filesystem', 's3', 'database'];
|
||||
|
||||
/** Storage mode for binary data. Defaults to 'filesystem' in regular mode, 'database' in scaling mode. */
|
||||
@Env('N8N_DEFAULT_BINARY_DATA_MODE', binaryDataModesSchema)
|
||||
mode!: z.infer<typeof binaryDataModesSchema>;
|
||||
|
||||
/** Path for binary data storage in "filesystem" mode. */
|
||||
@Env('N8N_BINARY_DATA_STORAGE_PATH')
|
||||
localStoragePath: string;
|
||||
|
||||
/**
|
||||
* Secret for creating publicly-accesible signed URLs for binary data.
|
||||
* When not passed in, this will be derived from the instances's encryption-key
|
||||
**/
|
||||
@Env('N8N_BINARY_DATA_SIGNING_SECRET')
|
||||
signingSecret: string;
|
||||
|
||||
/** Maximum file size (in MiB) for binary data in `database` mode. **/
|
||||
@Env('N8N_BINARY_DATA_DATABASE_MAX_FILE_SIZE', dbMaxFileSizeSchema)
|
||||
dbMaxFileSize: number = 512;
|
||||
|
||||
constructor(
|
||||
{ encryptionKey }: InstanceSettings,
|
||||
executionsConfig: ExecutionsConfig,
|
||||
storageConfig: StorageConfig,
|
||||
) {
|
||||
/**
|
||||
* Set the binary data storage path:
|
||||
*
|
||||
* - N8N_BINARY_DATA_STORAGE_PATH, else
|
||||
* - N8N_STORAGE_PATH, else
|
||||
* - ~/.n8n/storage
|
||||
*
|
||||
* `~/.n8n/binaryData` is no longer the default if the env var is unset.
|
||||
*/
|
||||
this.localStoragePath ??= storageConfig.storagePath;
|
||||
this.signingSecret = createHash('sha256')
|
||||
.update(`url-signing:${encryptionKey}`)
|
||||
.digest('base64');
|
||||
|
||||
this.mode ??= executionsConfig.mode === 'queue' ? 'database' : 'filesystem';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { Service } from '@n8n/di';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import type { StringValue as TimeUnitValue } from 'ms';
|
||||
import { BINARY_ENCODING, UnexpectedError } from 'n8n-workflow';
|
||||
import type { INodeExecutionData, IBinaryData } from 'n8n-workflow';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import prettyBytes from 'pretty-bytes';
|
||||
import type { Readable } from 'stream';
|
||||
|
||||
import { ErrorReporter } from '@/errors';
|
||||
|
||||
import { BinaryDataConfig } from './binary-data.config';
|
||||
import type { BinaryData } from './types';
|
||||
import { binaryToBuffer } from './utils';
|
||||
import { InvalidManagerError } from '../errors/invalid-manager.error';
|
||||
|
||||
@Service()
|
||||
export class BinaryDataService {
|
||||
private mode: BinaryData.ServiceMode = 'filesystem-v2';
|
||||
|
||||
private managers: Record<string, BinaryData.Manager> = {};
|
||||
|
||||
constructor(
|
||||
private readonly config: BinaryDataConfig,
|
||||
private readonly errorReporter: ErrorReporter,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
setManager(mode: BinaryData.ServiceMode, manager: BinaryData.Manager) {
|
||||
this.managers[mode] = manager;
|
||||
}
|
||||
|
||||
async init() {
|
||||
const { config } = this;
|
||||
|
||||
this.mode = config.mode === 'filesystem' ? 'filesystem-v2' : config.mode;
|
||||
|
||||
const { FileSystemManager } = await import('./file-system.manager');
|
||||
this.managers.filesystem = new FileSystemManager(config.localStoragePath, this.errorReporter);
|
||||
this.managers['filesystem-v2'] = this.managers.filesystem;
|
||||
await this.managers.filesystem.init();
|
||||
|
||||
// DB and S3 managers are set via `setManager()` from `cli`
|
||||
}
|
||||
|
||||
createSignedToken(binaryData: IBinaryData, expiresIn: TimeUnitValue = '1 day') {
|
||||
if (!binaryData.id) {
|
||||
throw new UnexpectedError('URL signing is not available in memory mode');
|
||||
}
|
||||
|
||||
const signingPayload: BinaryData.SigningPayload = {
|
||||
id: binaryData.id,
|
||||
};
|
||||
|
||||
const { signingSecret } = this.config;
|
||||
return jwt.sign(signingPayload, signingSecret, { expiresIn });
|
||||
}
|
||||
|
||||
validateSignedToken(token: string) {
|
||||
const { signingSecret } = this.config;
|
||||
const signedPayload = jwt.verify(token, signingSecret) as BinaryData.SigningPayload;
|
||||
return signedPayload.id;
|
||||
}
|
||||
|
||||
async copyBinaryFile(
|
||||
location: BinaryData.FileLocation,
|
||||
binaryData: IBinaryData,
|
||||
filePath: string,
|
||||
) {
|
||||
const manager = this.managers[this.mode];
|
||||
|
||||
if (!manager) {
|
||||
const { size } = await stat(filePath);
|
||||
binaryData.fileSize = prettyBytes(size);
|
||||
binaryData.bytes = size;
|
||||
binaryData.data = await readFile(filePath, { encoding: BINARY_ENCODING });
|
||||
|
||||
return binaryData;
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
fileName: binaryData.fileName,
|
||||
mimeType: binaryData.mimeType,
|
||||
};
|
||||
|
||||
const { fileId, fileSize } = await manager.copyByFilePath(location, filePath, metadata);
|
||||
|
||||
binaryData.id = this.createBinaryDataId(fileId);
|
||||
binaryData.fileSize = prettyBytes(fileSize);
|
||||
binaryData.bytes = fileSize;
|
||||
binaryData.data = this.mode; // clear binary data from memory
|
||||
|
||||
return binaryData;
|
||||
}
|
||||
|
||||
async store(
|
||||
location: BinaryData.FileLocation,
|
||||
bufferOrStream: Buffer | Readable,
|
||||
binaryData: IBinaryData,
|
||||
) {
|
||||
const manager = this.managers[this.mode];
|
||||
|
||||
if (!manager) {
|
||||
const buffer = await binaryToBuffer(bufferOrStream);
|
||||
binaryData.data = buffer.toString(BINARY_ENCODING);
|
||||
binaryData.fileSize = prettyBytes(buffer.length);
|
||||
binaryData.bytes = buffer.length;
|
||||
|
||||
return binaryData;
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
fileName: binaryData.fileName,
|
||||
mimeType: binaryData.mimeType,
|
||||
};
|
||||
|
||||
const { fileId, fileSize } = await manager.store(location, bufferOrStream, metadata);
|
||||
|
||||
binaryData.id = this.createBinaryDataId(fileId);
|
||||
binaryData.fileSize = prettyBytes(fileSize);
|
||||
binaryData.bytes = fileSize;
|
||||
binaryData.data = this.mode; // clear binary data from memory
|
||||
|
||||
return binaryData;
|
||||
}
|
||||
|
||||
async getAsStream(binaryDataId: string, chunkSize?: number) {
|
||||
const [mode, fileId] = binaryDataId.split(':');
|
||||
|
||||
return await this.getManager(mode).getAsStream(fileId, chunkSize);
|
||||
}
|
||||
|
||||
async getAsBuffer(binaryData: IBinaryData) {
|
||||
if (binaryData.id) {
|
||||
const [mode, fileId] = binaryData.id.split(':');
|
||||
|
||||
return await this.getManager(mode).getAsBuffer(fileId);
|
||||
}
|
||||
|
||||
return Buffer.from(binaryData.data, BINARY_ENCODING);
|
||||
}
|
||||
|
||||
getPath(binaryDataId: string) {
|
||||
const [mode, fileId] = binaryDataId.split(':');
|
||||
|
||||
return this.getManager(mode).getPath(fileId);
|
||||
}
|
||||
|
||||
async getMetadata(binaryDataId: string) {
|
||||
const [mode, fileId] = binaryDataId.split(':');
|
||||
|
||||
return await this.getManager(mode).getMetadata(fileId);
|
||||
}
|
||||
|
||||
async deleteMany(locations: BinaryData.FileLocation[]) {
|
||||
const manager = this.managers[this.mode];
|
||||
|
||||
if (!manager) return;
|
||||
|
||||
if (manager.deleteMany) await manager.deleteMany(locations);
|
||||
}
|
||||
|
||||
async deleteManyByBinaryDataId(ids: string[]) {
|
||||
const fileIdsByMode = new Map<string, string[]>();
|
||||
|
||||
for (const attachmentId of ids) {
|
||||
const [mode, fileId] = attachmentId.split(':');
|
||||
|
||||
if (!fileId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entry = fileIdsByMode.get(mode) ?? [];
|
||||
|
||||
fileIdsByMode.set(mode, entry.concat([fileId]));
|
||||
}
|
||||
|
||||
for (const [mode, fileIds] of fileIdsByMode) {
|
||||
const manager = this.managers[mode];
|
||||
|
||||
if (!manager) {
|
||||
this.logger.info(
|
||||
`File manager of mode ${mode} is missing. Skip deleting these files: ${fileIds.join(', ')}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await manager.deleteManyByFileId?.(fileIds);
|
||||
}
|
||||
}
|
||||
|
||||
async duplicateBinaryData(
|
||||
location: BinaryData.FileLocation,
|
||||
inputData: Array<INodeExecutionData[] | null>,
|
||||
) {
|
||||
if (inputData && this.managers[this.mode]) {
|
||||
const returnInputData = (inputData as INodeExecutionData[][]).map(
|
||||
async (executionDataArray) => {
|
||||
if (executionDataArray) {
|
||||
return await Promise.all(
|
||||
executionDataArray.map(async (executionData) => {
|
||||
if (executionData.binary) {
|
||||
return await this.duplicateBinaryDataInExecData(location, executionData);
|
||||
}
|
||||
|
||||
return executionData;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return executionDataArray;
|
||||
},
|
||||
);
|
||||
|
||||
return await Promise.all(returnInputData);
|
||||
}
|
||||
|
||||
return inputData as INodeExecutionData[][];
|
||||
}
|
||||
|
||||
async rename(oldFileId: string, newFileId: string) {
|
||||
const manager = this.getManager(this.mode);
|
||||
|
||||
if (!manager) return;
|
||||
|
||||
await manager.rename(oldFileId, newFileId);
|
||||
}
|
||||
|
||||
// ----------------------------------
|
||||
// private methods
|
||||
// ----------------------------------
|
||||
|
||||
private createBinaryDataId(fileId: string) {
|
||||
return `${this.mode}:${fileId}`;
|
||||
}
|
||||
|
||||
private async duplicateBinaryDataInExecData(
|
||||
location: BinaryData.FileLocation,
|
||||
executionData: INodeExecutionData,
|
||||
) {
|
||||
const manager = this.managers[this.mode];
|
||||
|
||||
if (executionData.binary) {
|
||||
const binaryDataKeys = Object.keys(executionData.binary);
|
||||
const bdPromises = binaryDataKeys.map(async (key: string) => {
|
||||
if (!executionData.binary) {
|
||||
return { key, newId: undefined };
|
||||
}
|
||||
|
||||
const binaryDataId = executionData.binary[key].id;
|
||||
if (!binaryDataId) {
|
||||
return { key, newId: undefined };
|
||||
}
|
||||
|
||||
const [_mode, fileId] = binaryDataId.split(':');
|
||||
|
||||
return await manager?.copyByFileId(location, fileId).then((newFileId) => ({
|
||||
newId: this.createBinaryDataId(newFileId),
|
||||
key,
|
||||
}));
|
||||
});
|
||||
|
||||
return await Promise.all(bdPromises).then((b) => {
|
||||
return b.reduce((acc, curr) => {
|
||||
if (acc.binary && curr) {
|
||||
acc.binary[curr.key].id = curr.newId;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, executionData);
|
||||
});
|
||||
}
|
||||
|
||||
return executionData;
|
||||
}
|
||||
|
||||
private getManager(mode: string) {
|
||||
const manager = this.managers[mode];
|
||||
|
||||
if (manager) return manager;
|
||||
|
||||
throw new InvalidManagerError(mode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { jsonParse, UnexpectedError } from 'n8n-workflow';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { Readable } from 'stream';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import type { ErrorReporter } from '@/errors';
|
||||
|
||||
import type { BinaryData } from './types';
|
||||
import { assertDir, exists, FileLocation } from './utils';
|
||||
import { DisallowedFilepathError } from '../errors/disallowed-filepath.error';
|
||||
import { FileNotFoundError } from '../errors/file-not-found.error';
|
||||
|
||||
const EXECUTION_PATH_MATCHER = /^workflows\/([^/]+)\/executions\/([^/]+)\//;
|
||||
|
||||
export class FileSystemManager implements BinaryData.Manager {
|
||||
constructor(
|
||||
private storagePath: string,
|
||||
private readonly errorReporter: ErrorReporter,
|
||||
) {}
|
||||
|
||||
async init() {
|
||||
await assertDir(this.storagePath);
|
||||
}
|
||||
|
||||
async store(
|
||||
location: BinaryData.FileLocation,
|
||||
bufferOrStream: Buffer | Readable,
|
||||
{ mimeType, fileName }: BinaryData.PreWriteMetadata,
|
||||
) {
|
||||
const fileId = this.toFileId(location);
|
||||
const filePath = this.resolvePath(fileId);
|
||||
|
||||
await assertDir(path.dirname(filePath));
|
||||
|
||||
await fs.writeFile(filePath, bufferOrStream);
|
||||
|
||||
const fileSize = await this.getSize(fileId);
|
||||
|
||||
await this.storeMetadata(fileId, { mimeType, fileName, fileSize });
|
||||
|
||||
return { fileId, fileSize };
|
||||
}
|
||||
|
||||
getPath(fileId: string) {
|
||||
return this.resolvePath(fileId);
|
||||
}
|
||||
|
||||
async getAsStream(fileId: string, chunkSize?: number) {
|
||||
const filePath = this.resolvePath(fileId);
|
||||
|
||||
if (!(await exists(filePath))) {
|
||||
throw new FileNotFoundError(filePath);
|
||||
}
|
||||
|
||||
return createReadStream(filePath, { highWaterMark: chunkSize });
|
||||
}
|
||||
|
||||
async getAsBuffer(fileId: string) {
|
||||
const filePath = this.resolvePath(fileId);
|
||||
|
||||
if (!(await exists(filePath))) {
|
||||
throw new FileNotFoundError(filePath);
|
||||
}
|
||||
|
||||
return await fs.readFile(filePath);
|
||||
}
|
||||
|
||||
async getMetadata(fileId: string): Promise<BinaryData.Metadata> {
|
||||
const filePath = this.resolvePath(`${fileId}.metadata`);
|
||||
|
||||
return await jsonParse(await fs.readFile(filePath, { encoding: 'utf-8' }));
|
||||
}
|
||||
|
||||
async deleteMany(locations: BinaryData.FileLocation[]) {
|
||||
if (locations.length === 0) return;
|
||||
|
||||
const binaryDataDirs = locations.map((location) =>
|
||||
this.resolvePath(this.toRelativePath(location)),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
binaryDataDirs.map(async (dir) => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async copyByFilePath(
|
||||
targetLocation: BinaryData.FileLocation,
|
||||
sourcePath: string,
|
||||
{ mimeType, fileName }: BinaryData.PreWriteMetadata,
|
||||
) {
|
||||
const targetFileId = this.toFileId(targetLocation);
|
||||
const targetPath = this.resolvePath(targetFileId);
|
||||
|
||||
await assertDir(path.dirname(targetPath));
|
||||
|
||||
await fs.cp(sourcePath, targetPath);
|
||||
|
||||
const fileSize = await this.getSize(targetFileId);
|
||||
|
||||
await this.storeMetadata(targetFileId, { mimeType, fileName, fileSize });
|
||||
|
||||
return { fileId: targetFileId, fileSize };
|
||||
}
|
||||
|
||||
async copyByFileId(targetLocation: BinaryData.FileLocation, sourceFileId: string) {
|
||||
const targetFileId = this.toFileId(targetLocation);
|
||||
const sourcePath = this.resolvePath(sourceFileId);
|
||||
const targetPath = this.resolvePath(targetFileId);
|
||||
const sourceMetadata = await this.getMetadata(sourceFileId);
|
||||
|
||||
await assertDir(path.dirname(targetPath));
|
||||
|
||||
await fs.copyFile(sourcePath, targetPath);
|
||||
|
||||
await this.storeMetadata(targetFileId, sourceMetadata);
|
||||
|
||||
return targetFileId;
|
||||
}
|
||||
|
||||
async rename(oldFileId: string, newFileId: string) {
|
||||
const oldPath = this.resolvePath(oldFileId);
|
||||
const newPath = this.resolvePath(newFileId);
|
||||
|
||||
await assertDir(path.dirname(newPath));
|
||||
|
||||
await Promise.all([
|
||||
fs.rename(oldPath, newPath),
|
||||
fs.rename(`${oldPath}.metadata`, `${newPath}.metadata`),
|
||||
]);
|
||||
|
||||
const [tempDirParent] = oldPath.split('/temp/');
|
||||
const tempDir = path.join(tempDirParent, 'temp');
|
||||
|
||||
await fs.rm(tempDir, { recursive: true });
|
||||
}
|
||||
|
||||
async deleteManyByFileId(ids: string[]): Promise<void> {
|
||||
const parsedIds = ids.flatMap((id) => {
|
||||
try {
|
||||
const parsed = this.parseFileId(id);
|
||||
|
||||
return [parsed];
|
||||
} catch (e) {
|
||||
this.errorReporter.warn(`Could not parse file ID ${id}. Skip deletion`);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
await this.deleteMany(parsedIds);
|
||||
}
|
||||
|
||||
// ----------------------------------
|
||||
// private methods
|
||||
// ----------------------------------
|
||||
|
||||
private toFileId(location: BinaryData.FileLocation) {
|
||||
return `${this.toRelativePath(location)}/binary_data/${uuid()}`;
|
||||
}
|
||||
|
||||
private toRelativePath(location: BinaryData.FileLocation) {
|
||||
switch (location.type) {
|
||||
case 'execution': {
|
||||
const executionId = location.executionId || 'temp'; // missing only in edge case, see PR #7244
|
||||
return `workflows/${location.workflowId}/executions/${executionId}`;
|
||||
}
|
||||
case 'custom':
|
||||
return location.pathSegments.join('/');
|
||||
}
|
||||
}
|
||||
|
||||
private parseFileId(fileId: string): BinaryData.FileLocation {
|
||||
const executionMatch = fileId.match(EXECUTION_PATH_MATCHER);
|
||||
|
||||
if (executionMatch) {
|
||||
return FileLocation.ofExecution(executionMatch[1], executionMatch[2]);
|
||||
}
|
||||
|
||||
const binaryDataIndex = fileId.indexOf('/binary_data/');
|
||||
if (binaryDataIndex !== -1) {
|
||||
const pathSegments = fileId.substring(0, binaryDataIndex).split('/');
|
||||
return FileLocation.ofCustom({ pathSegments });
|
||||
}
|
||||
|
||||
throw new UnexpectedError(`File ID ${fileId} has invalid format.`);
|
||||
}
|
||||
|
||||
private resolvePath(...args: string[]) {
|
||||
const returnPath = path.join(this.storagePath, ...args);
|
||||
|
||||
if (path.relative(this.storagePath, returnPath).startsWith('..')) {
|
||||
throw new DisallowedFilepathError(returnPath);
|
||||
}
|
||||
|
||||
return returnPath;
|
||||
}
|
||||
|
||||
private async storeMetadata(fileId: string, metadata: BinaryData.Metadata) {
|
||||
const filePath = this.resolvePath(`${fileId}.metadata`);
|
||||
|
||||
await fs.writeFile(filePath, JSON.stringify(metadata), { encoding: 'utf-8' });
|
||||
}
|
||||
|
||||
private async getSize(fileId: string) {
|
||||
const filePath = this.resolvePath(fileId);
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(filePath);
|
||||
return stats.size;
|
||||
} catch (error) {
|
||||
throw new FileNotFoundError(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './binary-data.service';
|
||||
export { BinaryDataConfig } from './binary-data.config';
|
||||
export type * from './types';
|
||||
export { isStoredMode as isValidNonDefaultMode, FileLocation, binaryToBuffer } from './utils';
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Service } from '@n8n/di';
|
||||
import fs from 'node:fs/promises';
|
||||
import type { Readable } from 'node:stream';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { ObjectStoreService } from './object-store/object-store.service.ee';
|
||||
import type { BinaryData } from './types';
|
||||
import { binaryToBuffer } from './utils';
|
||||
|
||||
@Service()
|
||||
export class ObjectStoreManager implements BinaryData.Manager {
|
||||
constructor(private readonly objectStoreService: ObjectStoreService) {}
|
||||
|
||||
async init() {
|
||||
await this.objectStoreService.checkConnection();
|
||||
}
|
||||
|
||||
async store(
|
||||
location: BinaryData.FileLocation,
|
||||
bufferOrStream: Buffer | Readable,
|
||||
metadata: BinaryData.PreWriteMetadata,
|
||||
) {
|
||||
const fileId = this.toFileId(location);
|
||||
const buffer = await binaryToBuffer(bufferOrStream);
|
||||
|
||||
await this.objectStoreService.put(fileId, buffer, metadata);
|
||||
|
||||
return { fileId, fileSize: buffer.length };
|
||||
}
|
||||
|
||||
getPath(fileId: string) {
|
||||
return fileId; // already full path, no transform needed
|
||||
}
|
||||
|
||||
async getAsBuffer(fileId: string) {
|
||||
return await this.objectStoreService.get(fileId, { mode: 'buffer' });
|
||||
}
|
||||
|
||||
async getAsStream(fileId: string) {
|
||||
return await this.objectStoreService.get(fileId, { mode: 'stream' });
|
||||
}
|
||||
|
||||
async getMetadata(fileId: string): Promise<BinaryData.Metadata> {
|
||||
const {
|
||||
'content-length': contentLength,
|
||||
'content-type': contentType,
|
||||
'x-amz-meta-filename': fileName,
|
||||
} = await this.objectStoreService.getMetadata(fileId);
|
||||
|
||||
const metadata: BinaryData.Metadata = { fileSize: Number(contentLength) };
|
||||
|
||||
if (contentType) metadata.mimeType = contentType;
|
||||
if (fileName) metadata.fileName = fileName;
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
async copyByFileId(targetLocation: BinaryData.FileLocation, sourceFileId: string) {
|
||||
const targetFileId = this.toFileId(targetLocation);
|
||||
|
||||
const sourceFile = await this.objectStoreService.get(sourceFileId, { mode: 'buffer' });
|
||||
|
||||
await this.objectStoreService.put(targetFileId, sourceFile);
|
||||
|
||||
return targetFileId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy to object store the temp file written by nodes like Webhook, FTP, and SSH.
|
||||
*/
|
||||
async copyByFilePath(
|
||||
targetLocation: BinaryData.FileLocation,
|
||||
sourcePath: string,
|
||||
metadata: BinaryData.PreWriteMetadata,
|
||||
) {
|
||||
const targetFileId = this.toFileId(targetLocation);
|
||||
const sourceFile = await fs.readFile(sourcePath);
|
||||
|
||||
await this.objectStoreService.put(targetFileId, sourceFile, metadata);
|
||||
|
||||
return { fileId: targetFileId, fileSize: sourceFile.length };
|
||||
}
|
||||
|
||||
async rename(oldFileId: string, newFileId: string) {
|
||||
const oldFile = await this.objectStoreService.get(oldFileId, { mode: 'buffer' });
|
||||
const oldFileMetadata = await this.objectStoreService.getMetadata(oldFileId);
|
||||
|
||||
await this.objectStoreService.put(newFileId, oldFile, oldFileMetadata);
|
||||
await this.objectStoreService.deleteOne(oldFileId);
|
||||
}
|
||||
|
||||
// ----------------------------------
|
||||
// private methods
|
||||
// ----------------------------------
|
||||
|
||||
private toFileId(location: BinaryData.FileLocation) {
|
||||
switch (location.type) {
|
||||
case 'execution': {
|
||||
const executionId = location.executionId || 'temp'; // missing only in edge case, see PR #7244
|
||||
return `workflows/${location.workflowId}/executions/${executionId}/binary_data/${uuid()}`;
|
||||
}
|
||||
case 'custom':
|
||||
return `${location.pathSegments.join('/')}/binary_data/${uuid()}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
DeleteObjectsCommand,
|
||||
GetObjectCommand,
|
||||
HeadBucketCommand,
|
||||
HeadObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
type S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { captor, mock } from 'jest-mock-extended';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import type { ObjectStoreConfig } from '../object-store.config';
|
||||
import { ObjectStoreService } from '../object-store.service.ee';
|
||||
|
||||
const mockS3Send = jest.fn();
|
||||
const s3Client = mock<S3Client>({ send: mockS3Send });
|
||||
jest.mock('@aws-sdk/client-s3', () => ({
|
||||
...jest.requireActual('@aws-sdk/client-s3'),
|
||||
S3Client: class {
|
||||
constructor() {
|
||||
return s3Client;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ObjectStoreService', () => {
|
||||
const mockBucket = { region: 'us-east-1', name: 'test-bucket' };
|
||||
const mockHost = `s3.${mockBucket.region}.amazonaws.com`;
|
||||
const FAILED_REQUEST_ERROR_MESSAGE = 'Request to S3 failed';
|
||||
const mockError = new Error('Something went wrong!');
|
||||
const workflowId = 'workflow-id';
|
||||
const executionId = 999;
|
||||
const binaryDataId = '71f6209b-5d48-41a2-a224-80d529d8bb32';
|
||||
const fileId = `workflows/${workflowId}/executions/${executionId}/binary_data/${binaryDataId}`;
|
||||
const mockBuffer = Buffer.from('Test data');
|
||||
const s3Config = mock<ObjectStoreConfig>({
|
||||
host: mockHost,
|
||||
bucket: mockBucket,
|
||||
credentials: {
|
||||
accessKey: 'mock-access-key',
|
||||
accessSecret: 'mock-secret-key',
|
||||
authAutoDetect: false,
|
||||
},
|
||||
protocol: 'https',
|
||||
});
|
||||
|
||||
let objectStoreService: ObjectStoreService;
|
||||
|
||||
const now = new Date('2024-02-01T01:23:45.678Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
beforeEach(async () => {
|
||||
objectStoreService = new ObjectStoreService(mock(), s3Config);
|
||||
await objectStoreService.init();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('getClientConfig()', () => {
|
||||
const credentials = {
|
||||
accessKeyId: s3Config.credentials.accessKey,
|
||||
secretAccessKey: s3Config.credentials.accessSecret,
|
||||
};
|
||||
|
||||
it('should return client config with endpoint and forcePathStyle when custom host is provided', () => {
|
||||
s3Config.host = 'example.com';
|
||||
|
||||
const clientConfig = objectStoreService.getClientConfig();
|
||||
|
||||
expect(clientConfig).toEqual({
|
||||
endpoint: 'https://example.com',
|
||||
forcePathStyle: true,
|
||||
region: mockBucket.region,
|
||||
credentials,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return client config without endpoint when host is not provided', () => {
|
||||
s3Config.host = '';
|
||||
|
||||
const clientConfig = objectStoreService.getClientConfig();
|
||||
|
||||
expect(clientConfig).toEqual({
|
||||
region: mockBucket.region,
|
||||
credentials,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return client config without credentials when authAutoDetect is true', () => {
|
||||
s3Config.credentials.authAutoDetect = true;
|
||||
|
||||
const clientConfig = objectStoreService.getClientConfig();
|
||||
|
||||
expect(clientConfig).toEqual({
|
||||
region: mockBucket.region,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkConnection()', () => {
|
||||
it('should send a HEAD request to the correct bucket', async () => {
|
||||
mockS3Send.mockResolvedValueOnce({});
|
||||
|
||||
objectStoreService.setReady(false);
|
||||
|
||||
await objectStoreService.checkConnection();
|
||||
|
||||
const commandCaptor = captor<HeadObjectCommand>();
|
||||
expect(mockS3Send).toHaveBeenCalledWith(commandCaptor);
|
||||
const command = commandCaptor.value;
|
||||
expect(command).toBeInstanceOf(HeadBucketCommand);
|
||||
expect(command.input).toEqual({ Bucket: 'test-bucket' });
|
||||
});
|
||||
|
||||
it('should throw an error on request failure', async () => {
|
||||
objectStoreService.setReady(false);
|
||||
|
||||
mockS3Send.mockRejectedValueOnce(mockError);
|
||||
|
||||
const promise = objectStoreService.checkConnection();
|
||||
|
||||
await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMetadata()', () => {
|
||||
it('should send a HEAD request to the correct bucket and key', async () => {
|
||||
mockS3Send.mockResolvedValueOnce({
|
||||
ContentType: 'text/plain',
|
||||
ContentLength: 1024,
|
||||
ETag: '"abc123"',
|
||||
LastModified: new Date(),
|
||||
Metadata: { filename: 'test.txt' },
|
||||
});
|
||||
|
||||
await objectStoreService.getMetadata(fileId);
|
||||
|
||||
const commandCaptor = captor<HeadObjectCommand>();
|
||||
expect(mockS3Send).toHaveBeenCalledWith(commandCaptor);
|
||||
const command = commandCaptor.value;
|
||||
expect(command).toBeInstanceOf(HeadObjectCommand);
|
||||
expect(command.input).toEqual({
|
||||
Bucket: 'test-bucket',
|
||||
Key: fileId,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error on request failure', async () => {
|
||||
mockS3Send.mockRejectedValueOnce(mockError);
|
||||
|
||||
const promise = objectStoreService.getMetadata(fileId);
|
||||
|
||||
await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('put()', () => {
|
||||
it('should send a PUT request to upload an object', async () => {
|
||||
const metadata = { fileName: 'file.txt', mimeType: 'text/plain' };
|
||||
|
||||
mockS3Send.mockResolvedValueOnce({});
|
||||
|
||||
await objectStoreService.put(fileId, mockBuffer, metadata);
|
||||
|
||||
const commandCaptor = captor<PutObjectCommand>();
|
||||
expect(mockS3Send).toHaveBeenCalledWith(commandCaptor);
|
||||
const command = commandCaptor.value;
|
||||
expect(command).toBeInstanceOf(PutObjectCommand);
|
||||
expect(command.input).toEqual({
|
||||
Bucket: 'test-bucket',
|
||||
Key: fileId,
|
||||
Body: mockBuffer,
|
||||
ContentLength: mockBuffer.length,
|
||||
ContentMD5: 'yh6gLBC3w39CW5t92G1eEQ==',
|
||||
ContentType: 'text/plain',
|
||||
Metadata: { filename: 'file.txt' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should encode filename with non-ASCII characters in metadata', async () => {
|
||||
const metadata = {
|
||||
fileName: 'Order Form - Gunes Ekspres Havacılık A.Ş.',
|
||||
mimeType: 'text/plain',
|
||||
};
|
||||
|
||||
mockS3Send.mockResolvedValueOnce({});
|
||||
|
||||
await objectStoreService.put(fileId, mockBuffer, metadata);
|
||||
|
||||
const commandCaptor = captor<PutObjectCommand>();
|
||||
expect(mockS3Send).toHaveBeenCalledWith(commandCaptor);
|
||||
const command = commandCaptor.value;
|
||||
expect(command).toBeInstanceOf(PutObjectCommand);
|
||||
expect(command.input).toEqual({
|
||||
Bucket: 'test-bucket',
|
||||
Key: fileId,
|
||||
Body: mockBuffer,
|
||||
ContentLength: mockBuffer.length,
|
||||
ContentMD5: 'yh6gLBC3w39CW5t92G1eEQ==',
|
||||
ContentType: 'text/plain',
|
||||
Metadata: {
|
||||
filename: 'Order%20Form%20-%20Gunes%20Ekspres%20Havac%C4%B1l%C4%B1k%20A.%C5%9E.',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error on request failure', async () => {
|
||||
const metadata = { fileName: 'file.txt', mimeType: 'text/plain' };
|
||||
|
||||
mockS3Send.mockRejectedValueOnce(mockError);
|
||||
|
||||
const promise = objectStoreService.put(fileId, mockBuffer, metadata);
|
||||
|
||||
await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get()', () => {
|
||||
it('should send a GET request to download an object as a buffer', async () => {
|
||||
const fileId = 'file.txt';
|
||||
const body = Readable.from(mockBuffer);
|
||||
|
||||
mockS3Send.mockResolvedValueOnce({ Body: body });
|
||||
|
||||
const result = await objectStoreService.get(fileId, { mode: 'buffer' });
|
||||
|
||||
const commandCaptor = captor<GetObjectCommand>();
|
||||
expect(mockS3Send).toHaveBeenCalledWith(commandCaptor);
|
||||
const command = commandCaptor.value;
|
||||
expect(command).toBeInstanceOf(GetObjectCommand);
|
||||
expect(command.input).toEqual({
|
||||
Bucket: 'test-bucket',
|
||||
Key: fileId,
|
||||
});
|
||||
|
||||
expect(Buffer.isBuffer(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('should send a GET request to download an object as a stream', async () => {
|
||||
const body = new Readable();
|
||||
|
||||
mockS3Send.mockResolvedValueOnce({ Body: body });
|
||||
|
||||
const result = await objectStoreService.get(fileId, { mode: 'stream' });
|
||||
|
||||
const commandCaptor = captor<GetObjectCommand>();
|
||||
expect(mockS3Send).toHaveBeenCalledWith(commandCaptor);
|
||||
const command = commandCaptor.value;
|
||||
expect(command).toBeInstanceOf(GetObjectCommand);
|
||||
expect(command.input).toEqual({
|
||||
Bucket: 'test-bucket',
|
||||
Key: fileId,
|
||||
});
|
||||
|
||||
expect(result instanceof Readable).toBe(true);
|
||||
expect(result).toBe(body);
|
||||
});
|
||||
|
||||
it('should throw an error on request failure', async () => {
|
||||
mockS3Send.mockRejectedValueOnce(mockError);
|
||||
|
||||
const promise = objectStoreService.get(fileId, { mode: 'buffer' });
|
||||
|
||||
await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteOne()', () => {
|
||||
it('should send a DELETE request to delete a single object', async () => {
|
||||
mockS3Send.mockResolvedValueOnce({});
|
||||
|
||||
await objectStoreService.deleteOne(fileId);
|
||||
|
||||
const commandCaptor = captor<DeleteObjectCommand>();
|
||||
expect(mockS3Send).toHaveBeenCalledWith(commandCaptor);
|
||||
const command = commandCaptor.value;
|
||||
expect(command).toBeInstanceOf(DeleteObjectCommand);
|
||||
expect(command.input).toEqual({
|
||||
Bucket: 'test-bucket',
|
||||
Key: fileId,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error on request failure', async () => {
|
||||
mockS3Send.mockRejectedValueOnce(mockError);
|
||||
|
||||
const promise = objectStoreService.deleteOne(fileId);
|
||||
|
||||
await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteMany()', () => {
|
||||
it('should send a DELETE request to delete multiple objects', async () => {
|
||||
const prefix = 'test-dir/';
|
||||
const fileName = 'file.txt';
|
||||
|
||||
const mockList = [
|
||||
{
|
||||
key: fileName,
|
||||
lastModified: '2023-09-24T12:34:56Z',
|
||||
eTag: 'abc123def456',
|
||||
size: 456789,
|
||||
storageClass: 'STANDARD',
|
||||
},
|
||||
];
|
||||
|
||||
objectStoreService.list = jest.fn().mockResolvedValue(mockList);
|
||||
mockS3Send.mockResolvedValueOnce({});
|
||||
|
||||
await objectStoreService.deleteMany(prefix);
|
||||
|
||||
const commandCaptor = captor<DeleteObjectsCommand>();
|
||||
expect(mockS3Send).toHaveBeenCalledWith(commandCaptor);
|
||||
const command = commandCaptor.value;
|
||||
expect(command).toBeInstanceOf(DeleteObjectsCommand);
|
||||
expect(command.input).toEqual({
|
||||
Bucket: 'test-bucket',
|
||||
Delete: {
|
||||
Objects: [{ Key: fileName }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not send a deletion request if no prefix match', async () => {
|
||||
objectStoreService.list = jest.fn().mockResolvedValue([]);
|
||||
|
||||
const result = await objectStoreService.deleteMany('non-matching-prefix');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockS3Send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error on request failure', async () => {
|
||||
objectStoreService.list = jest.fn().mockResolvedValue([{ key: 'file.txt' }]);
|
||||
mockS3Send.mockRejectedValueOnce(mockError);
|
||||
|
||||
const promise = objectStoreService.deleteMany('test-dir/');
|
||||
|
||||
await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list()', () => {
|
||||
it('should list objects with a common prefix', async () => {
|
||||
const prefix = 'test-dir/';
|
||||
|
||||
const mockListPage = {
|
||||
contents: [{ key: `${prefix}file1.txt` }, { key: `${prefix}file2.txt` }],
|
||||
isTruncated: false,
|
||||
};
|
||||
|
||||
objectStoreService.getListPage = jest.fn().mockResolvedValue(mockListPage);
|
||||
|
||||
const result = await objectStoreService.list(prefix);
|
||||
|
||||
expect(result).toEqual(mockListPage.contents);
|
||||
});
|
||||
|
||||
it('should consolidate pages', async () => {
|
||||
const prefix = 'test-dir/';
|
||||
|
||||
const mockFirstListPage = {
|
||||
contents: [{ key: `${prefix}file1.txt` }],
|
||||
isTruncated: true,
|
||||
nextContinuationToken: 'token1',
|
||||
};
|
||||
|
||||
const mockSecondListPage = {
|
||||
contents: [{ key: `${prefix}file2.txt` }],
|
||||
isTruncated: false,
|
||||
};
|
||||
|
||||
objectStoreService.getListPage = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(mockFirstListPage)
|
||||
.mockResolvedValueOnce(mockSecondListPage);
|
||||
|
||||
const result = await objectStoreService.list(prefix);
|
||||
|
||||
expect(result).toEqual([...mockFirstListPage.contents, ...mockSecondListPage.contents]);
|
||||
});
|
||||
|
||||
it('should throw an error on request failure', async () => {
|
||||
objectStoreService.getListPage = jest.fn().mockRejectedValueOnce(mockError);
|
||||
|
||||
const promise = objectStoreService.list('test-dir/');
|
||||
|
||||
await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getListPage()', () => {
|
||||
it('should fetch a page of objects with a common prefix', async () => {
|
||||
const prefix = 'test-dir/';
|
||||
const mockContents = [
|
||||
{
|
||||
Key: `${prefix}file1.txt`,
|
||||
LastModified: new Date(),
|
||||
ETag: '"abc123"',
|
||||
Size: 123,
|
||||
StorageClass: 'STANDARD',
|
||||
},
|
||||
];
|
||||
|
||||
mockS3Send.mockResolvedValueOnce({
|
||||
Contents: mockContents,
|
||||
IsTruncated: false,
|
||||
});
|
||||
|
||||
const result = await objectStoreService.getListPage(prefix);
|
||||
|
||||
const commandCaptor = captor<ListObjectsV2Command>();
|
||||
expect(mockS3Send).toHaveBeenCalledWith(commandCaptor);
|
||||
const command = commandCaptor.value;
|
||||
expect(command).toBeInstanceOf(ListObjectsV2Command);
|
||||
expect(command.input).toEqual({
|
||||
Bucket: 'test-bucket',
|
||||
Prefix: prefix,
|
||||
});
|
||||
|
||||
expect(result.contents).toHaveLength(1);
|
||||
expect(result.isTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it('should use continuation token when provided', async () => {
|
||||
const prefix = 'test-dir/';
|
||||
const token = 'next-page-token';
|
||||
|
||||
mockS3Send.mockResolvedValueOnce({
|
||||
Contents: [],
|
||||
IsTruncated: false,
|
||||
});
|
||||
|
||||
await objectStoreService.getListPage(prefix, token);
|
||||
|
||||
const commandCaptor = captor<ListObjectsV2Command>();
|
||||
expect(mockS3Send).toHaveBeenCalledWith(commandCaptor);
|
||||
const command = commandCaptor.value;
|
||||
expect(command.input).toEqual({
|
||||
Bucket: 'test-bucket',
|
||||
Prefix: prefix,
|
||||
ContinuationToken: token,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error on request failure', async () => {
|
||||
mockS3Send.mockRejectedValueOnce(mockError);
|
||||
|
||||
const promise = objectStoreService.getListPage('test-dir/');
|
||||
|
||||
await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Config, Env, Nested } from '@n8n/config';
|
||||
import { z } from 'zod';
|
||||
|
||||
const protocolSchema = z.enum(['http', 'https']);
|
||||
|
||||
export type Protocol = z.infer<typeof protocolSchema>;
|
||||
|
||||
@Config
|
||||
class ObjectStoreBucketConfig {
|
||||
/** Name of the n8n bucket in S3-compatible external storage */
|
||||
@Env('N8N_EXTERNAL_STORAGE_S3_BUCKET_NAME')
|
||||
name: string = '';
|
||||
|
||||
/** Region of the n8n bucket in S3-compatible external storage @example "us-east-1" */
|
||||
@Env('N8N_EXTERNAL_STORAGE_S3_BUCKET_REGION')
|
||||
region: string = '';
|
||||
}
|
||||
|
||||
@Config
|
||||
class ObjectStoreCredentialsConfig {
|
||||
/** Access key in S3-compatible external storage */
|
||||
@Env('N8N_EXTERNAL_STORAGE_S3_ACCESS_KEY')
|
||||
accessKey: string = '';
|
||||
|
||||
/** Access secret in S3-compatible external storage */
|
||||
@Env('N8N_EXTERNAL_STORAGE_S3_ACCESS_SECRET')
|
||||
accessSecret: string = '';
|
||||
|
||||
/**
|
||||
* Use automatic credential detection to authenticate S3 calls for external storage
|
||||
* This will ignore accessKey/accessSecret and use the default credential provider chain
|
||||
* https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html#credchain
|
||||
*/
|
||||
@Env('N8N_EXTERNAL_STORAGE_S3_AUTH_AUTO_DETECT')
|
||||
authAutoDetect: boolean = false;
|
||||
}
|
||||
|
||||
@Config
|
||||
export class ObjectStoreConfig {
|
||||
/**
|
||||
* Host of the object-store bucket in S3-compatible external storage
|
||||
* @example "s3.us-east-1.amazonaws.com"
|
||||
**/
|
||||
@Env('N8N_EXTERNAL_STORAGE_S3_HOST')
|
||||
host: string = '';
|
||||
|
||||
@Env('N8N_EXTERNAL_STORAGE_S3_PROTOCOL', protocolSchema)
|
||||
protocol: Protocol = 'https';
|
||||
|
||||
@Nested
|
||||
bucket: ObjectStoreBucketConfig = {} as ObjectStoreBucketConfig;
|
||||
|
||||
@Nested
|
||||
credentials: ObjectStoreCredentialsConfig = {} as ObjectStoreCredentialsConfig;
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import type {
|
||||
PutObjectCommandInput,
|
||||
DeleteObjectsCommandInput,
|
||||
ListObjectsV2CommandInput,
|
||||
S3ClientConfig,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import {
|
||||
S3Client,
|
||||
HeadBucketCommand,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
DeleteObjectsCommand,
|
||||
ListObjectsV2Command,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { Service } from '@n8n/di';
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { ObjectStoreConfig } from './object-store.config';
|
||||
import type { MetadataResponseHeaders } from './types';
|
||||
import type { BinaryData } from '../types';
|
||||
import { streamToBuffer } from '../utils';
|
||||
|
||||
@Service()
|
||||
export class ObjectStoreService {
|
||||
private s3Client: S3Client;
|
||||
|
||||
private isReady = false;
|
||||
|
||||
private bucket: string;
|
||||
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly s3Config: ObjectStoreConfig,
|
||||
) {
|
||||
const { bucket } = s3Config;
|
||||
if (bucket.name === '') {
|
||||
throw new UnexpectedError(
|
||||
'External storage bucket name not configured. Please set `N8N_EXTERNAL_STORAGE_S3_BUCKET_NAME`.',
|
||||
);
|
||||
}
|
||||
|
||||
this.bucket = bucket.name;
|
||||
this.s3Client = new S3Client(this.getClientConfig());
|
||||
}
|
||||
|
||||
/** This generates the config for the S3Client to make it work in all various auth configurations */
|
||||
getClientConfig() {
|
||||
const { host, bucket, protocol, credentials } = this.s3Config;
|
||||
const clientConfig: S3ClientConfig = {};
|
||||
const endpoint = host ? `${protocol}://${host}` : undefined;
|
||||
if (endpoint) {
|
||||
clientConfig.endpoint = endpoint;
|
||||
clientConfig.forcePathStyle = true; // Needed for non-AWS S3 compatible services
|
||||
}
|
||||
if (bucket.region.length) {
|
||||
clientConfig.region = bucket.region;
|
||||
}
|
||||
if (!credentials.authAutoDetect) {
|
||||
clientConfig.credentials = {
|
||||
accessKeyId: credentials.accessKey,
|
||||
secretAccessKey: credentials.accessSecret,
|
||||
};
|
||||
}
|
||||
return clientConfig;
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.checkConnection();
|
||||
this.setReady(true);
|
||||
}
|
||||
|
||||
setReady(newState: boolean) {
|
||||
this.isReady = newState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm that the configured bucket exists and the caller has permission to access it.
|
||||
*/
|
||||
async checkConnection() {
|
||||
if (this.isReady) return;
|
||||
|
||||
try {
|
||||
this.logger.debug('Checking connection to S3 bucket', { bucket: this.bucket });
|
||||
const command = new HeadBucketCommand({ Bucket: this.bucket });
|
||||
await this.s3Client.send(command);
|
||||
} catch (e) {
|
||||
throw new UnexpectedError('Request to S3 failed', { cause: e });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an object to the configured bucket.
|
||||
*/
|
||||
async put(filename: string, buffer: Buffer, metadata: BinaryData.PreWriteMetadata = {}) {
|
||||
try {
|
||||
const params: PutObjectCommandInput = {
|
||||
Bucket: this.bucket,
|
||||
Key: filename,
|
||||
Body: buffer,
|
||||
ContentLength: buffer.length,
|
||||
ContentMD5: createHash('md5').update(buffer).digest('base64'),
|
||||
};
|
||||
|
||||
if (metadata.fileName) {
|
||||
params.Metadata = { filename: encodeURIComponent(metadata.fileName) };
|
||||
}
|
||||
|
||||
if (metadata.mimeType) {
|
||||
params.ContentType = metadata.mimeType;
|
||||
}
|
||||
|
||||
const { Body: _body, ...logParams } = params;
|
||||
this.logger.debug('Sending PUT request to S3', { params: logParams });
|
||||
const command = new PutObjectCommand(params);
|
||||
return await this.s3Client.send(command);
|
||||
} catch (e) {
|
||||
throw new UnexpectedError('Request to S3 failed', { cause: e });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download an object as a stream or buffer from the configured bucket.
|
||||
*/
|
||||
async get(fileId: string, { mode }: { mode: 'buffer' }): Promise<Buffer>;
|
||||
async get(fileId: string, { mode }: { mode: 'stream' }): Promise<Readable>;
|
||||
async get(fileId: string, { mode }: { mode: 'stream' | 'buffer' }): Promise<Buffer | Readable> {
|
||||
this.logger.debug('Sending GET request to S3', { bucket: this.bucket, key: fileId });
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: fileId,
|
||||
});
|
||||
|
||||
try {
|
||||
const { Body: body } = await this.s3Client.send(command);
|
||||
if (!body) throw new UnexpectedError('Received empty response body');
|
||||
|
||||
if (mode === 'stream') {
|
||||
if (body instanceof Readable) return body;
|
||||
throw new UnexpectedError(`Expected stream but received ${typeof body}.`);
|
||||
}
|
||||
|
||||
return await streamToBuffer(body as Readable);
|
||||
} catch (e) {
|
||||
throw new UnexpectedError('Request to S3 failed', { cause: e });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve metadata for an object in the configured bucket.
|
||||
*/
|
||||
async getMetadata(fileId: string): Promise<MetadataResponseHeaders> {
|
||||
try {
|
||||
const command = new HeadObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: fileId,
|
||||
});
|
||||
|
||||
this.logger.debug('Sending HEAD request to S3', { bucket: this.bucket, key: fileId });
|
||||
const response = await this.s3Client.send(command);
|
||||
|
||||
// Convert response to the expected format for backward compatibility
|
||||
const headers: MetadataResponseHeaders = {};
|
||||
|
||||
if (response.ContentType) headers['content-type'] = response.ContentType;
|
||||
if (response.ContentLength) headers['content-length'] = String(response.ContentLength);
|
||||
if (response.ETag) headers.etag = response.ETag;
|
||||
if (response.LastModified) headers['last-modified'] = response.LastModified.toUTCString();
|
||||
|
||||
// Add metadata with the expected prefix format
|
||||
if (response.Metadata) {
|
||||
Object.entries(response.Metadata).forEach(([key, value]) => {
|
||||
headers[`x-amz-meta-${key.toLowerCase()}`] =
|
||||
key === 'filename' ? decodeURIComponent(value) : value;
|
||||
});
|
||||
}
|
||||
|
||||
return headers;
|
||||
} catch (e) {
|
||||
throw new UnexpectedError('Request to S3 failed', { cause: e });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single object in the configured bucket.
|
||||
*/
|
||||
async deleteOne(fileId: string) {
|
||||
try {
|
||||
const command = new DeleteObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: fileId,
|
||||
});
|
||||
|
||||
this.logger.debug('Sending DELETE request to S3', { bucket: this.bucket, key: fileId });
|
||||
return await this.s3Client.send(command);
|
||||
} catch (e) {
|
||||
throw new UnexpectedError('Request to S3 failed', { cause: e });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete objects with a common prefix in the configured bucket.
|
||||
*/
|
||||
async deleteMany(prefix: string) {
|
||||
try {
|
||||
const objects = await this.list(prefix);
|
||||
|
||||
if (objects.length === 0) return;
|
||||
|
||||
const params: DeleteObjectsCommandInput = {
|
||||
Bucket: this.bucket,
|
||||
Delete: {
|
||||
Objects: objects.map(({ key }) => ({ Key: key })),
|
||||
},
|
||||
};
|
||||
|
||||
this.logger.debug('Sending DELETE MANY request to S3', {
|
||||
bucket: this.bucket,
|
||||
objectCount: objects.length,
|
||||
});
|
||||
|
||||
const command = new DeleteObjectsCommand(params);
|
||||
return await this.s3Client.send(command);
|
||||
} catch (e) {
|
||||
throw new UnexpectedError('Request to S3 failed', { cause: e });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List objects with a common prefix in the configured bucket.
|
||||
*/
|
||||
async list(prefix: string) {
|
||||
const items = [];
|
||||
let isTruncated = true;
|
||||
let continuationToken;
|
||||
|
||||
try {
|
||||
while (isTruncated) {
|
||||
const listPage = await this.getListPage(prefix, continuationToken);
|
||||
|
||||
if (listPage.contents?.length > 0) {
|
||||
items.push(...listPage.contents);
|
||||
}
|
||||
|
||||
isTruncated = listPage.isTruncated;
|
||||
continuationToken = listPage.nextContinuationToken;
|
||||
}
|
||||
|
||||
return items;
|
||||
} catch (e) {
|
||||
throw new UnexpectedError('Request to S3 failed', { cause: e });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a page of objects with a common prefix in the configured bucket.
|
||||
*/
|
||||
async getListPage(prefix: string, continuationToken?: string) {
|
||||
try {
|
||||
const params: ListObjectsV2CommandInput = {
|
||||
Bucket: this.bucket,
|
||||
Prefix: prefix,
|
||||
};
|
||||
|
||||
if (continuationToken) {
|
||||
params.ContinuationToken = continuationToken;
|
||||
}
|
||||
|
||||
this.logger.debug('Sending list request to S3', { bucket: this.bucket, prefix });
|
||||
const command = new ListObjectsV2Command(params);
|
||||
const response = await this.s3Client.send(command);
|
||||
|
||||
// Convert response to match expected format for compatibility
|
||||
const contents =
|
||||
response.Contents?.map((item) => ({
|
||||
key: item.Key ?? '',
|
||||
lastModified: item.LastModified?.toISOString() ?? '',
|
||||
eTag: item.ETag ?? '',
|
||||
size: item.Size ?? 0,
|
||||
storageClass: item.StorageClass ?? '',
|
||||
})) ?? [];
|
||||
|
||||
return {
|
||||
contents,
|
||||
isTruncated: response.IsTruncated ?? false,
|
||||
nextContinuationToken: response.NextContinuationToken,
|
||||
};
|
||||
} catch (e) {
|
||||
throw new UnexpectedError('Request to S3 failed', { cause: e });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { BinaryData } from '../types';
|
||||
|
||||
export type MetadataResponseHeaders = Record<string, string> & {
|
||||
'content-length'?: string;
|
||||
'content-type'?: string;
|
||||
'x-amz-meta-filename'?: string;
|
||||
etag?: string;
|
||||
'last-modified'?: string;
|
||||
} & BinaryData.PreWriteMetadata;
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Readable } from 'stream';
|
||||
|
||||
import type { BINARY_DATA_MODES } from './binary-data.config';
|
||||
|
||||
export namespace BinaryData {
|
||||
type LegacyMode = 'filesystem';
|
||||
|
||||
type UpgradedMode = 'filesystem-v2';
|
||||
|
||||
/**
|
||||
* Binary data mode selectable by user via env var config.
|
||||
*/
|
||||
export type ConfigMode = (typeof BINARY_DATA_MODES)[number];
|
||||
|
||||
/**
|
||||
* Binary data mode used internally by binary data service. User-selected
|
||||
* legacy modes are replaced with upgraded modes.
|
||||
*/
|
||||
export type ServiceMode = Exclude<ConfigMode, LegacyMode> | UpgradedMode;
|
||||
|
||||
/**
|
||||
* Binary data mode in binary data ID in stored execution data. Both legacy
|
||||
* and upgraded modes may be present, except default in-memory mode.
|
||||
*/
|
||||
export type StoredMode = Exclude<ConfigMode | UpgradedMode, 'default'>;
|
||||
|
||||
export type Metadata = {
|
||||
fileName?: string;
|
||||
mimeType?: string;
|
||||
fileSize: number;
|
||||
};
|
||||
|
||||
export type WriteResult = { fileId: string; fileSize: number };
|
||||
|
||||
export type PreWriteMetadata = Omit<Metadata, 'fileSize'>;
|
||||
|
||||
export type FileLocation =
|
||||
| { type: 'execution'; workflowId: string; executionId: string }
|
||||
| { type: 'custom'; pathSegments: string[]; sourceType?: string; sourceId?: string };
|
||||
|
||||
export interface Manager {
|
||||
init(): Promise<void>;
|
||||
|
||||
store(
|
||||
location: FileLocation,
|
||||
bufferOrStream: Buffer | Readable,
|
||||
metadata: PreWriteMetadata,
|
||||
): Promise<WriteResult>;
|
||||
|
||||
getPath(fileId: string): string;
|
||||
getAsBuffer(fileId: string): Promise<Buffer>;
|
||||
getAsStream(fileId: string, chunkSize?: number): Promise<Readable>;
|
||||
getMetadata(fileId: string): Promise<Metadata>;
|
||||
|
||||
/**
|
||||
* Present for `FileSystem`, absent for `ObjectStore` (delegated to S3 lifecycle config)
|
||||
*/
|
||||
deleteMany?(locations: FileLocation[]): Promise<void>;
|
||||
deleteManyByFileId?(ids: string[]): Promise<void>;
|
||||
|
||||
copyByFileId(targetLocation: FileLocation, sourceFileId: string): Promise<string>;
|
||||
copyByFilePath(
|
||||
targetLocation: FileLocation,
|
||||
sourcePath: string,
|
||||
metadata: PreWriteMetadata,
|
||||
): Promise<WriteResult>;
|
||||
|
||||
rename(oldFileId: string, newFileId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export type SigningPayload = {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
import type { Readable } from 'node:stream';
|
||||
|
||||
import type { BinaryData } from './types';
|
||||
|
||||
export { assertDir, exists } from '@n8n/backend-common';
|
||||
|
||||
const STORED_MODES = ['filesystem', 'filesystem-v2', 's3', 'database'] as const;
|
||||
|
||||
export function isStoredMode(mode: string): mode is BinaryData.StoredMode {
|
||||
return STORED_MODES.includes(mode as BinaryData.StoredMode);
|
||||
}
|
||||
|
||||
/** Converts a readable stream to a buffer */
|
||||
export async function streamToBuffer(stream: Readable) {
|
||||
return await new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
stream.once('error', (cause) => {
|
||||
if ('code' in cause && cause.code === 'Z_DATA_ERROR')
|
||||
reject(new UnexpectedError('Failed to decompress response', { cause }));
|
||||
else reject(cause);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Converts a buffer or a readable stream to a buffer */
|
||||
export async function binaryToBuffer(body: Buffer | Readable) {
|
||||
if (Buffer.isBuffer(body)) return body;
|
||||
return await streamToBuffer(body);
|
||||
}
|
||||
|
||||
export const FileLocation = {
|
||||
ofExecution: (workflowId: string, executionId: string): BinaryData.FileLocation => ({
|
||||
type: 'execution',
|
||||
workflowId,
|
||||
executionId,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create a location for a binary file at a custom path,
|
||||
* e.g. ["chat-hub", "sessions", "abc", "messages", "def"] -> "chat-hub/sessions/abc/messages/def"
|
||||
*/
|
||||
ofCustom: ({
|
||||
pathSegments,
|
||||
sourceType,
|
||||
sourceId,
|
||||
}: {
|
||||
pathSegments: string[];
|
||||
sourceType?: string;
|
||||
sourceId?: string;
|
||||
}): BinaryData.FileLocation => ({
|
||||
type: 'custom',
|
||||
pathSegments,
|
||||
sourceType,
|
||||
sourceId,
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
export const CUSTOM_EXTENSION_ENV = 'N8N_CUSTOM_EXTENSIONS';
|
||||
export const PLACEHOLDER_EMPTY_EXECUTION_ID = '__UNKNOWN__';
|
||||
export const PLACEHOLDER_EMPTY_WORKFLOW_ID = '__EMPTY__';
|
||||
export const HTTP_REQUEST_NODE_TYPE = 'n8n-nodes-base.httpRequest';
|
||||
export const HTTP_REQUEST_AS_TOOL_NODE_TYPE = 'n8n-nodes-base.httpRequestTool';
|
||||
export const HTTP_REQUEST_TOOL_NODE_TYPE = '@n8n/n8n-nodes-langchain.toolHttpRequest';
|
||||
|
||||
export const RESTRICT_FILE_ACCESS_TO = 'N8N_RESTRICT_FILE_ACCESS_TO';
|
||||
export const BLOCK_FILE_ACCESS_TO_N8N_FILES = 'N8N_BLOCK_FILE_ACCESS_TO_N8N_FILES';
|
||||
export const CONFIG_FILES = 'N8N_CONFIG_FILES';
|
||||
export const BINARY_DATA_STORAGE_PATH = 'N8N_BINARY_DATA_STORAGE_PATH';
|
||||
export const UM_EMAIL_TEMPLATES_INVITE = 'N8N_UM_EMAIL_TEMPLATES_INVITE';
|
||||
export const UM_EMAIL_TEMPLATES_PWRESET = 'N8N_UM_EMAIL_TEMPLATES_PWRESET';
|
||||
|
||||
export const CREDENTIAL_ERRORS = {
|
||||
NO_DATA: 'No data is set on this credentials.',
|
||||
DECRYPTION_FAILED:
|
||||
'Credentials could not be decrypted. The likely reason is that a different "encryptionKey" was used to encrypt the data.',
|
||||
INVALID_JSON: 'Decrypted credentials data is not valid JSON.',
|
||||
INVALID_DATA: 'Credentials data is not in a valid format.',
|
||||
};
|
||||
|
||||
export const WAITING_TOKEN_QUERY_PARAM = 'signature';
|
||||
@@ -0,0 +1,82 @@
|
||||
import { isObjectLiteral } from '@n8n/backend-common';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { ICredentialDataDecryptedObject, ICredentialsEncrypted } from 'n8n-workflow';
|
||||
import { ApplicationError, ICredentials, jsonParse } from 'n8n-workflow';
|
||||
import * as a from 'node:assert';
|
||||
|
||||
import { CREDENTIAL_ERRORS } from '@/constants';
|
||||
import { Cipher } from '@/encryption/cipher';
|
||||
|
||||
export class CredentialDataError extends ApplicationError {
|
||||
constructor({ name, type, id }: Credentials<object>, message: string, cause?: unknown) {
|
||||
super(message, {
|
||||
extra: { name, type, id },
|
||||
cause,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class Credentials<
|
||||
T extends object = ICredentialDataDecryptedObject,
|
||||
> extends ICredentials<T> {
|
||||
private readonly cipher = Container.get(Cipher);
|
||||
|
||||
/**
|
||||
* Sets new credential object
|
||||
*/
|
||||
setData(data: T): void {
|
||||
a.ok(isObjectLiteral(data));
|
||||
|
||||
this.data = this.cipher.encrypt(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update parts of the credential data.
|
||||
* This decrypts the data, modifies it, and then re-encrypts the updated data back to a string.
|
||||
*/
|
||||
updateData(toUpdate: Partial<T>, toDelete: Array<keyof T> = []) {
|
||||
const updatedData: T = { ...this.getData(), ...toUpdate };
|
||||
for (const key of toDelete) {
|
||||
delete updatedData[key];
|
||||
}
|
||||
this.setData(updatedData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the decrypted credential object
|
||||
*/
|
||||
getData(): T {
|
||||
if (this.data === undefined) {
|
||||
throw new CredentialDataError(this, CREDENTIAL_ERRORS.NO_DATA);
|
||||
}
|
||||
|
||||
let decryptedData: string;
|
||||
try {
|
||||
decryptedData = this.cipher.decrypt(this.data);
|
||||
} catch (cause) {
|
||||
throw new CredentialDataError(this, CREDENTIAL_ERRORS.DECRYPTION_FAILED, cause);
|
||||
}
|
||||
|
||||
try {
|
||||
return jsonParse(decryptedData);
|
||||
} catch (cause) {
|
||||
throw new CredentialDataError(this, CREDENTIAL_ERRORS.INVALID_JSON, cause);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the encrypted credentials to be saved
|
||||
*/
|
||||
getDataToSave(): ICredentialsEncrypted {
|
||||
if (this.data === undefined) {
|
||||
throw new ApplicationError('No credentials were set to save.');
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
type: this.type,
|
||||
data: this.data,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import get from 'lodash/get';
|
||||
import type {
|
||||
IDataDeduplicator,
|
||||
ICheckProcessedOptions,
|
||||
IDeduplicationOutput,
|
||||
IDeduplicationOutputItems,
|
||||
IDataObject,
|
||||
DeduplicationScope,
|
||||
DeduplicationItemTypes,
|
||||
ICheckProcessedContextData,
|
||||
} from 'n8n-workflow';
|
||||
import * as assert from 'node:assert/strict';
|
||||
|
||||
/**
|
||||
* A singleton service responsible for data deduplication.
|
||||
* This service wraps around the IDataDeduplicator interface and provides methods to handle
|
||||
* deduplication-related operations such as checking, recording, and clearing processed data.
|
||||
*/
|
||||
export class DataDeduplicationService {
|
||||
private static instance: DataDeduplicationService;
|
||||
|
||||
private deduplicator: IDataDeduplicator;
|
||||
|
||||
private constructor(deduplicator: IDataDeduplicator) {
|
||||
this.deduplicator = deduplicator;
|
||||
}
|
||||
|
||||
private assertDeduplicator() {
|
||||
assert.ok(
|
||||
this.deduplicator,
|
||||
'Manager needs to initialized before use. Make sure to call init()',
|
||||
);
|
||||
}
|
||||
|
||||
private static assertInstance() {
|
||||
assert.ok(
|
||||
DataDeduplicationService.instance,
|
||||
'Instance needs to initialized before use. Make sure to call init()',
|
||||
);
|
||||
}
|
||||
|
||||
private static assertSingleInstance() {
|
||||
assert.ok(
|
||||
!DataDeduplicationService.instance,
|
||||
'Instance already initialized. Multiple initializations are not allowed.',
|
||||
);
|
||||
}
|
||||
|
||||
static async init(deduplicator: IDataDeduplicator): Promise<void> {
|
||||
this.assertSingleInstance();
|
||||
DataDeduplicationService.instance = new DataDeduplicationService(deduplicator);
|
||||
}
|
||||
|
||||
static getInstance(): DataDeduplicationService {
|
||||
this.assertInstance();
|
||||
return DataDeduplicationService.instance;
|
||||
}
|
||||
|
||||
async checkProcessedItemsAndRecord(
|
||||
propertyName: string,
|
||||
items: IDataObject[],
|
||||
scope: DeduplicationScope,
|
||||
contextData: ICheckProcessedContextData,
|
||||
options: ICheckProcessedOptions,
|
||||
): Promise<IDeduplicationOutputItems> {
|
||||
this.assertDeduplicator();
|
||||
let value;
|
||||
const itemLookup = items.reduce((acc, cur, index) => {
|
||||
value = JSON.stringify(get(cur, propertyName));
|
||||
acc[value ? value.toString() : ''] = index;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const checkedItems = await this.deduplicator.checkProcessedAndRecord(
|
||||
Object.keys(itemLookup),
|
||||
scope,
|
||||
contextData,
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
new: checkedItems.new.map((key) => items[itemLookup[key] as number]),
|
||||
processed: checkedItems.processed.map((key) => items[itemLookup[key] as number]),
|
||||
};
|
||||
}
|
||||
|
||||
async checkProcessedAndRecord(
|
||||
items: DeduplicationItemTypes[],
|
||||
scope: DeduplicationScope,
|
||||
contextData: ICheckProcessedContextData,
|
||||
options: ICheckProcessedOptions,
|
||||
): Promise<IDeduplicationOutput> {
|
||||
this.assertDeduplicator();
|
||||
return await this.deduplicator.checkProcessedAndRecord(items, scope, contextData, options);
|
||||
}
|
||||
|
||||
async removeProcessed(
|
||||
items: DeduplicationItemTypes[],
|
||||
scope: DeduplicationScope,
|
||||
contextData: ICheckProcessedContextData,
|
||||
options: ICheckProcessedOptions,
|
||||
): Promise<void> {
|
||||
this.assertDeduplicator();
|
||||
return await this.deduplicator.removeProcessed(items, scope, contextData, options);
|
||||
}
|
||||
|
||||
async clearAllProcessedItems(
|
||||
scope: DeduplicationScope,
|
||||
contextData: ICheckProcessedContextData,
|
||||
options: ICheckProcessedOptions,
|
||||
): Promise<void> {
|
||||
this.assertDeduplicator();
|
||||
return await this.deduplicator.clearAllProcessedItems(scope, contextData, options);
|
||||
}
|
||||
|
||||
async getProcessedDataCount(
|
||||
scope: DeduplicationScope,
|
||||
contextData: ICheckProcessedContextData,
|
||||
options: ICheckProcessedOptions,
|
||||
): Promise<number> {
|
||||
this.assertDeduplicator();
|
||||
return await this.deduplicator.getProcessedDataCount(scope, contextData, options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { InstanceSettings } from '@/instance-settings';
|
||||
import { mockInstance } from '@test/utils';
|
||||
|
||||
import { Cipher } from '../cipher';
|
||||
|
||||
describe('Cipher', () => {
|
||||
mockInstance(InstanceSettings, { encryptionKey: 'test_key' });
|
||||
const cipher = Container.get(Cipher);
|
||||
|
||||
describe('encrypt', () => {
|
||||
it('should encrypt strings', () => {
|
||||
const encrypted = cipher.encrypt('random-string');
|
||||
const decrypted = cipher.decrypt(encrypted);
|
||||
expect(decrypted).toEqual('random-string');
|
||||
});
|
||||
|
||||
it('should encrypt objects', () => {
|
||||
const encrypted = cipher.encrypt({ key: 'value' });
|
||||
const decrypted = cipher.decrypt(encrypted);
|
||||
expect(decrypted).toEqual('{"key":"value"}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decrypt', () => {
|
||||
it('should decrypt string', () => {
|
||||
const decrypted = cipher.decrypt('U2FsdGVkX194VEoX27o3+y5jUd1JTTmVwkOKjVhB6Jg=');
|
||||
expect(decrypted).toEqual('random-string');
|
||||
});
|
||||
|
||||
it('should not try to decrypt if the input is shorter than 16 bytes', () => {
|
||||
const decrypted = cipher.decrypt('U2FsdGVkX194VEo');
|
||||
expect(decrypted).toEqual('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getKeyAndIv', () => {
|
||||
it('should generate a key and iv using instance settings encryption key', () => {
|
||||
const salt = Buffer.from('test-salt');
|
||||
mockInstance(InstanceSettings, { encryptionKey: 'settings-encryption-key' });
|
||||
// Clear the cached Cipher instance to get a new one with the new mock
|
||||
Container.set(Cipher, new Cipher(Container.get(InstanceSettings)));
|
||||
const testCipher = Container.get(Cipher);
|
||||
const bufferFromSpy = jest.spyOn(Buffer, 'from');
|
||||
// @ts-expect-error - getKeyAndIv is private
|
||||
const [key, iv] = testCipher.getKeyAndIv(salt);
|
||||
expect(key).toBeInstanceOf(Buffer);
|
||||
expect(iv).toBeInstanceOf(Buffer);
|
||||
expect(bufferFromSpy).toHaveBeenCalledWith('settings-encryption-key', 'binary');
|
||||
bufferFromSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should generate a key and iv using custom encryption key', () => {
|
||||
const salt = Buffer.from('test-salt');
|
||||
mockInstance(InstanceSettings, { encryptionKey: 'settings-encryption-key' });
|
||||
// Clear the cached Cipher instance to get a new one with the new mock
|
||||
Container.set(Cipher, new Cipher(Container.get(InstanceSettings)));
|
||||
const testCipher = Container.get(Cipher);
|
||||
const bufferFromSpy = jest.spyOn(Buffer, 'from');
|
||||
// @ts-expect-error - getKeyAndIv is private
|
||||
const [key, iv] = testCipher.getKeyAndIv(salt, 'custom-encryption-key');
|
||||
expect(key).toBeInstanceOf(Buffer);
|
||||
expect(iv).toBeInstanceOf(Buffer);
|
||||
expect(bufferFromSpy).toHaveBeenCalledWith('custom-encryption-key', 'binary');
|
||||
bufferFromSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Service } from '@n8n/di';
|
||||
import { createHash, createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
||||
|
||||
import { InstanceSettings } from '@/instance-settings';
|
||||
|
||||
// Data encrypted by CryptoJS always starts with these bytes
|
||||
const RANDOM_BYTES = Buffer.from('53616c7465645f5f', 'hex');
|
||||
|
||||
@Service()
|
||||
export class Cipher {
|
||||
constructor(private readonly instanceSettings: InstanceSettings) {}
|
||||
|
||||
encrypt(data: string | object, customEncryptionKey?: string) {
|
||||
const salt = randomBytes(8);
|
||||
const [key, iv] = this.getKeyAndIv(salt, customEncryptionKey);
|
||||
const cipher = createCipheriv('aes-256-cbc', key, iv);
|
||||
const encrypted = cipher.update(typeof data === 'string' ? data : JSON.stringify(data));
|
||||
return Buffer.concat([RANDOM_BYTES, salt, encrypted, cipher.final()]).toString('base64');
|
||||
}
|
||||
|
||||
decrypt(data: string, customEncryptionKey?: string) {
|
||||
const input = Buffer.from(data, 'base64');
|
||||
if (input.length < 16) return '';
|
||||
const salt = input.subarray(8, 16);
|
||||
const [key, iv] = this.getKeyAndIv(salt, customEncryptionKey);
|
||||
const contents = input.subarray(16);
|
||||
const decipher = createDecipheriv('aes-256-cbc', key, iv);
|
||||
return Buffer.concat([decipher.update(contents), decipher.final()]).toString('utf-8');
|
||||
}
|
||||
|
||||
private getKeyAndIv(salt: Buffer, customEncryptionKey?: string): [Buffer, Buffer] {
|
||||
const encryptionKey = customEncryptionKey ?? this.instanceSettings.encryptionKey;
|
||||
const password = Buffer.concat([Buffer.from(encryptionKey, 'binary'), salt]);
|
||||
const hash1 = createHash('md5').update(password).digest();
|
||||
const hash2 = createHash('md5')
|
||||
.update(Buffer.concat([hash1, password]))
|
||||
.digest();
|
||||
const iv = createHash('md5')
|
||||
.update(Buffer.concat([hash2, password]))
|
||||
.digest();
|
||||
const key = Buffer.concat([hash1, hash2]);
|
||||
return [key, iv];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { Cipher } from './cipher';
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { QueryFailedError } from '@n8n/typeorm';
|
||||
import type { ErrorEvent } from '@sentry/core';
|
||||
import { AxiosError } from 'axios';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { ApplicationError, BaseError } from 'n8n-workflow';
|
||||
|
||||
import { ErrorReporter } from '../error-reporter';
|
||||
|
||||
jest.mock('@sentry/node', () => ({
|
||||
init: jest.fn(),
|
||||
setTag: jest.fn(),
|
||||
captureException: jest.fn(),
|
||||
Integrations: {},
|
||||
}));
|
||||
|
||||
jest.spyOn(process, 'on');
|
||||
|
||||
describe('ErrorReporter', () => {
|
||||
const errorReporter = new ErrorReporter(mock(), mock());
|
||||
const event = {} as ErrorEvent;
|
||||
|
||||
describe('beforeSend', () => {
|
||||
it('should ignore errors with level warning', async () => {
|
||||
const originalException = new ApplicationError('test');
|
||||
originalException.level = 'warning';
|
||||
|
||||
expect(await errorReporter.beforeSend(event, { originalException })).toEqual(null);
|
||||
});
|
||||
|
||||
it('should keep events with a cause with error level', async () => {
|
||||
const cause = new Error('cause-error');
|
||||
const originalException = new ApplicationError('test', cause);
|
||||
|
||||
expect(await errorReporter.beforeSend(event, { originalException })).toEqual(event);
|
||||
});
|
||||
|
||||
it('should ignore events with error cause with warning level', async () => {
|
||||
const cause: Error & { level?: 'warning' } = new Error('cause-error');
|
||||
cause.level = 'warning';
|
||||
const originalException = new ApplicationError('test', cause);
|
||||
|
||||
expect(await errorReporter.beforeSend(event, { originalException })).toEqual(null);
|
||||
});
|
||||
|
||||
it('should set level, extra, and tags from ApplicationError', async () => {
|
||||
const originalException = new ApplicationError('Test error', {
|
||||
level: 'error',
|
||||
extra: { foo: 'bar' },
|
||||
tags: { tag1: 'value1' },
|
||||
});
|
||||
|
||||
const testEvent = {} as ErrorEvent;
|
||||
|
||||
const result = await errorReporter.beforeSend(testEvent, { originalException });
|
||||
|
||||
expect(result).toEqual({
|
||||
level: 'error',
|
||||
extra: { foo: 'bar' },
|
||||
tags: { tag1: 'value1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should deduplicate errors with same stack trace', async () => {
|
||||
const originalException = new Error();
|
||||
|
||||
const firstResult = await errorReporter.beforeSend(event, { originalException });
|
||||
expect(firstResult).toEqual(event);
|
||||
|
||||
const secondResult = await errorReporter.beforeSend(event, { originalException });
|
||||
expect(secondResult).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle Promise rejections', async () => {
|
||||
const originalException = Promise.reject(new Error());
|
||||
|
||||
const result = await errorReporter.beforeSend(event, { originalException });
|
||||
|
||||
expect(result).toEqual(event);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['undefined', undefined],
|
||||
['null', null],
|
||||
['an AxiosError', new AxiosError()],
|
||||
['a rejected Promise with AxiosError', Promise.reject(new AxiosError())],
|
||||
[
|
||||
'a QueryFailedError with SQLITE_FULL',
|
||||
new QueryFailedError('', [], new Error('SQLITE_FULL')),
|
||||
],
|
||||
[
|
||||
'a QueryFailedError with SQLITE_IOERR',
|
||||
new QueryFailedError('', [], new Error('SQLITE_IOERR')),
|
||||
],
|
||||
['an ApplicationError with "warning" level', new ApplicationError('', { level: 'warning' })],
|
||||
[
|
||||
'an Error with ApplicationError as cause with "warning" level',
|
||||
new Error('', { cause: new ApplicationError('', { level: 'warning' }) }),
|
||||
],
|
||||
])('should ignore if originalException is %s', async (_, originalException) => {
|
||||
const result = await errorReporter.beforeSend(event, { originalException });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
describe('beforeSendFilter', () => {
|
||||
const newErrorReportedWithBeforeSendFilter = (beforeSendFilter: jest.Mock) => {
|
||||
const errorReporter = new ErrorReporter(mock(), mock());
|
||||
// @ts-expect-error - beforeSendFilter is private
|
||||
errorReporter.beforeSendFilter = beforeSendFilter;
|
||||
return errorReporter;
|
||||
};
|
||||
|
||||
it('should filter out based on the beforeSendFilter', async () => {
|
||||
const beforeSendFilter = jest.fn().mockReturnValue(true);
|
||||
const errorReporter = newErrorReportedWithBeforeSendFilter(beforeSendFilter);
|
||||
const hint = { originalException: new Error() };
|
||||
|
||||
const result = await errorReporter.beforeSend(event, hint);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(beforeSendFilter).toHaveBeenCalledWith(event, hint);
|
||||
});
|
||||
|
||||
it('should not filter out when beforeSendFilter returns false', async () => {
|
||||
const beforeSendFilter = jest.fn().mockReturnValue(false);
|
||||
const errorReporter = newErrorReportedWithBeforeSendFilter(beforeSendFilter);
|
||||
const hint = { originalException: new Error() };
|
||||
|
||||
const result = await errorReporter.beforeSend(event, hint);
|
||||
|
||||
expect(result).toEqual(event);
|
||||
expect(beforeSendFilter).toHaveBeenCalledWith(event, hint);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BaseError', () => {
|
||||
class TestError extends BaseError {}
|
||||
|
||||
it('should drop errors with shouldReport false', async () => {
|
||||
const originalException = new TestError('test', { shouldReport: false });
|
||||
|
||||
expect(await errorReporter.beforeSend(event, { originalException })).toEqual(null);
|
||||
});
|
||||
|
||||
it('should keep events with shouldReport true', async () => {
|
||||
const originalException = new TestError('test', { shouldReport: true });
|
||||
|
||||
expect(await errorReporter.beforeSend(event, { originalException })).toEqual(event);
|
||||
});
|
||||
|
||||
it('should set level, extra, and tags from BaseError', async () => {
|
||||
const originalException = new TestError('Test error', {
|
||||
level: 'error',
|
||||
extra: { foo: 'bar' },
|
||||
tags: { tag1: 'value1' },
|
||||
});
|
||||
|
||||
const testEvent = {} as ErrorEvent;
|
||||
|
||||
const result = await errorReporter.beforeSend(testEvent, { originalException });
|
||||
|
||||
expect(result).toEqual({
|
||||
level: 'error',
|
||||
extra: { foo: 'bar' },
|
||||
tags: {
|
||||
packageName: 'core',
|
||||
tag1: 'value1',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error', () => {
|
||||
let error: ApplicationError;
|
||||
let logger: Logger;
|
||||
let errorReporter: ErrorReporter;
|
||||
const metadata = undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
error = new ApplicationError('Test error');
|
||||
logger = mock<Logger>();
|
||||
errorReporter = new ErrorReporter(logger, mock());
|
||||
});
|
||||
|
||||
it('should include stack trace for error-level `ApplicationError`', () => {
|
||||
error.level = 'error';
|
||||
errorReporter.error(error);
|
||||
expect(logger.error).toHaveBeenCalledWith(`Test error\n${error.stack}\n`, metadata);
|
||||
});
|
||||
|
||||
it('should exclude stack trace for warning-level `ApplicationError`', () => {
|
||||
error.level = 'warning';
|
||||
errorReporter.error(error);
|
||||
expect(logger.error).toHaveBeenCalledWith('Test error', metadata);
|
||||
});
|
||||
|
||||
it.each([true, undefined])(
|
||||
'should log the error when shouldBeLogged is %s',
|
||||
(shouldBeLogged) => {
|
||||
error.level = 'error';
|
||||
errorReporter.error(error, { shouldBeLogged });
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it('should not log the error when shouldBeLogged is false', () => {
|
||||
error.level = 'error';
|
||||
errorReporter.error(error, { shouldBeLogged: false });
|
||||
expect(logger.error).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { ApplicationError } from '@n8n/errors';
|
||||
|
||||
export abstract class BinaryDataError extends ApplicationError {}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { ApplicationError } from '@n8n/errors';
|
||||
|
||||
export abstract class FileSystemError extends ApplicationError {
|
||||
constructor(message: string, filePath: string) {
|
||||
super(message, { extra: { filePath } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
|
||||
export class BinaryDataFileNotFoundError extends UnexpectedError {
|
||||
constructor(fileId: string) {
|
||||
super('Binary data file not found', { extra: { fileId } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { FileSystemError } from './abstract/filesystem.error';
|
||||
|
||||
export class DisallowedFilepathError extends FileSystemError {
|
||||
constructor(filePath: string) {
|
||||
super('Disallowed path detected', filePath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import { inTest, Logger } from '@n8n/backend-common';
|
||||
import { type InstanceType } from '@n8n/constants';
|
||||
import { Service } from '@n8n/di';
|
||||
import type { ReportingOptions } from '@n8n/errors';
|
||||
import type { ErrorEvent, EventHint } from '@sentry/core';
|
||||
import type { NodeOptions } from '@sentry/node';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ApplicationError, ExecutionCancelledError, BaseError } from 'n8n-workflow';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { Tracing, SentryTracing } from '@/observability';
|
||||
|
||||
type SentryIntegration = 'Redis' | 'Postgres' | 'Http' | 'Express';
|
||||
|
||||
type ErrorReporterInitOptions = {
|
||||
serverType: InstanceType | 'task_runner';
|
||||
dsn: string;
|
||||
release: string;
|
||||
environment: string;
|
||||
serverName: string;
|
||||
releaseDate?: Date;
|
||||
|
||||
/** Whether to enable event loop block detection, if Sentry is enabled. */
|
||||
withEventLoopBlockDetection: boolean;
|
||||
|
||||
/** Threshold in ms for event loop block detection. Only used if `withEventLoopBlockDetection` is true. */
|
||||
eventLoopBlockThreshold?: number;
|
||||
|
||||
/** Sample rate for Sentry traces (0.0 to 1.0). 0 means disabled */
|
||||
tracesSampleRate: number;
|
||||
|
||||
/** Sample rate for Sentry profiling (0.0 to 1.0). 0 means disabled */
|
||||
profilesSampleRate: number;
|
||||
|
||||
/**
|
||||
* Function to allow filtering out errors before they are sent to Sentry.
|
||||
* Return true if the error should be filtered out.
|
||||
*/
|
||||
beforeSendFilter?: (event: ErrorEvent, hint: EventHint) => boolean;
|
||||
|
||||
/**
|
||||
* Integrations eligible for enablement. `tracesSampleRate` still determines
|
||||
* whether they are actually enabled or not.
|
||||
*/
|
||||
eligibleIntegrations?: Partial<Record<SentryIntegration, boolean>>;
|
||||
|
||||
/** Health endpoint path */
|
||||
healthEndpoint?: string;
|
||||
};
|
||||
|
||||
const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000;
|
||||
const SIX_WEEKS_IN_MS = 6 * 7 * ONE_DAY_IN_MS;
|
||||
const RELEASE_EXPIRATION_WARNING =
|
||||
'Error tracking disabled because this release is older than 6 weeks.';
|
||||
|
||||
@Service()
|
||||
export class ErrorReporter {
|
||||
private expirationTimer?: NodeJS.Timeout;
|
||||
|
||||
/** Hashes of error stack traces, to deduplicate error reports. */
|
||||
private seenErrors = new Set<string>();
|
||||
|
||||
private report: (error: Error | string, options?: ReportingOptions) => void;
|
||||
|
||||
private beforeSendFilter?: (event: ErrorEvent, hint: EventHint) => boolean;
|
||||
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly tracing: Tracing,
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
this.report = this.defaultReport;
|
||||
}
|
||||
|
||||
private defaultReport(error: Error | string, options?: ReportingOptions) {
|
||||
if (error instanceof Error) {
|
||||
let e = error;
|
||||
|
||||
const { executionId } = options ?? {};
|
||||
const context = executionId ? ` (execution ${executionId})` : '';
|
||||
|
||||
do {
|
||||
let stack = '';
|
||||
let meta = undefined;
|
||||
if (e instanceof ApplicationError || e instanceof BaseError) {
|
||||
if (e.level === 'error' && e.stack) {
|
||||
stack = `\n${e.stack}\n`;
|
||||
}
|
||||
meta = e.extra;
|
||||
}
|
||||
const msg = [e.message + context, stack].join('');
|
||||
// Default to logging the error if option is not specified
|
||||
if (options?.shouldBeLogged ?? true) {
|
||||
this.logger.error(msg, meta);
|
||||
}
|
||||
e = e.cause as Error;
|
||||
} while (e);
|
||||
}
|
||||
}
|
||||
|
||||
async shutdown(timeoutInMs = 1000) {
|
||||
clearTimeout(this.expirationTimer);
|
||||
const { close } = await import('@sentry/node');
|
||||
await close(timeoutInMs);
|
||||
}
|
||||
|
||||
async init({
|
||||
beforeSendFilter,
|
||||
dsn,
|
||||
serverType,
|
||||
release,
|
||||
environment,
|
||||
serverName,
|
||||
releaseDate,
|
||||
withEventLoopBlockDetection,
|
||||
eventLoopBlockThreshold,
|
||||
profilesSampleRate,
|
||||
tracesSampleRate,
|
||||
eligibleIntegrations = {},
|
||||
healthEndpoint = '/healthz',
|
||||
}: ErrorReporterInitOptions) {
|
||||
if (inTest) return;
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
this.error(error);
|
||||
});
|
||||
|
||||
if (releaseDate) {
|
||||
const releaseExpiresAtMs = releaseDate.getTime() + SIX_WEEKS_IN_MS;
|
||||
const releaseExpiresInMs = () => releaseExpiresAtMs - Date.now();
|
||||
if (releaseExpiresInMs() <= 0) {
|
||||
this.logger.warn(RELEASE_EXPIRATION_WARNING);
|
||||
return;
|
||||
}
|
||||
const checkForExpiration = () => {
|
||||
// Once this release expires, reject all events
|
||||
if (releaseExpiresInMs() <= 0) {
|
||||
this.logger.warn(RELEASE_EXPIRATION_WARNING);
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
this.report = this.defaultReport;
|
||||
} else {
|
||||
this.expirationTimer = setTimeout(checkForExpiration, ONE_DAY_IN_MS);
|
||||
}
|
||||
};
|
||||
checkForExpiration();
|
||||
}
|
||||
|
||||
if (!dsn) return;
|
||||
|
||||
// Collect longer stacktraces
|
||||
Error.stackTraceLimit = 50;
|
||||
|
||||
const sentry = await import('@sentry/node');
|
||||
const {
|
||||
init,
|
||||
captureException,
|
||||
setTag,
|
||||
setUser,
|
||||
requestDataIntegration,
|
||||
rewriteFramesIntegration,
|
||||
} = sentry;
|
||||
|
||||
// Most of the integrations are listed here:
|
||||
// https://docs.sentry.io/platforms/javascript/guides/node/configuration/integrations/
|
||||
const enabledIntegrations = new Set([
|
||||
'InboundFilters',
|
||||
'FunctionToString',
|
||||
'LinkedErrors',
|
||||
'OnUnhandledRejection',
|
||||
'ContextLines',
|
||||
]);
|
||||
|
||||
const isTracingEnabled = tracesSampleRate > 0;
|
||||
if (isTracingEnabled) {
|
||||
const tracingIntegrations: SentryIntegration[] = ['Http', 'Postgres', 'Redis', 'Express'];
|
||||
tracingIntegrations
|
||||
.filter((integrationName) => !!eligibleIntegrations[integrationName])
|
||||
.forEach((integrationName) => enabledIntegrations.add(integrationName));
|
||||
|
||||
this.tracing.setTracingImplementation(new SentryTracing(sentry));
|
||||
}
|
||||
|
||||
const isProfilingEnabled = profilesSampleRate > 0;
|
||||
if (isProfilingEnabled && !isTracingEnabled) {
|
||||
this.logger.warn('Profiling is enabled but tracing is disabled. Profiling will not work.');
|
||||
}
|
||||
|
||||
const eventLoopBlockIntegration = withEventLoopBlockDetection
|
||||
? // The EventLoopBlockIntegration doesn't automatically include the
|
||||
// same tags, so we set them explicitly.
|
||||
await this.getEventLoopBlockIntegration(
|
||||
{
|
||||
server_name: serverName,
|
||||
server_type: serverType,
|
||||
},
|
||||
eventLoopBlockThreshold,
|
||||
)
|
||||
: [];
|
||||
|
||||
const profilingIntegration = isProfilingEnabled ? await this.getProfilingIntegration() : [];
|
||||
|
||||
init({
|
||||
dsn,
|
||||
release,
|
||||
environment,
|
||||
serverName,
|
||||
...(isTracingEnabled ? { tracesSampleRate } : {}),
|
||||
...(isProfilingEnabled ? { profilesSampleRate, profileLifecycle: 'trace' } : {}),
|
||||
beforeSend: this.beforeSend.bind(this) as NodeOptions['beforeSend'],
|
||||
ignoreTransactions: [`GET ${healthEndpoint}`, 'GET /metrics', 'SET search_path TO'],
|
||||
ignoreSpans: [`GET ${healthEndpoint}`, 'GET /metrics', 'SET search_path TO'],
|
||||
integrations: (integrations) => [
|
||||
...integrations.filter(({ name }) => enabledIntegrations.has(name)),
|
||||
rewriteFramesIntegration({ root: '/' }),
|
||||
requestDataIntegration({
|
||||
include: {
|
||||
cookies: false,
|
||||
data: false,
|
||||
headers: false,
|
||||
query_string: false,
|
||||
url: true,
|
||||
},
|
||||
}),
|
||||
...eventLoopBlockIntegration,
|
||||
...profilingIntegration,
|
||||
],
|
||||
});
|
||||
|
||||
setTag('server_type', serverType);
|
||||
|
||||
if (serverName) {
|
||||
setUser({ id: serverName });
|
||||
}
|
||||
|
||||
this.report = (error, options) => captureException(error, options);
|
||||
this.beforeSendFilter = beforeSendFilter;
|
||||
}
|
||||
|
||||
async beforeSend(event: ErrorEvent, hint: EventHint) {
|
||||
let { originalException } = hint;
|
||||
|
||||
if (!originalException) return null;
|
||||
|
||||
if (originalException instanceof Promise) {
|
||||
originalException = await originalException.catch((error) => error as Error);
|
||||
}
|
||||
|
||||
if (
|
||||
this.beforeSendFilter?.(event, {
|
||||
...hint,
|
||||
originalException,
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (originalException instanceof AxiosError) return null;
|
||||
|
||||
if (originalException instanceof BaseError) {
|
||||
if (!originalException.shouldReport) return null;
|
||||
|
||||
this.extractEventDetailsFromN8nError(event, originalException);
|
||||
}
|
||||
|
||||
if (this.isIgnoredSqliteError(originalException)) return null;
|
||||
if (originalException instanceof ApplicationError || originalException instanceof BaseError) {
|
||||
if (this.isIgnoredN8nError(originalException)) return null;
|
||||
|
||||
this.extractEventDetailsFromN8nError(event, originalException);
|
||||
}
|
||||
|
||||
if (
|
||||
originalException instanceof Error &&
|
||||
'cause' in originalException &&
|
||||
originalException.cause instanceof Error &&
|
||||
'level' in originalException.cause &&
|
||||
(originalException.cause.level === 'warning' || originalException.cause.level === 'info')
|
||||
) {
|
||||
// handle underlying errors propagating from dependencies like ai-assistant-sdk
|
||||
return null;
|
||||
}
|
||||
|
||||
if (originalException instanceof Error && originalException.stack) {
|
||||
const eventHash = createHash('sha1').update(originalException.stack).digest('base64');
|
||||
if (this.seenErrors.has(eventHash)) return null;
|
||||
this.seenErrors.add(eventHash);
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
error(e: unknown, options?: ReportingOptions) {
|
||||
if (e instanceof ExecutionCancelledError) return;
|
||||
const toReport = this.wrap(e);
|
||||
if (toReport) this.report(toReport, options);
|
||||
}
|
||||
|
||||
warn(warning: Error | string, options?: ReportingOptions) {
|
||||
this.error(warning, { ...options, level: 'warning' });
|
||||
}
|
||||
|
||||
info(msg: string, options?: ReportingOptions) {
|
||||
this.report(msg, { ...options, level: 'info' });
|
||||
}
|
||||
|
||||
private wrap(e: unknown) {
|
||||
if (e instanceof Error) return e;
|
||||
if (typeof e === 'string') return new ApplicationError(e);
|
||||
return;
|
||||
}
|
||||
|
||||
/** @returns true if the error should be filtered out */
|
||||
private isIgnoredSqliteError(error: unknown) {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
error.name === 'QueryFailedError' &&
|
||||
typeof error.message === 'string' &&
|
||||
['SQLITE_FULL', 'SQLITE_IOERR'].some((errMsg) => error.message.includes(errMsg))
|
||||
);
|
||||
}
|
||||
|
||||
private isIgnoredN8nError(error: ApplicationError | BaseError) {
|
||||
return error.level === 'warning' || error.level === 'info';
|
||||
}
|
||||
|
||||
private extractEventDetailsFromN8nError(
|
||||
event: ErrorEvent,
|
||||
originalException: ApplicationError | BaseError,
|
||||
) {
|
||||
const { level, extra, tags } = originalException;
|
||||
event.level = level;
|
||||
if (extra) event.extra = { ...event.extra, ...extra };
|
||||
if (tags) event.tags = { ...event.tags, ...tags };
|
||||
}
|
||||
|
||||
private async getEventLoopBlockIntegration(tags: Record<string, string>, threshold?: number) {
|
||||
try {
|
||||
const { eventLoopBlockIntegration } = await import('@sentry/node-native');
|
||||
return [
|
||||
eventLoopBlockIntegration({
|
||||
...(threshold ? { threshold } : {}),
|
||||
staticTags: tags,
|
||||
}),
|
||||
];
|
||||
} catch {
|
||||
this.logger.warn(
|
||||
"Sentry's event loop block integration is disabled, because the native binary for `@sentry/node-native` was not found",
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async getProfilingIntegration() {
|
||||
try {
|
||||
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
|
||||
return [nodeProfilingIntegration()];
|
||||
} catch {
|
||||
this.logger.warn(
|
||||
'Sentry profiling is disabled, because the `@sentry/profiling-node` package was not found',
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { FileSystemError } from './abstract/filesystem.error';
|
||||
|
||||
export class FileNotFoundError extends FileSystemError {
|
||||
constructor(filePath: string) {
|
||||
super('File not found', filePath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { UserError } from 'n8n-workflow';
|
||||
|
||||
export class FileTooLargeError extends UserError {
|
||||
constructor({
|
||||
fileSizeMb,
|
||||
maxFileSizeMb,
|
||||
fileId,
|
||||
fileName,
|
||||
}: {
|
||||
fileSizeMb: number;
|
||||
maxFileSizeMb: number;
|
||||
fileId: string;
|
||||
fileName?: string;
|
||||
}) {
|
||||
const id = fileName ? `"${fileName}" (${fileId})` : fileId;
|
||||
const roundedSize = Math.round(fileSizeMb * 100) / 100;
|
||||
super(
|
||||
`Failed to write binary file ${id} because its size of ${roundedSize} MB exceeds the max size limit of ${maxFileSizeMb} MB set for \`database\` mode. Consider increasing \`N8N_BINARY_DATA_DATABASE_MAX_FILE_SIZE\` up to 1 GB, or using S3 storage mode if you require writes larger than 1 GB.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export { BinaryDataFileNotFoundError } from './binary-data-file-not-found.error';
|
||||
export { FileNotFoundError } from './file-not-found.error';
|
||||
export { FileTooLargeError } from './file-too-large.error';
|
||||
export { DisallowedFilepathError } from './disallowed-filepath.error';
|
||||
export { InvalidManagerError } from './invalid-manager.error';
|
||||
export { InvalidExecutionMetadataError } from './invalid-execution-metadata.error';
|
||||
export { InvalidSourceTypeError } from './invalid-source-type.error';
|
||||
export { MissingSourceIdError } from './missing-source-id.error';
|
||||
export { UnrecognizedCredentialTypeError } from './unrecognized-credential-type.error';
|
||||
export { UnrecognizedNodeTypeError } from './unrecognized-node-type.error';
|
||||
|
||||
export { ErrorReporter } from './error-reporter';
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ApplicationError } from '@n8n/errors';
|
||||
|
||||
export class InvalidExecutionMetadataError extends ApplicationError {
|
||||
constructor(
|
||||
public type: 'key' | 'value',
|
||||
key: unknown,
|
||||
message?: string,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
super(message ?? `Custom data ${type}s must be a string (key "${key}")`, options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { BinaryDataError } from './abstract/binary-data.error';
|
||||
|
||||
export class InvalidManagerError extends BinaryDataError {
|
||||
constructor(mode: string) {
|
||||
super(`No binary data manager found for: ${mode}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
|
||||
export class InvalidSourceTypeError extends UnexpectedError {
|
||||
constructor(sourceType: string) {
|
||||
super(`Custom file location with invalid source type: ${sourceType}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
|
||||
export class MissingSourceIdError extends UnexpectedError {
|
||||
constructor(pathSegments: string[]) {
|
||||
super(`Custom file location missing sourceId: ${pathSegments.join('/')}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { UserError } from 'n8n-workflow';
|
||||
|
||||
export class UnrecognizedCredentialTypeError extends UserError {
|
||||
constructor(credentialType: string) {
|
||||
super(`Unrecognized credential type: ${credentialType}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { UserError } from 'n8n-workflow';
|
||||
|
||||
export class UnrecognizedNodeTypeError extends UserError {
|
||||
constructor(packageName: string, nodeType: string) {
|
||||
super(`Unrecognized node type: ${packageName}.${nodeType}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { WorkflowOperationError } from 'n8n-workflow';
|
||||
|
||||
export class WorkflowHasIssuesError extends WorkflowOperationError {
|
||||
constructor() {
|
||||
super('The workflow has issues and cannot be executed for that reason. Please fix them first.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
INode,
|
||||
ITriggerResponse,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
WorkflowActivateMode,
|
||||
WorkflowExecuteMode,
|
||||
TriggerTime,
|
||||
CronExpression,
|
||||
} from 'n8n-workflow';
|
||||
import { LoggerProxy, TriggerCloseError, WorkflowActivationError } from 'n8n-workflow';
|
||||
|
||||
import type { ErrorReporter } from '@/errors/error-reporter';
|
||||
import { Tracing } from '@/observability';
|
||||
|
||||
import { ActiveWorkflows } from '../active-workflows';
|
||||
import type { IGetExecuteTriggerFunctions } from '../interfaces';
|
||||
import type { PollContext } from '../node-execution-context';
|
||||
import type { ScheduledTaskManager } from '../scheduled-task-manager';
|
||||
import type { TriggersAndPollers } from '../triggers-and-pollers';
|
||||
|
||||
describe('ActiveWorkflows', () => {
|
||||
const workflowId = 'test-workflow-id';
|
||||
const workflow = mock<Workflow>();
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>();
|
||||
const mode: WorkflowExecuteMode = 'trigger';
|
||||
const activation: WorkflowActivateMode = 'init';
|
||||
const tracing = new Tracing();
|
||||
|
||||
const getTriggerFunctions = jest.fn() as IGetExecuteTriggerFunctions;
|
||||
const triggerResponse = mock<ITriggerResponse>();
|
||||
|
||||
const pollFunctions = mock<PollContext>();
|
||||
const getPollFunctions = jest.fn<PollContext, unknown[]>();
|
||||
|
||||
LoggerProxy.init(mock());
|
||||
const scheduledTaskManager = mock<ScheduledTaskManager>();
|
||||
const triggersAndPollers = mock<TriggersAndPollers>();
|
||||
const errorReporter = mock<ErrorReporter>();
|
||||
const triggerNode = mock<INode>();
|
||||
const pollNode = mock<INode>();
|
||||
|
||||
let activeWorkflows: ActiveWorkflows;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
activeWorkflows = new ActiveWorkflows(
|
||||
mock(),
|
||||
scheduledTaskManager,
|
||||
triggersAndPollers,
|
||||
errorReporter,
|
||||
tracing,
|
||||
);
|
||||
});
|
||||
|
||||
type PollTimes = { item: TriggerTime[] };
|
||||
type TestOptions = {
|
||||
triggerNodes?: INode[];
|
||||
pollNodes?: INode[];
|
||||
triggerError?: Error;
|
||||
pollError?: Error;
|
||||
pollTimes?: PollTimes;
|
||||
};
|
||||
|
||||
const addWorkflow = async ({
|
||||
triggerNodes = [],
|
||||
pollNodes = [],
|
||||
triggerError,
|
||||
pollError,
|
||||
pollTimes = { item: [{ mode: 'everyMinute' }] },
|
||||
}: TestOptions) => {
|
||||
workflow.getTriggerNodes.mockReturnValue(triggerNodes);
|
||||
workflow.getPollNodes.mockReturnValue(pollNodes);
|
||||
pollFunctions.getNodeParameter.calledWith('pollTimes').mockReturnValue(pollTimes);
|
||||
|
||||
if (triggerError) {
|
||||
triggersAndPollers.runTrigger.mockRejectedValueOnce(triggerError);
|
||||
} else {
|
||||
triggersAndPollers.runTrigger.mockResolvedValue(triggerResponse);
|
||||
}
|
||||
|
||||
if (pollError) {
|
||||
triggersAndPollers.runPoll.mockRejectedValueOnce(pollError);
|
||||
} else {
|
||||
getPollFunctions.mockReturnValue(pollFunctions);
|
||||
}
|
||||
|
||||
return await activeWorkflows.add(
|
||||
workflowId,
|
||||
workflow,
|
||||
additionalData,
|
||||
mode,
|
||||
activation,
|
||||
getTriggerFunctions,
|
||||
getPollFunctions,
|
||||
);
|
||||
};
|
||||
|
||||
describe('add()', () => {
|
||||
describe('should activate workflow', () => {
|
||||
it('with trigger nodes', async () => {
|
||||
await addWorkflow({ triggerNodes: [triggerNode] });
|
||||
|
||||
expect(activeWorkflows.isActive(workflowId)).toBe(true);
|
||||
expect(workflow.getTriggerNodes).toHaveBeenCalled();
|
||||
expect(triggersAndPollers.runTrigger).toHaveBeenCalledWith(
|
||||
workflow,
|
||||
triggerNode,
|
||||
getTriggerFunctions,
|
||||
additionalData,
|
||||
mode,
|
||||
activation,
|
||||
);
|
||||
});
|
||||
|
||||
it('with polling nodes', async () => {
|
||||
await addWorkflow({ pollNodes: [pollNode] });
|
||||
|
||||
expect(activeWorkflows.isActive(workflowId)).toBe(true);
|
||||
expect(workflow.getPollNodes).toHaveBeenCalled();
|
||||
expect(scheduledTaskManager.registerCron).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('with both trigger and polling nodes', async () => {
|
||||
await addWorkflow({ triggerNodes: [triggerNode], pollNodes: [pollNode] });
|
||||
|
||||
expect(activeWorkflows.isActive(workflowId)).toBe(true);
|
||||
expect(workflow.getTriggerNodes).toHaveBeenCalled();
|
||||
expect(workflow.getPollNodes).toHaveBeenCalled();
|
||||
expect(triggersAndPollers.runTrigger).toHaveBeenCalledWith(
|
||||
workflow,
|
||||
triggerNode,
|
||||
getTriggerFunctions,
|
||||
additionalData,
|
||||
mode,
|
||||
activation,
|
||||
);
|
||||
expect(scheduledTaskManager.registerCron).toHaveBeenCalled();
|
||||
expect(triggersAndPollers.runPoll).toHaveBeenCalledWith(workflow, pollNode, pollFunctions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should throw error', () => {
|
||||
it('if trigger activation fails', async () => {
|
||||
const error = new Error('Trigger activation failed');
|
||||
await expect(
|
||||
addWorkflow({ triggerNodes: [triggerNode], triggerError: error }),
|
||||
).rejects.toThrow(WorkflowActivationError);
|
||||
expect(activeWorkflows.isActive(workflowId)).toBe(false);
|
||||
});
|
||||
|
||||
it('if polling activation fails', async () => {
|
||||
const error = new Error('Failed to activate polling');
|
||||
await expect(addWorkflow({ pollNodes: [pollNode], pollError: error })).rejects.toThrow(
|
||||
WorkflowActivationError,
|
||||
);
|
||||
expect(activeWorkflows.isActive(workflowId)).toBe(false);
|
||||
});
|
||||
|
||||
it('if the polling interval is too short', async () => {
|
||||
const pollTimes: PollTimes = {
|
||||
item: [
|
||||
{
|
||||
mode: 'custom',
|
||||
cronExpression: '* * * * *' as CronExpression,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await expect(addWorkflow({ pollNodes: [pollNode], pollTimes })).rejects.toThrow(
|
||||
'The polling interval is too short. It has to be at least a minute.',
|
||||
);
|
||||
|
||||
expect(scheduledTaskManager.registerCron).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('should handle polling errors', () => {
|
||||
it('should throw error when poll fails during initial testing', async () => {
|
||||
const error = new Error('Poll function failed');
|
||||
|
||||
await expect(addWorkflow({ pollNodes: [pollNode], pollError: error })).rejects.toThrow(
|
||||
WorkflowActivationError,
|
||||
);
|
||||
|
||||
expect(triggersAndPollers.runPoll).toHaveBeenCalledWith(workflow, pollNode, pollFunctions);
|
||||
expect(pollFunctions.__emit).not.toHaveBeenCalled();
|
||||
expect(pollFunctions.__emitError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit error when poll fails during regular polling', async () => {
|
||||
const error = new Error('Poll function failed');
|
||||
triggersAndPollers.runPoll
|
||||
.mockResolvedValueOnce(null) // Succeed on first call (testing)
|
||||
.mockRejectedValueOnce(error); // Fail on second call (regular polling)
|
||||
|
||||
await addWorkflow({ pollNodes: [pollNode] });
|
||||
|
||||
// Get the executeTrigger function that was registered
|
||||
const registerCronCall = scheduledTaskManager.registerCron.mock.calls[0];
|
||||
const executeTrigger = registerCronCall[1] as () => Promise<void>;
|
||||
|
||||
// Execute the trigger function to simulate a regular poll
|
||||
await executeTrigger();
|
||||
|
||||
expect(triggersAndPollers.runPoll).toHaveBeenCalledTimes(2);
|
||||
expect(pollFunctions.__emit).not.toHaveBeenCalled();
|
||||
expect(pollFunctions.__emitError).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove()', () => {
|
||||
const setupForRemoval = async () => {
|
||||
await addWorkflow({ triggerNodes: [triggerNode] });
|
||||
return await activeWorkflows.remove(workflowId);
|
||||
};
|
||||
|
||||
it('should remove an active workflow', async () => {
|
||||
const result = await setupForRemoval();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(activeWorkflows.isActive(workflowId)).toBe(false);
|
||||
expect(scheduledTaskManager.deregisterCrons).toHaveBeenCalledWith(workflowId);
|
||||
expect(triggerResponse.closeFunction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false when removing non-existent workflow', async () => {
|
||||
const result = await activeWorkflows.remove('non-existent');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(scheduledTaskManager.deregisterCrons).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle TriggerCloseError when closing trigger', async () => {
|
||||
const triggerCloseError = new TriggerCloseError(triggerNode, { level: 'warning' });
|
||||
(triggerResponse.closeFunction as jest.Mock).mockRejectedValueOnce(triggerCloseError);
|
||||
|
||||
const result = await setupForRemoval();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(activeWorkflows.isActive(workflowId)).toBe(false);
|
||||
expect(triggerResponse.closeFunction).toHaveBeenCalled();
|
||||
expect(errorReporter.error).toHaveBeenCalledWith(triggerCloseError, {
|
||||
extra: { workflowId },
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw WorkflowDeactivationError when closeFunction throws regular error', async () => {
|
||||
const error = new Error('Close function failed');
|
||||
(triggerResponse.closeFunction as jest.Mock).mockRejectedValueOnce(error);
|
||||
|
||||
await addWorkflow({ triggerNodes: [triggerNode] });
|
||||
|
||||
await expect(activeWorkflows.remove(workflowId)).rejects.toThrow(
|
||||
`Failed to deactivate trigger of workflow ID "${workflowId}": "Close function failed"`,
|
||||
);
|
||||
|
||||
expect(triggerResponse.closeFunction).toHaveBeenCalled();
|
||||
expect(errorReporter.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('get() and isActive()', () => {
|
||||
it('should return workflow data for active workflow', async () => {
|
||||
await addWorkflow({ triggerNodes: [triggerNode] });
|
||||
|
||||
expect(activeWorkflows.isActive(workflowId)).toBe(true);
|
||||
expect(activeWorkflows.get(workflowId)).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return undefined for non-active workflow', () => {
|
||||
expect(activeWorkflows.isActive('non-existent')).toBe(false);
|
||||
expect(activeWorkflows.get('non-existent')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('allActiveWorkflows()', () => {
|
||||
it('should return all active workflow IDs', async () => {
|
||||
await addWorkflow({ triggerNodes: [triggerNode] });
|
||||
|
||||
const activeIds = activeWorkflows.allActiveWorkflows();
|
||||
|
||||
expect(activeIds).toEqual([workflowId]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeAllTriggerAndPollerBasedWorkflows()', () => {
|
||||
it('should remove all active workflows', async () => {
|
||||
await addWorkflow({ triggerNodes: [triggerNode] });
|
||||
|
||||
await activeWorkflows.removeAllTriggerAndPollerBasedWorkflows();
|
||||
|
||||
expect(activeWorkflows.allActiveWorkflows()).toEqual([]);
|
||||
expect(scheduledTaskManager.deregisterCrons).toHaveBeenCalledWith(workflowId);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,421 @@
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import {
|
||||
ContextEstablishmentHook,
|
||||
ContextEstablishmentHookMetadata,
|
||||
type ContextEstablishmentOptions,
|
||||
type ContextEstablishmentResult,
|
||||
type IContextEstablishmentHook,
|
||||
} from '@n8n/decorators';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { ExecutionContextHookRegistry } from '../execution-context-hook-registry.service';
|
||||
|
||||
describe('ExecutionContextHookRegistry', () => {
|
||||
let registry: ExecutionContextHookRegistry;
|
||||
let hookMetadata: ContextEstablishmentHookMetadata;
|
||||
let mockLogger: Logger;
|
||||
|
||||
beforeAll(() => {
|
||||
// Set up Container dependencies once for all tests
|
||||
mockLogger = {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
hookMetadata = Container.get(ContextEstablishmentHookMetadata);
|
||||
Container.set(Logger, mockLogger);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Manually clear the metadata's internal Set to remove hooks from previous tests
|
||||
// This prevents hooks from accumulating across tests
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(hookMetadata as any).contextEstablishmentHooks.clear();
|
||||
|
||||
// Create fresh registry instance for each test
|
||||
registry = new ExecutionContextHookRegistry(hookMetadata, mockLogger);
|
||||
});
|
||||
|
||||
describe('init()', () => {
|
||||
it('should register hooks from metadata', async () => {
|
||||
@ContextEstablishmentHook()
|
||||
class TestHook implements IContextEstablishmentHook {
|
||||
hookDescription = { name: 'test.hook' };
|
||||
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
|
||||
return {};
|
||||
}
|
||||
isApplicableToTriggerNode(_nodeType: string): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
await registry.init();
|
||||
|
||||
const hook = registry.getHookByName('test.hook');
|
||||
expect(hook).toBeDefined();
|
||||
expect(hook).toBeInstanceOf(TestHook);
|
||||
});
|
||||
|
||||
it('should register multiple hooks', async () => {
|
||||
@ContextEstablishmentHook()
|
||||
class FirstHook implements IContextEstablishmentHook {
|
||||
hookDescription = { name: 'first.hook' };
|
||||
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
|
||||
return {};
|
||||
}
|
||||
isApplicableToTriggerNode(_nodeType: string): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@ContextEstablishmentHook()
|
||||
class SecondHook implements IContextEstablishmentHook {
|
||||
hookDescription = { name: 'second.hook' };
|
||||
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
|
||||
return {};
|
||||
}
|
||||
isApplicableToTriggerNode(_nodeType: string): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
await registry.init();
|
||||
|
||||
const allHooks = registry.getAllHooks();
|
||||
expect(allHooks).toHaveLength(2);
|
||||
expect(registry.getHookByName('first.hook')).toBeInstanceOf(FirstHook);
|
||||
expect(registry.getHookByName('second.hook')).toBeInstanceOf(SecondHook);
|
||||
});
|
||||
|
||||
it('should call optional init() method on hooks', async () => {
|
||||
const initSpy = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
@ContextEstablishmentHook()
|
||||
// @ts-expect-error - Class is used via decorator side-effect
|
||||
class HookWithInit implements IContextEstablishmentHook {
|
||||
hookDescription = { name: 'hook.with.init' };
|
||||
init = initSpy;
|
||||
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
|
||||
return {};
|
||||
}
|
||||
isApplicableToTriggerNode(_nodeType: string): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
await registry.init();
|
||||
|
||||
expect(initSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not fail if hook does not have init() method', async () => {
|
||||
@ContextEstablishmentHook()
|
||||
// @ts-expect-error - Class is used via decorator side-effect
|
||||
class HookWithoutInit implements IContextEstablishmentHook {
|
||||
hookDescription = { name: 'hook.without.init' };
|
||||
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
|
||||
return {};
|
||||
}
|
||||
isApplicableToTriggerNode(_nodeType: string): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
await expect(registry.init()).resolves.not.toThrow();
|
||||
expect(registry.getHookByName('hook.without.init')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should skip hook registration if init() throws an error', async () => {
|
||||
const initError = new Error('Hook initialization failed');
|
||||
|
||||
@ContextEstablishmentHook()
|
||||
// @ts-expect-error - Class is used via decorator side-effect
|
||||
class FailingHook implements IContextEstablishmentHook {
|
||||
hookDescription = { name: 'failing.hook' };
|
||||
init = jest.fn().mockRejectedValue(initError);
|
||||
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
|
||||
return {};
|
||||
}
|
||||
isApplicableToTriggerNode(_nodeType: string): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@ContextEstablishmentHook()
|
||||
// @ts-expect-error - Class is used via decorator side-effect
|
||||
class SuccessfulHook implements IContextEstablishmentHook {
|
||||
hookDescription = { name: 'successful.hook' };
|
||||
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
|
||||
return {};
|
||||
}
|
||||
isApplicableToTriggerNode(_nodeType: string): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
await registry.init();
|
||||
|
||||
// Failing hook should NOT be registered
|
||||
expect(registry.getHookByName('failing.hook')).toBeUndefined();
|
||||
|
||||
// Successful hook should be registered
|
||||
expect(registry.getHookByName('successful.hook')).toBeDefined();
|
||||
|
||||
// Error should be logged
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to initialize execution context hook "failing.hook"'),
|
||||
expect.objectContaining({ error: initError }),
|
||||
);
|
||||
|
||||
// Only successful hook in registry
|
||||
expect(registry.getAllHooks()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should clear previous hooks when re-initialized', async () => {
|
||||
@ContextEstablishmentHook()
|
||||
// @ts-expect-error - Class is used via decorator side-effect
|
||||
class TestHook implements IContextEstablishmentHook {
|
||||
hookDescription = { name: 'test.hook' };
|
||||
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
|
||||
return {};
|
||||
}
|
||||
isApplicableToTriggerNode(_nodeType: string): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
await registry.init();
|
||||
expect(registry.getAllHooks()).toHaveLength(1);
|
||||
|
||||
// Re-initialize
|
||||
await registry.init();
|
||||
expect(registry.getAllHooks()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle duplicate hook names by keeping first registered and warning', async () => {
|
||||
@ContextEstablishmentHook()
|
||||
// @ts-expect-error - Class is used via decorator side-effect
|
||||
class FirstHook implements IContextEstablishmentHook {
|
||||
hookDescription = { name: 'duplicate.hook' };
|
||||
value = 'first';
|
||||
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
|
||||
return {};
|
||||
}
|
||||
isApplicableToTriggerNode(_nodeType: string): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@ContextEstablishmentHook()
|
||||
// @ts-expect-error - Class is used via decorator side-effect
|
||||
class SecondHook implements IContextEstablishmentHook {
|
||||
hookDescription = { name: 'duplicate.hook' };
|
||||
value = 'second';
|
||||
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
|
||||
return {};
|
||||
}
|
||||
isApplicableToTriggerNode(_nodeType: string): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
await registry.init();
|
||||
|
||||
// First hook should win
|
||||
const hook = registry.getHookByName('duplicate.hook');
|
||||
expect(hook).toBeDefined();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((hook as any).value).toBe('first');
|
||||
|
||||
// Should have logged a warning about the duplicate
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Execution context hook with name "duplicate.hook" is already registered',
|
||||
),
|
||||
);
|
||||
|
||||
// Only one hook should be registered despite duplicate names
|
||||
const allHooks = registry.getAllHooks();
|
||||
expect(allHooks).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHookByName()', () => {
|
||||
it('should return hook by name', async () => {
|
||||
@ContextEstablishmentHook()
|
||||
class TestHook implements IContextEstablishmentHook {
|
||||
hookDescription = { name: 'credentials.bearerToken' };
|
||||
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
|
||||
return {};
|
||||
}
|
||||
isApplicableToTriggerNode(_nodeType: string): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
await registry.init();
|
||||
|
||||
const hook = registry.getHookByName('credentials.bearerToken');
|
||||
expect(hook).toBeInstanceOf(TestHook);
|
||||
expect(hook?.hookDescription.name).toBe('credentials.bearerToken');
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent hook', async () => {
|
||||
await registry.init();
|
||||
|
||||
const hook = registry.getHookByName('non.existent.hook');
|
||||
expect(hook).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined before initialization', () => {
|
||||
const hook = registry.getHookByName('any.hook');
|
||||
expect(hook).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllHooks()', () => {
|
||||
it('should return empty array when no hooks registered', async () => {
|
||||
await registry.init();
|
||||
|
||||
const hooks = registry.getAllHooks();
|
||||
expect(hooks).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return all registered hooks', async () => {
|
||||
@ContextEstablishmentHook()
|
||||
// @ts-expect-error - Class is used via decorator side-effect
|
||||
class FirstHook implements IContextEstablishmentHook {
|
||||
hookDescription = { name: 'first.hook' };
|
||||
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
|
||||
return {};
|
||||
}
|
||||
isApplicableToTriggerNode(_nodeType: string): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@ContextEstablishmentHook()
|
||||
// @ts-expect-error - Class is used via decorator side-effect
|
||||
class SecondHook implements IContextEstablishmentHook {
|
||||
hookDescription = { name: 'second.hook' };
|
||||
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
|
||||
return {};
|
||||
}
|
||||
isApplicableToTriggerNode(_nodeType: string): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@ContextEstablishmentHook()
|
||||
// @ts-expect-error - Class is used via decorator side-effect
|
||||
class ThirdHook implements IContextEstablishmentHook {
|
||||
hookDescription = { name: 'third.hook' };
|
||||
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
|
||||
return {};
|
||||
}
|
||||
isApplicableToTriggerNode(_nodeType: string): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
await registry.init();
|
||||
|
||||
const hooks = registry.getAllHooks();
|
||||
expect(hooks).toHaveLength(3);
|
||||
expect(hooks.some((h) => h.hookDescription.name === 'first.hook')).toBe(true);
|
||||
expect(hooks.some((h) => h.hookDescription.name === 'second.hook')).toBe(true);
|
||||
expect(hooks.some((h) => h.hookDescription.name === 'third.hook')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHookForTriggerType()', () => {
|
||||
beforeEach(async () => {
|
||||
@ContextEstablishmentHook()
|
||||
// @ts-expect-error - Class is used via decorator side-effect
|
||||
class WebhookHook implements IContextEstablishmentHook {
|
||||
hookDescription = { name: 'webhook.hook' };
|
||||
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
|
||||
return {};
|
||||
}
|
||||
isApplicableToTriggerNode(nodeType: string): boolean {
|
||||
return nodeType === 'n8n-nodes-base.webhook';
|
||||
}
|
||||
}
|
||||
|
||||
@ContextEstablishmentHook()
|
||||
// @ts-expect-error - Class is used via decorator side-effect
|
||||
class FormHook implements IContextEstablishmentHook {
|
||||
hookDescription = { name: 'form.hook' };
|
||||
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
|
||||
return {};
|
||||
}
|
||||
isApplicableToTriggerNode(nodeType: string): boolean {
|
||||
return nodeType === 'n8n-nodes-base.formTrigger';
|
||||
}
|
||||
}
|
||||
|
||||
@ContextEstablishmentHook()
|
||||
// @ts-expect-error - Class is used via decorator side-effect
|
||||
class UniversalHook implements IContextEstablishmentHook {
|
||||
hookDescription = { name: 'universal.hook' };
|
||||
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
|
||||
return {};
|
||||
}
|
||||
isApplicableToTriggerNode(_nodeType: string): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
await registry.init();
|
||||
});
|
||||
|
||||
it('should return hooks applicable to trigger type', () => {
|
||||
const webhookHooks = registry.getHookForTriggerType('n8n-nodes-base.webhook');
|
||||
expect(webhookHooks).toHaveLength(2);
|
||||
expect(webhookHooks.some((h) => h.hookDescription.name === 'webhook.hook')).toBe(true);
|
||||
expect(webhookHooks.some((h) => h.hookDescription.name === 'universal.hook')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return different hooks for different trigger types', () => {
|
||||
const formHooks = registry.getHookForTriggerType('n8n-nodes-base.formTrigger');
|
||||
expect(formHooks).toHaveLength(2);
|
||||
expect(formHooks.some((h) => h.hookDescription.name === 'form.hook')).toBe(true);
|
||||
expect(formHooks.some((h) => h.hookDescription.name === 'universal.hook')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return only universal hooks for unknown trigger type', () => {
|
||||
const unknownHooks = registry.getHookForTriggerType('n8n-nodes-base.unknown');
|
||||
expect(unknownHooks).toHaveLength(1);
|
||||
expect(unknownHooks[0].hookDescription.name).toBe('universal.hook');
|
||||
});
|
||||
|
||||
it('should return empty array when no hooks match trigger type', async () => {
|
||||
// Clear existing hooks and create registry with only specific hook
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(hookMetadata as any).contextEstablishmentHooks.clear();
|
||||
|
||||
@ContextEstablishmentHook()
|
||||
// @ts-expect-error - Class is used via decorator side-effect
|
||||
class SpecificHook implements IContextEstablishmentHook {
|
||||
hookDescription = { name: 'specific.hook' };
|
||||
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
|
||||
return {};
|
||||
}
|
||||
isApplicableToTriggerNode(nodeType: string): boolean {
|
||||
return nodeType === 'n8n-nodes-base.webhook';
|
||||
}
|
||||
}
|
||||
|
||||
const newRegistry = new ExecutionContextHookRegistry(hookMetadata, mockLogger);
|
||||
await newRegistry.init();
|
||||
|
||||
const hooks = newRegistry.getHookForTriggerType('n8n-nodes-base.formTrigger');
|
||||
expect(hooks).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,623 @@
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { IContextEstablishmentHook } from '@n8n/decorators';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
IExecuteData,
|
||||
IExecutionContext,
|
||||
INode,
|
||||
INodeExecutionData,
|
||||
PlaintextExecutionContext,
|
||||
Workflow,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import type { Cipher } from '@/encryption';
|
||||
|
||||
import type { ExecutionContextHookRegistry } from '../execution-context-hook-registry.service';
|
||||
import { ExecutionContextService } from '../execution-context.service';
|
||||
|
||||
// Mock the helper functions from n8n-workflow
|
||||
jest.mock('n8n-workflow', () => ({
|
||||
...jest.requireActual('n8n-workflow'),
|
||||
toCredentialContext: jest.fn((data: string) => JSON.parse(data)),
|
||||
toExecutionContextEstablishmentHookParameter: jest.fn(),
|
||||
}));
|
||||
|
||||
const { toCredentialContext, toExecutionContextEstablishmentHookParameter } =
|
||||
jest.requireMock('n8n-workflow');
|
||||
|
||||
describe('ExecutionContextService', () => {
|
||||
let service: ExecutionContextService;
|
||||
let mockLogger: jest.Mocked<Logger>;
|
||||
let mockRegistry: jest.Mocked<ExecutionContextHookRegistry>;
|
||||
let mockCipher: jest.Mocked<Cipher>;
|
||||
let mockWorkflow: Workflow;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockLogger = {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
} as unknown as jest.Mocked<Logger>;
|
||||
|
||||
mockRegistry = {
|
||||
getHookByName: jest.fn(),
|
||||
} as unknown as jest.Mocked<ExecutionContextHookRegistry>;
|
||||
|
||||
mockCipher = {
|
||||
decrypt: jest.fn(),
|
||||
encrypt: jest.fn(),
|
||||
} as unknown as jest.Mocked<Cipher>;
|
||||
|
||||
mockWorkflow = mock<Workflow>();
|
||||
|
||||
service = new ExecutionContextService(mockLogger, mockRegistry, mockCipher);
|
||||
});
|
||||
|
||||
describe('decryptExecutionContext()', () => {
|
||||
it('should return context as-is when no credentials present', () => {
|
||||
const context: IExecutionContext = {
|
||||
version: 1,
|
||||
establishedAt: Date.now(),
|
||||
source: 'manual',
|
||||
};
|
||||
|
||||
const result = service.decryptExecutionContext(context);
|
||||
|
||||
expect(result).toEqual({
|
||||
...context,
|
||||
credentials: undefined,
|
||||
});
|
||||
expect(mockCipher.decrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should decrypt credentials when present', () => {
|
||||
const encryptedCreds = 'encrypted_data';
|
||||
const decryptedCreds = '{"version":1,"identity":"token123"}';
|
||||
const parsedCreds = { version: 1, identity: 'token123' };
|
||||
|
||||
const context: IExecutionContext = {
|
||||
version: 1,
|
||||
establishedAt: Date.now(),
|
||||
source: 'webhook',
|
||||
credentials: encryptedCreds,
|
||||
};
|
||||
|
||||
mockCipher.decrypt.mockReturnValue(decryptedCreds);
|
||||
toCredentialContext.mockReturnValue(parsedCreds);
|
||||
|
||||
const result = service.decryptExecutionContext(context);
|
||||
|
||||
expect(mockCipher.decrypt).toHaveBeenCalledWith(encryptedCreds);
|
||||
expect(toCredentialContext).toHaveBeenCalledWith(decryptedCreds);
|
||||
expect(result).toEqual({
|
||||
...context,
|
||||
credentials: parsedCreds,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('encryptExecutionContext()', () => {
|
||||
it('should return context as-is when no credentials present', () => {
|
||||
const context: PlaintextExecutionContext = {
|
||||
version: 1,
|
||||
establishedAt: Date.now(),
|
||||
source: 'manual',
|
||||
};
|
||||
|
||||
const result = service.encryptExecutionContext(context);
|
||||
|
||||
expect(result).toEqual({
|
||||
...context,
|
||||
credentials: undefined,
|
||||
});
|
||||
expect(mockCipher.encrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should encrypt credentials when present', () => {
|
||||
const plaintextCreds = { version: 1 as const, identity: 'token123' };
|
||||
const encryptedCreds = 'encrypted_data';
|
||||
|
||||
const context: PlaintextExecutionContext = {
|
||||
version: 1,
|
||||
establishedAt: Date.now(),
|
||||
source: 'webhook',
|
||||
credentials: plaintextCreds,
|
||||
};
|
||||
|
||||
mockCipher.encrypt.mockReturnValue(encryptedCreds);
|
||||
|
||||
const result = service.encryptExecutionContext(context);
|
||||
|
||||
expect(mockCipher.encrypt).toHaveBeenCalledWith(plaintextCreds);
|
||||
expect(result).toEqual({
|
||||
...context,
|
||||
credentials: encryptedCreds,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeExecutionContexts()', () => {
|
||||
it('should merge simple properties from contextToMerge into baseContext', () => {
|
||||
const baseContext: PlaintextExecutionContext = {
|
||||
version: 1,
|
||||
establishedAt: 100,
|
||||
source: 'manual',
|
||||
};
|
||||
|
||||
const contextToMerge: Partial<PlaintextExecutionContext> = {
|
||||
establishedAt: 200,
|
||||
source: 'webhook',
|
||||
};
|
||||
|
||||
const result = service.mergeExecutionContexts(baseContext, contextToMerge);
|
||||
|
||||
expect(result).toEqual({
|
||||
version: 1,
|
||||
establishedAt: 200,
|
||||
source: 'webhook',
|
||||
});
|
||||
});
|
||||
|
||||
it('should merge credentials deeply', () => {
|
||||
const baseContext: PlaintextExecutionContext = {
|
||||
version: 1,
|
||||
establishedAt: 100,
|
||||
source: 'manual',
|
||||
credentials: {
|
||||
version: 1 as const,
|
||||
identity: 'base_token',
|
||||
},
|
||||
};
|
||||
|
||||
const contextToMerge: Partial<PlaintextExecutionContext> = {
|
||||
credentials: {
|
||||
version: 1 as const,
|
||||
identity: 'base_token',
|
||||
metadata: { source: 'bearer-token' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = service.mergeExecutionContexts(baseContext, contextToMerge);
|
||||
|
||||
expect(result).toEqual({
|
||||
version: 1,
|
||||
establishedAt: 100,
|
||||
source: 'manual',
|
||||
credentials: {
|
||||
version: 1,
|
||||
identity: 'base_token',
|
||||
metadata: { source: 'bearer-token' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve baseContext properties not in contextToMerge', () => {
|
||||
const baseContext: PlaintextExecutionContext = {
|
||||
version: 1,
|
||||
establishedAt: 100,
|
||||
source: 'manual',
|
||||
credentials: { version: 1 as const, identity: 'token' },
|
||||
};
|
||||
|
||||
const contextToMerge: Partial<PlaintextExecutionContext> = {
|
||||
source: 'webhook',
|
||||
};
|
||||
|
||||
const result = service.mergeExecutionContexts(baseContext, contextToMerge);
|
||||
|
||||
expect(result).toEqual({
|
||||
version: 1,
|
||||
establishedAt: 100,
|
||||
source: 'webhook',
|
||||
credentials: { version: 1, identity: 'token' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty contextToMerge', () => {
|
||||
const baseContext: PlaintextExecutionContext = {
|
||||
version: 1,
|
||||
establishedAt: 100,
|
||||
source: 'manual',
|
||||
};
|
||||
|
||||
const result = service.mergeExecutionContexts(baseContext, {});
|
||||
|
||||
expect(result).toEqual(baseContext);
|
||||
});
|
||||
});
|
||||
|
||||
describe('augmentExecutionContextWithHooks()', () => {
|
||||
const createMockStartItem = (
|
||||
contextEstablishmentHooks?: unknown,
|
||||
triggerItems: INodeExecutionData[] = [{ json: {} }],
|
||||
): IExecuteData => ({
|
||||
node: {
|
||||
parameters: contextEstablishmentHooks ? { contextEstablishmentHooks } : {},
|
||||
} as INode,
|
||||
data: { main: [triggerItems] },
|
||||
source: { main: [{ previousNode: 'test' }] },
|
||||
});
|
||||
|
||||
it('should return original context when no hooks configured', async () => {
|
||||
const startItem = createMockStartItem();
|
||||
const context: IExecutionContext = {
|
||||
version: 1,
|
||||
establishedAt: Date.now(),
|
||||
source: 'manual',
|
||||
};
|
||||
|
||||
const result = await service.augmentExecutionContextWithHooks(
|
||||
mockWorkflow,
|
||||
startItem,
|
||||
context,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
context,
|
||||
triggerItems: startItem.data.main[0],
|
||||
});
|
||||
expect(mockRegistry.getHookByName).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle node with contextEstablishmentHooks but undefined hooks array', async () => {
|
||||
// Temporarily use real parsing function
|
||||
const realModule = jest.requireActual('n8n-workflow');
|
||||
toExecutionContextEstablishmentHookParameter.mockImplementationOnce(
|
||||
realModule.toExecutionContextEstablishmentHookParameter,
|
||||
);
|
||||
|
||||
// Node parameters with executionsHooksVersion but no hooks array
|
||||
const startItem: IExecuteData = {
|
||||
node: {
|
||||
name: 'Webhook',
|
||||
parameters: {
|
||||
executionsHooksVersion: 1,
|
||||
contextEstablishmentHooks: {
|
||||
// hooks array is undefined - should default to []
|
||||
},
|
||||
},
|
||||
} as unknown as INode,
|
||||
data: { main: [[{ json: {} }]] },
|
||||
source: { main: [{ previousNode: 'test' }] },
|
||||
};
|
||||
|
||||
const context: IExecutionContext = {
|
||||
version: 1,
|
||||
establishedAt: Date.now(),
|
||||
source: 'manual',
|
||||
};
|
||||
|
||||
// Mock workflow.getNode to return null (service handles this gracefully)
|
||||
mockWorkflow.getNode = jest.fn().mockReturnValue(null);
|
||||
|
||||
const result = await service.augmentExecutionContextWithHooks(
|
||||
mockWorkflow,
|
||||
startItem,
|
||||
context,
|
||||
);
|
||||
|
||||
// Should return original context unchanged
|
||||
expect(result).toEqual({
|
||||
context,
|
||||
triggerItems: startItem.data.main[0],
|
||||
});
|
||||
|
||||
// No hooks should be called since array defaults to empty
|
||||
expect(mockRegistry.getHookByName).not.toHaveBeenCalled();
|
||||
|
||||
// No warning should be logged (valid schema with optional hooks)
|
||||
expect(mockLogger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should execute hooks sequentially and merge context updates', async () => {
|
||||
const triggerItems: INodeExecutionData[] = [{ json: { data: 'value' } }];
|
||||
const hookConfig = {
|
||||
hooks: [
|
||||
{ hookName: 'hook1', isAllowedToFail: false, opt1: 'val1' },
|
||||
{ hookName: 'hook2', isAllowedToFail: false, opt2: 'val2' },
|
||||
],
|
||||
};
|
||||
const startItem = createMockStartItem(hookConfig, triggerItems);
|
||||
const initialContext: IExecutionContext = {
|
||||
version: 1,
|
||||
establishedAt: Date.now(),
|
||||
source: 'webhook',
|
||||
};
|
||||
|
||||
const mockHook1 = mock<IContextEstablishmentHook>();
|
||||
const mockHook2 = mock<IContextEstablishmentHook>();
|
||||
|
||||
mockHook1.execute.mockResolvedValue({
|
||||
contextUpdate: {
|
||||
credentials: { version: 1 as const, identity: 'hook1_token' },
|
||||
},
|
||||
});
|
||||
|
||||
mockHook2.execute.mockResolvedValue({
|
||||
contextUpdate: {
|
||||
credentials: {
|
||||
version: 1 as const,
|
||||
identity: 'hook1_token',
|
||||
metadata: { source: 'hook2' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
toExecutionContextEstablishmentHookParameter.mockReturnValue({
|
||||
data: { contextEstablishmentHooks: hookConfig },
|
||||
});
|
||||
|
||||
mockRegistry.getHookByName.mockImplementation((name: string) => {
|
||||
if (name === 'hook1') return mockHook1;
|
||||
if (name === 'hook2') return mockHook2;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockCipher.decrypt.mockReturnValue('{}');
|
||||
toCredentialContext.mockImplementation((data: string) => JSON.parse(data));
|
||||
mockCipher.encrypt.mockImplementation((data: unknown) => JSON.stringify(data));
|
||||
|
||||
const result = await service.augmentExecutionContextWithHooks(
|
||||
mockWorkflow,
|
||||
startItem,
|
||||
initialContext,
|
||||
);
|
||||
|
||||
// Verify hooks were called in order with correct options
|
||||
expect(mockHook1.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
triggerNode: startItem.node,
|
||||
workflow: mockWorkflow,
|
||||
triggerItems,
|
||||
options: { hookName: 'hook1', isAllowedToFail: false, opt1: 'val1' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockHook2.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
triggerNode: startItem.node,
|
||||
workflow: mockWorkflow,
|
||||
triggerItems,
|
||||
options: { hookName: 'hook2', isAllowedToFail: false, opt2: 'val2' },
|
||||
context: expect.objectContaining({
|
||||
credentials: { version: 1 as const, identity: 'hook1_token' },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify context was merged correctly
|
||||
expect(result.context).toBeDefined();
|
||||
});
|
||||
|
||||
it('should update trigger items when hook modifies them', async () => {
|
||||
const originalItems: INodeExecutionData[] = [
|
||||
{ json: { headers: { authorization: 'Bearer secret' } } },
|
||||
];
|
||||
const modifiedItems: INodeExecutionData[] = [
|
||||
{ json: { headers: { authorization: undefined } } },
|
||||
];
|
||||
const hookConfig = {
|
||||
hooks: [{ hookName: 'hook', isAllowedToFail: false }],
|
||||
};
|
||||
const startItem = createMockStartItem(hookConfig, originalItems);
|
||||
|
||||
const mockHook = mock<IContextEstablishmentHook>();
|
||||
mockHook.execute.mockResolvedValue({
|
||||
triggerItems: modifiedItems,
|
||||
contextUpdate: { credentials: { version: 1 as const, identity: 'secret' } },
|
||||
});
|
||||
|
||||
toExecutionContextEstablishmentHookParameter.mockReturnValue({
|
||||
data: { contextEstablishmentHooks: hookConfig },
|
||||
});
|
||||
mockRegistry.getHookByName.mockReturnValue(mockHook);
|
||||
mockCipher.decrypt.mockReturnValue('{}');
|
||||
toCredentialContext.mockReturnValue({});
|
||||
mockCipher.encrypt.mockImplementation((data: unknown) => JSON.stringify(data));
|
||||
|
||||
const result = await service.augmentExecutionContextWithHooks(mockWorkflow, startItem, {
|
||||
version: 1,
|
||||
establishedAt: Date.now(),
|
||||
source: 'webhook',
|
||||
});
|
||||
|
||||
expect(result.triggerItems).toEqual(modifiedItems);
|
||||
});
|
||||
|
||||
it('should pass modified trigger items to subsequent hooks', async () => {
|
||||
const item1: INodeExecutionData[] = [{ json: { step: 1 } }];
|
||||
const item2: INodeExecutionData[] = [{ json: { step: 2 } }];
|
||||
const item3: INodeExecutionData[] = [{ json: { step: 3 } }];
|
||||
const hookConfig = {
|
||||
hooks: [
|
||||
{ hookName: 'hook1', isAllowedToFail: false },
|
||||
{ hookName: 'hook2', isAllowedToFail: false },
|
||||
],
|
||||
};
|
||||
const startItem = createMockStartItem(hookConfig, item1);
|
||||
|
||||
const mockHook1 = mock<IContextEstablishmentHook>();
|
||||
const mockHook2 = mock<IContextEstablishmentHook>();
|
||||
|
||||
mockHook1.execute.mockResolvedValue({ triggerItems: item2 });
|
||||
mockHook2.execute.mockResolvedValue({ triggerItems: item3 });
|
||||
|
||||
toExecutionContextEstablishmentHookParameter.mockReturnValue({
|
||||
data: { contextEstablishmentHooks: hookConfig },
|
||||
});
|
||||
|
||||
mockRegistry.getHookByName.mockImplementation((name: string) => {
|
||||
if (name === 'hook1') return mockHook1;
|
||||
if (name === 'hook2') return mockHook2;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockCipher.decrypt.mockReturnValue('{}');
|
||||
toCredentialContext.mockReturnValue({});
|
||||
mockCipher.encrypt.mockReturnValue('encrypted');
|
||||
|
||||
await service.augmentExecutionContextWithHooks(mockWorkflow, startItem, {
|
||||
version: 1,
|
||||
establishedAt: Date.now(),
|
||||
source: 'webhook',
|
||||
});
|
||||
|
||||
// Hook 2 should receive items modified by hook 1
|
||||
expect(mockHook2.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
triggerItems: item2,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip hooks not found in registry and log warning', async () => {
|
||||
const hookConfig = {
|
||||
hooks: [{ hookName: 'nonexistent', isAllowedToFail: false }],
|
||||
};
|
||||
const startItem = createMockStartItem(hookConfig);
|
||||
|
||||
toExecutionContextEstablishmentHookParameter.mockReturnValue({
|
||||
data: { contextEstablishmentHooks: hookConfig },
|
||||
});
|
||||
mockRegistry.getHookByName.mockReturnValue(undefined);
|
||||
mockCipher.decrypt.mockReturnValue('{}');
|
||||
toCredentialContext.mockReturnValue({});
|
||||
mockCipher.encrypt.mockReturnValue('encrypted');
|
||||
|
||||
const result = await service.augmentExecutionContextWithHooks(mockWorkflow, startItem, {
|
||||
version: 1,
|
||||
establishedAt: Date.now(),
|
||||
source: 'webhook',
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
'Execution context establishment hook nonexistent not found, skipping this hook',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle hook errors when isAllowedToFail is true', async () => {
|
||||
const hookConfig = {
|
||||
hooks: [
|
||||
{ hookName: 'hook1', isAllowedToFail: true },
|
||||
{ hookName: 'hook2', isAllowedToFail: false },
|
||||
],
|
||||
};
|
||||
const startItem = createMockStartItem(hookConfig);
|
||||
const hookError = new Error('Hook execution failed');
|
||||
|
||||
const mockHook1 = mock<IContextEstablishmentHook>();
|
||||
const mockHook2 = mock<IContextEstablishmentHook>();
|
||||
|
||||
mockHook1.execute.mockRejectedValue(hookError);
|
||||
mockHook2.execute.mockResolvedValue({
|
||||
contextUpdate: { credentials: { version: 1 as const, identity: 'token' } },
|
||||
});
|
||||
|
||||
toExecutionContextEstablishmentHookParameter.mockReturnValue({
|
||||
data: { contextEstablishmentHooks: hookConfig },
|
||||
});
|
||||
|
||||
mockRegistry.getHookByName.mockImplementation((name: string) => {
|
||||
if (name === 'hook1') return mockHook1;
|
||||
if (name === 'hook2') return mockHook2;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockCipher.decrypt.mockReturnValue('{}');
|
||||
toCredentialContext.mockReturnValue({});
|
||||
mockCipher.encrypt.mockImplementation((data: unknown) => JSON.stringify(data));
|
||||
|
||||
const result = await service.augmentExecutionContextWithHooks(mockWorkflow, startItem, {
|
||||
version: 1,
|
||||
establishedAt: Date.now(),
|
||||
source: 'webhook',
|
||||
});
|
||||
|
||||
// Should log warning but continue
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
'Failed to execute context establishment hook hook1',
|
||||
{ error: hookError },
|
||||
);
|
||||
|
||||
// Should still execute hook2
|
||||
expect(mockHook2.execute).toHaveBeenCalled();
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('should throw hook errors when isAllowedToFail is false', async () => {
|
||||
const hookConfig = {
|
||||
hooks: [{ hookName: 'hook', isAllowedToFail: false }],
|
||||
};
|
||||
const startItem = createMockStartItem(hookConfig);
|
||||
const hookError = new Error('Critical hook failure');
|
||||
|
||||
const mockHook = mock<IContextEstablishmentHook>();
|
||||
mockHook.execute.mockRejectedValue(hookError);
|
||||
|
||||
toExecutionContextEstablishmentHookParameter.mockReturnValue({
|
||||
data: { contextEstablishmentHooks: hookConfig },
|
||||
});
|
||||
mockRegistry.getHookByName.mockReturnValue(mockHook);
|
||||
mockCipher.decrypt.mockReturnValue('{}');
|
||||
toCredentialContext.mockReturnValue({});
|
||||
|
||||
await expect(
|
||||
service.augmentExecutionContextWithHooks(mockWorkflow, startItem, {
|
||||
version: 1,
|
||||
establishedAt: Date.now(),
|
||||
source: 'webhook',
|
||||
}),
|
||||
).rejects.toThrow(hookError);
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
'Failed to execute context establishment hook hook',
|
||||
{ error: hookError },
|
||||
);
|
||||
});
|
||||
|
||||
it('should decrypt context before hooks and encrypt after', async () => {
|
||||
const hookConfig = {
|
||||
hooks: [{ hookName: 'hook', isAllowedToFail: false }],
|
||||
};
|
||||
const startItem = createMockStartItem(hookConfig);
|
||||
const encryptedContext: IExecutionContext = {
|
||||
version: 1,
|
||||
establishedAt: Date.now(),
|
||||
source: 'webhook',
|
||||
credentials: 'encrypted_data',
|
||||
};
|
||||
|
||||
const mockHook = mock<IContextEstablishmentHook>();
|
||||
mockHook.execute.mockResolvedValue({});
|
||||
|
||||
toExecutionContextEstablishmentHookParameter.mockReturnValue({
|
||||
data: { contextEstablishmentHooks: hookConfig },
|
||||
});
|
||||
mockRegistry.getHookByName.mockReturnValue(mockHook);
|
||||
mockCipher.decrypt.mockReturnValue('{"version":1,"identity":"decrypted"}');
|
||||
toCredentialContext.mockReturnValue({ version: 1, identity: 'decrypted' });
|
||||
mockCipher.encrypt.mockReturnValue('re_encrypted_data');
|
||||
|
||||
await service.augmentExecutionContextWithHooks(mockWorkflow, startItem, encryptedContext);
|
||||
|
||||
// Verify decrypt was called
|
||||
expect(mockCipher.decrypt).toHaveBeenCalledWith('encrypted_data');
|
||||
|
||||
// Verify hook received plaintext credentials
|
||||
expect(mockHook.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
context: expect.objectContaining({
|
||||
credentials: { version: 1 as const, identity: 'decrypted' },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify encrypt was called for return
|
||||
expect(mockCipher.encrypt).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,121 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteResponsePromiseData,
|
||||
INode,
|
||||
IRun,
|
||||
IRunExecutionData,
|
||||
ITaskData,
|
||||
ITaskStartedData,
|
||||
IWorkflowBase,
|
||||
Workflow,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import type {
|
||||
ExecutionLifecycleHookName,
|
||||
ExecutionLifecycleHookHandlers,
|
||||
} from '../execution-lifecycle-hooks';
|
||||
import { ExecutionLifecycleHooks } from '../execution-lifecycle-hooks';
|
||||
|
||||
describe('ExecutionLifecycleHooks', () => {
|
||||
const executionId = '123';
|
||||
const workflowData = mock<IWorkflowBase>();
|
||||
|
||||
let hooks: ExecutionLifecycleHooks;
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
hooks = new ExecutionLifecycleHooks('internal', executionId, workflowData);
|
||||
});
|
||||
|
||||
describe('constructor()', () => {
|
||||
it('should initialize with correct properties', () => {
|
||||
expect(hooks.mode).toBe('internal');
|
||||
expect(hooks.executionId).toBe(executionId);
|
||||
expect(hooks.workflowData).toBe(workflowData);
|
||||
expect(hooks.handlers).toEqual({
|
||||
nodeExecuteAfter: [],
|
||||
nodeExecuteBefore: [],
|
||||
nodeFetchedData: [],
|
||||
sendResponse: [],
|
||||
workflowExecuteAfter: [],
|
||||
workflowExecuteBefore: [],
|
||||
workflowExecuteResume: [],
|
||||
sendChunk: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('addHandler()', () => {
|
||||
const hooksHandlers =
|
||||
mock<{
|
||||
[K in keyof ExecutionLifecycleHookHandlers]: ExecutionLifecycleHookHandlers[K][number];
|
||||
}>();
|
||||
|
||||
const testCases: Array<{
|
||||
hook: ExecutionLifecycleHookName;
|
||||
args: Parameters<
|
||||
ExecutionLifecycleHookHandlers[keyof ExecutionLifecycleHookHandlers][number]
|
||||
>;
|
||||
}> = [
|
||||
{ hook: 'nodeExecuteBefore', args: ['testNode', mock<ITaskStartedData>()] },
|
||||
{
|
||||
hook: 'nodeExecuteAfter',
|
||||
args: ['testNode', mock<ITaskData>(), mock<IRunExecutionData>()],
|
||||
},
|
||||
{ hook: 'workflowExecuteBefore', args: [mock<Workflow>(), mock<IRunExecutionData>()] },
|
||||
{ hook: 'workflowExecuteAfter', args: [mock<IRun>(), mock<IDataObject>()] },
|
||||
{ hook: 'workflowExecuteResume', args: [mock<Workflow>(), mock<IRunExecutionData>()] },
|
||||
{ hook: 'sendResponse', args: [mock<IExecuteResponsePromiseData>()] },
|
||||
{ hook: 'nodeFetchedData', args: ['workflow123', mock<INode>()] },
|
||||
];
|
||||
|
||||
test.each(testCases)(
|
||||
'should add handlers to $hook hook and call them',
|
||||
async ({ hook, args }) => {
|
||||
hooks.addHandler(hook, hooksHandlers[hook]);
|
||||
await hooks.runHook(hook, args);
|
||||
expect(hooksHandlers[hook]).toHaveBeenCalledWith(...args);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('runHook()', () => {
|
||||
it('should execute multiple hooks in order', async () => {
|
||||
const executionOrder: string[] = [];
|
||||
const hook1 = jest.fn().mockImplementation(async () => {
|
||||
executionOrder.push('hook1');
|
||||
});
|
||||
const hook2 = jest.fn().mockImplementation(async () => {
|
||||
executionOrder.push('hook2');
|
||||
});
|
||||
|
||||
hooks.addHandler('nodeExecuteBefore', hook1, hook2);
|
||||
await hooks.runHook('nodeExecuteBefore', ['testNode', mock()]);
|
||||
|
||||
expect(executionOrder).toEqual(['hook1', 'hook2']);
|
||||
expect(hook1).toHaveBeenCalled();
|
||||
expect(hook2).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should maintain correct "this" context', async () => {
|
||||
const hook = jest.fn().mockImplementation(async function (this: ExecutionLifecycleHooks) {
|
||||
expect(this.executionId).toBe(executionId);
|
||||
expect(this.mode).toBe('internal');
|
||||
});
|
||||
|
||||
hooks.addHandler('nodeExecuteBefore', hook);
|
||||
await hooks.runHook('nodeExecuteBefore', ['testNode', mock()]);
|
||||
|
||||
expect(hook).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors in hooks', async () => {
|
||||
const errorHook = jest.fn().mockRejectedValue(new Error('Hook failed'));
|
||||
hooks.addHandler('nodeExecuteBefore', errorHook);
|
||||
|
||||
await expect(hooks.runHook('nodeExecuteBefore', ['testNode', mock()])).rejects.toThrow(
|
||||
'Hook failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import { ExternalSecretsProxy, type IExternalSecretsManager } from '../external-secrets-proxy';
|
||||
|
||||
describe('ExternalSecretsProxy', () => {
|
||||
let proxy: ExternalSecretsProxy;
|
||||
const manager = mock<IExternalSecretsManager>();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
proxy = new ExternalSecretsProxy();
|
||||
});
|
||||
|
||||
describe('getSecret', () => {
|
||||
it('should get secret from manager', () => {
|
||||
const secretValue = { key: 'value' };
|
||||
manager.getSecret.mockReturnValue(secretValue);
|
||||
proxy.setManager(manager);
|
||||
|
||||
const result = proxy.getSecret('aws', 'api-key');
|
||||
|
||||
expect(manager.getSecret).toHaveBeenCalledWith('aws', 'api-key');
|
||||
expect(result).toBe(secretValue);
|
||||
});
|
||||
|
||||
it('should return undefined when getting secret without a manager', () => {
|
||||
const result = proxy.getSecret('aws', 'api-key');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasSecret', () => {
|
||||
it('should check if secret exists', () => {
|
||||
manager.hasSecret.mockReturnValue(true);
|
||||
proxy.setManager(manager);
|
||||
|
||||
const result = proxy.hasSecret('aws', 'api-key');
|
||||
|
||||
expect(manager.hasSecret).toHaveBeenCalledWith('aws', 'api-key');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when checking secret without a manager', () => {
|
||||
const result = proxy.hasSecret('aws', 'api-key');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasProvider', () => {
|
||||
it('should check if provider exists', () => {
|
||||
manager.hasProvider.mockReturnValue(true);
|
||||
proxy.setManager(manager);
|
||||
|
||||
const result = proxy.hasProvider('aws');
|
||||
|
||||
expect(manager.hasProvider).toHaveBeenCalledWith('aws');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when checking provider without a manager', () => {
|
||||
const result = proxy.hasProvider('aws');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listProviders', () => {
|
||||
it('should list providers', () => {
|
||||
const providers = ['aws', 'gcp', 'azure'];
|
||||
manager.getProviderNames.mockReturnValue(providers);
|
||||
proxy.setManager(manager);
|
||||
|
||||
const result = proxy.listProviders();
|
||||
|
||||
expect(manager.getProviderNames).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(providers);
|
||||
});
|
||||
|
||||
it('should return empty array when listing providers without a manager', () => {
|
||||
const result = proxy.listProviders();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listSecrets', () => {
|
||||
it('should list secrets for a provider', () => {
|
||||
const secrets = ['api-key', 'api-secret', 'token'];
|
||||
manager.getSecretNames.mockReturnValue(secrets);
|
||||
proxy.setManager(manager);
|
||||
|
||||
const result = proxy.listSecrets('aws');
|
||||
|
||||
expect(manager.getSecretNames).toHaveBeenCalledWith('aws');
|
||||
expect(result).toEqual(secrets);
|
||||
});
|
||||
|
||||
it('should return empty array when listing secrets without a manager', () => {
|
||||
const result = proxy.listSecrets('aws');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
EngineResponse,
|
||||
EngineRequest,
|
||||
NodeOutput,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { NodeTypes } from '@test/helpers';
|
||||
|
||||
export const passThroughNode: INodeType = {
|
||||
description: {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
description: 'A minimal node for testing',
|
||||
defaults: { name: 'Test Node' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
},
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const items = this.getInputData();
|
||||
return await Promise.resolve([items]);
|
||||
},
|
||||
};
|
||||
|
||||
export const testNodeWithRequiredProperty: INodeType = {
|
||||
description: {
|
||||
displayName: 'Test Node with Required Property',
|
||||
name: 'testNodeWithRequiredProperty',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
description: 'A node for testing with required property',
|
||||
defaults: { name: 'Test Node with Required Property' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Required Text',
|
||||
name: 'requiredText',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'Enter some text',
|
||||
description: 'A required text input',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const items = this.getInputData();
|
||||
return await Promise.resolve([items]);
|
||||
},
|
||||
};
|
||||
|
||||
export const nodeTypeArguments = {
|
||||
passThrough: {
|
||||
type: passThroughNode,
|
||||
sourcePath: '',
|
||||
},
|
||||
testNodeWithRequiredProperty: {
|
||||
type: testNodeWithRequiredProperty,
|
||||
sourcePath: '',
|
||||
},
|
||||
};
|
||||
|
||||
export const nodeTypes = NodeTypes(nodeTypeArguments);
|
||||
|
||||
export const types: Record<keyof typeof nodeTypeArguments, string> = {
|
||||
passThrough: 'passThrough',
|
||||
testNodeWithRequiredProperty: 'testNodeWithRequiredProperty',
|
||||
};
|
||||
|
||||
/**
|
||||
* Union type representing all possible return values from a node's execute method.
|
||||
* Can be execution data, an engine request, or a function that processes engine responses.
|
||||
*/
|
||||
type NodeExecuteResult =
|
||||
| NodeOutput
|
||||
| ((response?: EngineResponse) => INodeExecutionData[][] | EngineRequest);
|
||||
|
||||
/**
|
||||
* Interface for building modified node behavior through method chaining.
|
||||
* Allows setting predetermined responses for sequential node executions.
|
||||
*/
|
||||
interface NodeModifier {
|
||||
/**
|
||||
* Sets a predetermined result for the next node execution call.
|
||||
* @param result - The result to return on the next execution
|
||||
*/
|
||||
return(result: NodeExecuteResult): NodeModifier;
|
||||
|
||||
/**
|
||||
* Finalizes the node modification and returns the modified node.
|
||||
*/
|
||||
done(): INodeType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a modified version of a node with predetermined execution responses.
|
||||
* Useful for testing scenarios where you need to control node execution results
|
||||
* across multiple calls in a predictable sequence.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const modifiedNode = modifyNode(originalNode)
|
||||
* .return([mockData1])
|
||||
* .return(engineRequest)
|
||||
* .return((response) => processResponse(response))
|
||||
* .done();
|
||||
* ```
|
||||
*
|
||||
* @param originalNode - The original node to modify
|
||||
* @returns A NodeModifier instance for configuring predetermined responses
|
||||
*/
|
||||
export function modifyNode(originalNode: INodeType): NodeModifier {
|
||||
const responses: NodeExecuteResult[] = [];
|
||||
let callCount = 0;
|
||||
|
||||
const modifier: NodeModifier = {
|
||||
return(result: NodeExecuteResult): NodeModifier {
|
||||
responses.push(result);
|
||||
return this;
|
||||
},
|
||||
|
||||
done(): INodeType {
|
||||
return {
|
||||
...originalNode,
|
||||
async execute(
|
||||
this: IExecuteFunctions,
|
||||
response?: EngineResponse,
|
||||
): Promise<INodeExecutionData[][] | EngineRequest | null> {
|
||||
const currentCall = callCount++;
|
||||
|
||||
// If we have a predetermined response for this call, use it
|
||||
if (currentCall < responses.length) {
|
||||
const predefinedResponse = responses[currentCall];
|
||||
|
||||
// Handle function responses (for Response parameter injection)
|
||||
if (typeof predefinedResponse === 'function') {
|
||||
return predefinedResponse.call(this, response);
|
||||
}
|
||||
|
||||
return predefinedResponse;
|
||||
}
|
||||
|
||||
// Fallback to original node's execute method
|
||||
if (originalNode.execute) {
|
||||
return await originalNode.execute.call(this, response);
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return [this.getInputData()];
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return modifier;
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteData, IRunData, EngineRequest, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import { DirectedGraph } from '../partial-execution-utils';
|
||||
import { createNodeData } from '../partial-execution-utils/__tests__/helpers';
|
||||
import { handleRequest } from '../requests-response';
|
||||
import { nodeTypes, types } from './mock-node-types';
|
||||
|
||||
describe('handleRequests', () => {
|
||||
test('throws if an action mentions a node that does not exist in the workflow', () => {
|
||||
// ARRANGE
|
||||
const request: EngineRequest = {
|
||||
actions: [
|
||||
{
|
||||
actionType: 'ExecutionNodeAction',
|
||||
nodeName: 'does not exist',
|
||||
input: { data: 'first node input' },
|
||||
type: 'ai_tool',
|
||||
id: 'first_action',
|
||||
metadata: {},
|
||||
},
|
||||
],
|
||||
metadata: {},
|
||||
};
|
||||
const currentNode = createNodeData({ name: 'trigger', type: types.passThrough });
|
||||
|
||||
const workflow = new DirectedGraph()
|
||||
.addNodes(currentNode)
|
||||
.toWorkflow({ name: '', active: false, nodeTypes });
|
||||
// ACT
|
||||
|
||||
// ASSERT
|
||||
expect(() =>
|
||||
handleRequest({
|
||||
workflow,
|
||||
currentNode,
|
||||
request,
|
||||
runIndex: 1,
|
||||
executionData: mock<IExecuteData>(),
|
||||
runData: mock<IRunData>(),
|
||||
}),
|
||||
).toThrowError('Workflow does not contain a node with the name of "does not exist".');
|
||||
});
|
||||
|
||||
test('merges agent input data with tool parameters for expression resolution', () => {
|
||||
// ARRANGE
|
||||
const toolNode = createNodeData({ name: 'Gmail Tool', type: types.passThrough });
|
||||
const agentNode = createNodeData({ name: 'AI Agent', type: types.passThrough });
|
||||
|
||||
const workflow = new DirectedGraph()
|
||||
.addNodes(toolNode, agentNode)
|
||||
.toWorkflow({ name: '', active: false, nodeTypes });
|
||||
|
||||
// Agent received data from previous nodes with workflow context
|
||||
const agentInputData: INodeExecutionData[] = [
|
||||
{
|
||||
json: {
|
||||
myNewField: 1,
|
||||
price_total: '344.00',
|
||||
existingData: 'from workflow',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const executionData: IExecuteData = {
|
||||
data: {
|
||||
main: [agentInputData], // Agent's main input at index 0
|
||||
},
|
||||
source: {
|
||||
main: [
|
||||
{
|
||||
previousNode: 'Process Quotes',
|
||||
previousNodeOutput: 0,
|
||||
previousNodeRun: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
node: agentNode,
|
||||
};
|
||||
|
||||
const request: EngineRequest = {
|
||||
actions: [
|
||||
{
|
||||
actionType: 'ExecutionNodeAction',
|
||||
nodeName: 'Gmail Tool',
|
||||
input: { subject: 'Test Email', toolParam: 'from LLM' }, // LLM-provided parameters
|
||||
type: 'ai_tool',
|
||||
id: 'tool_call_123',
|
||||
metadata: { itemIndex: 0 },
|
||||
},
|
||||
],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const runData: IRunData = {};
|
||||
|
||||
// ACT
|
||||
const result = handleRequest({
|
||||
workflow,
|
||||
currentNode: agentNode,
|
||||
request,
|
||||
runIndex: 0,
|
||||
executionData,
|
||||
runData,
|
||||
});
|
||||
|
||||
// ASSERT
|
||||
const toolNodeToExecute = result.nodesToBeExecuted.find((n) => n.parentNode === 'AI Agent');
|
||||
expect(toolNodeToExecute).toBeDefined();
|
||||
|
||||
// Verify merged data contains both workflow data and tool parameters
|
||||
const mergedJson = toolNodeToExecute!.parentOutputData[0][0].json;
|
||||
expect(mergedJson).toEqual({
|
||||
myNewField: 1,
|
||||
price_total: '344.00',
|
||||
existingData: 'from workflow',
|
||||
subject: 'Test Email',
|
||||
toolParam: 'from LLM',
|
||||
toolCallId: 'tool_call_123',
|
||||
});
|
||||
|
||||
// Verify tool parameters take precedence (override workflow data)
|
||||
const requestWithOverride: EngineRequest = {
|
||||
actions: [
|
||||
{
|
||||
actionType: 'ExecutionNodeAction',
|
||||
nodeName: 'Gmail Tool',
|
||||
input: { existingData: 'overridden by tool' }, // This should override workflow data
|
||||
type: 'ai_tool',
|
||||
id: 'tool_call_456',
|
||||
metadata: { itemIndex: 0 },
|
||||
},
|
||||
],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const runData2: IRunData = {};
|
||||
const result2 = handleRequest({
|
||||
workflow,
|
||||
currentNode: agentNode,
|
||||
request: requestWithOverride,
|
||||
runIndex: 0,
|
||||
executionData,
|
||||
runData: runData2,
|
||||
});
|
||||
|
||||
const toolNodeToExecute2 = result2.nodesToBeExecuted.find((n) => n.parentNode === 'AI Agent');
|
||||
const mergedJson2 = toolNodeToExecute2!.parentOutputData[0][0].json;
|
||||
expect(mergedJson2.existingData).toBe('overridden by tool');
|
||||
});
|
||||
|
||||
test('uses output index 0 for tools regardless of agent input connection', () => {
|
||||
// ARRANGE
|
||||
const toolNode = createNodeData({ name: 'Gmail Tool', type: types.passThrough });
|
||||
const agentNode = createNodeData({ name: 'AI Agent', type: types.passThrough });
|
||||
const switchNode = createNodeData({ name: 'Switch', type: types.passThrough });
|
||||
|
||||
const workflow = new DirectedGraph()
|
||||
.addNodes(toolNode, agentNode, switchNode)
|
||||
.toWorkflow({ name: '', active: false, nodeTypes });
|
||||
|
||||
// Agent is connected to output 2 of the Switch node
|
||||
const agentInputData: INodeExecutionData[] = [
|
||||
{
|
||||
json: {
|
||||
data: 'from switch output 2',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const executionData: IExecuteData = {
|
||||
data: {
|
||||
main: [agentInputData],
|
||||
},
|
||||
source: {
|
||||
main: [
|
||||
{
|
||||
previousNode: 'Switch',
|
||||
previousNodeOutput: 2, // Connected to Switch output 2
|
||||
previousNodeRun: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
node: agentNode,
|
||||
};
|
||||
|
||||
const request: EngineRequest = {
|
||||
actions: [
|
||||
{
|
||||
actionType: 'ExecutionNodeAction',
|
||||
nodeName: 'Gmail Tool',
|
||||
input: { message: 'test' },
|
||||
type: 'ai_tool',
|
||||
id: 'tool_call_789',
|
||||
metadata: { itemIndex: 0 },
|
||||
},
|
||||
],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const runData: IRunData = {};
|
||||
|
||||
// ACT
|
||||
const result = handleRequest({
|
||||
workflow,
|
||||
currentNode: agentNode,
|
||||
request,
|
||||
runIndex: 0,
|
||||
executionData,
|
||||
runData,
|
||||
});
|
||||
|
||||
// ASSERT
|
||||
const toolNodeToExecute = result.nodesToBeExecuted.find((n) => n.parentNode === 'AI Agent');
|
||||
expect(toolNodeToExecute).toBeDefined();
|
||||
|
||||
// Verify parentOutputIndex is always 0 for tools (agents have only one main output)
|
||||
// This prevents "Cannot read properties of undefined (reading 'map')" error
|
||||
expect(toolNodeToExecute!.parentOutputIndex).toBe(0);
|
||||
});
|
||||
|
||||
test('handles multiple items correctly using itemIndex from metadata', () => {
|
||||
// ARRANGE
|
||||
const toolNode = createNodeData({ name: 'Gmail Tool', type: types.passThrough });
|
||||
const agentNode = createNodeData({ name: 'AI Agent', type: types.passThrough });
|
||||
|
||||
const workflow = new DirectedGraph()
|
||||
.addNodes(toolNode, agentNode)
|
||||
.toWorkflow({ name: '', active: false, nodeTypes });
|
||||
|
||||
// Agent received multiple items from previous nodes
|
||||
const agentInputData: INodeExecutionData[] = [
|
||||
{ json: { id: 1, value: 'first item' } },
|
||||
{ json: { id: 2, value: 'second item' } },
|
||||
{ json: { id: 3, value: 'third item' } },
|
||||
];
|
||||
|
||||
const executionData: IExecuteData = {
|
||||
data: {
|
||||
main: [agentInputData],
|
||||
},
|
||||
source: {
|
||||
main: [
|
||||
{
|
||||
previousNode: 'Process Data',
|
||||
previousNodeOutput: 0,
|
||||
previousNodeRun: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
node: agentNode,
|
||||
};
|
||||
|
||||
const request: EngineRequest = {
|
||||
actions: [
|
||||
{
|
||||
actionType: 'ExecutionNodeAction',
|
||||
nodeName: 'Gmail Tool',
|
||||
input: { toolParam: 'for second item' },
|
||||
type: 'ai_tool',
|
||||
id: 'tool_call_abc',
|
||||
metadata: { itemIndex: 1 }, // Processing second item
|
||||
},
|
||||
],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const runData: IRunData = {};
|
||||
|
||||
// ACT
|
||||
const result = handleRequest({
|
||||
workflow,
|
||||
currentNode: agentNode,
|
||||
request,
|
||||
runIndex: 0,
|
||||
executionData,
|
||||
runData,
|
||||
});
|
||||
|
||||
// ASSERT
|
||||
const toolNodeToExecute = result.nodesToBeExecuted.find((n) => n.parentNode === 'AI Agent');
|
||||
expect(toolNodeToExecute).toBeDefined();
|
||||
|
||||
// Verify correct item data is merged (second item with id: 2)
|
||||
const mergedJson = toolNodeToExecute!.parentOutputData[0][0].json;
|
||||
expect(mergedJson.id).toBe(2);
|
||||
expect(mergedJson.value).toBe('second item');
|
||||
expect(mergedJson.toolParam).toBe('for second item');
|
||||
});
|
||||
|
||||
test('preserves sourceOverwrite metadata for tool execution', () => {
|
||||
const toolNode = createNodeData({ name: 'Gmail Tool', type: types.passThrough });
|
||||
const agentNode = createNodeData({ name: 'AI Agent', type: types.passThrough });
|
||||
|
||||
const workflow = new DirectedGraph()
|
||||
.addNodes(toolNode, agentNode)
|
||||
.toWorkflow({ name: '', active: false, nodeTypes });
|
||||
|
||||
const agentInputData: INodeExecutionData[] = [
|
||||
{
|
||||
json: { data: 'test' },
|
||||
},
|
||||
];
|
||||
|
||||
const executionData: IExecuteData = {
|
||||
data: {
|
||||
main: [agentInputData],
|
||||
},
|
||||
source: {
|
||||
main: [
|
||||
{
|
||||
previousNode: 'Process Quotes',
|
||||
previousNodeOutput: 0,
|
||||
previousNodeRun: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
node: agentNode,
|
||||
};
|
||||
|
||||
const request: EngineRequest = {
|
||||
actions: [
|
||||
{
|
||||
actionType: 'ExecutionNodeAction',
|
||||
nodeName: 'Gmail Tool',
|
||||
input: { subject: 'Test' },
|
||||
type: 'ai_tool',
|
||||
id: 'tool_call_123',
|
||||
metadata: { itemIndex: 0 },
|
||||
},
|
||||
],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const runData: IRunData = {};
|
||||
|
||||
const result = handleRequest({
|
||||
workflow,
|
||||
currentNode: agentNode,
|
||||
request,
|
||||
runIndex: 0,
|
||||
executionData,
|
||||
runData,
|
||||
});
|
||||
|
||||
const toolNodeToExecute = result.nodesToBeExecuted.find((n) => n.parentNode === 'AI Agent');
|
||||
expect(toolNodeToExecute).toBeDefined();
|
||||
|
||||
expect(toolNodeToExecute!.metadata).toHaveProperty('preserveSourceOverwrite', true);
|
||||
expect(toolNodeToExecute!.metadata).toHaveProperty('preservedSourceOverwrite');
|
||||
expect(toolNodeToExecute!.metadata!.preservedSourceOverwrite).toEqual({
|
||||
previousNode: 'Process Quotes',
|
||||
previousNodeOutput: 0,
|
||||
previousNodeRun: 0,
|
||||
});
|
||||
|
||||
const pairedItem = toolNodeToExecute!.parentOutputData[0][0].pairedItem;
|
||||
expect(pairedItem).toHaveProperty('sourceOverwrite');
|
||||
if (typeof pairedItem === 'object' && 'sourceOverwrite' in pairedItem) {
|
||||
expect(pairedItem.sourceOverwrite).toEqual({
|
||||
previousNode: 'Process Quotes',
|
||||
previousNodeOutput: 0,
|
||||
previousNodeRun: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('preserves existing preservedSourceOverwrite from metadata', () => {
|
||||
const toolNode = createNodeData({ name: 'Gmail Tool', type: types.passThrough });
|
||||
const agentNode = createNodeData({ name: 'AI Agent', type: types.passThrough });
|
||||
|
||||
const workflow = new DirectedGraph()
|
||||
.addNodes(toolNode, agentNode)
|
||||
.toWorkflow({ name: '', active: false, nodeTypes });
|
||||
|
||||
const agentInputData: INodeExecutionData[] = [
|
||||
{
|
||||
json: { data: 'test' },
|
||||
},
|
||||
];
|
||||
|
||||
const existingPreservedSource = {
|
||||
previousNode: 'Original Node',
|
||||
previousNodeOutput: 1,
|
||||
previousNodeRun: 2,
|
||||
};
|
||||
|
||||
const executionData: IExecuteData = {
|
||||
data: {
|
||||
main: [agentInputData],
|
||||
},
|
||||
source: {
|
||||
main: [
|
||||
{
|
||||
previousNode: 'Immediate Parent',
|
||||
previousNodeOutput: 0,
|
||||
previousNodeRun: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
node: agentNode,
|
||||
metadata: {
|
||||
preservedSourceOverwrite: existingPreservedSource,
|
||||
},
|
||||
};
|
||||
|
||||
const request: EngineRequest = {
|
||||
actions: [
|
||||
{
|
||||
actionType: 'ExecutionNodeAction',
|
||||
nodeName: 'Gmail Tool',
|
||||
input: { subject: 'Test' },
|
||||
type: 'ai_tool',
|
||||
id: 'tool_call_456',
|
||||
metadata: { itemIndex: 0 },
|
||||
},
|
||||
],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const runData: IRunData = {};
|
||||
|
||||
const result = handleRequest({
|
||||
workflow,
|
||||
currentNode: agentNode,
|
||||
request,
|
||||
runIndex: 0,
|
||||
executionData,
|
||||
runData,
|
||||
});
|
||||
|
||||
const toolNodeToExecute = result.nodesToBeExecuted.find((n) => n.parentNode === 'AI Agent');
|
||||
expect(toolNodeToExecute).toBeDefined();
|
||||
expect(toolNodeToExecute!.metadata!.preservedSourceOverwrite).toEqual(existingPreservedSource);
|
||||
});
|
||||
|
||||
test('propagates preservedSourceOverwrite metadata when resuming agent', () => {
|
||||
const agentNode = createNodeData({ name: 'AI Agent', type: types.passThrough });
|
||||
|
||||
const workflow = new DirectedGraph()
|
||||
.addNodes(agentNode)
|
||||
.toWorkflow({ name: '', active: false, nodeTypes });
|
||||
|
||||
const preservedSource = {
|
||||
previousNode: 'Original Node',
|
||||
previousNodeOutput: 1,
|
||||
previousNodeRun: 2,
|
||||
};
|
||||
|
||||
const executionData: IExecuteData = {
|
||||
data: {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
json: { result: 'tool result' },
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
source: {
|
||||
main: [
|
||||
{
|
||||
previousNode: 'Parent Node',
|
||||
previousNodeOutput: 0,
|
||||
previousNodeRun: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
node: agentNode,
|
||||
metadata: {
|
||||
preserveSourceOverwrite: true,
|
||||
preservedSourceOverwrite: preservedSource,
|
||||
},
|
||||
};
|
||||
|
||||
const request: EngineRequest = {
|
||||
actions: [],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const runData: IRunData = {};
|
||||
|
||||
const result = handleRequest({
|
||||
workflow,
|
||||
currentNode: agentNode,
|
||||
request,
|
||||
runIndex: 0,
|
||||
executionData,
|
||||
runData,
|
||||
});
|
||||
|
||||
const resumingNode = result.nodesToBeExecuted[0];
|
||||
expect(resumingNode).toBeDefined();
|
||||
expect(resumingNode.metadata).toHaveProperty('nodeWasResumed', true);
|
||||
expect(resumingNode.metadata).toHaveProperty('preserveSourceOverwrite', true);
|
||||
expect(resumingNode.metadata).toHaveProperty('preservedSourceOverwrite', preservedSource);
|
||||
});
|
||||
|
||||
test('does not add preserveSourceOverwrite metadata when not present', () => {
|
||||
const agentNode = createNodeData({ name: 'AI Agent', type: types.passThrough });
|
||||
|
||||
const workflow = new DirectedGraph()
|
||||
.addNodes(agentNode)
|
||||
.toWorkflow({ name: '', active: false, nodeTypes });
|
||||
|
||||
const executionData: IExecuteData = {
|
||||
data: {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
json: { result: 'tool result' },
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
source: {
|
||||
main: [
|
||||
{
|
||||
previousNode: 'Parent Node',
|
||||
previousNodeOutput: 0,
|
||||
previousNodeRun: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
node: agentNode,
|
||||
};
|
||||
|
||||
const request: EngineRequest = {
|
||||
actions: [],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const runData: IRunData = {};
|
||||
|
||||
const result = handleRequest({
|
||||
workflow,
|
||||
currentNode: agentNode,
|
||||
request,
|
||||
runIndex: 0,
|
||||
executionData,
|
||||
runData,
|
||||
});
|
||||
|
||||
const resumingNode = result.nodesToBeExecuted[0];
|
||||
expect(resumingNode).toBeDefined();
|
||||
expect(resumingNode.metadata).toHaveProperty('nodeWasResumed', true);
|
||||
expect(resumingNode.metadata).not.toHaveProperty('preserveSourceOverwrite');
|
||||
expect(resumingNode.metadata).not.toHaveProperty('preservedSourceOverwrite');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { CronContext, Workflow } from 'n8n-workflow';
|
||||
|
||||
import type { InstanceSettings } from '@/instance-settings';
|
||||
|
||||
import { ScheduledTaskManager } from '../scheduled-task-manager';
|
||||
|
||||
const logger = mock<Logger>({ scoped: jest.fn().mockReturnValue(mock<Logger>()) });
|
||||
|
||||
describe('ScheduledTaskManager', () => {
|
||||
const instanceSettings = mock<InstanceSettings>({ isLeader: true });
|
||||
const workflow = mock<Workflow>({ timezone: 'GMT' });
|
||||
const everyMinute = '0 * * * * *';
|
||||
|
||||
const onTick = jest.fn();
|
||||
|
||||
let scheduledTaskManager: ScheduledTaskManager;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
scheduledTaskManager = new ScheduledTaskManager(instanceSettings, logger, mock(), mock());
|
||||
});
|
||||
|
||||
it('should not register duplicate crons', () => {
|
||||
const ctx: CronContext = {
|
||||
workflowId: workflow.id,
|
||||
nodeId: 'test-node-id',
|
||||
timezone: workflow.timezone,
|
||||
expression: everyMinute,
|
||||
};
|
||||
|
||||
scheduledTaskManager.registerCron(ctx, onTick);
|
||||
expect(scheduledTaskManager.cronsByWorkflow.get(workflow.id)?.size).toBe(1);
|
||||
|
||||
scheduledTaskManager.registerCron(ctx, onTick);
|
||||
expect(scheduledTaskManager.cronsByWorkflow.get(workflow.id)?.size).toBe(1);
|
||||
});
|
||||
|
||||
it('should throw when workflow timezone is invalid', () => {
|
||||
expect(() =>
|
||||
scheduledTaskManager.registerCron(
|
||||
{
|
||||
workflowId: workflow.id,
|
||||
nodeId: 'test-node-id',
|
||||
timezone: 'somewhere',
|
||||
expression: everyMinute,
|
||||
},
|
||||
onTick,
|
||||
),
|
||||
).toThrow('Invalid timezone.');
|
||||
});
|
||||
|
||||
it('should throw when cron expression is invalid', () => {
|
||||
expect(() =>
|
||||
//@ts-expect-error invalid cron expression is a type-error
|
||||
scheduledTaskManager.registerCron(workflow, 'invalid-cron-expression', onTick),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should register valid CronJobs', () => {
|
||||
scheduledTaskManager.registerCron(
|
||||
{
|
||||
workflowId: workflow.id,
|
||||
nodeId: 'test-node-id',
|
||||
timezone: workflow.timezone,
|
||||
expression: everyMinute,
|
||||
},
|
||||
onTick,
|
||||
);
|
||||
|
||||
expect(onTick).not.toHaveBeenCalled();
|
||||
jest.advanceTimersByTime(10 * 60 * 1000); // 10 minutes
|
||||
expect(onTick).toHaveBeenCalledTimes(10);
|
||||
});
|
||||
|
||||
it('should not invoke on follower instances', () => {
|
||||
scheduledTaskManager = new ScheduledTaskManager(
|
||||
mock<InstanceSettings>({ isLeader: false }),
|
||||
logger,
|
||||
mock(),
|
||||
mock(),
|
||||
);
|
||||
|
||||
const ctx: CronContext = {
|
||||
workflowId: workflow.id,
|
||||
nodeId: 'test-node-id',
|
||||
timezone: workflow.timezone,
|
||||
expression: everyMinute,
|
||||
};
|
||||
|
||||
scheduledTaskManager.registerCron(ctx, onTick);
|
||||
|
||||
expect(onTick).not.toHaveBeenCalled();
|
||||
jest.advanceTimersByTime(10 * 60 * 1000); // 10 minutes
|
||||
expect(onTick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should deregister CronJobs for a workflow', () => {
|
||||
const ctx1: CronContext = {
|
||||
workflowId: workflow.id,
|
||||
nodeId: 'test-node-id-1',
|
||||
timezone: workflow.timezone,
|
||||
expression: everyMinute,
|
||||
};
|
||||
const ctx2: CronContext = {
|
||||
workflowId: workflow.id,
|
||||
nodeId: 'test-node-id-2',
|
||||
timezone: workflow.timezone,
|
||||
expression: everyMinute,
|
||||
};
|
||||
const ctx3: CronContext = {
|
||||
workflowId: workflow.id,
|
||||
nodeId: 'test-node-id-3',
|
||||
timezone: workflow.timezone,
|
||||
expression: everyMinute,
|
||||
};
|
||||
|
||||
scheduledTaskManager.registerCron(ctx1, onTick);
|
||||
scheduledTaskManager.registerCron(ctx2, onTick);
|
||||
scheduledTaskManager.registerCron(ctx3, onTick);
|
||||
|
||||
expect(scheduledTaskManager.cronsByWorkflow.get(workflow.id)?.size).toBe(3);
|
||||
|
||||
scheduledTaskManager.deregisterCrons(workflow.id);
|
||||
|
||||
expect(scheduledTaskManager.cronsByWorkflow.get(workflow.id)).toBeUndefined();
|
||||
|
||||
expect(onTick).not.toHaveBeenCalled();
|
||||
jest.advanceTimersByTime(10 * 60 * 1000); // 10 minutes
|
||||
expect(onTick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not set up log interval when activeInterval is 0', () => {
|
||||
const configWithZeroInterval = mock({ activeInterval: 0 });
|
||||
const manager = new ScheduledTaskManager(
|
||||
instanceSettings,
|
||||
logger,
|
||||
configWithZeroInterval,
|
||||
mock(),
|
||||
);
|
||||
|
||||
// @ts-expect-error Private property
|
||||
expect(manager.logInterval).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { Container } from '@n8n/di';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { SSHCredentials } from 'n8n-workflow';
|
||||
import { Client } from 'ssh2';
|
||||
|
||||
import { SSHClientsConfig, SSHClientsManager } from '../ssh-clients-manager';
|
||||
|
||||
const idleTimeout = 5 * 60;
|
||||
const cleanUpInterval = 60;
|
||||
const credentials: SSHCredentials = {
|
||||
sshAuthenticateWith: 'password',
|
||||
sshHost: 'example.com',
|
||||
sshPort: 22,
|
||||
sshUser: 'username',
|
||||
sshPassword: 'password',
|
||||
};
|
||||
|
||||
let sshClientsManager: SSHClientsManager;
|
||||
const connectSpy = jest.spyOn(Client.prototype, 'connect');
|
||||
const endSpy = jest.spyOn(Client.prototype, 'end');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
sshClientsManager = new SSHClientsManager(
|
||||
mock({ idleTimeout }),
|
||||
mock<Logger>({ scoped: () => mock<Logger>() }),
|
||||
);
|
||||
connectSpy.mockImplementation(function (this: Client) {
|
||||
this.emit('ready');
|
||||
return this;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sshClientsManager.onShutdown();
|
||||
});
|
||||
|
||||
describe('getClient', () => {
|
||||
it('should create a new SSH client', async () => {
|
||||
const client = await sshClientsManager.getClient(credentials);
|
||||
|
||||
expect(client).toBeInstanceOf(Client);
|
||||
});
|
||||
|
||||
it('should not create a new SSH client when connect fails', async () => {
|
||||
connectSpy.mockImplementation(function (this: Client) {
|
||||
throw new Error('Failed to connect');
|
||||
});
|
||||
await expect(sshClientsManager.getClient(credentials)).rejects.toThrow('Failed to connect');
|
||||
});
|
||||
|
||||
it('should reuse an existing SSH client', async () => {
|
||||
const client1 = await sshClientsManager.getClient(credentials);
|
||||
const client2 = await sshClientsManager.getClient(credentials);
|
||||
|
||||
expect(client1).toBe(client2);
|
||||
});
|
||||
|
||||
it('should not create multiple clients for the same credentials in parallel', async () => {
|
||||
// ARRANGE
|
||||
connectSpy.mockImplementation(function (this: Client) {
|
||||
setTimeout(() => this.emit('ready'), Math.random() * 10);
|
||||
return this;
|
||||
});
|
||||
|
||||
// ACT
|
||||
const clients = await Promise.all([
|
||||
sshClientsManager.getClient(credentials),
|
||||
sshClientsManager.getClient(credentials),
|
||||
sshClientsManager.getClient(credentials),
|
||||
sshClientsManager.getClient(credentials),
|
||||
sshClientsManager.getClient(credentials),
|
||||
sshClientsManager.getClient(credentials),
|
||||
]);
|
||||
|
||||
// ASSERT
|
||||
// returns the same client for all invocations
|
||||
const ogClient = await sshClientsManager.getClient(credentials);
|
||||
expect(clients).toHaveLength(6);
|
||||
for (const client of clients) {
|
||||
expect(client).toBe(ogClient);
|
||||
}
|
||||
expect(connectSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onShutdown', () => {
|
||||
it('should close all SSH connections when onShutdown is called', async () => {
|
||||
await sshClientsManager.getClient(credentials);
|
||||
sshClientsManager.onShutdown();
|
||||
|
||||
expect(endSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should close all SSH connections on process exit', async () => {
|
||||
// ARRANGE
|
||||
await sshClientsManager.getClient(credentials);
|
||||
|
||||
// ACT
|
||||
// @ts-expect-error we're not supposed to emit `exit` so it's missing from
|
||||
// the type definition
|
||||
process.emit('exit');
|
||||
|
||||
// ASSERT
|
||||
expect(endSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup', () => {
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers();
|
||||
sshClientsManager = new SSHClientsManager(
|
||||
mock({ idleTimeout }),
|
||||
mock<Logger>({ scoped: () => mock<Logger>() }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should cleanup stale SSH connections', async () => {
|
||||
await sshClientsManager.getClient({ ...credentials, sshHost: 'host1' });
|
||||
await sshClientsManager.getClient({ ...credentials, sshHost: 'host2' });
|
||||
await sshClientsManager.getClient({ ...credentials, sshHost: 'host3' });
|
||||
|
||||
jest.advanceTimersByTime((idleTimeout + cleanUpInterval + 1) * 1000);
|
||||
|
||||
expect(endSpy).toHaveBeenCalledTimes(3);
|
||||
expect(sshClientsManager.clients.size).toBe(0);
|
||||
});
|
||||
|
||||
describe('updateLastUsed', () => {
|
||||
test('updates lastUsed in the registration', async () => {
|
||||
// ARRANGE
|
||||
const client = await sshClientsManager.getClient(credentials);
|
||||
// schedule client for clean up soon
|
||||
jest.advanceTimersByTime((idleTimeout - 1) * 1000);
|
||||
|
||||
// ACT 1
|
||||
// updating lastUsed should prevent the clean up
|
||||
sshClientsManager.updateLastUsed(client);
|
||||
jest.advanceTimersByTime(idleTimeout * 1000);
|
||||
|
||||
// ASSERT 1
|
||||
expect(endSpy).toHaveBeenCalledTimes(0);
|
||||
|
||||
// ACT 1
|
||||
jest.advanceTimersByTime(cleanUpInterval * 1000);
|
||||
|
||||
// ASSERT 1
|
||||
expect(endSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('abort controller', () => {
|
||||
test('call `abort` when the client emits `error`', async () => {
|
||||
// ARRANGE
|
||||
const abortController = new AbortController();
|
||||
const client = await sshClientsManager.getClient(credentials, abortController);
|
||||
|
||||
// ACT 1
|
||||
client.emit('error', new Error());
|
||||
|
||||
// ASSERT 1
|
||||
expect(abortController.signal.aborted).toBe(true);
|
||||
expect(endSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('call `abort` when the client emits `end`', async () => {
|
||||
// ARRANGE
|
||||
const abortController = new AbortController();
|
||||
const client = await sshClientsManager.getClient(credentials, abortController);
|
||||
|
||||
// ACT 1
|
||||
client.emit('end');
|
||||
|
||||
// ASSERT 1
|
||||
expect(abortController.signal.aborted).toBe(true);
|
||||
expect(endSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('call `abort` when the client emits `close`', async () => {
|
||||
// ARRANGE
|
||||
const abortController = new AbortController();
|
||||
const client = await sshClientsManager.getClient(credentials, abortController);
|
||||
|
||||
// ACT 1
|
||||
client.emit('close');
|
||||
|
||||
// ASSERT 1
|
||||
expect(abortController.signal.aborted).toBe(true);
|
||||
expect(endSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('closes client when `abort` is being called', async () => {
|
||||
// ARRANGE
|
||||
const abortController = new AbortController();
|
||||
await sshClientsManager.getClient(credentials, abortController);
|
||||
|
||||
// ACT 1
|
||||
abortController.abort();
|
||||
|
||||
// ASSERT 1
|
||||
expect(endSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSHClientsConfig', () => {
|
||||
beforeEach(() => {
|
||||
Container.reset();
|
||||
});
|
||||
|
||||
test('allows overriding the default idle timeout', async () => {
|
||||
// ARRANGE
|
||||
process.env.N8N_SSH_TUNNEL_IDLE_TIMEOUT = '5';
|
||||
|
||||
// ACT
|
||||
const config = Container.get(SSHClientsConfig);
|
||||
|
||||
// ASSERT
|
||||
expect(config.idleTimeout).toBe(5);
|
||||
});
|
||||
|
||||
test.each(['-5', '0', 'foo'])(
|
||||
'fall back to default if N8N_SSH_TUNNEL_IDLE_TIMEOUT is `%s`',
|
||||
async (value) => {
|
||||
// ARRANGE
|
||||
process.env.N8N_SSH_TUNNEL_IDLE_TIMEOUT = value;
|
||||
|
||||
// ACT
|
||||
const config = Container.get(SSHClientsConfig);
|
||||
|
||||
// ASSERT
|
||||
expect(config.idleTimeout).toBe(300);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { ApplicationError } from '@n8n/errors';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
Workflow,
|
||||
INode,
|
||||
INodeExecutionData,
|
||||
IPollFunctions,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
INodeType,
|
||||
INodeTypes,
|
||||
ITriggerFunctions,
|
||||
IRun,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { ExecutionLifecycleHooks } from '../execution-lifecycle-hooks';
|
||||
import { TriggersAndPollers } from '../triggers-and-pollers';
|
||||
|
||||
describe('TriggersAndPollers', () => {
|
||||
const node = mock<INode>();
|
||||
const nodeType = mock<INodeType>({
|
||||
trigger: undefined,
|
||||
poll: undefined,
|
||||
});
|
||||
const nodeTypes = mock<INodeTypes>();
|
||||
const workflow = mock<Workflow>({ nodeTypes });
|
||||
const hooks = new ExecutionLifecycleHooks('internal', '123', mock());
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({ hooks });
|
||||
const triggersAndPollers = new TriggersAndPollers();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
});
|
||||
|
||||
describe('runTrigger()', () => {
|
||||
const triggerFunctions = mock<ITriggerFunctions>();
|
||||
const getTriggerFunctions = jest.fn().mockReturnValue(triggerFunctions);
|
||||
const triggerFn = jest.fn();
|
||||
const mockEmitData: INodeExecutionData[][] = [[{ json: { data: 'test' } }]];
|
||||
|
||||
const runTriggerHelper = async (mode: 'manual' | 'trigger' = 'trigger') =>
|
||||
await triggersAndPollers.runTrigger(
|
||||
workflow,
|
||||
node,
|
||||
getTriggerFunctions,
|
||||
additionalData,
|
||||
mode,
|
||||
'init',
|
||||
);
|
||||
|
||||
it('should throw error if node type does not have trigger function', async () => {
|
||||
await expect(runTriggerHelper()).rejects.toThrow(ApplicationError);
|
||||
});
|
||||
|
||||
it('should call trigger function in regular mode', async () => {
|
||||
nodeType.trigger = triggerFn;
|
||||
triggerFn.mockResolvedValue({ test: true });
|
||||
|
||||
const result = await runTriggerHelper();
|
||||
|
||||
expect(triggerFn).toHaveBeenCalled();
|
||||
expect(result).toEqual({ test: true });
|
||||
});
|
||||
|
||||
describe('manual mode', () => {
|
||||
const getMockTriggerFunctions = () => getTriggerFunctions.mock.results[0]?.value;
|
||||
|
||||
beforeEach(() => {
|
||||
nodeType.trigger = triggerFn;
|
||||
triggerFn.mockResolvedValue({ workflowId: '123' });
|
||||
});
|
||||
|
||||
it('should handle promise resolution', async () => {
|
||||
const result = await runTriggerHelper('manual');
|
||||
|
||||
expect(result?.manualTriggerResponse).toBeInstanceOf(Promise);
|
||||
getMockTriggerFunctions()?.emit?.(mockEmitData);
|
||||
});
|
||||
|
||||
it('should handle error emission', async () => {
|
||||
const testError = new Error('Test error');
|
||||
const result = await runTriggerHelper('manual');
|
||||
|
||||
getMockTriggerFunctions()?.emitError?.(testError);
|
||||
await expect(result?.manualTriggerResponse).rejects.toThrow(testError);
|
||||
});
|
||||
|
||||
it('should handle response promise', async () => {
|
||||
const responsePromise = { resolve: jest.fn(), reject: jest.fn() };
|
||||
await runTriggerHelper('manual');
|
||||
|
||||
getMockTriggerFunctions()?.emit?.(mockEmitData, responsePromise);
|
||||
|
||||
await hooks.runHook('sendResponse', [{ testResponse: true }]);
|
||||
expect(responsePromise.resolve).toHaveBeenCalledWith({ testResponse: true });
|
||||
});
|
||||
|
||||
it('should handle both response and done promises', async () => {
|
||||
const responsePromise = { resolve: jest.fn(), reject: jest.fn() };
|
||||
const donePromise = { resolve: jest.fn(), reject: jest.fn() };
|
||||
const mockRunData = mock<IRun>({ data: { resultData: { runData: {} } } });
|
||||
|
||||
await runTriggerHelper('manual');
|
||||
getMockTriggerFunctions()?.emit?.(mockEmitData, responsePromise, donePromise);
|
||||
|
||||
await hooks.runHook('sendResponse', [{ testResponse: true }]);
|
||||
expect(responsePromise.resolve).toHaveBeenCalledWith({ testResponse: true });
|
||||
|
||||
await hooks.runHook('workflowExecuteAfter', [mockRunData, {}]);
|
||||
expect(donePromise.resolve).toHaveBeenCalledWith(mockRunData);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('runPoll()', () => {
|
||||
const pollFunctions = mock<IPollFunctions>();
|
||||
const pollFn = jest.fn();
|
||||
|
||||
const runPollHelper = async () =>
|
||||
await triggersAndPollers.runPoll(workflow, node, pollFunctions);
|
||||
|
||||
it('should throw error if node type does not have poll function', async () => {
|
||||
await expect(runPollHelper()).rejects.toThrow(ApplicationError);
|
||||
});
|
||||
|
||||
it('should call poll function and return result', async () => {
|
||||
const mockPollResult: INodeExecutionData[][] = [[{ json: { data: 'test' } }]];
|
||||
nodeType.poll = pollFn;
|
||||
pollFn.mockResolvedValue(mockPollResult);
|
||||
|
||||
const result = await runPollHelper();
|
||||
|
||||
expect(pollFn).toHaveBeenCalled();
|
||||
expect(result).toBe(mockPollResult);
|
||||
});
|
||||
|
||||
it('should return null if poll function returns no data', async () => {
|
||||
nodeType.poll = pollFn;
|
||||
pollFn.mockResolvedValue(null);
|
||||
|
||||
const result = await runPollHelper();
|
||||
|
||||
expect(pollFn).toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should propagate errors from poll function', async () => {
|
||||
nodeType.poll = pollFn;
|
||||
pollFn.mockRejectedValue(new Error('Poll function failed'));
|
||||
|
||||
await expect(runPollHelper()).rejects.toThrow('Poll function failed');
|
||||
expect(pollFn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
+1098
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,288 @@
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { Service } from '@n8n/di';
|
||||
import type {
|
||||
CronContext,
|
||||
INode,
|
||||
IPollFunctions,
|
||||
ITriggerResponse,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
TriggerTime,
|
||||
Workflow,
|
||||
WorkflowActivateMode,
|
||||
WorkflowExecuteMode,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
toCronExpression,
|
||||
TriggerCloseError,
|
||||
UserError,
|
||||
WorkflowActivationError,
|
||||
WorkflowDeactivationError,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { ErrorReporter } from '@/errors/error-reporter';
|
||||
import type { IWorkflowData } from '@/interfaces';
|
||||
import { SpanStatus, Tracing } from '@/observability';
|
||||
|
||||
import type { IGetExecutePollFunctions, IGetExecuteTriggerFunctions } from './interfaces';
|
||||
import { ScheduledTaskManager } from './scheduled-task-manager';
|
||||
import { TriggersAndPollers } from './triggers-and-pollers';
|
||||
|
||||
@Service()
|
||||
export class ActiveWorkflows {
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly scheduledTaskManager: ScheduledTaskManager,
|
||||
private readonly triggersAndPollers: TriggersAndPollers,
|
||||
private readonly errorReporter: ErrorReporter,
|
||||
private readonly tracing: Tracing,
|
||||
) {}
|
||||
|
||||
private activeWorkflows: { [workflowId: string]: IWorkflowData } = {};
|
||||
|
||||
/**
|
||||
* Returns if the workflow is active in memory.
|
||||
*/
|
||||
isActive(workflowId: string) {
|
||||
return this.activeWorkflows.hasOwnProperty(workflowId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the IDs of the currently active workflows in memory.
|
||||
*/
|
||||
allActiveWorkflows() {
|
||||
return Object.keys(this.activeWorkflows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the workflow data for the given ID if currently active in memory.
|
||||
*/
|
||||
get(workflowId: string) {
|
||||
return this.activeWorkflows[workflowId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a workflow active
|
||||
*
|
||||
* @param {string} workflowId The id of the workflow to activate
|
||||
* @param {Workflow} workflow The workflow to activate
|
||||
* @param {IWorkflowExecuteAdditionalData} additionalData The additional data which is needed to run workflows
|
||||
*/
|
||||
async add(
|
||||
workflowId: string,
|
||||
workflow: Workflow,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
mode: WorkflowExecuteMode,
|
||||
activation: WorkflowActivateMode,
|
||||
getTriggerFunctions: IGetExecuteTriggerFunctions,
|
||||
getPollFunctions: IGetExecutePollFunctions,
|
||||
) {
|
||||
const triggerNodes = workflow.getTriggerNodes();
|
||||
|
||||
const triggerResponses: ITriggerResponse[] = [];
|
||||
|
||||
for (const triggerNode of triggerNodes) {
|
||||
try {
|
||||
const triggerResponse = await this.triggersAndPollers.runTrigger(
|
||||
workflow,
|
||||
triggerNode,
|
||||
getTriggerFunctions,
|
||||
additionalData,
|
||||
mode,
|
||||
activation,
|
||||
);
|
||||
if (triggerResponse !== undefined) {
|
||||
triggerResponses.push(triggerResponse);
|
||||
}
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : new Error(`${e}`);
|
||||
|
||||
throw new WorkflowActivationError(
|
||||
`There was a problem activating the workflow: "${error.message}"`,
|
||||
{ cause: error, node: triggerNode },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.activeWorkflows[workflowId] = { triggerResponses };
|
||||
|
||||
const pollingNodes = workflow.getPollNodes();
|
||||
|
||||
if (pollingNodes.length === 0) return;
|
||||
|
||||
for (const pollNode of pollingNodes) {
|
||||
try {
|
||||
await this.activatePolling(
|
||||
pollNode,
|
||||
workflow,
|
||||
additionalData,
|
||||
getPollFunctions,
|
||||
mode,
|
||||
activation,
|
||||
);
|
||||
} catch (e) {
|
||||
// Do not mark this workflow as active if there are no triggerResponses, and any polling activation failed
|
||||
if (triggerResponses.length === 0) {
|
||||
delete this.activeWorkflows[workflowId];
|
||||
}
|
||||
|
||||
const error = e instanceof Error ? e : new Error(`${e}`);
|
||||
|
||||
throw new WorkflowActivationError(
|
||||
`There was a problem activating the workflow: "${error.message}"`,
|
||||
{ cause: error, node: pollNode },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates polling for the given node
|
||||
*/
|
||||
private async activatePolling(
|
||||
node: INode,
|
||||
workflow: Workflow,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
getPollFunctions: IGetExecutePollFunctions,
|
||||
mode: WorkflowExecuteMode,
|
||||
activation: WorkflowActivateMode,
|
||||
): Promise<void> {
|
||||
const pollFunctions = getPollFunctions(workflow, node, additionalData, mode, activation);
|
||||
|
||||
const pollTimes = pollFunctions.getNodeParameter('pollTimes') as unknown as {
|
||||
item: TriggerTime[];
|
||||
};
|
||||
|
||||
// Get all the trigger times
|
||||
const cronExpressions = (pollTimes.item || []).map(toCronExpression);
|
||||
// The trigger function to execute when the cron-time got reached
|
||||
const executeTrigger = this.createPollExecuteFn(workflow, node, pollFunctions);
|
||||
|
||||
// Execute the trigger directly to be able to know if it works
|
||||
await executeTrigger(true);
|
||||
|
||||
for (const expression of cronExpressions) {
|
||||
if (expression.split(' ').at(0)?.includes('*')) {
|
||||
throw new UserError('The polling interval is too short. It has to be at least a minute.');
|
||||
}
|
||||
|
||||
const ctx: CronContext = {
|
||||
workflowId: workflow.id,
|
||||
timezone: workflow.timezone,
|
||||
nodeId: node.id,
|
||||
expression,
|
||||
};
|
||||
|
||||
this.scheduledTaskManager.registerCron(ctx, executeTrigger);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a workflow inactive in memory.
|
||||
*/
|
||||
async remove(workflowId: string) {
|
||||
if (!this.isActive(workflowId)) {
|
||||
this.logger.warn(`Cannot deactivate already inactive workflow ID "${workflowId}"`);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.scheduledTaskManager.deregisterCrons(workflowId);
|
||||
|
||||
const w = this.activeWorkflows[workflowId];
|
||||
for (const r of w.triggerResponses ?? []) {
|
||||
await this.closeTrigger(r, workflowId);
|
||||
}
|
||||
|
||||
delete this.activeWorkflows[workflowId];
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async removeAllTriggerAndPollerBasedWorkflows() {
|
||||
const activeWorkflowIds = Object.keys(this.activeWorkflows);
|
||||
|
||||
if (activeWorkflowIds.length === 0) return;
|
||||
|
||||
for (const workflowId of activeWorkflowIds) {
|
||||
await this.remove(workflowId);
|
||||
}
|
||||
|
||||
this.logger.debug('Deactivated all trigger- and poller-based workflows', {
|
||||
workflowIds: activeWorkflowIds,
|
||||
});
|
||||
}
|
||||
|
||||
private async closeTrigger(response: ITriggerResponse, workflowId: string) {
|
||||
if (!response.closeFunction) return;
|
||||
|
||||
try {
|
||||
await response.closeFunction();
|
||||
} catch (e) {
|
||||
if (e instanceof TriggerCloseError) {
|
||||
this.logger.error(
|
||||
`There was a problem calling "closeFunction" on "${e.node.name}" in workflow "${workflowId}"`,
|
||||
);
|
||||
this.errorReporter.error(e, { extra: { workflowId } });
|
||||
return;
|
||||
}
|
||||
|
||||
const error = e instanceof Error ? e : new Error(`${e}`);
|
||||
|
||||
throw new WorkflowDeactivationError(
|
||||
`Failed to deactivate trigger of workflow ID "${workflowId}": "${error.message}"`,
|
||||
{ cause: error, workflowId },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a function that executes the poll function for a given workflow
|
||||
* and node and triggers a workflow execution based on the output.
|
||||
*/
|
||||
private createPollExecuteFn(
|
||||
workflow: Workflow,
|
||||
node: INode,
|
||||
pollFunctions: IPollFunctions,
|
||||
): (testingTrigger?: boolean) => Promise<void> {
|
||||
return async (testingTrigger = false) => {
|
||||
return await this.tracing.startSpan(
|
||||
{
|
||||
name: 'Workflow Trigger Poll',
|
||||
op: 'trigger.poll',
|
||||
attributes: {
|
||||
...this.tracing.pickWorkflowAttributes(workflow),
|
||||
...this.tracing.pickNodeAttributes(node),
|
||||
},
|
||||
},
|
||||
async (span) => {
|
||||
this.logger.debug(`Polling trigger initiated for workflow "${workflow.name}"`, {
|
||||
workflowName: workflow.name,
|
||||
workflowId: workflow.id,
|
||||
});
|
||||
|
||||
try {
|
||||
const pollResponse = await this.triggersAndPollers.runPoll(
|
||||
workflow,
|
||||
node,
|
||||
pollFunctions,
|
||||
);
|
||||
|
||||
if (pollResponse !== null) {
|
||||
pollFunctions.__emit(pollResponse);
|
||||
}
|
||||
|
||||
span.setStatus({ code: SpanStatus.ok });
|
||||
} catch (error) {
|
||||
span.setStatus({ code: SpanStatus.error });
|
||||
// If the poll function fails in the first activation
|
||||
// throw the error back so we let the user know there is
|
||||
// an issue with the trigger.
|
||||
if (testingTrigger) {
|
||||
throw error;
|
||||
}
|
||||
pollFunctions.__emitError(error as Error);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { ContextEstablishmentHookMetadata, IContextEstablishmentHook } from '@n8n/decorators';
|
||||
import { Container, Service } from '@n8n/di';
|
||||
|
||||
/**
|
||||
* Registry for managing context establishment hooks during workflow execution.
|
||||
*
|
||||
* Provides discovery and access to hooks that extract data from trigger items
|
||||
* and build execution context (credentials, environment variables, etc.).
|
||||
* Hooks are automatically discovered via the @ContextEstablishmentHook decorator.
|
||||
*/
|
||||
@Service()
|
||||
export class ExecutionContextHookRegistry {
|
||||
private hookMap: Map<string, IContextEstablishmentHook> = new Map();
|
||||
|
||||
constructor(
|
||||
private readonly executionContextHookMetadata: ContextEstablishmentHookMetadata,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Initializes the registry by loading all decorated hooks from metadata.
|
||||
*
|
||||
* - Clears any previously registered hooks
|
||||
* - Instantiates hooks via DI container
|
||||
* - Calls optional hook.init() methods
|
||||
* - Handles duplicate hook names (first wins, logs warning)
|
||||
*
|
||||
* Should be called at least once during application startup, but it can be called
|
||||
* multiple times if needed (e.g. to reload hooks).
|
||||
*/
|
||||
async init() {
|
||||
this.hookMap.clear();
|
||||
|
||||
const hookClasses = this.executionContextHookMetadata.getClasses();
|
||||
this.logger.debug(`Registering ${hookClasses.length} execution context hooks.`);
|
||||
|
||||
for (const HookClass of hookClasses) {
|
||||
let hook: IContextEstablishmentHook;
|
||||
try {
|
||||
hook = Container.get(HookClass);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to instantiate execution context hook class "${HookClass.name}": ${(error as Error).message}`,
|
||||
{ error },
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.hookMap.has(hook.hookDescription.name)) {
|
||||
this.logger.warn(
|
||||
`Execution context hook with name "${hook.hookDescription.name}" is already registered. Conflicting classes are "${this.hookMap.get(hook.hookDescription.name)?.constructor.name}" and "${HookClass.name}". Skipping the latter.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (hook.init) {
|
||||
try {
|
||||
await hook.init();
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to initialize execution context hook "${hook.hookDescription.name}": ${(error as Error).message}`,
|
||||
{ error },
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
this.hookMap.set(hook.hookDescription.name, hook);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a hook by its unique name.
|
||||
*
|
||||
* @param name - The hook name (e.g., 'credentials.bearerToken')
|
||||
* @returns The hook instance, or undefined if not found
|
||||
*/
|
||||
getHookByName(name: string): IContextEstablishmentHook | undefined {
|
||||
return this.hookMap.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all registered hooks.
|
||||
*
|
||||
* @returns Array of all hook instances
|
||||
*/
|
||||
getAllHooks(): IContextEstablishmentHook[] {
|
||||
return Array.from(this.hookMap.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds hooks applicable to a specific trigger node type.
|
||||
*
|
||||
* Filters hooks by calling their isApplicableToTriggerNode() method.
|
||||
* Useful for UI filtering and validation.
|
||||
*
|
||||
* @param triggerType - The node type identifier (e.g., 'n8n-nodes-base.webhook')
|
||||
* @returns Array of applicable hooks (may be empty)
|
||||
*/
|
||||
getHookForTriggerType(triggerType: string): IContextEstablishmentHook[] {
|
||||
return Array.from(this.hookMap.values()).filter((hook) => {
|
||||
return hook.isApplicableToTriggerNode(triggerType);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { Service } from '@n8n/di';
|
||||
import {
|
||||
IExecuteData,
|
||||
IExecutionContext,
|
||||
INodeExecutionData,
|
||||
PlaintextExecutionContext,
|
||||
toCredentialContext,
|
||||
toExecutionContextEstablishmentHookParameter,
|
||||
Workflow,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { Cipher } from '@/encryption';
|
||||
import { deepMerge } from '@/utils/deep-merge';
|
||||
|
||||
import { ExecutionContextHookRegistry } from './execution-context-hook-registry.service';
|
||||
|
||||
@Service()
|
||||
export class ExecutionContextService {
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly executionContextHookRegistry: ExecutionContextHookRegistry,
|
||||
private readonly cipher: Cipher,
|
||||
) {}
|
||||
|
||||
decryptExecutionContext(context: IExecutionContext): PlaintextExecutionContext {
|
||||
let credentials = undefined;
|
||||
if (context.credentials) {
|
||||
const decrypted = this.cipher.decrypt(context.credentials);
|
||||
credentials = toCredentialContext(decrypted);
|
||||
}
|
||||
return {
|
||||
...context,
|
||||
credentials,
|
||||
};
|
||||
}
|
||||
|
||||
encryptExecutionContext(context: PlaintextExecutionContext): IExecutionContext {
|
||||
let credentials = undefined;
|
||||
if (context.credentials) {
|
||||
credentials = this.cipher.encrypt(context.credentials);
|
||||
}
|
||||
return {
|
||||
...context,
|
||||
credentials,
|
||||
};
|
||||
}
|
||||
|
||||
mergeExecutionContexts(
|
||||
baseContext: PlaintextExecutionContext,
|
||||
contextToMerge: Partial<PlaintextExecutionContext>,
|
||||
): PlaintextExecutionContext {
|
||||
return deepMerge(baseContext, contextToMerge);
|
||||
}
|
||||
|
||||
// startItem is mutated to reflect any changes to trigger items made by the hooks
|
||||
async augmentExecutionContextWithHooks(
|
||||
workflow: Workflow,
|
||||
startItem: IExecuteData,
|
||||
contextToAugment: IExecutionContext,
|
||||
): Promise<{
|
||||
context: IExecutionContext;
|
||||
triggerItems: INodeExecutionData[] | null;
|
||||
}> {
|
||||
// Main input data is an array of items, each item represents an event that triggers the workflow execution
|
||||
// The 'main' selector selects the input name of the nodes, and the 0 index represents the runIndex,
|
||||
// 0 being the first run of this node in the workflow.
|
||||
|
||||
let currentTriggerItems = startItem.data['main'][0];
|
||||
|
||||
const contextEstablishmentHookParameters = {
|
||||
...(workflow.getNode(startItem.node.name)?.parameters ?? {}),
|
||||
...startItem.node.parameters,
|
||||
};
|
||||
|
||||
const startNodeParametersResult = toExecutionContextEstablishmentHookParameter(
|
||||
contextEstablishmentHookParameters,
|
||||
);
|
||||
|
||||
if (!startNodeParametersResult || startNodeParametersResult.error) {
|
||||
if (startNodeParametersResult?.error) {
|
||||
this.logger.warn(
|
||||
`Failed to parse execution context establishment hook parameters for node ${startItem.node.name}: ${startNodeParametersResult.error.message}`,
|
||||
);
|
||||
}
|
||||
// no execution establishment hooks found, we just return the original context
|
||||
return {
|
||||
context: contextToAugment,
|
||||
triggerItems: currentTriggerItems,
|
||||
};
|
||||
}
|
||||
|
||||
// startNodeParameters will hold the parameters of the start node
|
||||
// this can be the settings for the different hooks to be executed
|
||||
// for example to extract the bearer token from the start node data.
|
||||
const startNodeParameters = startNodeParametersResult.data;
|
||||
|
||||
// decrypt the context to work with plaintext data
|
||||
let context = this.decryptExecutionContext(contextToAugment);
|
||||
|
||||
// based on startNodeParameters, startNodeType and currentTriggerItems we can now
|
||||
// iterate over the different hooks to extract specific data for the runtime context
|
||||
for (const hookParameters of startNodeParameters.contextEstablishmentHooks.hooks) {
|
||||
const hook = this.executionContextHookRegistry.getHookByName(hookParameters.hookName);
|
||||
|
||||
if (!hook) {
|
||||
this.logger.warn(
|
||||
`Execution context establishment hook ${hookParameters.hookName} not found, skipping this hook`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// call the hook to let it modify the context and/or the main input data
|
||||
const result = await hook.execute({
|
||||
triggerNode: startItem.node,
|
||||
workflow,
|
||||
triggerItems: currentTriggerItems,
|
||||
context,
|
||||
options: hookParameters,
|
||||
});
|
||||
|
||||
if (result.triggerItems !== undefined) {
|
||||
// Update trigger items in case they were modified by the hook
|
||||
currentTriggerItems = result.triggerItems;
|
||||
}
|
||||
|
||||
if (result.contextUpdate) {
|
||||
// Merge any returned context fields into the execution context
|
||||
context = this.mergeExecutionContexts(context, result.contextUpdate);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to execute context establishment hook ${hookParameters.hookName}`,
|
||||
{ error },
|
||||
);
|
||||
if (!hookParameters.isAllowedToFail) {
|
||||
// If the hook is not allowed to fail, rethrow the error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
context: this.encryptExecutionContext(context),
|
||||
triggerItems: currentTriggerItems,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { Container } from '@n8n/di';
|
||||
import {
|
||||
type IWorkflowExecuteAdditionalData,
|
||||
type WorkflowExecuteMode,
|
||||
type IRunExecutionData,
|
||||
type Workflow,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { assertExecutionDataExists } from '@/utils/assertions';
|
||||
|
||||
import { ExecutionContextService } from './execution-context.service';
|
||||
|
||||
/**
|
||||
* Establishes the execution context for a workflow run.
|
||||
*
|
||||
* This function creates or inherits the execution context that persists throughout the workflow
|
||||
* execution lifecycle. The context is stored in `runExecutionData.executionData.runtimeData`.
|
||||
*
|
||||
* @param workflow - The workflow instance being executed (reserved for future context extraction)
|
||||
* @param runExecutionData - The execution data structure that will be mutated to include the execution context
|
||||
* @param additionalData - Additional workflow execution data used for validation and future context extraction
|
||||
* @param mode - The workflow execution mode (manual, trigger, webhook, error, etc.)
|
||||
*
|
||||
* @returns Promise that resolves when context has been established
|
||||
*
|
||||
* @throws {UnexpectedError} When `runExecutionData.executionData` is missing or invalid
|
||||
*
|
||||
* @remarks
|
||||
* ## Context Establishment Strategy
|
||||
*
|
||||
* The function follows a priority-based approach to establish execution context:
|
||||
*
|
||||
* ### 1. Preserve Existing Context (Webhook Resume)
|
||||
* If `executionData.runtimeData` already exists, the function returns immediately without
|
||||
* modification. This preserves context when workflows resume from database (e.g., after
|
||||
* waiting for a webhook or manual continuation).
|
||||
*
|
||||
* ### 2. Inherit from Parent Execution (Sub-workflows)
|
||||
* If `runExecutionData.parentExecution` exists, creates a new context by inheriting all
|
||||
* fields from the parent context while generating fresh values for:
|
||||
* - `establishedAt`: Set to current timestamp
|
||||
* - `source`: Set to current execution mode
|
||||
* - `parentExecutionId`: Tracks the parent execution ID
|
||||
*
|
||||
* This applies to sub-workflows invoked via "Execute Workflow" node.
|
||||
*
|
||||
* ### 3. Inherit from Start Node Metadata (Error Workflows)
|
||||
* If `startItem.metadata.parentExecution.executionContext` exists, creates a new context
|
||||
* by inheriting from the parent context. This applies to error workflows that need to
|
||||
* preserve the original workflow's context.
|
||||
*
|
||||
* ### 4. Create Fresh Context (New Executions)
|
||||
* For new root executions, creates a fresh context with:
|
||||
* - `version`: 1
|
||||
* - `establishedAt`: Current timestamp
|
||||
* - `source`: Current execution mode
|
||||
*
|
||||
* ## Mutation Behavior
|
||||
* This function mutates `runExecutionData.executionData.runtimeData` with the execution context.
|
||||
*
|
||||
* ## Context Inheritance Pattern
|
||||
* When inheriting context, the strategy is:
|
||||
* 1. Spread all parent context fields (credentials, custom fields, etc.)
|
||||
* 2. Override `establishedAt` with current timestamp
|
||||
* 3. Override `source` with current execution mode
|
||||
* 4. Add `parentExecutionId` to track lineage
|
||||
*
|
||||
* This ensures child executions reflect their own timing and mode while preserving
|
||||
* contextual information like credentials and authentication state.
|
||||
*
|
||||
* ## Special Cases
|
||||
*
|
||||
* ### Chat Trigger Workflows
|
||||
* Workflows containing only Chat Trigger nodes have an empty `nodeExecutionStack`.
|
||||
* Basic context is still established with version and timestamp.
|
||||
*
|
||||
* ### Empty Execution Stack
|
||||
* If no start item exists and no parent context is available, establishes minimal
|
||||
* context (version, timestamp, source) without additional enrichment.
|
||||
*
|
||||
* ## Future Enhancements
|
||||
* The function is designed to support extracting context information from:
|
||||
* - Start node parameters (e.g., webhook authentication tokens)
|
||||
* - Start node type (trigger, manual, webhook, etc.)
|
||||
* - Input data from triggering events
|
||||
* - User identification from various sources
|
||||
*
|
||||
* ## Example Usage
|
||||
* ```typescript
|
||||
* // New execution
|
||||
* await establishExecutionContext(workflow, runExecutionData, additionalData, 'manual');
|
||||
* // Context: { version: 1, establishedAt: 1234567890, source: 'manual' }
|
||||
*
|
||||
* // Sub-workflow execution (with parent context)
|
||||
* await establishExecutionContext(workflow, runExecutionData, additionalData, 'trigger');
|
||||
* // Context: { ...parentContext, establishedAt: 9876543210, source: 'trigger', parentExecutionId: 'parent-id' }
|
||||
*
|
||||
* // Resumed execution (webhook wait completed)
|
||||
* await establishExecutionContext(workflow, runExecutionData, additionalData, 'webhook');
|
||||
* // Context: <preserved from original execution>
|
||||
* ```
|
||||
*
|
||||
* @see IExecutionContextV1 for context structure definition
|
||||
* @see IRunExecutionData for execution data structure
|
||||
* @see IWorkflowExecuteAdditionalData for additional execution data
|
||||
* @see RelatedExecution for parent execution context propagation
|
||||
*/
|
||||
export const establishExecutionContext = async (
|
||||
workflow: Workflow,
|
||||
runExecutionData: IRunExecutionData,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
mode: WorkflowExecuteMode,
|
||||
): Promise<void> => {
|
||||
assertExecutionDataExists(runExecutionData.executionData, workflow, additionalData, mode);
|
||||
|
||||
const executionData = runExecutionData.executionData;
|
||||
|
||||
if (executionData.runtimeData) {
|
||||
// Context is already established, no further action needed.
|
||||
// This can happen, when a workflow is resumed from the database.
|
||||
return;
|
||||
}
|
||||
|
||||
// At this point we have established the basic execution context.
|
||||
// If a context is already established we overwrite it.
|
||||
// This might change depending on the propagation strategy we want to implement in the future.
|
||||
executionData.runtimeData = {
|
||||
version: 1,
|
||||
establishedAt: Date.now(),
|
||||
source: mode,
|
||||
redaction: {
|
||||
version: 1,
|
||||
policy: workflow.settings?.redactionPolicy ?? 'none',
|
||||
},
|
||||
};
|
||||
|
||||
if (runExecutionData.parentExecution) {
|
||||
// Create a new context by inheriting everything from the parent execution context,
|
||||
// except for the establishedAt timestamp which we set to now and the source which we set to the current mode.
|
||||
// This ensures that the child execution context reflects the time it was established
|
||||
// and the mode in which it is running, while still retaining all other contextual information
|
||||
// from the parent execution.
|
||||
executionData.runtimeData = {
|
||||
...(runExecutionData.parentExecution.executionContext ?? {}),
|
||||
...executionData.runtimeData,
|
||||
parentExecutionId: runExecutionData.parentExecution.executionId,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Next, we attempt to extract additional context from the start node of the execution stack.
|
||||
const [startItem] = executionData.nodeExecutionStack;
|
||||
|
||||
// The nodeExecutionStack is typically initialized in one of three ways:
|
||||
// 1. run() method: Creates stack with start node (workflow-execute.ts:143-157)
|
||||
// 2. runPartialWorkflow2(): Recreates stack from existing runData via recreateNodeExecutionStack()
|
||||
// 3. Constructor with executionData: Pre-populated from caller (resume scenarios)
|
||||
//
|
||||
// However, the stack CAN be legitimately empty for workflows containing only Chat Trigger nodes
|
||||
// (see workflow-execute.ts:1368-1369). In such cases, we cannot extract context from a start
|
||||
// node, but we should still establish basic execution context.
|
||||
//
|
||||
// We cannot extract user specific information from the initial item though. So we exit early.
|
||||
if (!startItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store basic trigger node info in the context for reference
|
||||
executionData.runtimeData.triggerNode = {
|
||||
name: startItem.node.name,
|
||||
type: startItem.node.type,
|
||||
};
|
||||
|
||||
// We were triggered from a parent execution
|
||||
// and can inherit context from there
|
||||
if (startItem.metadata?.parentExecution?.executionContext) {
|
||||
executionData.runtimeData = {
|
||||
...startItem.metadata.parentExecution.executionContext,
|
||||
...executionData.runtimeData,
|
||||
parentExecutionId: startItem.metadata.parentExecution.executionId,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Call the execution context service to augment the context with any hook-based data
|
||||
const executionContextService = Container.get(ExecutionContextService);
|
||||
|
||||
try {
|
||||
const { context, triggerItems } =
|
||||
await executionContextService.augmentExecutionContextWithHooks(
|
||||
workflow,
|
||||
startItem,
|
||||
executionData.runtimeData,
|
||||
);
|
||||
|
||||
executionData.runtimeData = context;
|
||||
|
||||
// If the trigger items were modified by hooks, update the start item accordingly
|
||||
if (triggerItems) {
|
||||
startItem.data['main'][0] = triggerItems;
|
||||
}
|
||||
} catch (error) {
|
||||
// Log the error
|
||||
Container.get(Logger).error('Failed to augment execution context with hooks.', { error });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteResponsePromiseData,
|
||||
INode,
|
||||
IRun,
|
||||
IRunExecutionData,
|
||||
ITaskData,
|
||||
ITaskStartedData,
|
||||
IWorkflowBase,
|
||||
StructuredChunk,
|
||||
Workflow,
|
||||
WorkflowExecuteMode,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export type ExecutionLifecycleHookHandlers = {
|
||||
nodeExecuteBefore: Array<
|
||||
(
|
||||
this: ExecutionLifecycleHooks,
|
||||
nodeName: string,
|
||||
data: ITaskStartedData,
|
||||
) => Promise<void> | void
|
||||
>;
|
||||
|
||||
nodeExecuteAfter: Array<
|
||||
(
|
||||
this: ExecutionLifecycleHooks,
|
||||
nodeName: string,
|
||||
data: ITaskData,
|
||||
executionData: IRunExecutionData,
|
||||
) => Promise<void> | void
|
||||
>;
|
||||
|
||||
workflowExecuteBefore: Array<
|
||||
(
|
||||
this: ExecutionLifecycleHooks,
|
||||
workflow: Workflow,
|
||||
data?: IRunExecutionData,
|
||||
) => Promise<void> | void
|
||||
>;
|
||||
|
||||
workflowExecuteResume: Array<
|
||||
(
|
||||
this: ExecutionLifecycleHooks,
|
||||
workflow: Workflow,
|
||||
data?: IRunExecutionData,
|
||||
) => Promise<void> | void
|
||||
>;
|
||||
|
||||
workflowExecuteAfter: Array<
|
||||
(this: ExecutionLifecycleHooks, data: IRun, newStaticData: IDataObject) => Promise<void> | void
|
||||
>;
|
||||
|
||||
/** Used by trigger and webhook nodes to respond back to the request */
|
||||
sendResponse: Array<
|
||||
(this: ExecutionLifecycleHooks, response: IExecuteResponsePromiseData) => Promise<void> | void
|
||||
>;
|
||||
|
||||
/** Used by nodes to send chunks to streaming responses */
|
||||
sendChunk: Array<(this: ExecutionLifecycleHooks, chunk: StructuredChunk) => Promise<void> | void>;
|
||||
|
||||
/**
|
||||
* Executed after a node fetches data
|
||||
* - For a webhook node, after the node had been run.
|
||||
* - For a http-request node, or any other node that makes http requests that still use the deprecated request* methods, after every successful http request
|
||||
s */
|
||||
nodeFetchedData: Array<
|
||||
(this: ExecutionLifecycleHooks, workflowId: string, node: INode) => Promise<void> | void
|
||||
>;
|
||||
};
|
||||
|
||||
export type ExecutionLifecycleHookName = keyof ExecutionLifecycleHookHandlers;
|
||||
|
||||
/**
|
||||
* Contains hooks that trigger at specific events in an execution's lifecycle. Every hook has an array of callbacks to run.
|
||||
*
|
||||
* Common use cases include:
|
||||
* - Saving execution progress to database
|
||||
* - Pushing execution status updates to the frontend
|
||||
* - Recording workflow statistics
|
||||
* - Running external hooks for execution events
|
||||
* - Error and Cancellation handling and cleanup
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const hooks = new ExecutionLifecycleHooks(mode, executionId, workflowData);
|
||||
* hooks.add('workflowExecuteAfter, async function(fullRunData) {
|
||||
* await saveToDatabase(executionId, fullRunData);
|
||||
*});
|
||||
* ```
|
||||
*/
|
||||
export class ExecutionLifecycleHooks {
|
||||
readonly handlers: ExecutionLifecycleHookHandlers = {
|
||||
nodeExecuteAfter: [],
|
||||
nodeExecuteBefore: [],
|
||||
nodeFetchedData: [],
|
||||
sendResponse: [],
|
||||
workflowExecuteAfter: [],
|
||||
workflowExecuteBefore: [],
|
||||
workflowExecuteResume: [],
|
||||
sendChunk: [],
|
||||
};
|
||||
|
||||
constructor(
|
||||
readonly mode: WorkflowExecuteMode,
|
||||
readonly executionId: string,
|
||||
readonly workflowData: IWorkflowBase,
|
||||
) {}
|
||||
|
||||
addHandler<Hook extends keyof ExecutionLifecycleHookHandlers>(
|
||||
hookName: Hook,
|
||||
...handlers: Array<ExecutionLifecycleHookHandlers[Hook][number]>
|
||||
): void {
|
||||
// @ts-expect-error FIX THIS
|
||||
this.handlers[hookName].push(...handlers);
|
||||
}
|
||||
|
||||
async runHook<
|
||||
Hook extends keyof ExecutionLifecycleHookHandlers,
|
||||
Params extends unknown[] = Parameters<
|
||||
Exclude<ExecutionLifecycleHookHandlers[Hook], undefined>[number]
|
||||
>,
|
||||
>(hookName: Hook, parameters: Params) {
|
||||
const hooks = this.handlers[hookName];
|
||||
for (const hookFunction of hooks) {
|
||||
const typedHookFunction = hookFunction as unknown as (
|
||||
this: ExecutionLifecycleHooks,
|
||||
...args: Params
|
||||
) => Promise<void>;
|
||||
await typedHookFunction.apply(this, parameters);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
export interface IExternalSecretsManager {
|
||||
hasSecret(provider: string, name: string): boolean;
|
||||
getSecret(provider: string, name: string): unknown;
|
||||
getSecretNames(provider: string): string[];
|
||||
hasProvider(provider: string): boolean;
|
||||
getProviderNames(): string[];
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class ExternalSecretsProxy {
|
||||
private manager?: IExternalSecretsManager;
|
||||
|
||||
setManager(manager: IExternalSecretsManager) {
|
||||
this.manager = manager;
|
||||
}
|
||||
|
||||
getSecret(provider: string, name: string) {
|
||||
return this.manager?.getSecret(provider, name);
|
||||
}
|
||||
|
||||
hasSecret(provider: string, name: string): boolean {
|
||||
return !!this.manager && this.manager.hasSecret(provider, name);
|
||||
}
|
||||
|
||||
hasProvider(provider: string): boolean {
|
||||
return !!this.manager && this.manager.hasProvider(provider);
|
||||
}
|
||||
|
||||
listProviders(): string[] {
|
||||
return this.manager?.getProviderNames() ?? [];
|
||||
}
|
||||
|
||||
listSecrets(provider: string): string[] {
|
||||
return this.manager?.getSecretNames(provider) ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { DataTableProxyProvider, IExecutionContext, IWorkflowSettings } from 'n8n-workflow';
|
||||
|
||||
import type { ExecutionLifecycleHooks } from './execution-lifecycle-hooks';
|
||||
import type { ExternalSecretsProxy } from './external-secrets-proxy';
|
||||
|
||||
declare module 'n8n-workflow' {
|
||||
interface IWorkflowExecuteAdditionalData {
|
||||
hooks?: ExecutionLifecycleHooks;
|
||||
externalSecretsProxy: ExternalSecretsProxy;
|
||||
'data-table'?: { dataTableProxyProvider: DataTableProxyProvider };
|
||||
// Project ID is currently only added on the additionalData if the user
|
||||
// has data table listing permission for that project. We should consider
|
||||
// that only data tables belonging to their respective projects are shown.
|
||||
dataTableProjectId?: string;
|
||||
/**
|
||||
* Execution context for dynamic credential resolution (EE feature).
|
||||
* Contains encrypted credential context that can be decrypted by resolvers.
|
||||
*/
|
||||
executionContext?: IExecutionContext;
|
||||
/**
|
||||
* Workflow settings (EE feature).
|
||||
* Contains workflow-level configuration including credential resolver ID.
|
||||
*/
|
||||
workflowSettings?: IWorkflowSettings;
|
||||
}
|
||||
}
|
||||
|
||||
export * from './active-workflows';
|
||||
export type * from './interfaces';
|
||||
export * from './routing-node';
|
||||
export * from './node-execution-context';
|
||||
export * from './partial-execution-utils';
|
||||
export * from './node-execution-context/utils/execution-metadata';
|
||||
export * from './workflow-execute';
|
||||
export * from './execution-context-hook-registry.service';
|
||||
export { ExecutionLifecycleHooks } from './execution-lifecycle-hooks';
|
||||
export { ExternalSecretsProxy, type IExternalSecretsManager } from './external-secrets-proxy';
|
||||
export { isEngineRequest } from './requests-response';
|
||||
@@ -0,0 +1,29 @@
|
||||
import type {
|
||||
INode,
|
||||
IPollFunctions,
|
||||
ITriggerFunctions,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
WorkflowActivateMode,
|
||||
WorkflowExecuteMode,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export interface IGetExecutePollFunctions {
|
||||
(
|
||||
workflow: Workflow,
|
||||
node: INode,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
mode: WorkflowExecuteMode,
|
||||
activation: WorkflowActivateMode,
|
||||
): IPollFunctions;
|
||||
}
|
||||
|
||||
export interface IGetExecuteTriggerFunctions {
|
||||
(
|
||||
workflow: Workflow,
|
||||
node: INode,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
mode: WorkflowExecuteMode,
|
||||
activation: WorkflowActivateMode,
|
||||
): ITriggerFunctions;
|
||||
}
|
||||
+386
@@ -0,0 +1,386 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
INode,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
IRunExecutionData,
|
||||
INodeExecutionData,
|
||||
ITaskDataConnections,
|
||||
IExecuteData,
|
||||
Workflow,
|
||||
WorkflowExecuteMode,
|
||||
ICredentialsHelper,
|
||||
INodeType,
|
||||
INodeTypes,
|
||||
ICredentialDataDecryptedObject,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
ApplicationError,
|
||||
ExpressionError,
|
||||
NodeConnectionTypes,
|
||||
type WorkflowExpression,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import type { ExecutionLifecycleHooks } from '@/execution-engine/execution-lifecycle-hooks';
|
||||
|
||||
import { describeCommonTests } from './shared-tests';
|
||||
import { ExecuteContext } from '../execute-context';
|
||||
import * as validateUtil from '../utils/validate-value-against-schema';
|
||||
|
||||
describe('ExecuteContext', () => {
|
||||
const testCredentialType = 'testCredential';
|
||||
const nodeType = mock<INodeType>({
|
||||
description: {
|
||||
credentials: [
|
||||
{
|
||||
name: testCredentialType,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
name: 'testParameter',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const nodeTypes = mock<INodeTypes>();
|
||||
const expression = mock<WorkflowExpression>();
|
||||
const workflow = mock<Workflow>({ expression, nodeTypes });
|
||||
const node: INode = {
|
||||
id: 'test-node-id',
|
||||
name: 'Test Node',
|
||||
type: 'testNodeType',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
credentials: {
|
||||
[testCredentialType]: {
|
||||
id: 'testCredentialId',
|
||||
name: 'testCredential',
|
||||
},
|
||||
},
|
||||
parameters: {},
|
||||
};
|
||||
node.parameters = {
|
||||
testParameter: 'testValue',
|
||||
nullParameter: null,
|
||||
};
|
||||
const credentialsHelper = mock<ICredentialsHelper>();
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
|
||||
const mode: WorkflowExecuteMode = 'manual';
|
||||
const runExecutionData = mock<IRunExecutionData>();
|
||||
const connectionInputData: INodeExecutionData[] = [];
|
||||
const inputData: ITaskDataConnections = { main: [[{ json: { test: 'data' } }]] };
|
||||
const executeData = mock<IExecuteData>();
|
||||
const runIndex = 0;
|
||||
const closeFn = jest.fn();
|
||||
const abortSignal = mock<AbortSignal>();
|
||||
|
||||
const executeContext = new ExecuteContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
executeData,
|
||||
[closeFn],
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
expression.getParameterValue.mockImplementation((value) => value);
|
||||
});
|
||||
|
||||
describeCommonTests(executeContext, {
|
||||
abortSignal,
|
||||
node,
|
||||
workflow,
|
||||
executeData,
|
||||
runExecutionData,
|
||||
});
|
||||
|
||||
describe('getInputData', () => {
|
||||
const inputIndex = 0;
|
||||
const connectionType = NodeConnectionTypes.Main;
|
||||
|
||||
afterEach(() => {
|
||||
inputData[connectionType] = [[{ json: { test: 'data' } }]];
|
||||
});
|
||||
|
||||
it('should return the input data correctly', () => {
|
||||
const expectedData = [{ json: { test: 'data' } }];
|
||||
|
||||
expect(executeContext.getInputData(inputIndex, connectionType)).toEqual(expectedData);
|
||||
});
|
||||
|
||||
it('should return an empty array if the input name does not exist', () => {
|
||||
const connectionType = 'nonExistent' as typeof NodeConnectionTypes.Main;
|
||||
expect(executeContext.getInputData(inputIndex, connectionType)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should throw an error if the input index is out of range', () => {
|
||||
const inputIndex = 2;
|
||||
|
||||
expect(() => executeContext.getInputData(inputIndex, connectionType)).toThrow(
|
||||
ApplicationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the input index was not set', () => {
|
||||
inputData.main[inputIndex] = null;
|
||||
|
||||
expect(() => executeContext.getInputData(inputIndex, connectionType)).toThrow(
|
||||
ApplicationError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeParameter', () => {
|
||||
beforeEach(() => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
expression.getParameterValue.mockImplementation((value) => value);
|
||||
});
|
||||
|
||||
it('should throw if parameter is not defined on the node.parameters', () => {
|
||||
expect(() => executeContext.getNodeParameter('invalidParameter', 0)).toThrow(
|
||||
'Could not get parameter',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null if the parameter exists but has a null value', () => {
|
||||
const parameter = executeContext.getNodeParameter('nullParameter', 0);
|
||||
|
||||
expect(parameter).toBeNull();
|
||||
});
|
||||
|
||||
it('should return parameter value when it exists', () => {
|
||||
const parameter = executeContext.getNodeParameter('testParameter', 0);
|
||||
|
||||
expect(parameter).toBe('testValue');
|
||||
});
|
||||
|
||||
it('should return the fallback value when the parameter does not exist', () => {
|
||||
const parameter = executeContext.getNodeParameter('otherParameter', 0, 'fallback');
|
||||
|
||||
expect(parameter).toBe('fallback');
|
||||
});
|
||||
|
||||
it('should handle expression evaluation errors', () => {
|
||||
const error = new ExpressionError('Invalid expression');
|
||||
expression.getParameterValue.mockImplementationOnce(() => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
expect(() => executeContext.getNodeParameter('testParameter', 0)).toThrow(error);
|
||||
expect(error.context.parameter).toEqual('testParameter');
|
||||
});
|
||||
|
||||
it('should handle expression errors on Set nodes (Ticket #PAY-684)', () => {
|
||||
node.type = 'n8n-nodes-base.set';
|
||||
node.continueOnFail = true;
|
||||
|
||||
expression.getParameterValue.mockImplementationOnce(() => {
|
||||
throw new ExpressionError('Invalid expression');
|
||||
});
|
||||
|
||||
const parameter = executeContext.getNodeParameter('testParameter', 0);
|
||||
expect(parameter).toEqual([{ name: undefined, value: undefined }]);
|
||||
});
|
||||
|
||||
it('should not validate parameter if skipValidation in options', () => {
|
||||
const validateSpy = jest.spyOn(validateUtil, 'validateValueAgainstSchema');
|
||||
|
||||
executeContext.getNodeParameter('testParameter', 0, '', {
|
||||
skipValidation: true,
|
||||
});
|
||||
|
||||
expect(validateSpy).not.toHaveBeenCalled();
|
||||
|
||||
validateSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCredentials', () => {
|
||||
it('should get decrypted credentials', async () => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
|
||||
|
||||
const credentials = await executeContext.getCredentials<ICredentialDataDecryptedObject>(
|
||||
testCredentialType,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(credentials).toEqual({ secret: 'token' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExecuteData', () => {
|
||||
it('should return the execute data correctly', () => {
|
||||
expect(executeContext.getExecuteData()).toEqual(executeData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWorkflowDataProxy', () => {
|
||||
it('should return the workflow data proxy correctly', () => {
|
||||
const workflowDataProxy = executeContext.getWorkflowDataProxy(0);
|
||||
expect(workflowDataProxy.isProxy).toBe(true);
|
||||
expect(Object.keys(workflowDataProxy.$input)).toEqual([
|
||||
'all',
|
||||
'context',
|
||||
'first',
|
||||
'item',
|
||||
'last',
|
||||
'params',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logNodeOutput', () => {
|
||||
it('when in manual mode, should parse JSON', () => {
|
||||
const json = '{"key": "value", "nested": {"foo": "bar"}}';
|
||||
const expectedParsedObject = { key: 'value', nested: { foo: 'bar' } };
|
||||
const numberArg = 42;
|
||||
const stringArg = 'hello world!';
|
||||
|
||||
const manualModeContext = new ExecuteContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
'manual',
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
executeData,
|
||||
[closeFn],
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
const sendMessageSpy = jest.spyOn(manualModeContext, 'sendMessageToUI');
|
||||
|
||||
manualModeContext.logNodeOutput(json, numberArg, stringArg);
|
||||
|
||||
expect(sendMessageSpy.mock.calls[0][0]).toEqual(expectedParsedObject);
|
||||
expect(sendMessageSpy.mock.calls[0][1]).toBe(numberArg);
|
||||
expect(sendMessageSpy.mock.calls[0][2]).toBe(stringArg);
|
||||
|
||||
sendMessageSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendChunk', () => {
|
||||
test('should send call hook with structured chunk', async () => {
|
||||
const hooksMock: ExecutionLifecycleHooks = mock<ExecutionLifecycleHooks>({
|
||||
runHook: jest.fn(),
|
||||
});
|
||||
const additionalDataWithHooks: IWorkflowExecuteAdditionalData = {
|
||||
...additionalData,
|
||||
hooks: hooksMock,
|
||||
};
|
||||
|
||||
const testExecuteContext = new ExecuteContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalDataWithHooks,
|
||||
'manual',
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
executeData,
|
||||
[closeFn],
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
await testExecuteContext.sendChunk('item', 0, 'test');
|
||||
|
||||
expect(hooksMock.runHook).toHaveBeenCalledWith('sendChunk', [
|
||||
expect.objectContaining({
|
||||
type: 'item',
|
||||
content: 'test',
|
||||
metadata: expect.objectContaining({
|
||||
nodeName: 'Test Node',
|
||||
nodeId: 'test-node-id',
|
||||
runIndex: 0,
|
||||
itemIndex: 0,
|
||||
timestamp: expect.any(Number),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('should send chunk without content when content is undefined', async () => {
|
||||
const hooksMock: ExecutionLifecycleHooks = mock<ExecutionLifecycleHooks>({
|
||||
runHook: jest.fn(),
|
||||
});
|
||||
const additionalDataWithHooks: IWorkflowExecuteAdditionalData = {
|
||||
...additionalData,
|
||||
hooks: hooksMock,
|
||||
};
|
||||
|
||||
const testExecuteContext = new ExecuteContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalDataWithHooks,
|
||||
'manual',
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
executeData,
|
||||
[closeFn],
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
await testExecuteContext.sendChunk('begin', 0);
|
||||
|
||||
expect(hooksMock.runHook).toHaveBeenCalledWith('sendChunk', [
|
||||
expect.objectContaining({
|
||||
type: 'begin',
|
||||
content: undefined,
|
||||
metadata: expect.objectContaining({
|
||||
nodeName: 'Test Node',
|
||||
nodeId: 'test-node-id',
|
||||
runIndex: 0,
|
||||
itemIndex: 0,
|
||||
timestamp: expect.any(Number),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('should handle when hooks is undefined', async () => {
|
||||
const additionalDataWithoutHooks = {
|
||||
...additionalData,
|
||||
hooks: undefined,
|
||||
};
|
||||
|
||||
const testExecuteContext = new ExecuteContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalDataWithoutHooks,
|
||||
'manual',
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
executeData,
|
||||
[closeFn],
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
// Should not throw error
|
||||
await expect(testExecuteContext.sendChunk('item', 0, 'test')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isToolExecution', () => {
|
||||
it('should return false for regular workflow execution', () => {
|
||||
expect(executeContext.isToolExecution()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
INode,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
IRunExecutionData,
|
||||
INodeExecutionData,
|
||||
ITaskDataConnections,
|
||||
IExecuteData,
|
||||
Workflow,
|
||||
WorkflowExecuteMode,
|
||||
ICredentialsHelper,
|
||||
INodeType,
|
||||
INodeTypes,
|
||||
ICredentialDataDecryptedObject,
|
||||
} from 'n8n-workflow';
|
||||
import { ApplicationError, NodeConnectionTypes, type WorkflowExpression } from 'n8n-workflow';
|
||||
|
||||
import { describeCommonTests } from './shared-tests';
|
||||
import { ExecuteSingleContext } from '../execute-single-context';
|
||||
|
||||
describe('ExecuteSingleContext', () => {
|
||||
const testCredentialType = 'testCredential';
|
||||
const nodeType = mock<INodeType>({
|
||||
description: {
|
||||
credentials: [
|
||||
{
|
||||
name: testCredentialType,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
name: 'testParameter',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const nodeTypes = mock<INodeTypes>();
|
||||
const expression = mock<WorkflowExpression>();
|
||||
const workflow = mock<Workflow>({ expression, nodeTypes });
|
||||
const node = mock<INode>({
|
||||
name: 'Test Node',
|
||||
credentials: {
|
||||
[testCredentialType]: {
|
||||
id: 'testCredentialId',
|
||||
},
|
||||
},
|
||||
});
|
||||
node.parameters = {
|
||||
testParameter: 'testValue',
|
||||
};
|
||||
const credentialsHelper = mock<ICredentialsHelper>();
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
|
||||
const mode: WorkflowExecuteMode = 'manual';
|
||||
const runExecutionData = mock<IRunExecutionData>();
|
||||
const connectionInputData: INodeExecutionData[] = [];
|
||||
const inputData: ITaskDataConnections = { main: [[{ json: { test: 'data' } }]] };
|
||||
const executeData = mock<IExecuteData>();
|
||||
const runIndex = 0;
|
||||
const itemIndex = 0;
|
||||
const abortSignal = mock<AbortSignal>();
|
||||
|
||||
const executeSingleContext = new ExecuteSingleContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
itemIndex,
|
||||
executeData,
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
expression.getParameterValue.mockImplementation((value) => value);
|
||||
});
|
||||
|
||||
describeCommonTests(executeSingleContext, {
|
||||
abortSignal,
|
||||
node,
|
||||
workflow,
|
||||
executeData,
|
||||
runExecutionData,
|
||||
});
|
||||
|
||||
describe('getInputData', () => {
|
||||
const inputIndex = 0;
|
||||
const connectionType = NodeConnectionTypes.Main;
|
||||
|
||||
afterEach(() => {
|
||||
inputData[connectionType] = [[{ json: { test: 'data' } }]];
|
||||
});
|
||||
|
||||
it('should return the input data correctly', () => {
|
||||
const expectedData = { json: { test: 'data' } };
|
||||
|
||||
expect(executeSingleContext.getInputData(inputIndex, connectionType)).toEqual(expectedData);
|
||||
});
|
||||
|
||||
it('should return an empty object if the input name does not exist', () => {
|
||||
const connectionType = 'nonExistent' as typeof NodeConnectionTypes.Main;
|
||||
const expectedData = { json: {} };
|
||||
|
||||
expect(executeSingleContext.getInputData(inputIndex, connectionType)).toEqual(expectedData);
|
||||
});
|
||||
|
||||
it('should throw an error if the input index is out of range', () => {
|
||||
const inputIndex = 1;
|
||||
|
||||
expect(() => executeSingleContext.getInputData(inputIndex, connectionType)).toThrow(
|
||||
ApplicationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the input index was not set', () => {
|
||||
inputData.main[inputIndex] = null;
|
||||
|
||||
expect(() => executeSingleContext.getInputData(inputIndex, connectionType)).toThrow(
|
||||
ApplicationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the value of input with given index was not set', () => {
|
||||
delete inputData.main[inputIndex]![itemIndex];
|
||||
|
||||
expect(() => executeSingleContext.getInputData(inputIndex, connectionType)).toThrow(
|
||||
ApplicationError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getItemIndex', () => {
|
||||
it('should return the item index correctly', () => {
|
||||
expect(executeSingleContext.getItemIndex()).toEqual(itemIndex);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeParameter', () => {
|
||||
beforeEach(() => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
expression.getParameterValue.mockImplementation((value) => value);
|
||||
});
|
||||
|
||||
it('should return parameter value when it exists', () => {
|
||||
const parameter = executeSingleContext.getNodeParameter('testParameter');
|
||||
|
||||
expect(parameter).toBe('testValue');
|
||||
});
|
||||
|
||||
it('should return the fallback value when the parameter does not exist', () => {
|
||||
const parameter = executeSingleContext.getNodeParameter('otherParameter', 'fallback');
|
||||
|
||||
expect(parameter).toBe('fallback');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCredentials', () => {
|
||||
it('should get decrypted credentials', async () => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
|
||||
|
||||
const credentials =
|
||||
await executeSingleContext.getCredentials<ICredentialDataDecryptedObject>(
|
||||
testCredentialType,
|
||||
);
|
||||
|
||||
expect(credentials).toEqual({ secret: 'token' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExecuteData', () => {
|
||||
it('should return the execute data correctly', () => {
|
||||
expect(executeSingleContext.getExecuteData()).toEqual(executeData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWorkflowDataProxy', () => {
|
||||
it('should return the workflow data proxy correctly', () => {
|
||||
const workflowDataProxy = executeSingleContext.getWorkflowDataProxy();
|
||||
expect(workflowDataProxy.isProxy).toBe(true);
|
||||
expect(Object.keys(workflowDataProxy.$input)).toEqual([
|
||||
'all',
|
||||
'context',
|
||||
'first',
|
||||
'item',
|
||||
'last',
|
||||
'params',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
import { ApplicationError } from '@n8n/errors';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialsHelper,
|
||||
INode,
|
||||
INodeType,
|
||||
INodeTypes,
|
||||
IWebhookDescription,
|
||||
IWebhookData,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
WorkflowActivateMode,
|
||||
WorkflowExecuteMode,
|
||||
WorkflowExpression,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { HookContext } from '../hook-context';
|
||||
|
||||
describe('HookContext', () => {
|
||||
const testCredentialType = 'testCredential';
|
||||
const webhookDescription: IWebhookDescription = {
|
||||
name: 'default',
|
||||
httpMethod: 'GET',
|
||||
responseMode: 'onReceived',
|
||||
path: 'testPath',
|
||||
};
|
||||
const nodeType = mock<INodeType>({
|
||||
description: {
|
||||
credentials: [
|
||||
{
|
||||
name: testCredentialType,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
name: 'testParameter',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
nodeType.description.webhooks = [webhookDescription];
|
||||
const nodeTypes = mock<INodeTypes>();
|
||||
const expression = mock<WorkflowExpression>();
|
||||
const workflow = mock<Workflow>({ expression, nodeTypes });
|
||||
const node = mock<INode>({
|
||||
credentials: {
|
||||
[testCredentialType]: {
|
||||
id: 'testCredentialId',
|
||||
},
|
||||
},
|
||||
});
|
||||
node.parameters = {
|
||||
testParameter: 'testValue',
|
||||
};
|
||||
const credentialsHelper = mock<ICredentialsHelper>();
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
|
||||
const mode: WorkflowExecuteMode = 'manual';
|
||||
const activation: WorkflowActivateMode = 'init';
|
||||
const webhookData = mock<IWebhookData>({
|
||||
webhookDescription: {
|
||||
name: 'default',
|
||||
isFullPath: true,
|
||||
},
|
||||
});
|
||||
|
||||
const hookContext = new HookContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
activation,
|
||||
webhookData,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
expression.getParameterValue.mockImplementation((value) => value);
|
||||
expression.getSimpleParameterValue.mockImplementation((_, value) => value);
|
||||
});
|
||||
|
||||
describe('getActivationMode', () => {
|
||||
it('should return the activation property', () => {
|
||||
const result = hookContext.getActivationMode();
|
||||
expect(result).toBe(activation);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCredentials', () => {
|
||||
it('should get decrypted credentials', async () => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
|
||||
|
||||
const credentials =
|
||||
await hookContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
|
||||
|
||||
expect(credentials).toEqual({ secret: 'token' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeParameter', () => {
|
||||
it('should return parameter value when it exists', () => {
|
||||
const parameter = hookContext.getNodeParameter('testParameter');
|
||||
|
||||
expect(parameter).toBe('testValue');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeWebhookUrl', () => {
|
||||
it('should return node webhook url', () => {
|
||||
const url = hookContext.getNodeWebhookUrl('default');
|
||||
|
||||
expect(url).toContain('testPath');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWebhookName', () => {
|
||||
it('should return webhook name', () => {
|
||||
const name = hookContext.getWebhookName();
|
||||
|
||||
expect(name).toBe('default');
|
||||
});
|
||||
|
||||
it('should throw an error if webhookData is undefined', () => {
|
||||
const hookContextWithoutWebhookData = new HookContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
activation,
|
||||
);
|
||||
|
||||
expect(() => hookContextWithoutWebhookData.getWebhookName()).toThrow(ApplicationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWebhookDescription', () => {
|
||||
it('should return webhook description', () => {
|
||||
const description = hookContext.getWebhookDescription('default');
|
||||
|
||||
expect(description).toEqual<IWebhookDescription>(webhookDescription);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExecutionContext', () => {
|
||||
it('should return undefined', () => {
|
||||
expect(hookContext.getExecutionContext()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialsHelper,
|
||||
INode,
|
||||
INodeType,
|
||||
INodeTypes,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
WorkflowExpression,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { LoadOptionsContext } from '../load-options-context';
|
||||
|
||||
describe('LoadOptionsContext', () => {
|
||||
const testCredentialType = 'testCredential';
|
||||
const nodeType = mock<INodeType>({
|
||||
description: {
|
||||
credentials: [
|
||||
{
|
||||
name: testCredentialType,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
name: 'testParameter',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const nodeTypes = mock<INodeTypes>();
|
||||
const expression = mock<WorkflowExpression>();
|
||||
const workflow = mock<Workflow>({ expression, nodeTypes });
|
||||
const node = mock<INode>({
|
||||
credentials: {
|
||||
[testCredentialType]: {
|
||||
id: 'testCredentialId',
|
||||
},
|
||||
},
|
||||
});
|
||||
node.parameters = {
|
||||
testParameter: 'testValue',
|
||||
};
|
||||
const credentialsHelper = mock<ICredentialsHelper>();
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
|
||||
const path = 'testPath';
|
||||
|
||||
const loadOptionsContext = new LoadOptionsContext(workflow, node, additionalData, path);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getCredentials', () => {
|
||||
it('should get decrypted credentials', async () => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
|
||||
|
||||
const credentials =
|
||||
await loadOptionsContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
|
||||
|
||||
expect(credentials).toEqual({ secret: 'token' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrentNodeParameter', () => {
|
||||
beforeEach(() => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
});
|
||||
|
||||
it('should return parameter value when it exists', () => {
|
||||
additionalData.currentNodeParameters = {
|
||||
testParameter: 'testValue',
|
||||
};
|
||||
|
||||
const parameter = loadOptionsContext.getCurrentNodeParameter('testParameter');
|
||||
|
||||
expect(parameter).toBe('testValue');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeParameter', () => {
|
||||
beforeEach(() => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
expression.getParameterValue.mockImplementation((value) => value);
|
||||
});
|
||||
|
||||
it('should return parameter value when it exists', () => {
|
||||
const parameter = loadOptionsContext.getNodeParameter('testParameter');
|
||||
|
||||
expect(parameter).toBe('testValue');
|
||||
});
|
||||
|
||||
it('should return the fallback value when the parameter does not exist', () => {
|
||||
const parameter = loadOptionsContext.getNodeParameter('otherParameter', 'fallback');
|
||||
|
||||
expect(parameter).toBe('fallback');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExecutionContext', () => {
|
||||
it('should return undefined', () => {
|
||||
expect(loadOptionsContext.getExecutionContext()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
INode,
|
||||
INodeTypes,
|
||||
IWorkflowBase,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
IWorkflowLoader,
|
||||
} from 'n8n-workflow';
|
||||
import { ApplicationError, Workflow } from 'n8n-workflow';
|
||||
|
||||
import { LocalLoadOptionsContext } from '../local-load-options-context';
|
||||
import { LoadWorkflowNodeContext } from '../workflow-node-context';
|
||||
|
||||
jest.mock('n8n-workflow', () => ({
|
||||
...jest.requireActual('n8n-workflow'),
|
||||
Workflow: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('LocalLoadOptionsContext', () => {
|
||||
const nodeTypes = mock<INodeTypes>();
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>();
|
||||
const workflowLoader = mock<IWorkflowLoader>();
|
||||
const path = '';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getWorkflowNodeContext', () => {
|
||||
const targetNodeType = 'n8n-nodes-base.executeWorkflowTrigger';
|
||||
|
||||
it('should throw TypeError when workflowId parameter is missing', async () => {
|
||||
additionalData.currentNodeParameters = {};
|
||||
|
||||
const context = new LocalLoadOptionsContext(nodeTypes, additionalData, path, workflowLoader);
|
||||
|
||||
await expect(context.getWorkflowNodeContext(targetNodeType)).rejects.toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should throw ApplicationError when workflowId value is not a string', async () => {
|
||||
additionalData.currentNodeParameters = {
|
||||
workflowId: { value: 123 },
|
||||
};
|
||||
|
||||
const context = new LocalLoadOptionsContext(nodeTypes, additionalData, path, workflowLoader);
|
||||
|
||||
await expect(context.getWorkflowNodeContext(targetNodeType)).rejects.toThrow(
|
||||
ApplicationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ApplicationError when workflowId value is empty', async () => {
|
||||
additionalData.currentNodeParameters = {
|
||||
workflowId: { value: '' },
|
||||
};
|
||||
|
||||
const context = new LocalLoadOptionsContext(nodeTypes, additionalData, path, workflowLoader);
|
||||
|
||||
await expect(context.getWorkflowNodeContext(targetNodeType)).rejects.toThrow(
|
||||
ApplicationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ApplicationError when useActiveVersion is true but no activeVersion exists', async () => {
|
||||
const workflowId = 'workflow-123';
|
||||
additionalData.currentNodeParameters = {
|
||||
workflowId: { value: workflowId },
|
||||
};
|
||||
|
||||
const dbWorkflow = mock<IWorkflowBase>({
|
||||
id: workflowId,
|
||||
name: 'Test Workflow',
|
||||
nodes: [],
|
||||
activeVersion: null,
|
||||
});
|
||||
workflowLoader.get.mockResolvedValue(dbWorkflow);
|
||||
|
||||
const context = new LocalLoadOptionsContext(nodeTypes, additionalData, path, workflowLoader);
|
||||
|
||||
await expect(context.getWorkflowNodeContext(targetNodeType, true)).rejects.toThrow(
|
||||
ApplicationError,
|
||||
);
|
||||
await expect(context.getWorkflowNodeContext(targetNodeType, true)).rejects.toThrow(
|
||||
`No active version found for workflow "${workflowId}"!`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null when no node of the specified type exists in the workflow', async () => {
|
||||
const workflowId = 'workflow-123';
|
||||
additionalData.currentNodeParameters = {
|
||||
workflowId: { value: workflowId },
|
||||
};
|
||||
|
||||
const otherNode = mock<INode>({
|
||||
type: 'n8n-nodes-base.otherNode',
|
||||
name: 'Other Node',
|
||||
});
|
||||
const dbWorkflow = mock<IWorkflowBase>({
|
||||
id: workflowId,
|
||||
name: 'Test Workflow',
|
||||
nodes: [otherNode],
|
||||
});
|
||||
workflowLoader.get.mockResolvedValue(dbWorkflow);
|
||||
|
||||
const context = new LocalLoadOptionsContext(nodeTypes, additionalData, path, workflowLoader);
|
||||
|
||||
const result = await context.getWorkflowNodeContext(targetNodeType);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return LoadWorkflowNodeContext when node type exists in the workflow', async () => {
|
||||
const workflowId = 'workflow-123';
|
||||
const nodeParameters = { inputSource: 'passthrough' };
|
||||
additionalData.currentNodeParameters = {
|
||||
workflowId: { value: workflowId },
|
||||
};
|
||||
|
||||
const targetNode = mock<INode>({
|
||||
type: targetNodeType,
|
||||
name: 'Execute Workflow Trigger',
|
||||
parameters: nodeParameters,
|
||||
});
|
||||
const dbWorkflow = mock<IWorkflowBase>({
|
||||
id: workflowId,
|
||||
name: 'Test Workflow',
|
||||
nodes: [targetNode],
|
||||
});
|
||||
workflowLoader.get.mockResolvedValue(dbWorkflow);
|
||||
|
||||
const context = new LocalLoadOptionsContext(nodeTypes, additionalData, path, workflowLoader);
|
||||
|
||||
const result = await context.getWorkflowNodeContext(targetNodeType);
|
||||
|
||||
expect(result).toBeInstanceOf(LoadWorkflowNodeContext);
|
||||
expect(Workflow).toHaveBeenCalledWith({
|
||||
id: workflowId,
|
||||
name: 'Test Workflow',
|
||||
nodes: [targetNode],
|
||||
connections: {},
|
||||
active: false,
|
||||
nodeTypes,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use activeVersion nodes when useActiveVersion is true', async () => {
|
||||
const workflowId = 'workflow-123';
|
||||
const nodeParameters = { inputSource: 'passthrough' };
|
||||
additionalData.currentNodeParameters = {
|
||||
workflowId: { value: workflowId },
|
||||
};
|
||||
|
||||
const regularNode = mock<INode>({
|
||||
type: targetNodeType,
|
||||
name: 'Regular Trigger',
|
||||
});
|
||||
const activeVersionNode = mock<INode>({
|
||||
type: targetNodeType,
|
||||
name: 'Active Version Trigger',
|
||||
parameters: nodeParameters,
|
||||
});
|
||||
const dbWorkflow = mock<IWorkflowBase>({
|
||||
id: workflowId,
|
||||
name: 'Test Workflow',
|
||||
nodes: [regularNode],
|
||||
activeVersion: {
|
||||
versionId: 'version-1',
|
||||
workflowId,
|
||||
nodes: [activeVersionNode],
|
||||
connections: {},
|
||||
authors: 'test',
|
||||
name: 'Test Workflow',
|
||||
description: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
workflowLoader.get.mockResolvedValue(dbWorkflow);
|
||||
|
||||
const context = new LocalLoadOptionsContext(nodeTypes, additionalData, path, workflowLoader);
|
||||
|
||||
const result = await context.getWorkflowNodeContext(targetNodeType, true);
|
||||
|
||||
expect(result).toBeInstanceOf(LoadWorkflowNodeContext);
|
||||
expect(Workflow).toHaveBeenCalledWith({
|
||||
id: workflowId,
|
||||
name: 'Test Workflow',
|
||||
nodes: [activeVersionNode],
|
||||
connections: {},
|
||||
active: false,
|
||||
nodeTypes,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null when node type does not exist in activeVersion nodes', async () => {
|
||||
const workflowId = 'workflow-123';
|
||||
additionalData.currentNodeParameters = {
|
||||
workflowId: { value: workflowId },
|
||||
};
|
||||
|
||||
const regularNode = mock<INode>({
|
||||
type: targetNodeType,
|
||||
name: 'Regular Trigger',
|
||||
});
|
||||
const activeVersionNode = mock<INode>({
|
||||
type: 'n8n-nodes-base.otherNode',
|
||||
name: 'Other Node',
|
||||
});
|
||||
const dbWorkflow = mock<IWorkflowBase>({
|
||||
id: workflowId,
|
||||
name: 'Test Workflow',
|
||||
nodes: [regularNode],
|
||||
activeVersion: {
|
||||
versionId: 'version-1',
|
||||
workflowId,
|
||||
nodes: [activeVersionNode],
|
||||
connections: {},
|
||||
authors: 'test',
|
||||
name: 'Test Workflow',
|
||||
description: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
workflowLoader.get.mockResolvedValue(dbWorkflow);
|
||||
|
||||
const context = new LocalLoadOptionsContext(nodeTypes, additionalData, path, workflowLoader);
|
||||
|
||||
const result = await context.getWorkflowNodeContext(targetNodeType, true);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrentNodeParameter', () => {
|
||||
it('should return the parameter value when it exists', () => {
|
||||
additionalData.currentNodeParameters = {
|
||||
testParam: 'testValue',
|
||||
};
|
||||
|
||||
const context = new LocalLoadOptionsContext(nodeTypes, additionalData, path, workflowLoader);
|
||||
|
||||
const result = context.getCurrentNodeParameter('testParam');
|
||||
|
||||
expect(result).toBe('testValue');
|
||||
});
|
||||
|
||||
it('should return undefined when parameter does not exist', () => {
|
||||
additionalData.currentNodeParameters = {};
|
||||
|
||||
const context = new LocalLoadOptionsContext(nodeTypes, additionalData, path, workflowLoader);
|
||||
|
||||
const result = context.getCurrentNodeParameter('nonExistent');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should resolve nested parameter paths', () => {
|
||||
additionalData.currentNodeParameters = {
|
||||
parent: {
|
||||
child: 'nestedValue',
|
||||
},
|
||||
};
|
||||
|
||||
const context = new LocalLoadOptionsContext(nodeTypes, additionalData, path, workflowLoader);
|
||||
|
||||
const result = context.getCurrentNodeParameter('parent.child');
|
||||
|
||||
expect(result).toBe('nestedValue');
|
||||
});
|
||||
});
|
||||
});
|
||||
+718
@@ -0,0 +1,718 @@
|
||||
import { Container } from '@n8n/di';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
INode,
|
||||
INodeType,
|
||||
INodeTypes,
|
||||
INodeExecutionData,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
IWorkflowSettings,
|
||||
Workflow,
|
||||
WorkflowExecuteMode,
|
||||
WorkflowExpression,
|
||||
} from 'n8n-workflow';
|
||||
import { CHAT_TRIGGER_NODE_TYPE, createRunExecutionData, NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import { InstanceSettings } from '@/instance-settings';
|
||||
|
||||
import { NodeExecutionContext } from '../node-execution-context';
|
||||
|
||||
class TestContext extends NodeExecutionContext {}
|
||||
|
||||
describe('NodeExecutionContext', () => {
|
||||
const instanceSettings = mock<InstanceSettings>({
|
||||
instanceId: 'abc123',
|
||||
encryptionKey: 'testEncryptionKey',
|
||||
hmacSignatureSecret: 'testHmacSignatureSecret',
|
||||
});
|
||||
Container.set(InstanceSettings, instanceSettings);
|
||||
|
||||
const node = mock<INode>();
|
||||
const nodeType = mock<INodeType>({ description: mock() });
|
||||
const nodeTypes = mock<INodeTypes>();
|
||||
const expression = mock<WorkflowExpression>();
|
||||
const workflow = mock<Workflow>({
|
||||
id: '123',
|
||||
name: 'Test Workflow',
|
||||
active: true,
|
||||
nodeTypes,
|
||||
timezone: 'UTC',
|
||||
expression,
|
||||
});
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||
credentialsHelper: mock(),
|
||||
});
|
||||
|
||||
const mode: WorkflowExecuteMode = 'manual';
|
||||
let testContext: TestContext;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
testContext = new TestContext(workflow, node, additionalData, mode);
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
});
|
||||
|
||||
describe('getNode', () => {
|
||||
it('should return a deep copy of the node', () => {
|
||||
const result = testContext.getNode();
|
||||
expect(result).not.toBe(node);
|
||||
expect(JSON.stringify(result)).toEqual(JSON.stringify(node));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWorkflow', () => {
|
||||
it('should return the id, name, and active properties of the workflow', () => {
|
||||
const result = testContext.getWorkflow();
|
||||
|
||||
expect(result).toEqual({ id: '123', name: 'Test Workflow', active: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMode', () => {
|
||||
it('should return the mode property', () => {
|
||||
const result = testContext.getMode();
|
||||
expect(result).toBe(mode);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWorkflowStaticData', () => {
|
||||
it('should call getStaticData method of workflow', () => {
|
||||
testContext.getWorkflowStaticData('testType');
|
||||
expect(workflow.getStaticData).toHaveBeenCalledWith('testType', node);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChildNodes', () => {
|
||||
it('should return an array of NodeTypeAndVersion objects for the child nodes of the given node', () => {
|
||||
const childNode1 = mock<INode>({ name: 'Child Node 1', type: 'testType1', typeVersion: 1 });
|
||||
const childNode2 = mock<INode>({ name: 'Child Node 2', type: 'testType2', typeVersion: 2 });
|
||||
workflow.getChildNodes.mockReturnValue(['Child Node 1', 'Child Node 2']);
|
||||
workflow.nodes = {
|
||||
'Child Node 1': childNode1,
|
||||
'Child Node 2': childNode2,
|
||||
};
|
||||
|
||||
const result = testContext.getChildNodes('Test Node');
|
||||
|
||||
expect(result).toMatchObject([
|
||||
{ name: 'Child Node 1', type: 'testType1', typeVersion: 1 },
|
||||
{ name: 'Child Node 2', type: 'testType2', typeVersion: 2 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getParentNodes', () => {
|
||||
it('should return an array of NodeTypeAndVersion objects for the parent nodes of the given node', () => {
|
||||
const parentNode1 = mock<INode>({ name: 'Parent Node 1', type: 'testType1', typeVersion: 1 });
|
||||
const parentNode2 = mock<INode>({ name: 'Parent Node 2', type: 'testType2', typeVersion: 2 });
|
||||
workflow.getParentNodes.mockReturnValue(['Parent Node 1', 'Parent Node 2']);
|
||||
workflow.nodes = {
|
||||
'Parent Node 1': parentNode1,
|
||||
'Parent Node 2': parentNode2,
|
||||
};
|
||||
|
||||
const result = testContext.getParentNodes('Test Node');
|
||||
|
||||
expect(result).toMatchObject([
|
||||
{ name: 'Parent Node 1', type: 'testType1', typeVersion: 1 },
|
||||
{ name: 'Parent Node 2', type: 'testType2', typeVersion: 2 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChatTrigger', () => {
|
||||
it('should return a chat trigger node if it exists in the workflow', () => {
|
||||
const chatNode = mock<INode>({ name: 'Chat', type: CHAT_TRIGGER_NODE_TYPE });
|
||||
|
||||
workflow.nodes = {
|
||||
Chat: chatNode,
|
||||
};
|
||||
|
||||
const result = testContext.getChatTrigger();
|
||||
|
||||
expect(result).toEqual(chatNode);
|
||||
});
|
||||
it('should return a null if there is no chat trigger node in the workflow', () => {
|
||||
const someNode = mock<INode>({ name: 'Some Node', type: 'someType' });
|
||||
|
||||
workflow.nodes = {
|
||||
'Some Node': someNode,
|
||||
};
|
||||
|
||||
const result = testContext.getChatTrigger();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getKnownNodeTypes', () => {
|
||||
it('should call getKnownTypes method of nodeTypes', () => {
|
||||
testContext.getKnownNodeTypes();
|
||||
expect(nodeTypes.getKnownTypes).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRestApiUrl', () => {
|
||||
it('should return the restApiUrl property of additionalData', () => {
|
||||
additionalData.restApiUrl = 'https://example.com/api';
|
||||
|
||||
const result = testContext.getRestApiUrl();
|
||||
|
||||
expect(result).toBe('https://example.com/api');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInstanceBaseUrl', () => {
|
||||
it('should return the instanceBaseUrl property of additionalData', () => {
|
||||
additionalData.instanceBaseUrl = 'https://example.com';
|
||||
|
||||
const result = testContext.getInstanceBaseUrl();
|
||||
|
||||
expect(result).toBe('https://example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInstanceId', () => {
|
||||
it('should return the instanceId property of instanceSettings', () => {
|
||||
const result = testContext.getInstanceId();
|
||||
|
||||
expect(result).toBe('abc123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimezone', () => {
|
||||
it('should return the timezone property of workflow', () => {
|
||||
const result = testContext.getTimezone();
|
||||
expect(result).toBe('UTC');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCredentialsProperties', () => {
|
||||
it('should call getCredentialsProperties method of additionalData.credentialsHelper', () => {
|
||||
testContext.getCredentialsProperties('testType');
|
||||
expect(additionalData.credentialsHelper.getCredentialsProperties).toHaveBeenCalledWith(
|
||||
'testType',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_getCredentials', () => {
|
||||
it('should set executionContext on additionalData before retrieving credentials', async () => {
|
||||
const credentialDetails = { id: 'cred123', name: 'Test Credential' };
|
||||
const testNode = mock<INode>({
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
});
|
||||
testNode.credentials = { testCredential: credentialDetails };
|
||||
|
||||
const runtimeData = {
|
||||
version: 1 as const,
|
||||
establishedAt: Date.now(),
|
||||
source: 'manual' as const,
|
||||
};
|
||||
const testRunExecutionData = createRunExecutionData({
|
||||
resultData: { runData: {} },
|
||||
executionData: { runtimeData },
|
||||
});
|
||||
|
||||
let capturedExecutionContext: unknown;
|
||||
const mockCredentialsHelper = {
|
||||
getDecrypted: jest
|
||||
.fn()
|
||||
.mockImplementation(async (additionalData: IWorkflowExecuteAdditionalData) => {
|
||||
// Capture the executionContext value at the moment getDecrypted is called
|
||||
capturedExecutionContext = additionalData.executionContext;
|
||||
return { token: 'test-token' };
|
||||
}),
|
||||
getCredentialsProperties: jest.fn(),
|
||||
};
|
||||
|
||||
const mockAdditionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||
credentialsHelper: mockCredentialsHelper,
|
||||
});
|
||||
|
||||
const contextWithCredentials = new TestContext(
|
||||
workflow,
|
||||
testNode,
|
||||
mockAdditionalData,
|
||||
mode,
|
||||
testRunExecutionData,
|
||||
);
|
||||
|
||||
await contextWithCredentials['_getCredentials']('testCredential');
|
||||
|
||||
// Assert that executionContext was already set when getDecrypted was called
|
||||
expect(capturedExecutionContext).toEqual(runtimeData);
|
||||
expect(mockCredentialsHelper.getDecrypted).toHaveBeenCalledWith(
|
||||
mockAdditionalData,
|
||||
credentialDetails,
|
||||
'testCredential',
|
||||
mode,
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareOutputData', () => {
|
||||
it('should return the input array wrapped in another array', async () => {
|
||||
const outputData = [mock<INodeExecutionData>(), mock<INodeExecutionData>()];
|
||||
|
||||
const result = await testContext.prepareOutputData(outputData);
|
||||
|
||||
expect(result).toEqual([outputData]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeInputs', () => {
|
||||
it('should return static inputs array when inputs is an array', () => {
|
||||
nodeType.description.inputs = [NodeConnectionTypes.Main, NodeConnectionTypes.AiLanguageModel];
|
||||
|
||||
const result = testContext.getNodeInputs();
|
||||
|
||||
expect(result).toEqual([
|
||||
{ type: NodeConnectionTypes.Main },
|
||||
{ type: NodeConnectionTypes.AiLanguageModel },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return input objects when inputs contains configurations', () => {
|
||||
nodeType.description.inputs = [
|
||||
{ type: NodeConnectionTypes.Main },
|
||||
{ type: NodeConnectionTypes.AiLanguageModel, required: true },
|
||||
];
|
||||
|
||||
const result = testContext.getNodeInputs();
|
||||
|
||||
expect(result).toEqual([
|
||||
{ type: NodeConnectionTypes.Main },
|
||||
{ type: NodeConnectionTypes.AiLanguageModel, required: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should evaluate dynamic inputs when inputs is a function', () => {
|
||||
const inputsExpressions = '={{ ["main", "ai_languageModel"] }}';
|
||||
nodeType.description.inputs = inputsExpressions;
|
||||
expression.getSimpleParameterValue.mockReturnValue([
|
||||
NodeConnectionTypes.Main,
|
||||
NodeConnectionTypes.AiLanguageModel,
|
||||
]);
|
||||
|
||||
const result = testContext.getNodeInputs();
|
||||
|
||||
expect(result).toEqual([
|
||||
{ type: NodeConnectionTypes.Main },
|
||||
{ type: NodeConnectionTypes.AiLanguageModel },
|
||||
]);
|
||||
expect(expression.getSimpleParameterValue).toHaveBeenCalledWith(
|
||||
node,
|
||||
inputsExpressions,
|
||||
'internal',
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeOutputs', () => {
|
||||
it('should return static outputs array when outputs is an array', () => {
|
||||
nodeType.description.outputs = [
|
||||
NodeConnectionTypes.Main,
|
||||
NodeConnectionTypes.AiLanguageModel,
|
||||
];
|
||||
|
||||
const result = testContext.getNodeOutputs();
|
||||
|
||||
expect(result).toEqual([
|
||||
{ type: NodeConnectionTypes.Main },
|
||||
{ type: NodeConnectionTypes.AiLanguageModel },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return output objects when outputs contains configurations', () => {
|
||||
nodeType.description.outputs = [
|
||||
{ type: NodeConnectionTypes.Main },
|
||||
{ type: NodeConnectionTypes.AiLanguageModel, required: true },
|
||||
];
|
||||
|
||||
const result = testContext.getNodeOutputs();
|
||||
|
||||
expect(result).toEqual([
|
||||
{ type: NodeConnectionTypes.Main },
|
||||
{ type: NodeConnectionTypes.AiLanguageModel, required: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should evaluate dynamic outputs when outputs is a function', () => {
|
||||
const outputsExpressions = '={{ ["main", "ai_languageModel"] }}';
|
||||
nodeType.description.outputs = outputsExpressions;
|
||||
expression.getSimpleParameterValue.mockReturnValue([
|
||||
NodeConnectionTypes.Main,
|
||||
NodeConnectionTypes.AiLanguageModel,
|
||||
]);
|
||||
|
||||
const result = testContext.getNodeOutputs();
|
||||
|
||||
expect(result).toEqual([
|
||||
{ type: NodeConnectionTypes.Main },
|
||||
{ type: NodeConnectionTypes.AiLanguageModel },
|
||||
]);
|
||||
expect(expression.getSimpleParameterValue).toHaveBeenCalledWith(
|
||||
node,
|
||||
outputsExpressions,
|
||||
'internal',
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('should add error output when node has continueOnFail error handling', () => {
|
||||
const nodeWithError = mock<INode>({ onError: 'continueErrorOutput' });
|
||||
const contextWithError = new TestContext(workflow, nodeWithError, additionalData, mode);
|
||||
nodeType.description.outputs = [NodeConnectionTypes.Main];
|
||||
|
||||
const result = contextWithError.getNodeOutputs();
|
||||
|
||||
expect(result).toEqual([
|
||||
{ type: NodeConnectionTypes.Main, displayName: 'Success' },
|
||||
{ type: NodeConnectionTypes.Main, displayName: 'Error', category: 'error' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConnectedNodes', () => {
|
||||
it('should return connected nodes of given type', () => {
|
||||
const node1 = mock<INode>({ name: 'Node 1', type: 'test', disabled: false });
|
||||
const node2 = mock<INode>({ name: 'Node 2', type: 'test', disabled: false });
|
||||
|
||||
workflow.getParentNodes.mockReturnValue(['Node 1', 'Node 2']);
|
||||
workflow.getNode.mockImplementation((name) => {
|
||||
if (name === 'Node 1') return node1;
|
||||
if (name === 'Node 2') return node2;
|
||||
return null;
|
||||
});
|
||||
|
||||
const result = testContext.getConnectedNodes(NodeConnectionTypes.Main);
|
||||
|
||||
expect(result).toEqual([node1, node2]);
|
||||
expect(workflow.getParentNodes).toHaveBeenCalledWith(node.name, NodeConnectionTypes.Main, 1);
|
||||
});
|
||||
|
||||
it('should filter out disabled nodes', () => {
|
||||
const node1 = mock<INode>({ name: 'Node 1', type: 'test', disabled: false });
|
||||
const node2 = mock<INode>({ name: 'Node 2', type: 'test', disabled: true });
|
||||
|
||||
workflow.getParentNodes.mockReturnValue(['Node 1', 'Node 2']);
|
||||
workflow.getNode.mockImplementation((name) => {
|
||||
if (name === 'Node 1') return node1;
|
||||
if (name === 'Node 2') return node2;
|
||||
return null;
|
||||
});
|
||||
|
||||
const result = testContext.getConnectedNodes(NodeConnectionTypes.Main);
|
||||
|
||||
expect(result).toEqual([node1]);
|
||||
});
|
||||
|
||||
it('should filter out non-existent nodes', () => {
|
||||
const node1 = mock<INode>({ name: 'Node 1', type: 'test', disabled: false });
|
||||
|
||||
workflow.getParentNodes.mockReturnValue(['Node 1', 'NonExistent']);
|
||||
workflow.getNode.mockImplementation((name) => {
|
||||
if (name === 'Node 1') return node1;
|
||||
return null;
|
||||
});
|
||||
|
||||
const result = testContext.getConnectedNodes(NodeConnectionTypes.Main);
|
||||
|
||||
expect(result).toEqual([node1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSignedResumeUrl', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
testContext = new TestContext(
|
||||
workflow,
|
||||
mock<INode>({
|
||||
id: 'node456',
|
||||
}),
|
||||
mock<IWorkflowExecuteAdditionalData>({
|
||||
executionId: '123',
|
||||
webhookWaitingBaseUrl: 'http://localhost/waiting-webhook',
|
||||
}),
|
||||
mode,
|
||||
createRunExecutionData({
|
||||
validateSignature: true,
|
||||
resultData: { runData: {} },
|
||||
}),
|
||||
);
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
});
|
||||
it('should return a signed resume URL with no query parameters', () => {
|
||||
const result = testContext.getSignedResumeUrl();
|
||||
|
||||
expect(result).toBe(
|
||||
'http://localhost/waiting-webhook/123/node456?signature=8e48dfd1107c1a736f70e7399493ffc50a2e8edd44f389c5f9c058da961682e7',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return a signed resume URL with query parameters', () => {
|
||||
const result = testContext.getSignedResumeUrl({ approved: 'true' });
|
||||
|
||||
expect(result).toBe(
|
||||
'http://localhost/waiting-webhook/123/node456?approved=true&signature=11c5efc97a0d6f2ea9045dba6e397596cba29dc24adb44a9ebd3d1272c991e9b',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nodeFeatures', () => {
|
||||
it('should return empty object when features are not defined', () => {
|
||||
node.typeVersion = 2.4;
|
||||
nodeType.description.features = undefined;
|
||||
|
||||
const result = testContext['nodeFeatures'];
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should return enabled features based on node version', () => {
|
||||
node.typeVersion = 2.4;
|
||||
nodeType.description.features = {
|
||||
useFeatureA: { '@version': [{ _cnd: { gte: 2.4 } }] },
|
||||
useFeatureB: { '@version': [{ _cnd: { lte: 2.1 } }] },
|
||||
useFeatureC: { '@version': [{ _cnd: { gte: 2.2 } }] },
|
||||
};
|
||||
|
||||
const result = testContext['nodeFeatures'];
|
||||
|
||||
expect(result).toEqual({
|
||||
useFeatureA: true,
|
||||
useFeatureB: false,
|
||||
useFeatureC: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return correct features for version 2.1', () => {
|
||||
node.typeVersion = 2.1;
|
||||
nodeType.description.features = {
|
||||
useFeatureA: { '@version': [{ _cnd: { gte: 2.4 } }] },
|
||||
useFeatureB: { '@version': [{ _cnd: { lte: 2.1 } }] },
|
||||
useFeatureC: { '@version': [{ _cnd: { gte: 2.2 } }] },
|
||||
};
|
||||
|
||||
const result = testContext['nodeFeatures'];
|
||||
|
||||
expect(result).toEqual({
|
||||
useFeatureA: false,
|
||||
useFeatureB: true,
|
||||
useFeatureC: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle simple version number conditions', () => {
|
||||
node.typeVersion = 2;
|
||||
nodeType.description.features = {
|
||||
useFeatureD: { '@version': [2] },
|
||||
useFeatureE: { '@version': [{ _cnd: { lt: 2.3 } }] },
|
||||
};
|
||||
|
||||
const result = testContext['nodeFeatures'];
|
||||
|
||||
expect(result).toEqual({
|
||||
useFeatureD: true,
|
||||
useFeatureE: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNodeFeatureEnabled', () => {
|
||||
it('should return true when feature is enabled', () => {
|
||||
node.typeVersion = 2.4;
|
||||
nodeType.description.features = {
|
||||
useFeatureA: { '@version': [{ _cnd: { gte: 2.4 } }] },
|
||||
};
|
||||
|
||||
const result = testContext.isNodeFeatureEnabled('useFeatureA');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when feature is disabled', () => {
|
||||
node.typeVersion = 2.3;
|
||||
nodeType.description.features = {
|
||||
useFeatureA: { '@version': [{ _cnd: { gte: 2.4 } }] },
|
||||
};
|
||||
|
||||
const result = testContext.isNodeFeatureEnabled('useFeatureA');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when feature does not exist', () => {
|
||||
node.typeVersion = 2.4;
|
||||
nodeType.description.features = {
|
||||
useFeatureA: { '@version': [{ _cnd: { gte: 2.4 } }] },
|
||||
};
|
||||
|
||||
const result = testContext.isNodeFeatureEnabled('nonExistentFeature');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when features are not defined', () => {
|
||||
node.typeVersion = 2.4;
|
||||
nodeType.description.features = undefined;
|
||||
|
||||
const result = testContext.isNodeFeatureEnabled('useFeatureA');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle multiple features correctly', () => {
|
||||
node.typeVersion = 2.4;
|
||||
nodeType.description.features = {
|
||||
useFeatureA: { '@version': [{ _cnd: { gte: 2.4 } }] },
|
||||
useFeatureB: { '@version': [{ _cnd: { lte: 2.1 } }] },
|
||||
useFeatureC: { '@version': [{ _cnd: { gte: 2.2 } }] },
|
||||
};
|
||||
|
||||
expect(testContext.isNodeFeatureEnabled('useFeatureA')).toBe(true);
|
||||
expect(testContext.isNodeFeatureEnabled('useFeatureB')).toBe(false);
|
||||
expect(testContext.isNodeFeatureEnabled('useFeatureC')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWorkflowSettings', () => {
|
||||
it('should return workflow settings', () => {
|
||||
const settings: IWorkflowSettings = {
|
||||
saveDataErrorExecution: 'all',
|
||||
saveDataSuccessExecution: 'all',
|
||||
};
|
||||
workflow.settings = settings;
|
||||
|
||||
const result = testContext.getWorkflowSettings();
|
||||
|
||||
expect(result).toEqual(settings);
|
||||
});
|
||||
|
||||
it('should return a frozen object that cannot be modified', () => {
|
||||
const settings: IWorkflowSettings = {
|
||||
saveDataErrorExecution: 'all',
|
||||
saveDataSuccessExecution: 'all',
|
||||
};
|
||||
workflow.settings = settings;
|
||||
|
||||
const result = testContext.getWorkflowSettings();
|
||||
|
||||
expect(Object.isFrozen(result)).toBe(true);
|
||||
expect(() => {
|
||||
(result as Record<string, unknown>).saveDataErrorExecution = 'none';
|
||||
}).toThrow(TypeError);
|
||||
expect(result.saveDataErrorExecution).toBe('all');
|
||||
});
|
||||
|
||||
it('should return a deep clone that does not affect the original workflow settings', () => {
|
||||
const settings: IWorkflowSettings = {
|
||||
saveDataErrorExecution: 'all',
|
||||
saveDataSuccessExecution: 'all',
|
||||
};
|
||||
workflow.settings = settings;
|
||||
|
||||
const result = testContext.getWorkflowSettings();
|
||||
|
||||
expect(() => {
|
||||
(result as Record<string, unknown>).saveDataErrorExecution = 'none';
|
||||
}).toThrow(TypeError);
|
||||
expect(workflow.settings.saveDataErrorExecution).toBe('all');
|
||||
|
||||
const result2 = testContext.getWorkflowSettings();
|
||||
expect(result2.saveDataErrorExecution).toBe('all');
|
||||
});
|
||||
|
||||
it('should memoize the result and return the same reference on multiple calls', () => {
|
||||
const settings: IWorkflowSettings = {
|
||||
saveDataErrorExecution: 'all',
|
||||
saveDataSuccessExecution: 'all',
|
||||
};
|
||||
workflow.settings = settings;
|
||||
|
||||
const result1 = testContext.getWorkflowSettings();
|
||||
const result2 = testContext.getWorkflowSettings();
|
||||
|
||||
expect(result1).toBe(result2);
|
||||
});
|
||||
|
||||
it('should handle binaryMode setting correctly', () => {
|
||||
const settings: IWorkflowSettings = {
|
||||
saveDataErrorExecution: 'all',
|
||||
binaryMode: 'separate',
|
||||
};
|
||||
workflow.settings = settings;
|
||||
|
||||
const result = testContext.getWorkflowSettings();
|
||||
|
||||
expect(Object.isFrozen(result)).toBe(true);
|
||||
expect(result.binaryMode).toBe('separate');
|
||||
expect(() => {
|
||||
(result as Record<string, unknown>).binaryMode = 'combined';
|
||||
}).toThrow(TypeError);
|
||||
expect(result.binaryMode).toBe('separate');
|
||||
});
|
||||
|
||||
it('should prevent modification of all workflow settings properties', () => {
|
||||
const settings: IWorkflowSettings = {
|
||||
timezone: 'America/New_York',
|
||||
saveDataErrorExecution: 'all',
|
||||
saveDataSuccessExecution: 'none',
|
||||
executionTimeout: 3600,
|
||||
binaryMode: 'combined',
|
||||
};
|
||||
workflow.settings = settings;
|
||||
|
||||
const result = testContext.getWorkflowSettings();
|
||||
|
||||
expect(Object.isFrozen(result)).toBe(true);
|
||||
expect(() => {
|
||||
(result as Record<string, unknown>).timezone = 'UTC';
|
||||
}).toThrow(TypeError);
|
||||
expect(() => {
|
||||
(result as Record<string, unknown>).executionTimeout = 7200;
|
||||
}).toThrow(TypeError);
|
||||
expect(() => {
|
||||
(result as Record<string, unknown>).binaryMode = 'separate';
|
||||
}).toThrow(TypeError);
|
||||
expect(result.timezone).toBe('America/New_York');
|
||||
expect(result.executionTimeout).toBe(3600);
|
||||
expect(result.binaryMode).toBe('combined');
|
||||
});
|
||||
|
||||
it('should freeze nested objects in settings', () => {
|
||||
const settingsWithNested = {
|
||||
saveDataErrorExecution: 'all' as const,
|
||||
callerIds: 'workflow1,workflow2',
|
||||
hypotheticalNested: { key: 'value', deep: { prop: 'test' } },
|
||||
};
|
||||
workflow.settings = settingsWithNested as IWorkflowSettings;
|
||||
|
||||
const result = testContext.getWorkflowSettings();
|
||||
|
||||
expect(Object.isFrozen(result)).toBe(true);
|
||||
|
||||
const nested = (result as Record<string, unknown>).hypotheticalNested;
|
||||
if (nested && typeof nested === 'object') {
|
||||
const isFrozen = Object.isFrozen(nested);
|
||||
if (isFrozen) {
|
||||
expect(() => {
|
||||
(nested as Record<string, unknown>).key = 'modified';
|
||||
}).toThrow(TypeError);
|
||||
|
||||
const deep = (nested as Record<string, unknown>).deep;
|
||||
if (deep && typeof deep === 'object' && Object.isFrozen(deep)) {
|
||||
expect(() => {
|
||||
(deep as Record<string, unknown>).prop = 'modified';
|
||||
}).toThrow(TypeError);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialsHelper,
|
||||
INode,
|
||||
INodeType,
|
||||
INodeTypes,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
WorkflowActivateMode,
|
||||
WorkflowExecuteMode,
|
||||
WorkflowExpression,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { PollContext } from '../poll-context';
|
||||
|
||||
describe('PollContext', () => {
|
||||
const testCredentialType = 'testCredential';
|
||||
const nodeType = mock<INodeType>({
|
||||
description: {
|
||||
credentials: [
|
||||
{
|
||||
name: testCredentialType,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
name: 'testParameter',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const nodeTypes = mock<INodeTypes>();
|
||||
const expression = mock<WorkflowExpression>();
|
||||
const workflow = mock<Workflow>({ expression, nodeTypes });
|
||||
const node = mock<INode>({
|
||||
credentials: {
|
||||
[testCredentialType]: {
|
||||
id: 'testCredentialId',
|
||||
},
|
||||
},
|
||||
});
|
||||
node.parameters = {
|
||||
testParameter: 'testValue',
|
||||
};
|
||||
const credentialsHelper = mock<ICredentialsHelper>();
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
|
||||
const mode: WorkflowExecuteMode = 'manual';
|
||||
const activation: WorkflowActivateMode = 'init';
|
||||
|
||||
const pollContext = new PollContext(workflow, node, additionalData, mode, activation);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getActivationMode', () => {
|
||||
it('should return the activation property', () => {
|
||||
const result = pollContext.getActivationMode();
|
||||
expect(result).toBe(activation);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCredentials', () => {
|
||||
it('should get decrypted credentials', async () => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
|
||||
|
||||
const credentials =
|
||||
await pollContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
|
||||
|
||||
expect(credentials).toEqual({ secret: 'token' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeParameter', () => {
|
||||
beforeEach(() => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
expression.getParameterValue.mockImplementation((value) => value);
|
||||
});
|
||||
|
||||
it('should return parameter value when it exists', () => {
|
||||
const parameter = pollContext.getNodeParameter('testParameter');
|
||||
|
||||
expect(parameter).toBe('testValue');
|
||||
});
|
||||
|
||||
it('should return the fallback value when the parameter does not exist', () => {
|
||||
const parameter = pollContext.getNodeParameter('otherParameter', 'fallback');
|
||||
|
||||
expect(parameter).toBe('fallback');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExecutionContext', () => {
|
||||
it('should return undefined', () => {
|
||||
expect(pollContext.getExecutionContext()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,303 @@
|
||||
import { Container } from '@n8n/di';
|
||||
import { captor, mock, type MockProxy } from 'jest-mock-extended';
|
||||
import type {
|
||||
IRunExecutionData,
|
||||
ContextType,
|
||||
IContextObject,
|
||||
INode,
|
||||
OnError,
|
||||
Workflow,
|
||||
ITaskMetadata,
|
||||
ISourceData,
|
||||
IExecuteData,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
ExecuteWorkflowData,
|
||||
RelatedExecution,
|
||||
IExecuteWorkflowInfo,
|
||||
IExecutionContext,
|
||||
} from 'n8n-workflow';
|
||||
import { ApplicationError, NodeHelpers, WAIT_INDEFINITELY } from 'n8n-workflow';
|
||||
|
||||
import { BinaryDataService } from '@/binary-data/binary-data.service';
|
||||
|
||||
import type { BaseExecuteContext } from '../base-execute-context';
|
||||
|
||||
const binaryDataService = mock<BinaryDataService>();
|
||||
Container.set(BinaryDataService, binaryDataService);
|
||||
|
||||
export const describeCommonTests = (
|
||||
context: BaseExecuteContext,
|
||||
{
|
||||
abortSignal,
|
||||
node,
|
||||
workflow,
|
||||
runExecutionData,
|
||||
executeData,
|
||||
}: {
|
||||
abortSignal: AbortSignal;
|
||||
node: INode;
|
||||
workflow: Workflow;
|
||||
runExecutionData: IRunExecutionData;
|
||||
executeData: IExecuteData;
|
||||
},
|
||||
) => {
|
||||
const additionalData = context.additionalData as MockProxy<IWorkflowExecuteAdditionalData>;
|
||||
|
||||
describe('getExecutionCancelSignal', () => {
|
||||
it('should return the abort signal', () => {
|
||||
expect(context.getExecutionCancelSignal()).toBe(abortSignal);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExecutionContext', () => {
|
||||
it('should return execution context when runtimeData exists', () => {
|
||||
const mockContext: IExecutionContext = {
|
||||
version: 1,
|
||||
establishedAt: Date.now(),
|
||||
source: 'manual',
|
||||
credentials: 'encrypted-credential-data',
|
||||
};
|
||||
|
||||
runExecutionData.executionData = {
|
||||
contextData: {},
|
||||
runtimeData: mockContext,
|
||||
nodeExecutionStack: [],
|
||||
metadata: {},
|
||||
waitingExecution: {},
|
||||
waitingExecutionSource: null,
|
||||
};
|
||||
|
||||
const result = context.getExecutionContext();
|
||||
|
||||
expect(result).toEqual(mockContext);
|
||||
expect(result?.version).toBe(1);
|
||||
expect(result?.establishedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return undefined when executionData is not set', () => {
|
||||
runExecutionData.executionData = undefined;
|
||||
|
||||
expect(context.getExecutionContext()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when runtimeData is not set', () => {
|
||||
runExecutionData.executionData = {
|
||||
contextData: {},
|
||||
runtimeData: undefined,
|
||||
nodeExecutionStack: [],
|
||||
metadata: {},
|
||||
waitingExecution: {},
|
||||
waitingExecutionSource: null,
|
||||
};
|
||||
|
||||
expect(context.getExecutionContext()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle optional credentials field', () => {
|
||||
const contextWithoutCredentials: IExecutionContext = {
|
||||
version: 1,
|
||||
establishedAt: Date.now(),
|
||||
source: 'manual',
|
||||
};
|
||||
|
||||
runExecutionData.executionData = {
|
||||
contextData: {},
|
||||
runtimeData: contextWithoutCredentials,
|
||||
nodeExecutionStack: [],
|
||||
metadata: {},
|
||||
waitingExecution: {},
|
||||
waitingExecutionSource: null,
|
||||
};
|
||||
|
||||
const result = context.getExecutionContext();
|
||||
|
||||
expect(result).toEqual(contextWithoutCredentials);
|
||||
expect(result?.credentials).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onExecutionCancellation', () => {
|
||||
const handler = jest.fn();
|
||||
context.onExecutionCancellation(handler);
|
||||
|
||||
const fnCaptor = captor<() => void>();
|
||||
expect(abortSignal.addEventListener).toHaveBeenCalledWith('abort', fnCaptor);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
|
||||
fnCaptor.value();
|
||||
expect(abortSignal.removeEventListener).toHaveBeenCalledWith('abort', fnCaptor);
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('continueOnFail', () => {
|
||||
afterEach(() => {
|
||||
node.onError = undefined;
|
||||
node.continueOnFail = false;
|
||||
});
|
||||
|
||||
it('should return false for nodes by default', () => {
|
||||
expect(context.continueOnFail()).toEqual(false);
|
||||
});
|
||||
|
||||
it('should return true if node has continueOnFail set to true', () => {
|
||||
node.continueOnFail = true;
|
||||
expect(context.continueOnFail()).toEqual(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['continueRegularOutput', true],
|
||||
['continueErrorOutput', true],
|
||||
['stopWorkflow', false],
|
||||
])('if node has onError set to %s, it should return %s', (onError, expected) => {
|
||||
node.onError = onError as OnError;
|
||||
expect(context.continueOnFail()).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContext', () => {
|
||||
it('should return the context object', () => {
|
||||
const contextType: ContextType = 'node';
|
||||
const expectedContext = mock<IContextObject>();
|
||||
const getContextSpy = jest.spyOn(NodeHelpers, 'getContext');
|
||||
getContextSpy.mockReturnValue(expectedContext);
|
||||
|
||||
expect(context.getContext(contextType)).toEqual(expectedContext);
|
||||
|
||||
expect(getContextSpy).toHaveBeenCalledWith(runExecutionData, contextType, node);
|
||||
|
||||
getContextSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendMessageToUI', () => {
|
||||
it('should send console messages to the frontend', () => {
|
||||
context.sendMessageToUI('Testing', 1, 2, {});
|
||||
expect(additionalData.sendDataToUI).toHaveBeenCalledWith('sendConsoleMessage', {
|
||||
source: '[Node: "Test Node"]',
|
||||
messages: ['Testing', 1, 2, {}],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('logAiEvent', () => {
|
||||
it('should log the AI event correctly', () => {
|
||||
const eventName = 'ai-tool-called';
|
||||
const msg = 'test message';
|
||||
|
||||
context.logAiEvent(eventName, msg);
|
||||
|
||||
expect(additionalData.logAiEvent).toHaveBeenCalledWith(eventName, {
|
||||
executionId: additionalData.executionId,
|
||||
nodeName: node.name,
|
||||
workflowName: workflow.name,
|
||||
nodeType: node.type,
|
||||
workflowId: workflow.id,
|
||||
msg,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInputSourceData', () => {
|
||||
it('should return the input source data correctly', () => {
|
||||
const inputSourceData = mock<ISourceData>();
|
||||
executeData.source = { main: [inputSourceData] };
|
||||
|
||||
expect(context.getInputSourceData()).toEqual(inputSourceData);
|
||||
});
|
||||
|
||||
it('should throw an error if the source data is missing', () => {
|
||||
executeData.source = null;
|
||||
|
||||
expect(() => context.getInputSourceData()).toThrow(ApplicationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setMetadata', () => {
|
||||
it('sets metadata on execution data', () => {
|
||||
const metadata: ITaskMetadata = {
|
||||
subExecution: {
|
||||
workflowId: '123',
|
||||
executionId: 'xyz',
|
||||
},
|
||||
};
|
||||
|
||||
expect(context.getExecuteData().metadata?.subExecution).toEqual(undefined);
|
||||
context.setMetadata(metadata);
|
||||
expect(context.getExecuteData().metadata?.subExecution).toEqual(metadata.subExecution);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluateExpression', () => {
|
||||
it('should evaluate the expression correctly', () => {
|
||||
const expression = '$json.test';
|
||||
const expectedResult = 'data';
|
||||
const resolveSimpleParameterValueSpy = jest.spyOn(
|
||||
workflow.expression,
|
||||
'resolveSimpleParameterValue',
|
||||
);
|
||||
resolveSimpleParameterValueSpy.mockReturnValue(expectedResult);
|
||||
|
||||
expect(context.evaluateExpression(expression, 0)).toEqual(expectedResult);
|
||||
|
||||
expect(resolveSimpleParameterValueSpy).toHaveBeenCalledWith(
|
||||
`=${expression}`,
|
||||
{},
|
||||
runExecutionData,
|
||||
0,
|
||||
0,
|
||||
node.name,
|
||||
[],
|
||||
'manual',
|
||||
expect.objectContaining({}),
|
||||
executeData,
|
||||
);
|
||||
|
||||
resolveSimpleParameterValueSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('putExecutionToWait', () => {
|
||||
it('should set waitTill and execution status', async () => {
|
||||
const waitTill = new Date();
|
||||
|
||||
await context.putExecutionToWait(waitTill);
|
||||
|
||||
expect(runExecutionData.waitTill).toEqual(waitTill);
|
||||
expect(additionalData.setExecutionStatus).toHaveBeenCalledWith('waiting');
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeWorkflow', () => {
|
||||
const data = [[{ json: { test: true } }]];
|
||||
const executeWorkflowData = mock<ExecuteWorkflowData>({ data });
|
||||
const workflowInfo = mock<IExecuteWorkflowInfo>();
|
||||
const parentExecution: RelatedExecution = {
|
||||
executionId: 'parent_execution_id',
|
||||
workflowId: 'parent_workflow_id',
|
||||
};
|
||||
|
||||
it('should execute workflow and return data', async () => {
|
||||
additionalData.executeWorkflow.mockResolvedValue(executeWorkflowData);
|
||||
|
||||
const result = await context.executeWorkflow(workflowInfo, undefined, undefined, {
|
||||
parentExecution,
|
||||
});
|
||||
|
||||
expect(result.data).toEqual(data);
|
||||
expect(result).toBe(executeWorkflowData);
|
||||
});
|
||||
|
||||
it('should put execution to wait if waitTill is returned', async () => {
|
||||
const waitTill = new Date();
|
||||
additionalData.executeWorkflow.mockResolvedValue({ ...executeWorkflowData, waitTill });
|
||||
|
||||
const result = await context.executeWorkflow(workflowInfo, undefined, undefined, {
|
||||
parentExecution,
|
||||
});
|
||||
|
||||
expect(additionalData.setExecutionStatus).toHaveBeenCalledWith('waiting');
|
||||
expect(runExecutionData.waitTill).toEqual(WAIT_INDEFINITELY);
|
||||
expect(result.waitTill).toBe(waitTill);
|
||||
});
|
||||
});
|
||||
};
|
||||
+674
@@ -0,0 +1,674 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
INode,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
IRunExecutionData,
|
||||
INodeExecutionData,
|
||||
ITaskDataConnections,
|
||||
IExecuteData,
|
||||
Workflow,
|
||||
WorkflowExecuteMode,
|
||||
ICredentialsHelper,
|
||||
INodeType,
|
||||
INodeTypes,
|
||||
ICredentialDataDecryptedObject,
|
||||
NodeConnectionType,
|
||||
IRunData,
|
||||
WorkflowExpression,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
ApplicationError,
|
||||
createRunExecutionData,
|
||||
ManualExecutionCancelledError,
|
||||
NodeConnectionTypes,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { describeCommonTests } from './shared-tests';
|
||||
import { SupplyDataContext } from '../supply-data-context';
|
||||
|
||||
describe('SupplyDataContext', () => {
|
||||
const testCredentialType = 'testCredential';
|
||||
const nodeType = mock<INodeType>({
|
||||
description: {
|
||||
credentials: [
|
||||
{
|
||||
name: testCredentialType,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
name: 'testParameter',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const nodeTypes = mock<INodeTypes>();
|
||||
const expression = mock<WorkflowExpression>();
|
||||
const workflow = mock<Workflow>({ expression, nodeTypes });
|
||||
const node = mock<INode>({
|
||||
name: 'Test Node',
|
||||
credentials: {
|
||||
[testCredentialType]: {
|
||||
id: 'testCredentialId',
|
||||
},
|
||||
},
|
||||
});
|
||||
node.parameters = {
|
||||
testParameter: 'testValue',
|
||||
};
|
||||
const credentialsHelper = mock<ICredentialsHelper>();
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
|
||||
const mode: WorkflowExecuteMode = 'manual';
|
||||
const runExecutionData = mock<IRunExecutionData>({
|
||||
resultData: { runData: {} },
|
||||
});
|
||||
const connectionInputData: INodeExecutionData[] = [];
|
||||
const connectionType = NodeConnectionTypes.Main;
|
||||
const inputData: ITaskDataConnections = { [connectionType]: [[{ json: { test: 'data' } }]] };
|
||||
const executeData = mock<IExecuteData>();
|
||||
const runIndex = 0;
|
||||
const closeFn = jest.fn();
|
||||
const abortSignal = mock<AbortSignal>();
|
||||
|
||||
const supplyDataContext = new SupplyDataContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
connectionType,
|
||||
executeData,
|
||||
[closeFn],
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
expression.getParameterValue.mockImplementation((value) => value);
|
||||
});
|
||||
|
||||
describeCommonTests(supplyDataContext, {
|
||||
abortSignal,
|
||||
node,
|
||||
workflow,
|
||||
executeData,
|
||||
runExecutionData,
|
||||
});
|
||||
|
||||
describe('getInputData', () => {
|
||||
const inputIndex = 0;
|
||||
|
||||
afterEach(() => {
|
||||
inputData[connectionType] = [[{ json: { test: 'data' } }]];
|
||||
});
|
||||
|
||||
it('should return the input data correctly', () => {
|
||||
const expectedData = [{ json: { test: 'data' } }];
|
||||
|
||||
expect(supplyDataContext.getInputData(inputIndex, connectionType)).toEqual(expectedData);
|
||||
});
|
||||
|
||||
it('should return an empty array if the input name does not exist', () => {
|
||||
const connectionType = 'nonExistent';
|
||||
expect(
|
||||
supplyDataContext.getInputData(inputIndex, connectionType as NodeConnectionType),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('should throw an error if the input index is out of range', () => {
|
||||
const inputIndex = 2;
|
||||
|
||||
expect(() => supplyDataContext.getInputData(inputIndex, connectionType)).toThrow(
|
||||
ApplicationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the input index was not set', () => {
|
||||
inputData.main[inputIndex] = null;
|
||||
|
||||
expect(() => supplyDataContext.getInputData(inputIndex, connectionType)).toThrow(
|
||||
ApplicationError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeParameter', () => {
|
||||
beforeEach(() => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
expression.getParameterValue.mockImplementation((value) => value);
|
||||
});
|
||||
|
||||
it('should return parameter value when it exists', () => {
|
||||
const parameter = supplyDataContext.getNodeParameter('testParameter', 0);
|
||||
|
||||
expect(parameter).toBe('testValue');
|
||||
});
|
||||
|
||||
it('should return the fallback value when the parameter does not exist', () => {
|
||||
const parameter = supplyDataContext.getNodeParameter('otherParameter', 0, 'fallback');
|
||||
|
||||
expect(parameter).toBe('fallback');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCredentials', () => {
|
||||
it('should get decrypted credentials', async () => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
|
||||
|
||||
const credentials = await supplyDataContext.getCredentials<ICredentialDataDecryptedObject>(
|
||||
testCredentialType,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(credentials).toEqual({ secret: 'token' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWorkflowDataProxy', () => {
|
||||
it('should return the workflow data proxy correctly', () => {
|
||||
const workflowDataProxy = supplyDataContext.getWorkflowDataProxy(0);
|
||||
expect(workflowDataProxy.isProxy).toBe(true);
|
||||
expect(Object.keys(workflowDataProxy.$input)).toEqual([
|
||||
'all',
|
||||
'context',
|
||||
'first',
|
||||
'item',
|
||||
'last',
|
||||
'params',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cloneWith', () => {
|
||||
it('should return a new copy', () => {
|
||||
const clone = supplyDataContext.cloneWith({ runIndex: 12, inputData: [[{ json: {} }]] });
|
||||
expect(clone.runIndex).toBe(12);
|
||||
expect(clone).not.toBe(supplyDataContext);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextRunIndex', () => {
|
||||
it('should return 0 as the default latest run index', () => {
|
||||
const latestRunIndex = supplyDataContext.getNextRunIndex();
|
||||
expect(latestRunIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('should return the length of the run execution data for the node', () => {
|
||||
const runData = mock<IRunData>();
|
||||
const runExecutionData = mock<IRunExecutionData>({
|
||||
resultData: { runData: { [node.name]: [runData, runData] } },
|
||||
});
|
||||
const supplyDataContext = new SupplyDataContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
connectionType,
|
||||
executeData,
|
||||
[closeFn],
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
const latestRunIndex = supplyDataContext.getNextRunIndex();
|
||||
|
||||
expect(latestRunIndex).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logNodeOutput', () => {
|
||||
it('it should parse JSON', () => {
|
||||
const json = '{"key": "value", "nested": {"foo": "bar"}}';
|
||||
const expectedParsedObject = { key: 'value', nested: { foo: 'bar' } };
|
||||
const numberArg = 42;
|
||||
const stringArg = 'hello world!';
|
||||
|
||||
const supplyDataContext = new SupplyDataContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
connectionType,
|
||||
executeData,
|
||||
[closeFn],
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
const sendMessageSpy = jest.spyOn(supplyDataContext, 'sendMessageToUI');
|
||||
|
||||
supplyDataContext.logNodeOutput(json, numberArg, stringArg);
|
||||
|
||||
expect(sendMessageSpy.mock.calls[0][0]).toEqual(expectedParsedObject);
|
||||
expect(sendMessageSpy.mock.calls[0][1]).toBe(numberArg);
|
||||
expect(sendMessageSpy.mock.calls[0][2]).toBe(stringArg);
|
||||
|
||||
sendMessageSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addExecutionDataFunctions', () => {
|
||||
it('should preserve canceled status when execution is aborted and output has error', async () => {
|
||||
const errorData = new ManualExecutionCancelledError('Execution was aborted');
|
||||
const abortedSignal = mock<AbortSignal>({ aborted: true });
|
||||
const mockHooks = {
|
||||
runHook: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const testAdditionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||
credentialsHelper,
|
||||
hooks: mockHooks,
|
||||
currentNodeExecutionIndex: 0,
|
||||
});
|
||||
const testRunExecutionData = mock<IRunExecutionData>({
|
||||
resultData: {
|
||||
runData: {
|
||||
[node.name]: [
|
||||
{
|
||||
executionStatus: 'canceled',
|
||||
startTime: Date.now(),
|
||||
executionTime: 0,
|
||||
executionIndex: 0,
|
||||
error: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
error: undefined,
|
||||
},
|
||||
executionData: { metadata: {} },
|
||||
});
|
||||
|
||||
const contextWithAbort = new SupplyDataContext(
|
||||
workflow,
|
||||
node,
|
||||
testAdditionalData,
|
||||
mode,
|
||||
testRunExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
'ai_agent',
|
||||
executeData,
|
||||
[closeFn],
|
||||
abortedSignal,
|
||||
);
|
||||
|
||||
await contextWithAbort.addExecutionDataFunctions(
|
||||
'output',
|
||||
errorData,
|
||||
'ai_agent',
|
||||
node.name,
|
||||
0,
|
||||
);
|
||||
|
||||
const taskData = testRunExecutionData.resultData.runData[node.name][0];
|
||||
expect(taskData.executionStatus).toBe('canceled');
|
||||
expect(taskData.error).toBeUndefined();
|
||||
|
||||
// Verify nodeExecuteAfter hook was called correctly
|
||||
expect(mockHooks.runHook).toHaveBeenCalledWith('nodeExecuteAfter', [
|
||||
node.name,
|
||||
taskData,
|
||||
testRunExecutionData,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addExecutionHints', () => {
|
||||
it('should add single hint to context', () => {
|
||||
const testContext = new SupplyDataContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
connectionType,
|
||||
executeData,
|
||||
[closeFn],
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
const hint = {
|
||||
message: 'Test warning message',
|
||||
location: 'outputPane' as const,
|
||||
};
|
||||
|
||||
testContext.addExecutionHints(hint);
|
||||
|
||||
expect(testContext.hints).toHaveLength(1);
|
||||
expect(testContext.hints[0]).toEqual(hint);
|
||||
});
|
||||
|
||||
it('should add multiple hints to context', () => {
|
||||
const testContext = new SupplyDataContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
connectionType,
|
||||
executeData,
|
||||
[closeFn],
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
const hint1 = {
|
||||
message: 'First hint',
|
||||
location: 'outputPane' as const,
|
||||
};
|
||||
const hint2 = {
|
||||
message: 'Second hint',
|
||||
location: 'inputPane' as const,
|
||||
type: 'warning' as const,
|
||||
};
|
||||
|
||||
testContext.addExecutionHints(hint1, hint2);
|
||||
|
||||
expect(testContext.hints).toHaveLength(2);
|
||||
expect(testContext.hints[0]).toEqual(hint1);
|
||||
expect(testContext.hints[1]).toEqual(hint2);
|
||||
});
|
||||
|
||||
it('should accumulate hints across multiple calls', () => {
|
||||
const testContext = new SupplyDataContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
connectionType,
|
||||
executeData,
|
||||
[closeFn],
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
const hint1 = {
|
||||
message: 'First hint',
|
||||
location: 'outputPane' as const,
|
||||
};
|
||||
const hint2 = {
|
||||
message: 'Second hint',
|
||||
location: 'outputPane' as const,
|
||||
};
|
||||
|
||||
testContext.addExecutionHints(hint1);
|
||||
testContext.addExecutionHints(hint2);
|
||||
|
||||
expect(testContext.hints).toHaveLength(2);
|
||||
expect(testContext.hints[0]).toEqual(hint1);
|
||||
expect(testContext.hints[1]).toEqual(hint2);
|
||||
});
|
||||
|
||||
it('should attach hints to task data when adding output', async () => {
|
||||
const mockHooks = {
|
||||
runHook: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const testAdditionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||
credentialsHelper,
|
||||
hooks: mockHooks,
|
||||
currentNodeExecutionIndex: 0,
|
||||
});
|
||||
const testRunExecutionData = mock<IRunExecutionData>({
|
||||
resultData: {
|
||||
runData: {
|
||||
[node.name]: [
|
||||
{
|
||||
startTime: Date.now(),
|
||||
executionTime: 0,
|
||||
executionIndex: 0,
|
||||
executionStatus: 'running' as const,
|
||||
source: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
error: undefined,
|
||||
},
|
||||
executionData: { metadata: {} },
|
||||
});
|
||||
|
||||
const testContext = new SupplyDataContext(
|
||||
workflow,
|
||||
node,
|
||||
testAdditionalData,
|
||||
mode,
|
||||
testRunExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
NodeConnectionTypes.AiTool,
|
||||
executeData,
|
||||
[closeFn],
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
const hint = {
|
||||
message: 'Value to match is null or undefined',
|
||||
location: 'outputPane' as const,
|
||||
type: 'warning' as const,
|
||||
};
|
||||
|
||||
// Add hint to context
|
||||
testContext.addExecutionHints(hint);
|
||||
|
||||
// Add output data which should trigger storing hints in task data
|
||||
await testContext.addExecutionDataFunctions(
|
||||
'output',
|
||||
[[{ json: { result: 'success' } }]],
|
||||
NodeConnectionTypes.AiTool,
|
||||
node.name,
|
||||
0,
|
||||
);
|
||||
|
||||
// Verify hints were stored in task data
|
||||
const taskData = testRunExecutionData.resultData.runData[node.name][0];
|
||||
expect(taskData.hints).toBeDefined();
|
||||
expect(taskData.hints).toHaveLength(1);
|
||||
expect(taskData.hints![0]).toEqual(hint);
|
||||
});
|
||||
|
||||
it('should not add hints property to task data if no hints exist', async () => {
|
||||
const mockHooks = {
|
||||
runHook: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const testAdditionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||
credentialsHelper,
|
||||
hooks: mockHooks,
|
||||
currentNodeExecutionIndex: 0,
|
||||
});
|
||||
|
||||
// Create run execution data with plain object (not mock) to avoid mock functions
|
||||
const testRunExecutionData = createRunExecutionData({
|
||||
resultData: {
|
||||
runData: {
|
||||
[node.name]: [
|
||||
{
|
||||
startTime: Date.now(),
|
||||
executionTime: 0,
|
||||
executionIndex: 0,
|
||||
executionStatus: 'running' as const,
|
||||
source: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
executionData: {
|
||||
metadata: {},
|
||||
contextData: {},
|
||||
nodeExecutionStack: [],
|
||||
waitingExecution: {},
|
||||
waitingExecutionSource: {},
|
||||
},
|
||||
});
|
||||
|
||||
const testContext = new SupplyDataContext(
|
||||
workflow,
|
||||
node,
|
||||
testAdditionalData,
|
||||
mode,
|
||||
testRunExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
NodeConnectionTypes.AiTool,
|
||||
executeData,
|
||||
[closeFn],
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
// Don't add any hints
|
||||
|
||||
// Add output data
|
||||
await testContext.addExecutionDataFunctions(
|
||||
'output',
|
||||
[[{ json: { result: 'success' } }]],
|
||||
NodeConnectionTypes.AiTool,
|
||||
node.name,
|
||||
0,
|
||||
);
|
||||
|
||||
// Verify hints property was not added
|
||||
const taskData = testRunExecutionData.resultData.runData[node.name][0];
|
||||
expect(taskData.hints).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle hints when tool is used in AI workflow', async () => {
|
||||
// This test simulates the Google Sheets Update Row tool scenario
|
||||
const mockHooks = {
|
||||
runHook: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const testAdditionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||
credentialsHelper,
|
||||
hooks: mockHooks,
|
||||
currentNodeExecutionIndex: 0,
|
||||
});
|
||||
const testRunExecutionData = mock<IRunExecutionData>({
|
||||
resultData: {
|
||||
runData: {},
|
||||
error: undefined,
|
||||
},
|
||||
executionData: { metadata: {} },
|
||||
});
|
||||
|
||||
const testContext = new SupplyDataContext(
|
||||
workflow,
|
||||
node,
|
||||
testAdditionalData,
|
||||
mode,
|
||||
testRunExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
NodeConnectionTypes.AiTool,
|
||||
executeData,
|
||||
[closeFn],
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
// Simulate input data being added
|
||||
testContext.addInputData(NodeConnectionTypes.AiTool, [[{ json: { query: 'test' } }]]);
|
||||
|
||||
// Simulate the node adding a hint during execution (like Google Sheets does)
|
||||
const hint = {
|
||||
message: 'Warning: The value of column to match is null or undefined',
|
||||
location: 'outputPane' as const,
|
||||
};
|
||||
testContext.addExecutionHints(hint);
|
||||
|
||||
// Add output data
|
||||
const outputData = [[{ json: { response: 'Row updated' } }]];
|
||||
await testContext.addExecutionDataFunctions(
|
||||
'output',
|
||||
outputData,
|
||||
NodeConnectionTypes.AiTool,
|
||||
node.name,
|
||||
0,
|
||||
);
|
||||
|
||||
// Verify the hint is stored in task data and accessible
|
||||
const taskData = testRunExecutionData.resultData.runData[node.name][0];
|
||||
expect(taskData.hints).toBeDefined();
|
||||
expect(taskData.hints).toHaveLength(1);
|
||||
expect(taskData.hints![0].message).toContain('null or undefined');
|
||||
expect(taskData.hints![0].location).toBe('outputPane');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isToolExecution', () => {
|
||||
it('should return true when connectionType is AiTool', () => {
|
||||
const testContext = new SupplyDataContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
NodeConnectionTypes.AiTool,
|
||||
executeData,
|
||||
[closeFn],
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
expect(testContext.isToolExecution()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when connectionType is Main', () => {
|
||||
const testContext = new SupplyDataContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
NodeConnectionTypes.Main,
|
||||
executeData,
|
||||
[closeFn],
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
expect(testContext.isToolExecution()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when connectionType is AiAgent', () => {
|
||||
const testContext = new SupplyDataContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
NodeConnectionTypes.AiAgent,
|
||||
executeData,
|
||||
[closeFn],
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
expect(testContext.isToolExecution()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialsHelper,
|
||||
INode,
|
||||
INodeType,
|
||||
INodeTypes,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
WorkflowActivateMode,
|
||||
WorkflowExecuteMode,
|
||||
WorkflowExpression,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { TriggerContext } from '../trigger-context';
|
||||
|
||||
describe('TriggerContext', () => {
|
||||
const testCredentialType = 'testCredential';
|
||||
const nodeType = mock<INodeType>({
|
||||
description: {
|
||||
credentials: [
|
||||
{
|
||||
name: testCredentialType,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
name: 'testParameter',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const nodeTypes = mock<INodeTypes>();
|
||||
const expression = mock<WorkflowExpression>();
|
||||
const workflow = mock<Workflow>({ expression, nodeTypes });
|
||||
const node = mock<INode>({
|
||||
credentials: {
|
||||
[testCredentialType]: {
|
||||
id: 'testCredentialId',
|
||||
},
|
||||
},
|
||||
});
|
||||
node.parameters = {
|
||||
testParameter: 'testValue',
|
||||
};
|
||||
const credentialsHelper = mock<ICredentialsHelper>();
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
|
||||
const mode: WorkflowExecuteMode = 'manual';
|
||||
const activation: WorkflowActivateMode = 'init';
|
||||
|
||||
const triggerContext = new TriggerContext(workflow, node, additionalData, mode, activation);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getActivationMode', () => {
|
||||
it('should return the activation property', () => {
|
||||
const result = triggerContext.getActivationMode();
|
||||
expect(result).toBe(activation);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCredentials', () => {
|
||||
it('should get decrypted credentials', async () => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
|
||||
|
||||
const credentials =
|
||||
await triggerContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
|
||||
|
||||
expect(credentials).toEqual({ secret: 'token' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeParameter', () => {
|
||||
beforeEach(() => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
expression.getParameterValue.mockImplementation((value) => value);
|
||||
});
|
||||
|
||||
it('should return parameter value when it exists', () => {
|
||||
const parameter = triggerContext.getNodeParameter('testParameter');
|
||||
|
||||
expect(parameter).toBe('testValue');
|
||||
});
|
||||
|
||||
it('should return the fallback value when the parameter does not exist', () => {
|
||||
const parameter = triggerContext.getNodeParameter('otherParameter', 'fallback');
|
||||
|
||||
expect(parameter).toBe('fallback');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExecutionContext', () => {
|
||||
it('should return undefined', () => {
|
||||
expect(triggerContext.getExecutionContext()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialsHelper,
|
||||
INode,
|
||||
INodeType,
|
||||
INodeTypes,
|
||||
IWebhookData,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
WorkflowExecuteMode,
|
||||
WorkflowExpression,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { WebhookContext } from '../webhook-context';
|
||||
|
||||
describe('WebhookContext', () => {
|
||||
const testCredentialType = 'testCredential';
|
||||
const nodeType = mock<INodeType>({
|
||||
description: {
|
||||
credentials: [
|
||||
{
|
||||
name: testCredentialType,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
name: 'testParameter',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const nodeTypes = mock<INodeTypes>();
|
||||
const expression = mock<WorkflowExpression>();
|
||||
const workflow = mock<Workflow>({ expression, nodeTypes });
|
||||
const node = mock<INode>({
|
||||
credentials: {
|
||||
[testCredentialType]: {
|
||||
id: 'testCredentialId',
|
||||
},
|
||||
},
|
||||
});
|
||||
node.parameters = {
|
||||
testParameter: 'testValue',
|
||||
};
|
||||
const credentialsHelper = mock<ICredentialsHelper>();
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||
credentialsHelper,
|
||||
});
|
||||
additionalData.httpRequest = {
|
||||
body: { test: 'body' },
|
||||
headers: { test: 'header' },
|
||||
params: { test: 'param' },
|
||||
query: { test: 'query' },
|
||||
} as unknown as Request;
|
||||
additionalData.httpResponse = mock<Response>();
|
||||
const mode: WorkflowExecuteMode = 'manual';
|
||||
const webhookData = mock<IWebhookData>({
|
||||
webhookDescription: {
|
||||
name: 'default',
|
||||
},
|
||||
});
|
||||
const runExecutionData = null;
|
||||
|
||||
const webhookContext = new WebhookContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
webhookData,
|
||||
[],
|
||||
runExecutionData,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getCredentials', () => {
|
||||
it('should get decrypted credentials', async () => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
|
||||
|
||||
const credentials =
|
||||
await webhookContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
|
||||
|
||||
expect(credentials).toEqual({ secret: 'token' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBodyData', () => {
|
||||
it('should return the body data of the request', () => {
|
||||
const bodyData = webhookContext.getBodyData();
|
||||
expect(bodyData).toEqual({ test: 'body' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHeaderData', () => {
|
||||
it('should return the header data of the request', () => {
|
||||
const headerData = webhookContext.getHeaderData();
|
||||
expect(headerData).toEqual({ test: 'header' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getParamsData', () => {
|
||||
it('should return the params data of the request', () => {
|
||||
const paramsData = webhookContext.getParamsData();
|
||||
expect(paramsData).toEqual({ test: 'param' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getQueryData', () => {
|
||||
it('should return the query data of the request', () => {
|
||||
const queryData = webhookContext.getQueryData();
|
||||
expect(queryData).toEqual({ test: 'query' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRequestObject', () => {
|
||||
it('should return the request object', () => {
|
||||
const request = webhookContext.getRequestObject();
|
||||
expect(request).toBe(additionalData.httpRequest);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getResponseObject', () => {
|
||||
it('should return the response object', () => {
|
||||
const response = webhookContext.getResponseObject();
|
||||
expect(response).toBe(additionalData.httpResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWebhookName', () => {
|
||||
it('should return the name of the webhook', () => {
|
||||
const webhookName = webhookContext.getWebhookName();
|
||||
expect(webhookName).toBe('default');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeParameter', () => {
|
||||
beforeEach(() => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
expression.getParameterValue.mockImplementation((value) => value);
|
||||
});
|
||||
|
||||
it('should return parameter value when it exists', () => {
|
||||
const parameter = webhookContext.getNodeParameter('testParameter');
|
||||
|
||||
expect(parameter).toBe('testValue');
|
||||
});
|
||||
|
||||
it('should return the fallback value when the parameter does not exist', () => {
|
||||
const parameter = webhookContext.getNodeParameter('otherParameter', 'fallback');
|
||||
|
||||
expect(parameter).toBe('fallback');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import get from 'lodash/get';
|
||||
import type {
|
||||
Workflow,
|
||||
INode,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
WorkflowExecuteMode,
|
||||
IRunExecutionData,
|
||||
INodeExecutionData,
|
||||
ITaskDataConnections,
|
||||
IExecuteData,
|
||||
ICredentialDataDecryptedObject,
|
||||
CallbackManager,
|
||||
IExecuteWorkflowInfo,
|
||||
RelatedExecution,
|
||||
ExecuteWorkflowData,
|
||||
ITaskMetadata,
|
||||
ContextType,
|
||||
IContextObject,
|
||||
IWorkflowDataProxyData,
|
||||
ISourceData,
|
||||
AiEvent,
|
||||
NodeConnectionType,
|
||||
Result,
|
||||
IExecuteFunctions,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
ApplicationError,
|
||||
NodeHelpers,
|
||||
NodeConnectionTypes,
|
||||
WAIT_INDEFINITELY,
|
||||
WorkflowDataProxy,
|
||||
createEnvProviderState,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { NodeExecutionContext } from './node-execution-context';
|
||||
|
||||
export class BaseExecuteContext extends NodeExecutionContext {
|
||||
constructor(
|
||||
workflow: Workflow,
|
||||
node: INode,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
mode: WorkflowExecuteMode,
|
||||
readonly runExecutionData: IRunExecutionData,
|
||||
runIndex: number,
|
||||
readonly connectionInputData: INodeExecutionData[],
|
||||
readonly inputData: ITaskDataConnections,
|
||||
readonly executeData: IExecuteData,
|
||||
readonly abortSignal?: AbortSignal,
|
||||
) {
|
||||
super(workflow, node, additionalData, mode, runExecutionData, runIndex);
|
||||
}
|
||||
|
||||
getExecutionContext() {
|
||||
return this.runExecutionData.executionData?.runtimeData;
|
||||
}
|
||||
|
||||
getExecutionCancelSignal() {
|
||||
return this.abortSignal;
|
||||
}
|
||||
|
||||
onExecutionCancellation(handler: () => unknown) {
|
||||
const fn = () => {
|
||||
this.abortSignal?.removeEventListener('abort', fn);
|
||||
handler();
|
||||
};
|
||||
this.abortSignal?.addEventListener('abort', fn);
|
||||
}
|
||||
|
||||
getExecuteData() {
|
||||
return this.executeData;
|
||||
}
|
||||
|
||||
setMetadata(metadata: ITaskMetadata): void {
|
||||
this.executeData.metadata = {
|
||||
...(this.executeData.metadata ?? {}),
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
getContext(type: ContextType): IContextObject {
|
||||
return NodeHelpers.getContext(this.runExecutionData, type, this.node);
|
||||
}
|
||||
|
||||
/** Returns if execution should be continued even if there was an error */
|
||||
continueOnFail(): boolean {
|
||||
const onError = get(this.node, 'onError', undefined);
|
||||
|
||||
if (onError === undefined) {
|
||||
return get(this.node, 'continueOnFail', false);
|
||||
}
|
||||
|
||||
return ['continueRegularOutput', 'continueErrorOutput'].includes(onError);
|
||||
}
|
||||
|
||||
async getCredentials<T extends object = ICredentialDataDecryptedObject>(
|
||||
type: string,
|
||||
itemIndex: number,
|
||||
) {
|
||||
return await this._getCredentials<T>(
|
||||
type,
|
||||
this.executeData,
|
||||
this.connectionInputData,
|
||||
itemIndex,
|
||||
);
|
||||
}
|
||||
|
||||
async putExecutionToWait(waitTill: Date): Promise<void> {
|
||||
this.runExecutionData.waitTill = waitTill;
|
||||
if (this.additionalData.setExecutionStatus) {
|
||||
this.additionalData.setExecutionStatus('waiting');
|
||||
}
|
||||
}
|
||||
|
||||
async executeWorkflow(
|
||||
workflowInfo: IExecuteWorkflowInfo,
|
||||
inputData?: INodeExecutionData[],
|
||||
parentCallbackManager?: CallbackManager,
|
||||
options?: {
|
||||
doNotWaitToFinish?: boolean;
|
||||
parentExecution?: RelatedExecution;
|
||||
executionMode?: WorkflowExecuteMode;
|
||||
},
|
||||
): Promise<ExecuteWorkflowData> {
|
||||
if (options?.parentExecution) {
|
||||
// We inject the execution context of the current execution
|
||||
// to the sub-workflow so that it can be accessed there
|
||||
// this should only happen for the direct parent execution
|
||||
// if a workflow starts a sub-workflow for a workflow that is not itself
|
||||
// then the context should not be passed down
|
||||
if (
|
||||
!options.parentExecution.executionContext &&
|
||||
options.parentExecution.executionId === this.getExecutionId()
|
||||
) {
|
||||
options.parentExecution.executionContext = this.getExecutionContext();
|
||||
}
|
||||
}
|
||||
const result = await this.additionalData.executeWorkflow(workflowInfo, this.additionalData, {
|
||||
...options,
|
||||
parentWorkflowId: this.workflow.id,
|
||||
inputData,
|
||||
parentWorkflowSettings: this.workflow.settings,
|
||||
node: this.node,
|
||||
parentCallbackManager,
|
||||
});
|
||||
|
||||
// If a sub-workflow execution goes into the waiting state
|
||||
if (result.waitTill) {
|
||||
// then put the parent workflow execution also into the waiting state,
|
||||
// but do not use the sub-workflow `waitTill` to avoid WaitTracker resuming the parent execution at the same time as the sub-workflow
|
||||
await this.putExecutionToWait(WAIT_INDEFINITELY);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async getExecutionDataById(executionId: string): Promise<IRunExecutionData | undefined> {
|
||||
return await this.additionalData.getRunExecutionData(executionId);
|
||||
}
|
||||
|
||||
protected getInputItems(inputIndex: number, connectionType: NodeConnectionType) {
|
||||
const inputData = this.inputData[connectionType];
|
||||
if (inputData.length < inputIndex) {
|
||||
throw new ApplicationError('Could not get input with given index', {
|
||||
extra: { inputIndex, connectionType },
|
||||
});
|
||||
}
|
||||
|
||||
const allItems = inputData[inputIndex] as INodeExecutionData[] | null | undefined;
|
||||
if (allItems === null) {
|
||||
throw new ApplicationError('Input index was not set', {
|
||||
extra: { inputIndex, connectionType },
|
||||
});
|
||||
}
|
||||
|
||||
return allItems;
|
||||
}
|
||||
|
||||
getInputSourceData(inputIndex = 0, connectionType = NodeConnectionTypes.Main): ISourceData {
|
||||
if (this.executeData?.source === null) {
|
||||
// Should never happen as n8n sets it automatically
|
||||
throw new ApplicationError('Source data is missing');
|
||||
}
|
||||
return this.executeData.source[connectionType][inputIndex]!;
|
||||
}
|
||||
|
||||
getWorkflowDataProxy(itemIndex: number): IWorkflowDataProxyData {
|
||||
return new WorkflowDataProxy(
|
||||
this.workflow,
|
||||
this.runExecutionData,
|
||||
this.runIndex,
|
||||
itemIndex,
|
||||
this.node.name,
|
||||
this.connectionInputData,
|
||||
{},
|
||||
this.mode,
|
||||
this.additionalKeys,
|
||||
this.executeData,
|
||||
).getDataProxy();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
sendMessageToUI(...args: any[]): void {
|
||||
if (this.mode !== 'manual') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (this.additionalData.sendDataToUI) {
|
||||
args = args.map((arg) => {
|
||||
// prevent invalid dates from being logged as null
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return
|
||||
if (arg.isLuxonDateTime && arg.invalidReason) return { ...arg };
|
||||
|
||||
// log valid dates in human readable format, as in browser
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument
|
||||
if (arg.isLuxonDateTime) return new Date(arg.ts).toString();
|
||||
if (arg instanceof Date) return arg.toString();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return arg;
|
||||
});
|
||||
|
||||
this.additionalData.sendDataToUI('sendConsoleMessage', {
|
||||
source: `[Node: "${this.node.name}"]`,
|
||||
messages: args,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
this.logger.warn(`There was a problem sending message to UI: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
logAiEvent(eventName: AiEvent, msg: string) {
|
||||
return this.additionalData.logAiEvent(eventName, {
|
||||
executionId: this.additionalData.executionId ?? 'unsaved-execution',
|
||||
nodeName: this.node.name,
|
||||
workflowName: this.workflow.name ?? 'Unnamed workflow',
|
||||
nodeType: this.node.type,
|
||||
workflowId: this.workflow.id ?? 'unsaved-workflow',
|
||||
msg,
|
||||
});
|
||||
}
|
||||
|
||||
async startJob<T = unknown, E = unknown>(
|
||||
jobType: string,
|
||||
settings: unknown,
|
||||
itemIndex: number,
|
||||
): Promise<Result<T, E>> {
|
||||
return await this.additionalData.startRunnerTask<T, E>(
|
||||
this.additionalData,
|
||||
jobType,
|
||||
settings,
|
||||
this as IExecuteFunctions,
|
||||
this.inputData,
|
||||
this.node,
|
||||
this.workflow,
|
||||
this.runExecutionData,
|
||||
this.runIndex,
|
||||
itemIndex,
|
||||
this.node.name,
|
||||
this.connectionInputData,
|
||||
{},
|
||||
this.mode,
|
||||
createEnvProviderState(),
|
||||
this.executeData,
|
||||
);
|
||||
}
|
||||
|
||||
getRunnerStatus(taskType: string): { available: true } | { available: false; reason?: string } {
|
||||
return this.additionalData.getRunnerStatus?.(taskType) ?? { available: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { Memoized } from '@n8n/decorators';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { ICredentialTestFunctions } from 'n8n-workflow';
|
||||
|
||||
import { proxyRequestToAxios } from './utils/request-helper-functions';
|
||||
import { getSSHTunnelFunctions } from './utils/ssh-tunnel-helper-functions';
|
||||
|
||||
export class CredentialTestContext implements ICredentialTestFunctions {
|
||||
readonly helpers: ICredentialTestFunctions['helpers'];
|
||||
|
||||
constructor() {
|
||||
this.helpers = {
|
||||
...getSSHTunnelFunctions(),
|
||||
request: async (uriOrObject: string | object, options?: object) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return await proxyRequestToAxios(undefined, undefined, undefined, uriOrObject, options);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Memoized
|
||||
get logger() {
|
||||
return Container.get(Logger);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import type {
|
||||
AINodeConnectionType,
|
||||
CallbackManager,
|
||||
ChunkType,
|
||||
CloseFunction,
|
||||
IDataObject,
|
||||
IExecuteData,
|
||||
IExecuteFunctions,
|
||||
IExecuteResponsePromiseData,
|
||||
IGetNodeParameterOptions,
|
||||
INode,
|
||||
INodeExecutionData,
|
||||
IRunExecutionData,
|
||||
ITaskDataConnections,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
NodeExecutionHint,
|
||||
StructuredChunk,
|
||||
Workflow,
|
||||
WorkflowExecuteMode,
|
||||
EngineResponse,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
ApplicationError,
|
||||
createDeferredPromise,
|
||||
jsonParse,
|
||||
NodeConnectionTypes,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { BaseExecuteContext } from './base-execute-context';
|
||||
import {
|
||||
assertBinaryData,
|
||||
getBinaryDataBuffer,
|
||||
copyBinaryFile,
|
||||
getBinaryHelperFunctions,
|
||||
detectBinaryEncoding,
|
||||
} from './utils/binary-helper-functions';
|
||||
import { constructExecutionMetaData } from './utils/construct-execution-metadata';
|
||||
import { copyInputItems } from './utils/copy-input-items';
|
||||
import { getDataTableHelperFunctions } from './utils/data-table-helper-functions';
|
||||
import { getDeduplicationHelperFunctions } from './utils/deduplication-helper-functions';
|
||||
import { getFileSystemHelperFunctions } from './utils/file-system-helper-functions';
|
||||
import { getInputConnectionData } from './utils/get-input-connection-data';
|
||||
import { normalizeItems } from './utils/normalize-items';
|
||||
import { getRequestHelperFunctions } from './utils/request-helper-functions';
|
||||
import { returnJsonArray } from './utils/return-json-array';
|
||||
import { getSSHTunnelFunctions } from './utils/ssh-tunnel-helper-functions';
|
||||
|
||||
export class ExecuteContext extends BaseExecuteContext implements IExecuteFunctions {
|
||||
readonly helpers: IExecuteFunctions['helpers'];
|
||||
|
||||
readonly nodeHelpers: IExecuteFunctions['nodeHelpers'];
|
||||
|
||||
readonly getNodeParameter: IExecuteFunctions['getNodeParameter'];
|
||||
|
||||
readonly hints: NodeExecutionHint[] = [];
|
||||
|
||||
constructor(
|
||||
workflow: Workflow,
|
||||
node: INode,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
mode: WorkflowExecuteMode,
|
||||
runExecutionData: IRunExecutionData,
|
||||
runIndex: number,
|
||||
connectionInputData: INodeExecutionData[],
|
||||
inputData: ITaskDataConnections,
|
||||
executeData: IExecuteData,
|
||||
private readonly closeFunctions: CloseFunction[],
|
||||
abortSignal?: AbortSignal,
|
||||
public subNodeExecutionResults?: EngineResponse,
|
||||
) {
|
||||
super(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
executeData,
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
this.helpers = {
|
||||
createDeferredPromise,
|
||||
returnJsonArray,
|
||||
copyInputItems,
|
||||
normalizeItems,
|
||||
constructExecutionMetaData,
|
||||
...getRequestHelperFunctions(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
runExecutionData,
|
||||
connectionInputData,
|
||||
),
|
||||
...getBinaryHelperFunctions(additionalData, workflow.id),
|
||||
...getDataTableHelperFunctions(additionalData, workflow, node),
|
||||
...getSSHTunnelFunctions(),
|
||||
...getFileSystemHelperFunctions(node),
|
||||
...getDeduplicationHelperFunctions(workflow, node),
|
||||
|
||||
assertBinaryData: (itemIndex, propertyName) =>
|
||||
assertBinaryData(inputData, node, itemIndex, propertyName, 0, workflow.settings.binaryMode),
|
||||
getBinaryDataBuffer: async (itemIndex, propertyName) =>
|
||||
await getBinaryDataBuffer(
|
||||
inputData,
|
||||
itemIndex,
|
||||
propertyName,
|
||||
0,
|
||||
workflow.settings.binaryMode,
|
||||
),
|
||||
detectBinaryEncoding: (buffer: Buffer) => detectBinaryEncoding(buffer),
|
||||
};
|
||||
|
||||
this.nodeHelpers = {
|
||||
copyBinaryFile: async (filePath, fileName, mimeType) =>
|
||||
await copyBinaryFile(
|
||||
this.workflow.id,
|
||||
this.additionalData.executionId!,
|
||||
filePath,
|
||||
fileName,
|
||||
mimeType,
|
||||
),
|
||||
};
|
||||
|
||||
this.getNodeParameter = ((
|
||||
parameterName: string,
|
||||
itemIndex: number,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fallbackValue?: any,
|
||||
options?: IGetNodeParameterOptions,
|
||||
) =>
|
||||
this._getNodeParameter(
|
||||
parameterName,
|
||||
itemIndex,
|
||||
fallbackValue,
|
||||
options,
|
||||
)) as IExecuteFunctions['getNodeParameter'];
|
||||
}
|
||||
|
||||
isStreaming(): boolean {
|
||||
// Check if we have sendChunk handlers
|
||||
const handlers = this.additionalData.hooks?.handlers?.sendChunk?.length;
|
||||
const hasHandlers = handlers !== undefined && handlers > 0;
|
||||
|
||||
// Check if streaming was enabled for this execution
|
||||
const streamingEnabled = this.additionalData.streamingEnabled === true;
|
||||
|
||||
// Check current execution mode supports streaming
|
||||
const executionModeSupportsStreaming = ['manual', 'webhook', 'integrated', 'chat'];
|
||||
const isStreamingMode = executionModeSupportsStreaming.includes(this.mode);
|
||||
|
||||
return hasHandlers && isStreamingMode && streamingEnabled;
|
||||
}
|
||||
|
||||
async sendChunk(
|
||||
type: ChunkType,
|
||||
itemIndex: number,
|
||||
content?: IDataObject | string,
|
||||
): Promise<void> {
|
||||
const node = this.getNode();
|
||||
const metadata = {
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
itemIndex,
|
||||
runIndex: this.runIndex,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const parsedContent = typeof content === 'string' ? content : JSON.stringify(content);
|
||||
|
||||
const message: StructuredChunk = {
|
||||
type,
|
||||
content: parsedContent,
|
||||
metadata,
|
||||
};
|
||||
|
||||
await this.additionalData.hooks?.runHook('sendChunk', [message]);
|
||||
}
|
||||
|
||||
async getInputConnectionData(
|
||||
connectionType: AINodeConnectionType,
|
||||
itemIndex: number,
|
||||
): Promise<unknown> {
|
||||
return await getInputConnectionData.call(
|
||||
this,
|
||||
this.workflow,
|
||||
this.runExecutionData,
|
||||
this.runIndex,
|
||||
this.connectionInputData,
|
||||
this.inputData,
|
||||
this.additionalData,
|
||||
this.executeData,
|
||||
this.mode,
|
||||
this.closeFunctions,
|
||||
connectionType,
|
||||
itemIndex,
|
||||
this.abortSignal,
|
||||
);
|
||||
}
|
||||
|
||||
getInputData(inputIndex = 0, connectionType = NodeConnectionTypes.Main) {
|
||||
if (!this.inputData.hasOwnProperty(connectionType)) {
|
||||
// Return empty array because else it would throw error when nothing is connected to input
|
||||
return [];
|
||||
}
|
||||
return super.getInputItems(inputIndex, connectionType) ?? [];
|
||||
}
|
||||
|
||||
logNodeOutput(...args: unknown[]): void {
|
||||
if (this.mode === 'manual') {
|
||||
const parsedLogArgs = args.map((arg) =>
|
||||
typeof arg === 'string' ? jsonParse(arg, { fallbackValue: arg }) : arg,
|
||||
);
|
||||
this.sendMessageToUI(...parsedLogArgs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.CODE_ENABLE_STDOUT === 'true') {
|
||||
console.log(`[Workflow "${this.getWorkflow().id}"][Node "${this.node.name}"]`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
async sendResponse(response: IExecuteResponsePromiseData): Promise<void> {
|
||||
await this.additionalData.hooks?.runHook('sendResponse', [response]);
|
||||
}
|
||||
|
||||
/** @deprecated use ISupplyDataFunctions.addInputData */
|
||||
addInputData(): { index: number } {
|
||||
throw new ApplicationError('addInputData should not be called on IExecuteFunctions');
|
||||
}
|
||||
|
||||
/** @deprecated use ISupplyDataFunctions.addOutputData */
|
||||
addOutputData(): void {
|
||||
throw new ApplicationError('addOutputData should not be called on IExecuteFunctions');
|
||||
}
|
||||
|
||||
getParentCallbackManager(): CallbackManager | undefined {
|
||||
return this.additionalData.parentCallbackManager;
|
||||
}
|
||||
|
||||
addExecutionHints(...hints: NodeExecutionHint[]) {
|
||||
this.hints.push(...hints);
|
||||
}
|
||||
|
||||
/** Returns true if the node is being executed as an AI Agent tool */
|
||||
isToolExecution(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
IGetNodeParameterOptions,
|
||||
INode,
|
||||
INodeExecutionData,
|
||||
IRunExecutionData,
|
||||
IExecuteSingleFunctions,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
WorkflowExecuteMode,
|
||||
ITaskDataConnections,
|
||||
IExecuteData,
|
||||
} from 'n8n-workflow';
|
||||
import { ApplicationError, createDeferredPromise, NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import { BaseExecuteContext } from './base-execute-context';
|
||||
import {
|
||||
assertBinaryData,
|
||||
detectBinaryEncoding,
|
||||
getBinaryDataBuffer,
|
||||
getBinaryHelperFunctions,
|
||||
} from './utils/binary-helper-functions';
|
||||
import { getRequestHelperFunctions } from './utils/request-helper-functions';
|
||||
import { returnJsonArray } from './utils/return-json-array';
|
||||
|
||||
export class ExecuteSingleContext extends BaseExecuteContext implements IExecuteSingleFunctions {
|
||||
readonly helpers: IExecuteSingleFunctions['helpers'];
|
||||
|
||||
constructor(
|
||||
workflow: Workflow,
|
||||
node: INode,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
mode: WorkflowExecuteMode,
|
||||
runExecutionData: IRunExecutionData,
|
||||
runIndex: number,
|
||||
connectionInputData: INodeExecutionData[],
|
||||
inputData: ITaskDataConnections,
|
||||
private readonly itemIndex: number,
|
||||
executeData: IExecuteData,
|
||||
abortSignal?: AbortSignal,
|
||||
) {
|
||||
super(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
executeData,
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
this.helpers = {
|
||||
createDeferredPromise,
|
||||
returnJsonArray,
|
||||
...getRequestHelperFunctions(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
runExecutionData,
|
||||
connectionInputData,
|
||||
),
|
||||
...getBinaryHelperFunctions(additionalData, workflow.id),
|
||||
|
||||
assertBinaryData: (propertyName, inputIndex = 0) =>
|
||||
assertBinaryData(
|
||||
inputData,
|
||||
node,
|
||||
itemIndex,
|
||||
propertyName,
|
||||
inputIndex,
|
||||
workflow.settings.binaryMode,
|
||||
),
|
||||
getBinaryDataBuffer: async (propertyName, inputIndex = 0) =>
|
||||
await getBinaryDataBuffer(
|
||||
inputData,
|
||||
itemIndex,
|
||||
propertyName,
|
||||
inputIndex,
|
||||
workflow.settings.binaryMode,
|
||||
),
|
||||
detectBinaryEncoding: (buffer) => detectBinaryEncoding(buffer),
|
||||
};
|
||||
}
|
||||
|
||||
evaluateExpression(expression: string, itemIndex: number = this.itemIndex) {
|
||||
return super.evaluateExpression(expression, itemIndex);
|
||||
}
|
||||
|
||||
getInputData(inputIndex = 0, connectionType = NodeConnectionTypes.Main) {
|
||||
if (!this.inputData.hasOwnProperty(connectionType)) {
|
||||
// Return empty array because else it would throw error when nothing is connected to input
|
||||
return { json: {} };
|
||||
}
|
||||
|
||||
const allItems = super.getInputItems(inputIndex, connectionType);
|
||||
|
||||
const data = allItems?.[this.itemIndex];
|
||||
if (data === undefined) {
|
||||
throw new ApplicationError('Value of input with given index was not set', {
|
||||
extra: { inputIndex, connectionType, itemIndex: this.itemIndex },
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
getItemIndex() {
|
||||
return this.itemIndex;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getNodeParameter(parameterName: string, fallbackValue?: any, options?: IGetNodeParameterOptions) {
|
||||
return this._getNodeParameter(parameterName, this.itemIndex, fallbackValue, options);
|
||||
}
|
||||
|
||||
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
|
||||
return await super.getCredentials<T>(type, this.itemIndex);
|
||||
}
|
||||
|
||||
getWorkflowDataProxy() {
|
||||
return super.getWorkflowDataProxy(this.itemIndex);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ApplicationError } from '@n8n/errors';
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
INode,
|
||||
IHookFunctions,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
WorkflowActivateMode,
|
||||
WorkflowExecuteMode,
|
||||
IWebhookData,
|
||||
WebhookType,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { NodeExecutionContext } from './node-execution-context';
|
||||
import { getRequestHelperFunctions } from './utils/request-helper-functions';
|
||||
import { getNodeWebhookUrl, getWebhookDescription } from './utils/webhook-helper-functions';
|
||||
|
||||
export class HookContext extends NodeExecutionContext implements IHookFunctions {
|
||||
readonly helpers: IHookFunctions['helpers'];
|
||||
|
||||
constructor(
|
||||
workflow: Workflow,
|
||||
node: INode,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
mode: WorkflowExecuteMode,
|
||||
private readonly activation: WorkflowActivateMode,
|
||||
private readonly webhookData?: IWebhookData,
|
||||
) {
|
||||
super(workflow, node, additionalData, mode);
|
||||
|
||||
this.helpers = getRequestHelperFunctions(workflow, node, additionalData);
|
||||
}
|
||||
|
||||
getActivationMode() {
|
||||
return this.activation;
|
||||
}
|
||||
|
||||
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
|
||||
return await this._getCredentials<T>(type);
|
||||
}
|
||||
|
||||
getNodeWebhookUrl(name: WebhookType): string | undefined {
|
||||
return getNodeWebhookUrl(
|
||||
name,
|
||||
this.workflow,
|
||||
this.node,
|
||||
this.additionalData,
|
||||
this.mode,
|
||||
this.additionalKeys,
|
||||
this.webhookData?.isTest,
|
||||
);
|
||||
}
|
||||
|
||||
getWebhookName(): string {
|
||||
if (this.webhookData === undefined) {
|
||||
throw new ApplicationError('Only supported in webhook functions');
|
||||
}
|
||||
return this.webhookData.webhookDescription.name;
|
||||
}
|
||||
|
||||
getWebhookDescription(name: WebhookType) {
|
||||
return getWebhookDescription(name, this.workflow, this.node);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export { CredentialTestContext } from './credentials-test-context';
|
||||
export { ExecuteContext } from './execute-context';
|
||||
export { ExecuteSingleContext } from './execute-single-context';
|
||||
export { HookContext } from './hook-context';
|
||||
export { LoadOptionsContext } from './load-options-context';
|
||||
export { LocalLoadOptionsContext } from './local-load-options-context';
|
||||
export { PollContext } from './poll-context';
|
||||
export { SupplyDataContext } from './supply-data-context';
|
||||
export { TriggerContext } from './trigger-context';
|
||||
export { WebhookContext } from './webhook-context';
|
||||
|
||||
export { StructuredToolkit, type SupplyDataToolResponse } from './utils/ai-tool-types';
|
||||
export { constructExecutionMetaData } from './utils/construct-execution-metadata';
|
||||
export { getAdditionalKeys, getNonWorkflowAdditionalKeys } from './utils/get-additional-keys';
|
||||
export { normalizeItems } from './utils/normalize-items';
|
||||
export { parseIncomingMessage } from './utils/parse-incoming-message';
|
||||
export { parseRequestObject } from './utils/request-helper-functions';
|
||||
export { returnJsonArray } from './utils/return-json-array';
|
||||
export { resolveSourceOverwrite } from './utils/resolve-source-overwrite';
|
||||
export * from './utils/binary-helper-functions';
|
||||
@@ -0,0 +1,72 @@
|
||||
import get from 'lodash/get';
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
IGetNodeParameterOptions,
|
||||
INode,
|
||||
ILoadOptionsFunctions,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
NodeParameterValueType,
|
||||
Workflow,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { NodeExecutionContext } from './node-execution-context';
|
||||
import { getDataTableHelperFunctions } from './utils/data-table-helper-functions';
|
||||
import { extractValue } from './utils/extract-value';
|
||||
import { getRequestHelperFunctions } from './utils/request-helper-functions';
|
||||
import { getSSHTunnelFunctions } from './utils/ssh-tunnel-helper-functions';
|
||||
|
||||
export class LoadOptionsContext extends NodeExecutionContext implements ILoadOptionsFunctions {
|
||||
readonly helpers: ILoadOptionsFunctions['helpers'];
|
||||
|
||||
constructor(
|
||||
workflow: Workflow,
|
||||
node: INode,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
private readonly path: string,
|
||||
) {
|
||||
super(workflow, node, additionalData, 'internal');
|
||||
|
||||
this.helpers = {
|
||||
...getSSHTunnelFunctions(),
|
||||
...getRequestHelperFunctions(workflow, node, additionalData),
|
||||
...getDataTableHelperFunctions(additionalData, workflow, node),
|
||||
};
|
||||
}
|
||||
|
||||
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
|
||||
return await this._getCredentials<T>(type);
|
||||
}
|
||||
|
||||
getCurrentNodeParameter(
|
||||
parameterPath: string,
|
||||
options?: IGetNodeParameterOptions,
|
||||
): NodeParameterValueType | object | undefined {
|
||||
const nodeParameters = this.additionalData.currentNodeParameters;
|
||||
|
||||
if (parameterPath.charAt(0) === '&') {
|
||||
parameterPath = `${this.path.split('.').slice(1, -1).join('.')}.${parameterPath.slice(1)}`;
|
||||
}
|
||||
|
||||
let returnData = get(nodeParameters, parameterPath);
|
||||
|
||||
// This is outside the try/catch because it throws errors with proper messages
|
||||
if (options?.extractValue) {
|
||||
const nodeType = this.workflow.nodeTypes.getByNameAndVersion(
|
||||
this.node.type,
|
||||
this.node.typeVersion,
|
||||
);
|
||||
returnData = extractValue(
|
||||
returnData,
|
||||
parameterPath,
|
||||
this.node,
|
||||
nodeType,
|
||||
) as NodeParameterValueType;
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
getCurrentNodeParameters() {
|
||||
return this.additionalData.currentNodeParameters;
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import get from 'lodash/get';
|
||||
import { ApplicationError, resolveRelativePath, Workflow } from 'n8n-workflow';
|
||||
import type {
|
||||
INodeParameterResourceLocator,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
NodeParameterValueType,
|
||||
ILocalLoadOptionsFunctions,
|
||||
IWorkflowLoader,
|
||||
IWorkflowNodeContext,
|
||||
INodeTypes,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { LoadWorkflowNodeContext } from './workflow-node-context';
|
||||
|
||||
export class LocalLoadOptionsContext implements ILocalLoadOptionsFunctions {
|
||||
constructor(
|
||||
private nodeTypes: INodeTypes,
|
||||
private additionalData: IWorkflowExecuteAdditionalData,
|
||||
private path: string,
|
||||
private workflowLoader: IWorkflowLoader,
|
||||
) {}
|
||||
|
||||
async getWorkflowNodeContext(
|
||||
nodeType: string,
|
||||
useActiveVersion: boolean = false,
|
||||
): Promise<IWorkflowNodeContext | null> {
|
||||
const { value: workflowId } = this.getCurrentNodeParameter(
|
||||
'workflowId',
|
||||
) as INodeParameterResourceLocator;
|
||||
|
||||
if (typeof workflowId !== 'string' || !workflowId) {
|
||||
throw new ApplicationError(`No workflowId parameter defined on node of type "${nodeType}"!`);
|
||||
}
|
||||
|
||||
const dbWorkflow = await this.workflowLoader.get(workflowId);
|
||||
|
||||
if (useActiveVersion && !dbWorkflow.activeVersion) {
|
||||
throw new ApplicationError(`No active version found for workflow "${workflowId}"!`);
|
||||
}
|
||||
|
||||
const selectedWorkflowNode = (
|
||||
useActiveVersion ? dbWorkflow.activeVersion!.nodes : dbWorkflow.nodes
|
||||
).find((node) => node.type === nodeType);
|
||||
|
||||
if (selectedWorkflowNode) {
|
||||
const selectedSingleNodeWorkflow = new Workflow({
|
||||
id: dbWorkflow.id,
|
||||
name: dbWorkflow.name,
|
||||
nodes: [selectedWorkflowNode],
|
||||
connections: {},
|
||||
active: false,
|
||||
nodeTypes: this.nodeTypes,
|
||||
});
|
||||
|
||||
const workflowAdditionalData = {
|
||||
...this.additionalData,
|
||||
currentNodeParameters: selectedWorkflowNode.parameters,
|
||||
};
|
||||
|
||||
return new LoadWorkflowNodeContext(
|
||||
selectedSingleNodeWorkflow,
|
||||
selectedWorkflowNode,
|
||||
workflowAdditionalData,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getCurrentNodeParameter(parameterPath: string): NodeParameterValueType | object | undefined {
|
||||
const nodeParameters = this.additionalData.currentNodeParameters;
|
||||
|
||||
parameterPath = resolveRelativePath(this.path, parameterPath);
|
||||
|
||||
return get(nodeParameters, parameterPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { Memoized } from '@n8n/decorators';
|
||||
import { Container } from '@n8n/di';
|
||||
import get from 'lodash/get';
|
||||
import type {
|
||||
FunctionsBase,
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialsExpressionResolveValues,
|
||||
IExecuteData,
|
||||
IGetNodeParameterOptions,
|
||||
INode,
|
||||
INodeCredentialDescription,
|
||||
INodeCredentialsDetails,
|
||||
INodeExecutionData,
|
||||
INodeInputConfiguration,
|
||||
INodeOutputConfiguration,
|
||||
IRunExecutionData,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
NodeConnectionType,
|
||||
NodeFeatures,
|
||||
NodeInputConnections,
|
||||
NodeParameterValueType,
|
||||
NodeTypeAndVersion,
|
||||
Workflow,
|
||||
WorkflowExecuteMode,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
ApplicationError,
|
||||
CHAT_TRIGGER_NODE_TYPE,
|
||||
deepCopy,
|
||||
ExpressionError,
|
||||
NodeHelpers,
|
||||
NodeOperationError,
|
||||
UnexpectedError,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
HTTP_REQUEST_AS_TOOL_NODE_TYPE,
|
||||
HTTP_REQUEST_NODE_TYPE,
|
||||
HTTP_REQUEST_TOOL_NODE_TYPE,
|
||||
WAITING_TOKEN_QUERY_PARAM,
|
||||
} from '@/constants';
|
||||
import { InstanceSettings } from '@/instance-settings';
|
||||
|
||||
import { cleanupParameterData } from './utils/cleanup-parameter-data';
|
||||
import { ensureType } from './utils/ensure-type';
|
||||
import { extractValue } from './utils/extract-value';
|
||||
import { getAdditionalKeys } from './utils/get-additional-keys';
|
||||
import { validateValueAgainstSchema } from './utils/validate-value-against-schema';
|
||||
import { generateUrlSignature, prepareUrlForSigning } from '../../utils/signature-helpers';
|
||||
|
||||
export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCredentials'> {
|
||||
protected readonly instanceSettings = Container.get(InstanceSettings);
|
||||
|
||||
constructor(
|
||||
readonly workflow: Workflow,
|
||||
readonly node: INode,
|
||||
readonly additionalData: IWorkflowExecuteAdditionalData,
|
||||
readonly mode: WorkflowExecuteMode,
|
||||
readonly runExecutionData: IRunExecutionData | null = null,
|
||||
readonly runIndex = 0,
|
||||
readonly connectionInputData: INodeExecutionData[] = [],
|
||||
readonly executeData?: IExecuteData,
|
||||
) {}
|
||||
|
||||
@Memoized
|
||||
get logger() {
|
||||
return Container.get(Logger);
|
||||
}
|
||||
|
||||
getExecutionContext() {
|
||||
return this.runExecutionData?.executionData?.runtimeData;
|
||||
}
|
||||
|
||||
getExecutionId() {
|
||||
return this.additionalData.executionId!;
|
||||
}
|
||||
|
||||
getNode(): INode {
|
||||
return deepCopy(this.node);
|
||||
}
|
||||
|
||||
getWorkflow() {
|
||||
const { id, name, active } = this.workflow;
|
||||
return { id, name, active };
|
||||
}
|
||||
|
||||
getMode() {
|
||||
return this.mode;
|
||||
}
|
||||
|
||||
getWorkflowStaticData(type: string) {
|
||||
return this.workflow.getStaticData(type, this.node);
|
||||
}
|
||||
|
||||
getChildNodes(nodeName: string, options?: { includeNodeParameters?: boolean }) {
|
||||
const output: NodeTypeAndVersion[] = [];
|
||||
const nodeNames = this.workflow.getChildNodes(nodeName);
|
||||
|
||||
for (const n of nodeNames) {
|
||||
const node = this.workflow.nodes[n];
|
||||
const entry: NodeTypeAndVersion = {
|
||||
name: node.name,
|
||||
type: node.type,
|
||||
typeVersion: node.typeVersion,
|
||||
disabled: node.disabled ?? false,
|
||||
};
|
||||
|
||||
if (options?.includeNodeParameters) {
|
||||
entry.parameters = node.parameters;
|
||||
}
|
||||
|
||||
output.push(entry);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
getParentNodes(
|
||||
nodeName: string,
|
||||
options?: {
|
||||
includeNodeParameters?: boolean;
|
||||
connectionType?: NodeConnectionType;
|
||||
depth?: number;
|
||||
},
|
||||
) {
|
||||
const output: NodeTypeAndVersion[] = [];
|
||||
const nodeNames = this.workflow.getParentNodes(
|
||||
nodeName,
|
||||
options?.connectionType,
|
||||
options?.depth,
|
||||
);
|
||||
|
||||
for (const n of nodeNames) {
|
||||
const node = this.workflow.nodes[n];
|
||||
const entry: NodeTypeAndVersion = {
|
||||
name: node.name,
|
||||
type: node.type,
|
||||
typeVersion: node.typeVersion,
|
||||
disabled: node.disabled ?? false,
|
||||
};
|
||||
|
||||
if (options?.includeNodeParameters) {
|
||||
entry.parameters = node.parameters;
|
||||
}
|
||||
|
||||
output.push(entry);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the chat trigger node
|
||||
*
|
||||
* this is needed for sub-nodes where the parent nodes are not available
|
||||
*/
|
||||
getChatTrigger() {
|
||||
for (const node of Object.values(this.workflow.nodes)) {
|
||||
if (this.workflow.nodes[node.name].type === CHAT_TRIGGER_NODE_TYPE) {
|
||||
return this.workflow.nodes[node.name];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Memoized
|
||||
get workflowSettings() {
|
||||
return Object.freeze(structuredClone(this.workflow.settings));
|
||||
}
|
||||
|
||||
getWorkflowSettings() {
|
||||
return this.workflowSettings;
|
||||
}
|
||||
|
||||
@Memoized
|
||||
get nodeType() {
|
||||
const { type, typeVersion } = this.node;
|
||||
return this.workflow.nodeTypes.getByNameAndVersion(type, typeVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the feature flags for the current node version.
|
||||
* Uses declarative features from the node type description.
|
||||
* @private
|
||||
*/
|
||||
@Memoized
|
||||
private get nodeFeatures(): NodeFeatures {
|
||||
const description = this.nodeType.description;
|
||||
return NodeHelpers.getNodeFeatures(description.features, this.node.typeVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a feature is enabled for the current node version.
|
||||
* @param featureName - The name of the feature to check
|
||||
* @returns true if the feature is enabled, false otherwise
|
||||
*/
|
||||
isNodeFeatureEnabled(featureName: string): boolean {
|
||||
return this.nodeFeatures[featureName] ?? false;
|
||||
}
|
||||
|
||||
@Memoized
|
||||
get nodeInputs() {
|
||||
return NodeHelpers.getNodeInputs(this.workflow, this.node, this.nodeType.description).map(
|
||||
(input) => (typeof input === 'string' ? { type: input } : input),
|
||||
);
|
||||
}
|
||||
|
||||
getNodeInputs(): INodeInputConfiguration[] {
|
||||
return this.nodeInputs;
|
||||
}
|
||||
|
||||
@Memoized
|
||||
get nodeOutputs() {
|
||||
return NodeHelpers.getNodeOutputs(this.workflow, this.node, this.nodeType.description).map(
|
||||
(output) => (typeof output === 'string' ? { type: output } : output),
|
||||
);
|
||||
}
|
||||
|
||||
getConnectedNodes(connectionType: NodeConnectionType): INode[] {
|
||||
return this.workflow
|
||||
.getParentNodes(this.node.name, connectionType, 1)
|
||||
.map((nodeName) => this.workflow.getNode(nodeName))
|
||||
.filter((node) => !!node)
|
||||
.filter((node) => node.disabled !== true);
|
||||
}
|
||||
|
||||
getConnections(destination: INode, connectionType: NodeConnectionType): NodeInputConnections {
|
||||
return this.workflow.connectionsByDestinationNode[destination.name]?.[connectionType] ?? [];
|
||||
}
|
||||
|
||||
getNodeOutputs(): INodeOutputConfiguration[] {
|
||||
return this.nodeOutputs;
|
||||
}
|
||||
|
||||
getKnownNodeTypes() {
|
||||
return this.workflow.nodeTypes.getKnownTypes();
|
||||
}
|
||||
|
||||
getRestApiUrl() {
|
||||
return this.additionalData.restApiUrl;
|
||||
}
|
||||
|
||||
getInstanceBaseUrl() {
|
||||
return this.additionalData.instanceBaseUrl;
|
||||
}
|
||||
|
||||
getInstanceId() {
|
||||
return this.instanceSettings.instanceId;
|
||||
}
|
||||
|
||||
setSignatureValidationRequired() {
|
||||
if (this.runExecutionData) this.runExecutionData.validateSignature = true;
|
||||
}
|
||||
|
||||
getSignedResumeUrl(parameters: Record<string, string> = {}) {
|
||||
const { webhookWaitingBaseUrl, executionId } = this.additionalData;
|
||||
|
||||
if (typeof executionId !== 'string') {
|
||||
throw new UnexpectedError('Execution id is missing');
|
||||
}
|
||||
|
||||
const baseURL = new URL(`${webhookWaitingBaseUrl}/${executionId}/${this.node.id}`);
|
||||
|
||||
for (const [key, value] of Object.entries(parameters)) {
|
||||
baseURL.searchParams.set(key, value);
|
||||
}
|
||||
|
||||
const urlForSigning = prepareUrlForSigning(baseURL);
|
||||
|
||||
const token = generateUrlSignature(urlForSigning, this.instanceSettings.hmacSignatureSecret);
|
||||
|
||||
baseURL.searchParams.set(WAITING_TOKEN_QUERY_PARAM, token);
|
||||
|
||||
return baseURL.toString();
|
||||
}
|
||||
|
||||
getTimezone() {
|
||||
return this.workflow.timezone;
|
||||
}
|
||||
|
||||
getCredentialsProperties(type: string) {
|
||||
return this.additionalData.credentialsHelper.getCredentialsProperties(type);
|
||||
}
|
||||
|
||||
/** Returns the requested decrypted credentials if the node has access to them */
|
||||
protected async _getCredentials<T extends object = ICredentialDataDecryptedObject>(
|
||||
type: string,
|
||||
executeData?: IExecuteData,
|
||||
connectionInputData?: INodeExecutionData[],
|
||||
itemIndex?: number,
|
||||
): Promise<T> {
|
||||
const { workflow, node, additionalData, mode, runExecutionData, runIndex } = this;
|
||||
// Get the NodeType as it has the information if the credentials are required
|
||||
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||
|
||||
// Hardcode for now for security reasons that only a single node can access
|
||||
// all credentials
|
||||
const fullAccess = [
|
||||
HTTP_REQUEST_NODE_TYPE,
|
||||
HTTP_REQUEST_TOOL_NODE_TYPE,
|
||||
HTTP_REQUEST_AS_TOOL_NODE_TYPE,
|
||||
].includes(node.type);
|
||||
|
||||
let nodeCredentialDescription: INodeCredentialDescription | undefined;
|
||||
if (!fullAccess) {
|
||||
if (nodeType.description.credentials === undefined) {
|
||||
throw new NodeOperationError(
|
||||
node,
|
||||
`Node type "${node.type}" does not have any credentials defined`,
|
||||
{ level: 'warning' },
|
||||
);
|
||||
}
|
||||
|
||||
nodeCredentialDescription = nodeType.description.credentials.find(
|
||||
(credentialTypeDescription) => credentialTypeDescription.name === type,
|
||||
);
|
||||
if (nodeCredentialDescription === undefined) {
|
||||
throw new NodeOperationError(
|
||||
node,
|
||||
`Node type "${node.type}" does not have any credentials of type "${type}" defined`,
|
||||
{ level: 'warning' },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!NodeHelpers.displayParameter(
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
additionalData.currentNodeParameters || node.parameters,
|
||||
nodeCredentialDescription,
|
||||
node,
|
||||
nodeType.description,
|
||||
node.parameters,
|
||||
)
|
||||
) {
|
||||
// Credentials should not be displayed even if they would be defined
|
||||
throw new NodeOperationError(node, 'Credentials not found');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if node has any credentials defined
|
||||
if (!fullAccess && !node.credentials?.[type]) {
|
||||
// If none are defined check if the credentials are required or not
|
||||
|
||||
if (nodeCredentialDescription?.required === true) {
|
||||
// Credentials are required so error
|
||||
if (!node.credentials) {
|
||||
throw new NodeOperationError(node, 'Node does not have any credentials set', {
|
||||
level: 'warning',
|
||||
});
|
||||
}
|
||||
if (!node.credentials[type]) {
|
||||
throw new NodeOperationError(
|
||||
node,
|
||||
`Node does not have any credentials set for "${type}"`,
|
||||
{
|
||||
level: 'warning',
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Credentials are not required
|
||||
throw new NodeOperationError(node, 'Node does not require credentials');
|
||||
}
|
||||
}
|
||||
|
||||
if (fullAccess && !node.credentials?.[type]) {
|
||||
// Make sure that fullAccess nodes still behave like before that if they
|
||||
// request access to credentials that are currently not set it returns undefined
|
||||
throw new NodeOperationError(node, 'Credentials not found');
|
||||
}
|
||||
|
||||
let expressionResolveValues: ICredentialsExpressionResolveValues | undefined;
|
||||
if (connectionInputData && runExecutionData && runIndex !== undefined) {
|
||||
expressionResolveValues = {
|
||||
connectionInputData,
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
itemIndex: itemIndex || 0,
|
||||
node,
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
workflow,
|
||||
} as ICredentialsExpressionResolveValues;
|
||||
}
|
||||
|
||||
const nodeCredentials = node.credentials
|
||||
? node.credentials[type]
|
||||
: ({} as INodeCredentialsDetails);
|
||||
|
||||
// TODO: solve using credentials via expression
|
||||
// if (name.charAt(0) === '=') {
|
||||
// // If the credential name is an expression resolve it
|
||||
// const additionalKeys = getAdditionalKeys(additionalData, mode);
|
||||
// name = workflow.expression.getParameterValue(
|
||||
// name,
|
||||
// runExecutionData || null,
|
||||
// runIndex || 0,
|
||||
// itemIndex || 0,
|
||||
// node.name,
|
||||
// connectionInputData || [],
|
||||
// mode,
|
||||
// additionalKeys,
|
||||
// ) as string;
|
||||
// }
|
||||
|
||||
additionalData.executionContext = this.getExecutionContext();
|
||||
const decryptedDataObject = await additionalData.credentialsHelper.getDecrypted(
|
||||
additionalData,
|
||||
nodeCredentials,
|
||||
type,
|
||||
mode,
|
||||
executeData,
|
||||
false,
|
||||
expressionResolveValues,
|
||||
);
|
||||
|
||||
return decryptedDataObject as T;
|
||||
}
|
||||
|
||||
@Memoized
|
||||
protected get additionalKeys() {
|
||||
return getAdditionalKeys(this.additionalData, this.mode, this.runExecutionData);
|
||||
}
|
||||
|
||||
/** Returns the requested resolved (all expressions replaced) node parameters. */
|
||||
getNodeParameter(
|
||||
parameterName: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fallbackValue?: any,
|
||||
options?: IGetNodeParameterOptions,
|
||||
): NodeParameterValueType | object {
|
||||
const itemIndex = 0;
|
||||
return this._getNodeParameter(parameterName, itemIndex, fallbackValue, options);
|
||||
}
|
||||
|
||||
protected _getNodeParameter(
|
||||
parameterName: string,
|
||||
itemIndex: number,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fallbackValue?: any,
|
||||
options?: IGetNodeParameterOptions,
|
||||
): NodeParameterValueType | object {
|
||||
const { workflow, node, mode, runExecutionData, runIndex, connectionInputData, executeData } =
|
||||
this;
|
||||
|
||||
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const value = get(node.parameters, parameterName, fallbackValue);
|
||||
|
||||
if (value === undefined) {
|
||||
throw new ApplicationError('Could not get parameter', { extra: { parameterName } });
|
||||
}
|
||||
|
||||
if (options?.rawExpressions) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return value;
|
||||
}
|
||||
|
||||
const { additionalKeys } = this;
|
||||
|
||||
let returnData;
|
||||
|
||||
try {
|
||||
returnData = workflow.expression.getParameterValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
value,
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
itemIndex,
|
||||
node.name,
|
||||
connectionInputData,
|
||||
mode,
|
||||
additionalKeys,
|
||||
executeData,
|
||||
false,
|
||||
{},
|
||||
options?.contextNode?.name,
|
||||
);
|
||||
cleanupParameterData(returnData);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof ExpressionError &&
|
||||
node.continueOnFail &&
|
||||
node.type === 'n8n-nodes-base.set'
|
||||
) {
|
||||
// https://linear.app/n8n/issue/PAY-684
|
||||
returnData = [{ name: undefined, value: undefined }];
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
if (e.context) e.context.parameter = parameterName;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||
e.cause = value;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// This is outside the try/catch because it throws errors with proper messages
|
||||
if (options?.extractValue) {
|
||||
returnData = extractValue(returnData, parameterName, node, nodeType, itemIndex);
|
||||
}
|
||||
|
||||
// Make sure parameter value is the type specified in the ensureType option, if needed convert it
|
||||
if (options?.ensureType) {
|
||||
returnData = ensureType(options.ensureType, returnData, parameterName, {
|
||||
itemIndex,
|
||||
runIndex,
|
||||
nodeCause: node.name,
|
||||
});
|
||||
}
|
||||
|
||||
if (options?.skipValidation) return returnData;
|
||||
|
||||
// Validate parameter value if it has a schema defined(RMC) or validateType defined
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
returnData = validateValueAgainstSchema(
|
||||
node,
|
||||
nodeType,
|
||||
returnData,
|
||||
parameterName,
|
||||
runIndex,
|
||||
itemIndex,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return returnData;
|
||||
}
|
||||
|
||||
evaluateExpression(expression: string, itemIndex: number = 0) {
|
||||
return this.workflow.expression.resolveSimpleParameterValue(
|
||||
`=${expression}`,
|
||||
{},
|
||||
this.runExecutionData,
|
||||
this.runIndex,
|
||||
itemIndex,
|
||||
this.node.name,
|
||||
this.connectionInputData,
|
||||
this.mode,
|
||||
this.additionalKeys,
|
||||
this.executeData,
|
||||
);
|
||||
}
|
||||
|
||||
async prepareOutputData(outputData: INodeExecutionData[]) {
|
||||
return [outputData];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
INode,
|
||||
IPollFunctions,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
WorkflowActivateMode,
|
||||
WorkflowExecuteMode,
|
||||
} from 'n8n-workflow';
|
||||
import { ApplicationError, createDeferredPromise } from 'n8n-workflow';
|
||||
|
||||
import { NodeExecutionContext } from './node-execution-context';
|
||||
import { getBinaryHelperFunctions } from './utils/binary-helper-functions';
|
||||
import { getRequestHelperFunctions } from './utils/request-helper-functions';
|
||||
import { returnJsonArray } from './utils/return-json-array';
|
||||
import { getSchedulingFunctions } from './utils/scheduling-helper-functions';
|
||||
|
||||
const throwOnEmit = () => {
|
||||
throw new ApplicationError('Overwrite PollContext.__emit function');
|
||||
};
|
||||
|
||||
const throwOnEmitError = () => {
|
||||
throw new ApplicationError('Overwrite PollContext.__emitError function');
|
||||
};
|
||||
|
||||
export class PollContext extends NodeExecutionContext implements IPollFunctions {
|
||||
readonly helpers: IPollFunctions['helpers'];
|
||||
|
||||
constructor(
|
||||
workflow: Workflow,
|
||||
node: INode,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
mode: WorkflowExecuteMode,
|
||||
private readonly activation: WorkflowActivateMode,
|
||||
readonly __emit: IPollFunctions['__emit'] = throwOnEmit,
|
||||
readonly __emitError: IPollFunctions['__emitError'] = throwOnEmitError,
|
||||
) {
|
||||
super(workflow, node, additionalData, mode);
|
||||
|
||||
this.helpers = {
|
||||
createDeferredPromise,
|
||||
returnJsonArray,
|
||||
...getRequestHelperFunctions(workflow, node, additionalData),
|
||||
...getBinaryHelperFunctions(additionalData, workflow.id),
|
||||
...getSchedulingFunctions(workflow.id, workflow.timezone, node.id),
|
||||
};
|
||||
}
|
||||
|
||||
getActivationMode() {
|
||||
return this.activation;
|
||||
}
|
||||
|
||||
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
|
||||
return await this._getCredentials<T>(type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import get from 'lodash/get';
|
||||
import type {
|
||||
AINodeConnectionType,
|
||||
CloseFunction,
|
||||
ExecutionBaseError,
|
||||
IExecuteData,
|
||||
IGetNodeParameterOptions,
|
||||
INode,
|
||||
INodeExecutionData,
|
||||
IRunExecutionData,
|
||||
ISupplyDataFunctions,
|
||||
ITaskData,
|
||||
ITaskDataConnections,
|
||||
ITaskMetadata,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
WorkflowExecuteMode,
|
||||
NodeConnectionType,
|
||||
ISourceData,
|
||||
NodeExecutionHint,
|
||||
} from 'n8n-workflow';
|
||||
import { createDeferredPromise, jsonParse, NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import { BaseExecuteContext } from './base-execute-context';
|
||||
import {
|
||||
assertBinaryData,
|
||||
detectBinaryEncoding,
|
||||
getBinaryDataBuffer,
|
||||
getBinaryHelperFunctions,
|
||||
} from './utils/binary-helper-functions';
|
||||
import { constructExecutionMetaData } from './utils/construct-execution-metadata';
|
||||
import { copyInputItems } from './utils/copy-input-items';
|
||||
import { getDataTableHelperFunctions } from './utils/data-table-helper-functions';
|
||||
import { getDeduplicationHelperFunctions } from './utils/deduplication-helper-functions';
|
||||
import { getFileSystemHelperFunctions } from './utils/file-system-helper-functions';
|
||||
// eslint-disable-next-line import-x/no-cycle
|
||||
import { getInputConnectionData } from './utils/get-input-connection-data';
|
||||
import { normalizeItems } from './utils/normalize-items';
|
||||
import { getRequestHelperFunctions } from './utils/request-helper-functions';
|
||||
import { returnJsonArray } from './utils/return-json-array';
|
||||
import { getSSHTunnelFunctions } from './utils/ssh-tunnel-helper-functions';
|
||||
|
||||
export class SupplyDataContext extends BaseExecuteContext implements ISupplyDataFunctions {
|
||||
readonly helpers: ISupplyDataFunctions['helpers'];
|
||||
|
||||
readonly getNodeParameter: ISupplyDataFunctions['getNodeParameter'];
|
||||
|
||||
readonly parentNode?: INode;
|
||||
|
||||
readonly hints: NodeExecutionHint[] = [];
|
||||
|
||||
constructor(
|
||||
workflow: Workflow,
|
||||
node: INode,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
mode: WorkflowExecuteMode,
|
||||
runExecutionData: IRunExecutionData,
|
||||
runIndex: number,
|
||||
connectionInputData: INodeExecutionData[],
|
||||
inputData: ITaskDataConnections,
|
||||
private readonly connectionType: NodeConnectionType,
|
||||
executeData: IExecuteData,
|
||||
private readonly closeFunctions: CloseFunction[],
|
||||
abortSignal?: AbortSignal,
|
||||
parentNode?: INode,
|
||||
) {
|
||||
super(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
executeData,
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
this.parentNode = parentNode;
|
||||
|
||||
this.helpers = {
|
||||
createDeferredPromise,
|
||||
copyInputItems,
|
||||
...getRequestHelperFunctions(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
runExecutionData,
|
||||
connectionInputData,
|
||||
),
|
||||
...getSSHTunnelFunctions(),
|
||||
...getFileSystemHelperFunctions(node),
|
||||
...getBinaryHelperFunctions(additionalData, workflow.id),
|
||||
...getDataTableHelperFunctions(additionalData, workflow, node),
|
||||
...getDeduplicationHelperFunctions(workflow, node),
|
||||
assertBinaryData: (itemIndex, propertyName) =>
|
||||
assertBinaryData(inputData, node, itemIndex, propertyName, 0, workflow.settings.binaryMode),
|
||||
getBinaryDataBuffer: async (itemIndex, propertyName) =>
|
||||
await getBinaryDataBuffer(
|
||||
inputData,
|
||||
itemIndex,
|
||||
propertyName,
|
||||
0,
|
||||
workflow.settings.binaryMode,
|
||||
),
|
||||
detectBinaryEncoding: (buffer: Buffer) => detectBinaryEncoding(buffer),
|
||||
|
||||
returnJsonArray,
|
||||
normalizeItems,
|
||||
constructExecutionMetaData,
|
||||
};
|
||||
|
||||
this.getNodeParameter = ((
|
||||
parameterName: string,
|
||||
itemIndex: number,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fallbackValue?: any,
|
||||
options?: IGetNodeParameterOptions,
|
||||
) =>
|
||||
this._getNodeParameter(
|
||||
parameterName,
|
||||
itemIndex,
|
||||
fallbackValue,
|
||||
options,
|
||||
)) as ISupplyDataFunctions['getNodeParameter'];
|
||||
}
|
||||
|
||||
cloneWith(replacements: {
|
||||
runIndex: number;
|
||||
inputData: INodeExecutionData[][];
|
||||
}): SupplyDataContext {
|
||||
const context = new SupplyDataContext(
|
||||
this.workflow,
|
||||
this.node,
|
||||
this.additionalData,
|
||||
this.mode,
|
||||
this.runExecutionData,
|
||||
replacements.runIndex,
|
||||
this.connectionInputData,
|
||||
{},
|
||||
this.connectionType,
|
||||
this.executeData,
|
||||
this.closeFunctions,
|
||||
this.abortSignal,
|
||||
this.parentNode,
|
||||
);
|
||||
context.addInputData(NodeConnectionTypes.AiTool, replacements.inputData);
|
||||
return context;
|
||||
}
|
||||
|
||||
async getInputConnectionData(
|
||||
connectionType: AINodeConnectionType,
|
||||
itemIndex: number,
|
||||
): Promise<unknown> {
|
||||
return await getInputConnectionData.call(
|
||||
this,
|
||||
this.workflow,
|
||||
this.runExecutionData,
|
||||
this.runIndex,
|
||||
this.connectionInputData,
|
||||
this.inputData,
|
||||
this.additionalData,
|
||||
this.executeData,
|
||||
this.mode,
|
||||
this.closeFunctions,
|
||||
connectionType,
|
||||
itemIndex,
|
||||
this.abortSignal,
|
||||
);
|
||||
}
|
||||
|
||||
getInputData(inputIndex = 0, connectionType = this.connectionType) {
|
||||
if (!this.inputData.hasOwnProperty(connectionType)) {
|
||||
// Return empty array because else it would throw error when nothing is connected to input
|
||||
return [];
|
||||
}
|
||||
return super.getInputItems(inputIndex, connectionType) ?? [];
|
||||
}
|
||||
|
||||
getNextRunIndex(): number {
|
||||
const nodeName = this.node.name;
|
||||
return this.runExecutionData.resultData.runData[nodeName]?.length ?? 0;
|
||||
}
|
||||
|
||||
/** Returns true if the node is being executed as an AI Agent tool */
|
||||
isToolExecution(): boolean {
|
||||
return this.connectionType === NodeConnectionTypes.AiTool;
|
||||
}
|
||||
|
||||
/** @deprecated create a context object with inputData for every runIndex */
|
||||
addInputData(
|
||||
connectionType: AINodeConnectionType,
|
||||
data: INodeExecutionData[][],
|
||||
runIndex?: number,
|
||||
): { index: number } {
|
||||
const nodeName = this.node.name;
|
||||
const currentNodeRunIndex = this.getNextRunIndex();
|
||||
|
||||
this.addExecutionDataFunctions(
|
||||
'input',
|
||||
data,
|
||||
connectionType,
|
||||
nodeName,
|
||||
currentNodeRunIndex,
|
||||
undefined,
|
||||
runIndex,
|
||||
).catch((error) => {
|
||||
this.logger.warn(
|
||||
`There was a problem logging input data of node "${nodeName}": ${
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
error.message
|
||||
}`,
|
||||
);
|
||||
});
|
||||
|
||||
return { index: currentNodeRunIndex };
|
||||
}
|
||||
|
||||
/** @deprecated Switch to WorkflowExecute to store output on runExecutionData.resultData.runData */
|
||||
addOutputData(
|
||||
connectionType: AINodeConnectionType,
|
||||
currentNodeRunIndex: number,
|
||||
data: INodeExecutionData[][] | ExecutionBaseError,
|
||||
metadata?: ITaskMetadata,
|
||||
sourceNodeRunIndex?: number,
|
||||
): void {
|
||||
const nodeName = this.node.name;
|
||||
this.addExecutionDataFunctions(
|
||||
'output',
|
||||
data,
|
||||
connectionType,
|
||||
nodeName,
|
||||
currentNodeRunIndex,
|
||||
metadata,
|
||||
sourceNodeRunIndex,
|
||||
).catch((error) => {
|
||||
this.logger.warn(
|
||||
`There was a problem logging output data of node "${nodeName}": ${
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
error.message
|
||||
}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async addExecutionDataFunctions(
|
||||
type: 'input' | 'output',
|
||||
data: INodeExecutionData[][] | ExecutionBaseError,
|
||||
connectionType: AINodeConnectionType,
|
||||
sourceNodeName: string,
|
||||
currentNodeRunIndex: number,
|
||||
metadata?: ITaskMetadata,
|
||||
sourceNodeRunIndex?: number,
|
||||
): Promise<void> {
|
||||
const {
|
||||
additionalData,
|
||||
runExecutionData,
|
||||
runIndex: currentRunIndex,
|
||||
node: { name: nodeName },
|
||||
} = this;
|
||||
|
||||
let taskData: ITaskData | undefined;
|
||||
const source: ISourceData[] = this.parentNode
|
||||
? [
|
||||
{
|
||||
previousNode: this.parentNode.name,
|
||||
previousNodeRun: sourceNodeRunIndex ?? currentRunIndex,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
if (type === 'input') {
|
||||
taskData = {
|
||||
startTime: Date.now(),
|
||||
executionTime: 0,
|
||||
executionIndex: additionalData.currentNodeExecutionIndex++,
|
||||
executionStatus: 'running',
|
||||
source,
|
||||
};
|
||||
} else {
|
||||
// At the moment we expect that there is always an input sent before the output
|
||||
taskData = get(
|
||||
runExecutionData,
|
||||
['resultData', 'runData', nodeName, currentNodeRunIndex],
|
||||
undefined,
|
||||
);
|
||||
if (taskData === undefined) {
|
||||
return;
|
||||
}
|
||||
taskData.metadata = metadata;
|
||||
taskData.source = source;
|
||||
}
|
||||
taskData = taskData!;
|
||||
|
||||
if (data instanceof Error) {
|
||||
// if running node was already marked as "canceled" because execution was aborted
|
||||
// leave as "canceled" instead of showing "This operation was aborted" error
|
||||
if (
|
||||
!(type === 'output' && this.abortSignal?.aborted && taskData.executionStatus === 'canceled')
|
||||
) {
|
||||
taskData.executionStatus = 'error';
|
||||
taskData.error = data;
|
||||
}
|
||||
} else {
|
||||
if (type === 'output') {
|
||||
taskData.executionStatus = 'success';
|
||||
}
|
||||
taskData.data = {
|
||||
[connectionType]: data,
|
||||
} as ITaskDataConnections;
|
||||
}
|
||||
|
||||
if (type === 'input') {
|
||||
if (!(data instanceof Error)) {
|
||||
this.inputData[connectionType] = data;
|
||||
// TODO: remove inputOverride
|
||||
taskData.inputOverride = {
|
||||
[connectionType]: data,
|
||||
} as ITaskDataConnections;
|
||||
}
|
||||
|
||||
if (!runExecutionData.resultData.runData.hasOwnProperty(nodeName)) {
|
||||
runExecutionData.resultData.runData[nodeName] = [];
|
||||
}
|
||||
|
||||
runExecutionData.resultData.runData[nodeName][currentNodeRunIndex] = taskData;
|
||||
await additionalData.hooks?.runHook('nodeExecuteBefore', [nodeName, taskData]);
|
||||
} else {
|
||||
// Outputs
|
||||
taskData.executionTime = Date.now() - taskData.startTime;
|
||||
|
||||
// Add hints to task data if any were collected
|
||||
if (this.hints.length > 0) {
|
||||
taskData.hints = this.hints;
|
||||
}
|
||||
|
||||
await additionalData.hooks?.runHook('nodeExecuteAfter', [
|
||||
nodeName,
|
||||
taskData,
|
||||
this.runExecutionData,
|
||||
]);
|
||||
|
||||
if (get(runExecutionData, 'executionData.metadata', undefined) === undefined) {
|
||||
runExecutionData.executionData!.metadata = {};
|
||||
}
|
||||
|
||||
let sourceTaskData = runExecutionData.executionData?.metadata?.[sourceNodeName];
|
||||
|
||||
if (!sourceTaskData) {
|
||||
runExecutionData.executionData!.metadata[sourceNodeName] = [];
|
||||
sourceTaskData = runExecutionData.executionData!.metadata[sourceNodeName];
|
||||
}
|
||||
if (!sourceTaskData[currentNodeRunIndex]) {
|
||||
sourceTaskData[currentNodeRunIndex] = {
|
||||
subRun: [],
|
||||
};
|
||||
}
|
||||
|
||||
sourceTaskData[currentNodeRunIndex].subRun!.push({
|
||||
node: nodeName,
|
||||
runIndex: currentNodeRunIndex,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logNodeOutput(...args: unknown[]): void {
|
||||
if (this.mode === 'manual') {
|
||||
const parsedLogArgs = args.map((arg) =>
|
||||
typeof arg === 'string' ? jsonParse(arg, { fallbackValue: arg }) : arg,
|
||||
);
|
||||
this.sendMessageToUI(...parsedLogArgs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.CODE_ENABLE_STDOUT === 'true') {
|
||||
console.log(`[Workflow "${this.getWorkflow().id}"][Node "${this.node.name}"]`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
addExecutionHints(...hints: NodeExecutionHint[]) {
|
||||
this.hints.push(...hints);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
INode,
|
||||
ITriggerFunctions,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
WorkflowActivateMode,
|
||||
WorkflowExecuteMode,
|
||||
} from 'n8n-workflow';
|
||||
import { ApplicationError, createDeferredPromise } from 'n8n-workflow';
|
||||
|
||||
import { NodeExecutionContext } from './node-execution-context';
|
||||
import { getBinaryHelperFunctions } from './utils/binary-helper-functions';
|
||||
import { getRequestHelperFunctions } from './utils/request-helper-functions';
|
||||
import { returnJsonArray } from './utils/return-json-array';
|
||||
import { getSchedulingFunctions } from './utils/scheduling-helper-functions';
|
||||
import { getSSHTunnelFunctions } from './utils/ssh-tunnel-helper-functions';
|
||||
|
||||
const throwOnEmit = () => {
|
||||
throw new ApplicationError('Overwrite TriggerContext.emit function');
|
||||
};
|
||||
|
||||
const throwOnEmitError = () => {
|
||||
throw new ApplicationError('Overwrite TriggerContext.emitError function');
|
||||
};
|
||||
|
||||
const throwOnSaveFailedExecution = () => {
|
||||
throw new ApplicationError('Overwrite TriggerContext.saveFailedExecution function');
|
||||
};
|
||||
|
||||
export class TriggerContext extends NodeExecutionContext implements ITriggerFunctions {
|
||||
readonly helpers: ITriggerFunctions['helpers'];
|
||||
|
||||
constructor(
|
||||
workflow: Workflow,
|
||||
node: INode,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
mode: WorkflowExecuteMode,
|
||||
private readonly activation: WorkflowActivateMode,
|
||||
readonly emit: ITriggerFunctions['emit'] = throwOnEmit,
|
||||
readonly emitError: ITriggerFunctions['emitError'] = throwOnEmitError,
|
||||
readonly saveFailedExecution: ITriggerFunctions['saveFailedExecution'] = throwOnSaveFailedExecution,
|
||||
) {
|
||||
super(workflow, node, additionalData, mode);
|
||||
|
||||
this.helpers = {
|
||||
createDeferredPromise,
|
||||
returnJsonArray,
|
||||
...getSSHTunnelFunctions(),
|
||||
...getRequestHelperFunctions(workflow, node, additionalData),
|
||||
...getBinaryHelperFunctions(additionalData, workflow.id),
|
||||
...getSchedulingFunctions(workflow.id, workflow.timezone, node.id),
|
||||
};
|
||||
}
|
||||
|
||||
getActivationMode() {
|
||||
return this.activation;
|
||||
}
|
||||
|
||||
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
|
||||
return await this._getCredentials<T>(type);
|
||||
}
|
||||
}
|
||||
+1447
File diff suppressed because it is too large
Load Diff
+38
@@ -0,0 +1,38 @@
|
||||
import toPlainObject from 'lodash/toPlainObject';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { NodeParameterValue } from 'n8n-workflow';
|
||||
|
||||
import { cleanupParameterData } from '../cleanup-parameter-data';
|
||||
|
||||
describe('cleanupParameterData', () => {
|
||||
it('should stringify Luxon dates in-place', () => {
|
||||
const input = { x: 1, y: DateTime.now() as unknown as NodeParameterValue };
|
||||
expect(typeof input.y).toBe('object');
|
||||
cleanupParameterData(input);
|
||||
expect(typeof input.y).toBe('string');
|
||||
});
|
||||
|
||||
it('should stringify plain Luxon dates in-place', () => {
|
||||
const input = {
|
||||
x: 1,
|
||||
y: toPlainObject(DateTime.now()),
|
||||
};
|
||||
expect(typeof input.y).toBe('object');
|
||||
cleanupParameterData(input);
|
||||
expect(typeof input.y).toBe('string');
|
||||
});
|
||||
|
||||
it('should handle objects with nameless constructors', () => {
|
||||
const input = { x: 1, y: { constructor: {} } as NodeParameterValue };
|
||||
expect(typeof input.y).toBe('object');
|
||||
cleanupParameterData(input);
|
||||
expect(typeof input.y).toBe('object');
|
||||
});
|
||||
|
||||
it('should handle objects without a constructor', () => {
|
||||
const input = { x: 1, y: { constructor: undefined } as unknown as NodeParameterValue };
|
||||
expect(typeof input.y).toBe('object');
|
||||
cleanupParameterData(input);
|
||||
expect(typeof input.y).toBe('object');
|
||||
});
|
||||
});
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import type { INodeExecutionData, IPairedItemData, NodeExecutionWithMetadata } from 'n8n-workflow';
|
||||
|
||||
import { constructExecutionMetaData } from '../construct-execution-metadata';
|
||||
|
||||
describe('constructExecutionMetaData', () => {
|
||||
const tests: Array<{
|
||||
description: string;
|
||||
inputData: INodeExecutionData[];
|
||||
itemData: IPairedItemData | IPairedItemData[];
|
||||
expected: NodeExecutionWithMetadata[];
|
||||
}> = [
|
||||
{
|
||||
description: 'should add pairedItem to single input data',
|
||||
inputData: [{ json: { name: 'John' } }],
|
||||
itemData: { item: 0 },
|
||||
expected: [{ json: { name: 'John' }, pairedItem: { item: 0 } }],
|
||||
},
|
||||
{
|
||||
description: 'should add pairedItem to multiple input data with different properties',
|
||||
inputData: [{ json: { name: 'John' } }, { json: { name: 'Jane' } }],
|
||||
itemData: [{ item: 0 }, { item: 1 }],
|
||||
expected: [
|
||||
{ json: { name: 'John' }, pairedItem: [{ item: 0 }, { item: 1 }] },
|
||||
{ json: { name: 'Jane' }, pairedItem: [{ item: 0 }, { item: 1 }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
description: 'should handle empty input data and itemData',
|
||||
inputData: [],
|
||||
itemData: [],
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
description: 'should handle multiple pairedItem with single input data',
|
||||
inputData: [{ json: { name: 'John' } }],
|
||||
itemData: [{ item: 0 }, { item: 1 }],
|
||||
expected: [{ json: { name: 'John' }, pairedItem: [{ item: 0 }, { item: 1 }] }],
|
||||
},
|
||||
];
|
||||
test.each(tests)('$description', ({ inputData, itemData, expected }) => {
|
||||
const result = constructExecutionMetaData(inputData, { itemData });
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { copyInputItems } from '../copy-input-items';
|
||||
|
||||
describe('copyInputItems', () => {
|
||||
it('should pick only selected properties', () => {
|
||||
const output = copyInputItems(
|
||||
[
|
||||
{
|
||||
json: {
|
||||
a: 1,
|
||||
b: true,
|
||||
c: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
['a'],
|
||||
);
|
||||
expect(output).toEqual([{ a: 1 }]);
|
||||
});
|
||||
|
||||
it('should convert undefined to null', () => {
|
||||
const output = copyInputItems(
|
||||
[
|
||||
{
|
||||
json: {
|
||||
a: undefined,
|
||||
},
|
||||
},
|
||||
],
|
||||
['a'],
|
||||
);
|
||||
expect(output).toEqual([{ a: null }]);
|
||||
});
|
||||
|
||||
it('should clone objects', () => {
|
||||
const input = {
|
||||
a: { b: 5 },
|
||||
};
|
||||
const output = copyInputItems(
|
||||
[
|
||||
{
|
||||
json: input,
|
||||
},
|
||||
],
|
||||
['a'],
|
||||
);
|
||||
expect(output[0].a).toEqual(input.a);
|
||||
expect(output[0].a === input.a).toEqual(false);
|
||||
});
|
||||
});
|
||||
+559
@@ -0,0 +1,559 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { INodeType, ISupplyDataFunctions, INode } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { createNodeAsTool } from '../create-node-as-tool';
|
||||
|
||||
jest.mock('@langchain/core/tools', () => ({
|
||||
DynamicStructuredTool: jest.fn().mockImplementation((config) => ({
|
||||
name: config.name,
|
||||
description: config.description,
|
||||
schema: config.schema,
|
||||
func: config.func,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('createNodeAsTool', () => {
|
||||
const context = mock<ISupplyDataFunctions>({
|
||||
getNodeParameter: jest.fn(),
|
||||
addInputData: jest.fn(),
|
||||
addOutputData: jest.fn(),
|
||||
getNode: jest.fn(),
|
||||
});
|
||||
const handleToolInvocation = jest.fn();
|
||||
const nodeType = mock<INodeType>({
|
||||
description: {
|
||||
name: 'TestNode',
|
||||
description: 'Test node description',
|
||||
defaults: {
|
||||
name: 'Test Node',
|
||||
},
|
||||
properties: [],
|
||||
},
|
||||
});
|
||||
const node = mock<INode>({ name: 'Test_Node' });
|
||||
const options = { node, nodeType, handleToolInvocation };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(context.addInputData as jest.Mock).mockReturnValue({ index: 0 });
|
||||
(context.getNode as jest.Mock).mockReturnValue(node);
|
||||
(nodeType.execute as jest.Mock).mockResolvedValue([[{ json: { result: 'test' } }]]);
|
||||
|
||||
node.parameters = {
|
||||
param1: "={{$fromAI('param1', 'Test parameter', 'string') }}",
|
||||
param2: 'static value',
|
||||
nestedParam: {
|
||||
subParam: "={{ $fromAI('subparam', 'Nested parameter', 'string') }}",
|
||||
},
|
||||
descriptionType: 'auto',
|
||||
resource: 'testResource',
|
||||
operation: 'testOperation',
|
||||
};
|
||||
});
|
||||
|
||||
describe('Tool Creation and Basic Properties', () => {
|
||||
it('should create a DynamicStructuredTool with correct properties', () => {
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool).toBeDefined();
|
||||
expect(tool.name).toBe('Test_Node');
|
||||
expect(tool.description).toBe('testOperation testResource in Test Node');
|
||||
expect(tool.schema).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use toolDescription if provided', () => {
|
||||
node.parameters.descriptionType = 'manual';
|
||||
node.parameters.toolDescription = 'Custom tool description';
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.description).toBe('Custom tool description');
|
||||
});
|
||||
|
||||
it('should use toolDescription when descriptionType is absent', () => {
|
||||
delete node.parameters.descriptionType;
|
||||
node.parameters.toolDescription = 'Another custom tool description';
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.description).toBe('Another custom tool description');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Schema Creation and Parameter Handling', () => {
|
||||
it('should create a schema based on fromAI arguments in nodeParameters', () => {
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema).toBeDefined();
|
||||
expect(tool.schema.shape).toHaveProperty('param1');
|
||||
expect(tool.schema.shape).toHaveProperty('subparam');
|
||||
expect(tool.schema.shape).not.toHaveProperty('param2');
|
||||
});
|
||||
|
||||
it('should handle fromAI arguments correctly', () => {
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.param1).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.subparam).toBeInstanceOf(z.ZodString);
|
||||
});
|
||||
|
||||
it('should handle default values correctly', () => {
|
||||
node.parameters = {
|
||||
paramWithDefault:
|
||||
"={{ $fromAI('paramWithDefault', 'Parameter with default', 'string', 'default value') }}",
|
||||
numberWithDefault:
|
||||
"={{ $fromAI('numberWithDefault', 'Number with default', 'number', 42) }}",
|
||||
booleanWithDefault:
|
||||
"={{ $fromAI('booleanWithDefault', 'Boolean with default', 'boolean', true) }}",
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.paramWithDefault.description).toBe('Parameter with default');
|
||||
expect(tool.schema.shape.numberWithDefault.description).toBe('Number with default');
|
||||
expect(tool.schema.shape.booleanWithDefault.description).toBe('Boolean with default');
|
||||
});
|
||||
|
||||
it('should allow omitting parameters with default values', () => {
|
||||
node.parameters = {
|
||||
requiredParam: "={{ $fromAI('requiredParam', 'Required parameter', 'string') }}",
|
||||
optionalParam:
|
||||
"={{ $fromAI('optionalParam', 'Optional parameter', 'string', 'default value') }}",
|
||||
optionalNumber: "={{ $fromAI('optionalNumber', 'Optional number', 'number', 42) }}",
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
// Test that the schema accepts an object with only the required field
|
||||
// This should NOT throw an error if fields with defaults are truly optional
|
||||
const parseResult = tool.schema.safeParse({ requiredParam: 'test' });
|
||||
|
||||
expect(parseResult.success).toBe(true);
|
||||
if (parseResult.success) {
|
||||
expect(parseResult.data.requiredParam).toBe('test');
|
||||
expect(parseResult.data.optionalParam).toBe('default value');
|
||||
expect(parseResult.data.optionalNumber).toBe(42);
|
||||
}
|
||||
|
||||
// Test that all fields can still be provided
|
||||
const fullParseResult = tool.schema.safeParse({
|
||||
requiredParam: 'test',
|
||||
optionalParam: 'custom value',
|
||||
optionalNumber: 100,
|
||||
});
|
||||
|
||||
expect(fullParseResult.success).toBe(true);
|
||||
if (fullParseResult.success) {
|
||||
expect(fullParseResult.data.requiredParam).toBe('test');
|
||||
expect(fullParseResult.data.optionalParam).toBe('custom value');
|
||||
expect(fullParseResult.data.optionalNumber).toBe(100);
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow omitting parameters with default values = empty string', () => {
|
||||
node.parameters = {
|
||||
requiredParam: "={{ $fromAI('requiredParam', 'Required parameter', 'string') }}",
|
||||
optionalParam: "={{ $fromAI('optionalParam', 'Optional parameter', 'string', '') }}",
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
// Test that the schema accepts an object with only the required field
|
||||
// This should NOT throw an error if fields with defaults are truly optional
|
||||
const parseResult = tool.schema.safeParse({ requiredParam: 'test' });
|
||||
|
||||
expect(parseResult.success).toBe(true);
|
||||
if (parseResult.success) {
|
||||
expect(parseResult.data.requiredParam).toBe('test');
|
||||
expect(parseResult.data.optionalParam).toBe('');
|
||||
}
|
||||
|
||||
// Test that all fields can still be provided
|
||||
const fullParseResult = tool.schema.safeParse({
|
||||
requiredParam: 'test',
|
||||
optionalParam: 'custom value',
|
||||
});
|
||||
|
||||
expect(fullParseResult.success).toBe(true);
|
||||
if (fullParseResult.success) {
|
||||
expect(fullParseResult.data.requiredParam).toBe('test');
|
||||
expect(fullParseResult.data.optionalParam).toBe('custom value');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle nested parameters correctly', () => {
|
||||
node.parameters = {
|
||||
topLevel: "={{ $fromAI('topLevel', 'Top level parameter', 'string') }}",
|
||||
nested: {
|
||||
level1: "={{ $fromAI('level1', 'Nested level 1', 'string') }}",
|
||||
deeperNested: {
|
||||
level2: "={{ $fromAI('level2', 'Nested level 2', 'number') }}",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.topLevel).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.level1).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.level2).toBeInstanceOf(z.ZodNumber);
|
||||
});
|
||||
|
||||
it('should handle array parameters correctly', () => {
|
||||
node.parameters = {
|
||||
arrayParam: [
|
||||
"={{ $fromAI('item1', 'First item', 'string') }}",
|
||||
"={{ $fromAI('item2', 'Second item', 'number') }}",
|
||||
],
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.item1).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.item2).toBeInstanceOf(z.ZodNumber);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling and Edge Cases', () => {
|
||||
it('should handle error during node execution', async () => {
|
||||
nodeType.execute = jest.fn().mockRejectedValue(new Error('Execution failed'));
|
||||
const tool = createNodeAsTool(options).response;
|
||||
handleToolInvocation.mockReturnValue('Error during node execution: some random issue.');
|
||||
|
||||
const result = await tool.func({ param1: 'test value' });
|
||||
|
||||
expect(result).toContain('Error during node execution:');
|
||||
});
|
||||
|
||||
it('should throw an error for invalid parameter names', () => {
|
||||
node.parameters.invalidParam = "$fromAI('invalid param', 'Invalid parameter', 'string')";
|
||||
|
||||
expect(() => createNodeAsTool(options)).toThrow('Parameter key `invalid param` is invalid');
|
||||
});
|
||||
|
||||
it('should throw an error for $fromAI calls with unsupported types', () => {
|
||||
node.parameters = {
|
||||
invalidTypeParam:
|
||||
"={{ $fromAI('invalidType', 'Param with unsupported type', 'unsupportedType') }}",
|
||||
};
|
||||
|
||||
expect(() => createNodeAsTool(options)).toThrow('Invalid type: unsupportedType');
|
||||
});
|
||||
|
||||
it('should handle empty parameters and parameters with no fromAI calls', () => {
|
||||
node.parameters = {
|
||||
param1: 'static value 1',
|
||||
param2: 'static value 2',
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parameter Name and Description Handling', () => {
|
||||
it('should accept parameter names with underscores and hyphens', () => {
|
||||
node.parameters = {
|
||||
validName1:
|
||||
"={{ $fromAI('param_name-1', 'Valid name with underscore and hyphen', 'string') }}",
|
||||
validName2: "={{ $fromAI('param_name_2', 'Another valid name', 'number') }}",
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape['param_name-1']).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape['param_name-1'].description).toBe(
|
||||
'Valid name with underscore and hyphen',
|
||||
);
|
||||
|
||||
expect(tool.schema.shape.param_name_2).toBeInstanceOf(z.ZodNumber);
|
||||
expect(tool.schema.shape.param_name_2.description).toBe('Another valid name');
|
||||
});
|
||||
|
||||
it('should throw an error for parameter names with invalid special characters', () => {
|
||||
node.parameters = {
|
||||
invalidNameParam:
|
||||
"={{ $fromAI('param@name!', 'Invalid name with special characters', 'string') }}",
|
||||
};
|
||||
|
||||
expect(() => createNodeAsTool(options)).toThrow('Parameter key `param@name!` is invalid');
|
||||
});
|
||||
|
||||
it('should throw an error for empty parameter name', () => {
|
||||
node.parameters = {
|
||||
invalidNameParam: "={{ $fromAI('', 'Invalid name with special characters', 'string') }}",
|
||||
};
|
||||
|
||||
expect(() => createNodeAsTool(options)).toThrow(
|
||||
'You must specify a key when using $fromAI()',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle parameter names with exact and exceeding character limits', () => {
|
||||
const longName = 'a'.repeat(64);
|
||||
const tooLongName = 'a'.repeat(65);
|
||||
node.parameters = {
|
||||
longNameParam: `={{ $fromAI('${longName}', 'Param with 64 character name', 'string') }}`,
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape[longName]).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape[longName].description).toBe('Param with 64 character name');
|
||||
|
||||
node.parameters = {
|
||||
tooLongNameParam: `={{ $fromAI('${tooLongName}', 'Param with 65 character name', 'string') }}`,
|
||||
};
|
||||
expect(() => createNodeAsTool(options)).toThrow(
|
||||
`Parameter key \`${tooLongName}\` is invalid`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle $fromAI calls with empty description', () => {
|
||||
node.parameters = {
|
||||
emptyDescriptionParam: "={{ $fromAI('emptyDescription', '', 'number') }}",
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.emptyDescription).toBeInstanceOf(z.ZodNumber);
|
||||
expect(tool.schema.shape.emptyDescription.description).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw an error for calls with the same parameter but different descriptions', () => {
|
||||
node.parameters = {
|
||||
duplicateParam1: "={{ $fromAI('duplicate', 'First duplicate', 'string') }}",
|
||||
duplicateParam2: "={{ $fromAI('duplicate', 'Second duplicate', 'number') }}",
|
||||
};
|
||||
|
||||
expect(() => createNodeAsTool(options)).toThrow(
|
||||
"Duplicate key 'duplicate' found with different description or type",
|
||||
);
|
||||
});
|
||||
it('should throw an error for calls with the same parameter but different types', () => {
|
||||
node.parameters = {
|
||||
duplicateParam1: "={{ $fromAI('duplicate', 'First duplicate', 'string') }}",
|
||||
duplicateParam2: "={{ $fromAI('duplicate', 'First duplicate', 'number') }}",
|
||||
};
|
||||
|
||||
expect(() => createNodeAsTool(options)).toThrow(
|
||||
"Duplicate key 'duplicate' found with different description or type",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex Parsing Scenarios', () => {
|
||||
it('should correctly parse $fromAI calls with varying spaces, capitalization, and within template literals', () => {
|
||||
node.parameters = {
|
||||
varyingSpacing1: "={{$fromAI('param1','Description1','string')}}",
|
||||
varyingSpacing2: "={{ $fromAI ( 'param2' , 'Description2' , 'number' ) }}",
|
||||
varyingSpacing3: "={{ $FROMai('param3', 'Description3', 'boolean') }}",
|
||||
wrongCapitalization: "={{$fromai('param4','Description4','number')}}",
|
||||
templateLiteralParam:
|
||||
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
|
||||
"={{ `Value is: ${$fromAI('templatedParam', 'Templated param description', 'string')}` }}",
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.param1).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.param1.description).toBe('Description1');
|
||||
|
||||
expect(tool.schema.shape.param2).toBeInstanceOf(z.ZodNumber);
|
||||
expect(tool.schema.shape.param2.description).toBe('Description2');
|
||||
|
||||
expect(tool.schema.shape.param3).toBeInstanceOf(z.ZodBoolean);
|
||||
expect(tool.schema.shape.param3.description).toBe('Description3');
|
||||
|
||||
expect(tool.schema.shape.param4).toBeInstanceOf(z.ZodNumber);
|
||||
expect(tool.schema.shape.param4.description).toBe('Description4');
|
||||
|
||||
expect(tool.schema.shape.templatedParam).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.templatedParam.description).toBe('Templated param description');
|
||||
});
|
||||
|
||||
it('should correctly parse multiple $fromAI calls interleaved with regular text', () => {
|
||||
node.parameters = {
|
||||
interleavedParams:
|
||||
"={{ 'Start ' + $fromAI('param1', 'First param', 'string') + ' Middle ' + $fromAI('param2', 'Second param', 'number') + ' End' }}",
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.param1).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.param1.description).toBe('First param');
|
||||
|
||||
expect(tool.schema.shape.param2).toBeInstanceOf(z.ZodNumber);
|
||||
expect(tool.schema.shape.param2.description).toBe('Second param');
|
||||
});
|
||||
|
||||
it('should correctly parse $fromAI calls with complex JSON default values', () => {
|
||||
node.parameters = {
|
||||
complexJsonDefault:
|
||||
'={{ $fromAI(\'complexJson\', \'Param with complex JSON default\', \'json\', \'{"nested": {"key": "value"}, "array": [1, 2, 3]}\') }}',
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.complexJson._def.innerType).toBeInstanceOf(z.ZodEffects);
|
||||
expect(tool.schema.shape.complexJson.description).toBe('Param with complex JSON default');
|
||||
expect(tool.schema.shape.complexJson._def.defaultValue()).toEqual({
|
||||
nested: { key: 'value' },
|
||||
array: [1, 2, 3],
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore $fromAI calls embedded in non-string node parameters', () => {
|
||||
node.parameters = {
|
||||
numberParam: 42,
|
||||
booleanParam: false,
|
||||
objectParam: {
|
||||
innerString: "={{ $fromAI('innerParam', 'Inner param', 'string') }}",
|
||||
innerNumber: 100,
|
||||
innerObject: {
|
||||
deepParam: "={{ $fromAI('deepParam', 'Deep param', 'number') }}",
|
||||
},
|
||||
},
|
||||
arrayParam: [
|
||||
"={{ $fromAI('arrayParam1', 'First array param', 'string') }}",
|
||||
200,
|
||||
"={{ $fromAI('nestedArrayParam', 'Nested array param', 'boolean') }}",
|
||||
],
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.innerParam).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.innerParam.description).toBe('Inner param');
|
||||
|
||||
expect(tool.schema.shape.deepParam).toBeInstanceOf(z.ZodNumber);
|
||||
expect(tool.schema.shape.deepParam.description).toBe('Deep param');
|
||||
|
||||
expect(tool.schema.shape.arrayParam1).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.arrayParam1.description).toBe('First array param');
|
||||
|
||||
expect(tool.schema.shape.nestedArrayParam).toBeInstanceOf(z.ZodBoolean);
|
||||
expect(tool.schema.shape.nestedArrayParam.description).toBe('Nested array param');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Escaping and Special Characters', () => {
|
||||
it('should handle escaped single quotes in parameter names and descriptions', () => {
|
||||
node.parameters = {
|
||||
escapedQuotesParam:
|
||||
"={{ $fromAI('paramName', 'Description with \\'escaped\\' quotes', 'string') }}",
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.paramName).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.paramName.description).toBe("Description with 'escaped' quotes");
|
||||
});
|
||||
|
||||
it('should handle escaped double quotes in parameter names and descriptions', () => {
|
||||
node.parameters = {
|
||||
escapedQuotesParam:
|
||||
'={{ $fromAI("paramName", "Description with \\"escaped\\" quotes", "string") }}',
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.paramName).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.paramName.description).toBe('Description with "escaped" quotes');
|
||||
});
|
||||
|
||||
it('should handle escaped backslashes in parameter names and descriptions', () => {
|
||||
node.parameters = {
|
||||
escapedBackslashesParam:
|
||||
"={{ $fromAI('paramName', 'Description with \\\\ backslashes', 'string') }}",
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.paramName).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.paramName.description).toBe('Description with \\ backslashes');
|
||||
});
|
||||
|
||||
it('should handle mixed escaped characters in parameter names and descriptions', () => {
|
||||
node.parameters = {
|
||||
mixedEscapesParam:
|
||||
'={{ $fromAI(`paramName`, \'Description with \\\'mixed" characters\', "number") }}',
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.paramName).toBeInstanceOf(z.ZodNumber);
|
||||
expect(tool.schema.shape.paramName.description).toBe('Description with \'mixed" characters');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases and Limitations', () => {
|
||||
it('should ignore excess arguments in $fromAI calls beyond the fourth argument', () => {
|
||||
node.parameters = {
|
||||
excessArgsParam:
|
||||
"={{ $fromAI('excessArgs', 'Param with excess arguments', 'string', 'default', 'extraArg1', 'extraArg2') }}",
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.excessArgs._def.innerType).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.excessArgs.description).toBe('Param with excess arguments');
|
||||
expect(tool.schema.shape.excessArgs._def.defaultValue()).toBe('default');
|
||||
});
|
||||
|
||||
it('should correctly parse $fromAI calls with nested parentheses', () => {
|
||||
node.parameters = {
|
||||
nestedParenthesesParam:
|
||||
"={{ $fromAI('paramWithNested', 'Description with ((nested)) parentheses', 'string') }}",
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.paramWithNested).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.paramWithNested.description).toBe(
|
||||
'Description with ((nested)) parentheses',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle $fromAI calls with very long descriptions', () => {
|
||||
const longDescription = 'A'.repeat(1000);
|
||||
node.parameters = {
|
||||
longParam: `={{ $fromAI('longParam', '${longDescription}', 'string') }}`,
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.longParam).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.longParam.description).toBe(longDescription);
|
||||
});
|
||||
|
||||
it('should handle $fromAI calls with only some parameters', () => {
|
||||
node.parameters = {
|
||||
partialParam1: "={{ $fromAI('partial1') }}",
|
||||
partialParam2: "={{ $fromAI('partial2', 'Description only') }}",
|
||||
partialParam3: "={{ $fromAI('partial3', '', 'number') }}",
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.partial1).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.partial2).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.partial3).toBeInstanceOf(z.ZodNumber);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unicode and Internationalization', () => {
|
||||
it('should handle $fromAI calls with unicode characters', () => {
|
||||
node.parameters = {
|
||||
unicodeParam: "={{ $fromAI('unicodeParam', '🌈 Unicode parameter 你好', 'string') }}",
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.unicodeParam).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.unicodeParam.description).toBe('🌈 Unicode parameter 你好');
|
||||
});
|
||||
});
|
||||
});
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { Workflow, INode } from 'n8n-workflow';
|
||||
|
||||
import { getDeduplicationHelperFunctions } from '../deduplication-helper-functions';
|
||||
|
||||
describe('getDeduplicationHelperFunctions', () => {
|
||||
const workflow = mock<Workflow>();
|
||||
const node = mock<INode>();
|
||||
const helperFunctions = getDeduplicationHelperFunctions(workflow, node);
|
||||
|
||||
it('should create helper functions with correct context', () => {
|
||||
const expectedMethods = [
|
||||
'checkProcessedAndRecord',
|
||||
'checkProcessedItemsAndRecord',
|
||||
'removeProcessed',
|
||||
'clearAllProcessedItems',
|
||||
'getProcessedDataCount',
|
||||
] as const;
|
||||
|
||||
expectedMethods.forEach((method) => {
|
||||
expect(helperFunctions).toHaveProperty(method);
|
||||
expect(typeof helperFunctions[method]).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import { ExpressionError } from 'n8n-workflow';
|
||||
|
||||
import { ensureType } from '../ensure-type';
|
||||
|
||||
describe('ensureType', () => {
|
||||
it('throws error for null value', () => {
|
||||
expect(() => ensureType('string', null, 'myParam')).toThrowError(
|
||||
new ExpressionError("Parameter 'myParam' must not be null"),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws error for undefined value', () => {
|
||||
expect(() => ensureType('string', undefined, 'myParam')).toThrowError(
|
||||
new ExpressionError("Parameter 'myParam' could not be 'undefined'"),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns string value without modification', () => {
|
||||
const value = 'hello';
|
||||
const expectedValue = value;
|
||||
const result = ensureType('string', value, 'myParam');
|
||||
expect(result).toBe(expectedValue);
|
||||
});
|
||||
|
||||
it('returns number value without modification', () => {
|
||||
const value = 42;
|
||||
const expectedValue = value;
|
||||
const result = ensureType('number', value, 'myParam');
|
||||
expect(result).toBe(expectedValue);
|
||||
});
|
||||
|
||||
it('returns boolean value without modification', () => {
|
||||
const value = true;
|
||||
const expectedValue = value;
|
||||
const result = ensureType('boolean', value, 'myParam');
|
||||
expect(result).toBe(expectedValue);
|
||||
});
|
||||
|
||||
it('converts object to string if toType is string', () => {
|
||||
const value = { name: 'John' };
|
||||
const expectedValue = JSON.stringify(value);
|
||||
const result = ensureType('string', value, 'myParam');
|
||||
expect(result).toBe(expectedValue);
|
||||
});
|
||||
|
||||
it('converts string to number if toType is number', () => {
|
||||
const value = '10';
|
||||
const expectedValue = 10;
|
||||
const result = ensureType('number', value, 'myParam');
|
||||
expect(result).toBe(expectedValue);
|
||||
});
|
||||
|
||||
it('throws error for invalid conversion to number', () => {
|
||||
const value = 'invalid';
|
||||
expect(() => ensureType('number', value, 'myParam')).toThrowError(
|
||||
new ExpressionError("Parameter 'myParam' must be a number, but we got 'invalid'"),
|
||||
);
|
||||
});
|
||||
|
||||
it('parses valid JSON string to object if toType is object', () => {
|
||||
const value = '{"name": "Alice"}';
|
||||
const expectedValue = JSON.parse(value);
|
||||
const result = ensureType('object', value, 'myParam');
|
||||
expect(result).toEqual(expectedValue);
|
||||
});
|
||||
|
||||
it('throws error for invalid JSON string to object conversion', () => {
|
||||
const value = 'invalid_json';
|
||||
expect(() => ensureType('object', value, 'myParam')).toThrowError(
|
||||
new ExpressionError("Parameter 'myParam' could not be parsed"),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws error for non-array value if toType is array', () => {
|
||||
const value = { name: 'Alice' };
|
||||
expect(() => ensureType('array', value, 'myParam')).toThrowError(
|
||||
new ExpressionError("Parameter 'myParam' must be an array, but we got object"),
|
||||
);
|
||||
});
|
||||
});
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
import { createRunExecutionData, type IRunExecutionData } from 'n8n-workflow';
|
||||
|
||||
import { InvalidExecutionMetadataError } from '@/errors/invalid-execution-metadata.error';
|
||||
|
||||
import {
|
||||
setWorkflowExecutionMetadata,
|
||||
setAllWorkflowExecutionMetadata,
|
||||
KV_LIMIT,
|
||||
getWorkflowExecutionMetadata,
|
||||
getAllWorkflowExecutionMetadata,
|
||||
} from '../execution-metadata';
|
||||
|
||||
describe('Execution Metadata functions', () => {
|
||||
const createExecutionDataWithMetadata = (
|
||||
metadata: Record<string, string> = {},
|
||||
): {
|
||||
metadata: Record<string, string>;
|
||||
executionData: IRunExecutionData;
|
||||
} => {
|
||||
const executionData = createRunExecutionData({ resultData: { metadata } });
|
||||
|
||||
return {
|
||||
metadata,
|
||||
executionData,
|
||||
};
|
||||
};
|
||||
|
||||
test('setWorkflowExecutionMetadata will set a value', () => {
|
||||
const { metadata, executionData } = createExecutionDataWithMetadata();
|
||||
|
||||
setWorkflowExecutionMetadata(executionData, 'test1', 'value1');
|
||||
|
||||
expect(metadata).toEqual({
|
||||
test1: 'value1',
|
||||
});
|
||||
});
|
||||
|
||||
test('setAllWorkflowExecutionMetadata will set multiple values', () => {
|
||||
const { metadata, executionData } = createExecutionDataWithMetadata();
|
||||
|
||||
setAllWorkflowExecutionMetadata(executionData, {
|
||||
test1: 'value1',
|
||||
test2: 'value2',
|
||||
});
|
||||
|
||||
expect(metadata).toEqual({
|
||||
test1: 'value1',
|
||||
test2: 'value2',
|
||||
});
|
||||
});
|
||||
|
||||
test('setWorkflowExecutionMetadata should only convert numbers to strings', () => {
|
||||
const { metadata, executionData } = createExecutionDataWithMetadata();
|
||||
|
||||
expect(() => setWorkflowExecutionMetadata(executionData, 'test1', 1234)).not.toThrow(
|
||||
InvalidExecutionMetadataError,
|
||||
);
|
||||
|
||||
expect(metadata).toEqual({
|
||||
test1: '1234',
|
||||
});
|
||||
|
||||
expect(() => setWorkflowExecutionMetadata(executionData, 'test2', {})).toThrow(
|
||||
InvalidExecutionMetadataError,
|
||||
);
|
||||
|
||||
expect(metadata).not.toEqual({
|
||||
test1: '1234',
|
||||
test2: {},
|
||||
});
|
||||
});
|
||||
|
||||
test('setAllWorkflowExecutionMetadata should not convert values to strings and should set other values correctly', () => {
|
||||
const { metadata, executionData } = createExecutionDataWithMetadata();
|
||||
|
||||
expect(() =>
|
||||
setAllWorkflowExecutionMetadata(executionData, {
|
||||
test1: {} as unknown as string,
|
||||
test2: [] as unknown as string,
|
||||
test3: 'value3',
|
||||
test4: 'value4',
|
||||
}),
|
||||
).toThrow(InvalidExecutionMetadataError);
|
||||
|
||||
expect(metadata).toEqual({
|
||||
test3: 'value3',
|
||||
test4: 'value4',
|
||||
});
|
||||
});
|
||||
|
||||
test('setWorkflowExecutionMetadata should validate key characters', () => {
|
||||
const { metadata, executionData } = createExecutionDataWithMetadata();
|
||||
|
||||
expect(() => setWorkflowExecutionMetadata(executionData, 'te$t1$', 1234)).toThrow(
|
||||
InvalidExecutionMetadataError,
|
||||
);
|
||||
|
||||
expect(metadata).not.toEqual({
|
||||
test1: '1234',
|
||||
});
|
||||
});
|
||||
|
||||
test('setWorkflowExecutionMetadata should limit the number of metadata entries', () => {
|
||||
const { metadata, executionData } = createExecutionDataWithMetadata();
|
||||
|
||||
const expected: Record<string, string> = {};
|
||||
for (let i = 0; i < KV_LIMIT; i++) {
|
||||
expected[`test${i + 1}`] = `value${i + 1}`;
|
||||
}
|
||||
|
||||
for (let i = 0; i < KV_LIMIT + 10; i++) {
|
||||
setWorkflowExecutionMetadata(executionData, `test${i + 1}`, `value${i + 1}`);
|
||||
}
|
||||
|
||||
expect(metadata).toEqual(expected);
|
||||
});
|
||||
|
||||
test('getWorkflowExecutionMetadata should return a single value for an existing key', () => {
|
||||
const { executionData } = createExecutionDataWithMetadata({ test1: 'value1' });
|
||||
|
||||
expect(getWorkflowExecutionMetadata(executionData, 'test1')).toBe('value1');
|
||||
});
|
||||
|
||||
test('getWorkflowExecutionMetadata should return undefined for an unset key', () => {
|
||||
const { executionData } = createExecutionDataWithMetadata({ test1: 'value1' });
|
||||
|
||||
expect(getWorkflowExecutionMetadata(executionData, 'test2')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getAllWorkflowExecutionMetadata should return all metadata', () => {
|
||||
const { metadata, executionData } = createExecutionDataWithMetadata({
|
||||
test1: 'value1',
|
||||
test2: 'value2',
|
||||
});
|
||||
|
||||
expect(getAllWorkflowExecutionMetadata(executionData)).toEqual(metadata);
|
||||
});
|
||||
|
||||
test('getAllWorkflowExecutionMetadata should not an object that modifies internal state', () => {
|
||||
const { metadata, executionData } = createExecutionDataWithMetadata({
|
||||
test1: 'value1',
|
||||
test2: 'value2',
|
||||
});
|
||||
|
||||
getAllWorkflowExecutionMetadata(executionData).test1 = 'changed';
|
||||
|
||||
expect(metadata.test1).not.toBe('changed');
|
||||
expect(metadata.test1).toBe('value1');
|
||||
});
|
||||
|
||||
test('setWorkflowExecutionMetadata should truncate long keys', () => {
|
||||
const { metadata, executionData } = createExecutionDataWithMetadata();
|
||||
|
||||
setWorkflowExecutionMetadata(
|
||||
executionData,
|
||||
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab',
|
||||
'value1',
|
||||
);
|
||||
|
||||
expect(metadata).toEqual({
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: 'value1',
|
||||
});
|
||||
});
|
||||
|
||||
test('setWorkflowExecutionMetadata should truncate long values', () => {
|
||||
const { metadata, executionData } = createExecutionDataWithMetadata();
|
||||
|
||||
const longValue = 'a'.repeat(513);
|
||||
|
||||
setWorkflowExecutionMetadata(executionData, 'test1', longValue);
|
||||
|
||||
expect(metadata).toEqual({
|
||||
test1: longValue.slice(0, 512),
|
||||
});
|
||||
});
|
||||
});
|
||||
+536
@@ -0,0 +1,536 @@
|
||||
import { SecurityConfig } from '@n8n/config';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import { constants } from 'node:fs';
|
||||
import {
|
||||
access as fsAccess,
|
||||
realpath as fsRealpath,
|
||||
stat as fsStat,
|
||||
open as fsOpen,
|
||||
} from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
BINARY_DATA_STORAGE_PATH,
|
||||
BLOCK_FILE_ACCESS_TO_N8N_FILES,
|
||||
CONFIG_FILES,
|
||||
CUSTOM_EXTENSION_ENV,
|
||||
UM_EMAIL_TEMPLATES_INVITE,
|
||||
UM_EMAIL_TEMPLATES_PWRESET,
|
||||
} from '@/constants';
|
||||
import { InstanceSettings } from '@/instance-settings';
|
||||
|
||||
import { getFileSystemHelperFunctions } from '../file-system-helper-functions';
|
||||
|
||||
jest.mock('node:fs');
|
||||
jest.mock('node:fs/promises');
|
||||
|
||||
const originalProcessEnv = { ...process.env };
|
||||
|
||||
let instanceSettings: InstanceSettings;
|
||||
let securityConfig: SecurityConfig;
|
||||
let originalBlockedFilePatterns: string;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalProcessEnv };
|
||||
|
||||
const error = new Error('ENOENT');
|
||||
// @ts-expect-error undefined property
|
||||
error.code = 'ENOENT';
|
||||
(fsAccess as jest.Mock).mockRejectedValue(error);
|
||||
(fsRealpath as jest.Mock).mockImplementation((path: string) => path);
|
||||
|
||||
instanceSettings = Container.get(InstanceSettings);
|
||||
securityConfig = Container.get(SecurityConfig);
|
||||
securityConfig.restrictFileAccessTo = '';
|
||||
originalBlockedFilePatterns = securityConfig.blockFilePatterns;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
securityConfig.blockFilePatterns = originalBlockedFilePatterns;
|
||||
});
|
||||
|
||||
describe('isFilePathBlocked', () => {
|
||||
const node = { type: 'TestNode' } as INode;
|
||||
const { isFilePathBlocked, resolvePath } = getFileSystemHelperFunctions(node);
|
||||
beforeEach(() => {
|
||||
process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] = 'true';
|
||||
});
|
||||
|
||||
it('should return true for static cache dir', async () => {
|
||||
const filePath = instanceSettings.staticCacheDir;
|
||||
expect(isFilePathBlocked(await resolvePath(filePath))).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for restricted paths', async () => {
|
||||
const restrictedPath = instanceSettings.n8nFolder;
|
||||
expect(isFilePathBlocked(await resolvePath(restrictedPath))).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty allowed paths', async () => {
|
||||
securityConfig.restrictFileAccessTo = '';
|
||||
const result = isFilePathBlocked(await resolvePath('/some/random/path'));
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle multiple allowed paths', async () => {
|
||||
securityConfig.restrictFileAccessTo = '/path1;/path2;/path3';
|
||||
const allowedPath = '/path2/somefile';
|
||||
expect(isFilePathBlocked(await resolvePath(allowedPath))).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty strings in allowed paths', async () => {
|
||||
securityConfig.restrictFileAccessTo = '/path1;;/path2';
|
||||
const allowedPath = '/path2/somefile';
|
||||
expect(isFilePathBlocked(await resolvePath(allowedPath))).toBe(false);
|
||||
});
|
||||
|
||||
it('should trim whitespace in allowed paths', async () => {
|
||||
securityConfig.restrictFileAccessTo = ' /path1 ; /path2 ; /path3 ';
|
||||
const allowedPath = '/path2/somefile';
|
||||
expect(isFilePathBlocked(await resolvePath(allowedPath))).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when BLOCK_FILE_ACCESS_TO_N8N_FILES is false', async () => {
|
||||
process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] = 'false';
|
||||
const restrictedPath = instanceSettings.n8nFolder;
|
||||
expect(isFilePathBlocked(await resolvePath(restrictedPath))).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when path is in allowed paths but still restricted', async () => {
|
||||
securityConfig.restrictFileAccessTo = '/some/allowed/path';
|
||||
const restrictedPath = instanceSettings.n8nFolder;
|
||||
expect(isFilePathBlocked(await resolvePath(restrictedPath))).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when path is in allowed paths', async () => {
|
||||
const allowedPath = '/some/allowed/path';
|
||||
securityConfig.restrictFileAccessTo = allowedPath;
|
||||
expect(isFilePathBlocked(await resolvePath(allowedPath))).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when file paths in CONFIG_FILES', async () => {
|
||||
process.env[CONFIG_FILES] = '/path/to/config1,/path/to/config2';
|
||||
const configPath = '/path/to/config1/somefile';
|
||||
expect(isFilePathBlocked(await resolvePath(configPath))).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when file paths in CUSTOM_EXTENSION_ENV', async () => {
|
||||
process.env[CUSTOM_EXTENSION_ENV] = '/path/to/extensions1;/path/to/extensions2';
|
||||
const extensionPath = '/path/to/extensions1/somefile';
|
||||
expect(isFilePathBlocked(await resolvePath(extensionPath))).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when file paths in BINARY_DATA_STORAGE_PATH', async () => {
|
||||
process.env[BINARY_DATA_STORAGE_PATH] = '/path/to/binary/storage';
|
||||
const binaryPath = '/path/to/binary/storage/somefile';
|
||||
expect(isFilePathBlocked(await resolvePath(binaryPath))).toBe(true);
|
||||
});
|
||||
|
||||
it('should block file paths in email template paths', async () => {
|
||||
process.env[UM_EMAIL_TEMPLATES_INVITE] = '/path/to/invite/templates';
|
||||
process.env[UM_EMAIL_TEMPLATES_PWRESET] = '/path/to/pwreset/templates';
|
||||
|
||||
const invitePath = '/path/to/invite/templates/invite.html';
|
||||
const pwResetPath = '/path/to/pwreset/templates/reset.html';
|
||||
|
||||
expect(isFilePathBlocked(await resolvePath(invitePath))).toBe(true);
|
||||
expect(isFilePathBlocked(await resolvePath(pwResetPath))).toBe(true);
|
||||
});
|
||||
|
||||
it('should block access to n8n files if restrict and block are set', async () => {
|
||||
const homeVarName = process.platform === 'win32' ? 'USERPROFILE' : 'HOME';
|
||||
const userHome = process.env.N8N_USER_FOLDER ?? process.env[homeVarName] ?? process.cwd();
|
||||
|
||||
securityConfig.restrictFileAccessTo = userHome;
|
||||
process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] = 'true';
|
||||
const restrictedPath = instanceSettings.n8nFolder;
|
||||
expect(isFilePathBlocked(await resolvePath(restrictedPath))).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow access to parent folder if restrict and block are set', async () => {
|
||||
const homeVarName = process.platform === 'win32' ? 'USERPROFILE' : 'HOME';
|
||||
const userHome = process.env.N8N_USER_FOLDER ?? process.env[homeVarName] ?? process.cwd();
|
||||
|
||||
securityConfig.restrictFileAccessTo = userHome;
|
||||
process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] = 'true';
|
||||
const restrictedPath = await resolvePath(join(userHome, 'somefile.txt'));
|
||||
expect(isFilePathBlocked(restrictedPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not block similar paths', async () => {
|
||||
const homeVarName = process.platform === 'win32' ? 'USERPROFILE' : 'HOME';
|
||||
const userHome = process.env.N8N_USER_FOLDER ?? process.env[homeVarName] ?? process.cwd();
|
||||
|
||||
securityConfig.restrictFileAccessTo = userHome;
|
||||
process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] = 'true';
|
||||
const restrictedPath = await resolvePath(join(userHome, '.n8n_x'));
|
||||
expect(isFilePathBlocked(restrictedPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for a symlink in a allowed path to a restricted path', async () => {
|
||||
securityConfig.restrictFileAccessTo = '/path1';
|
||||
const allowedPath = '/path1/symlink';
|
||||
const actualPath = '/path2/realfile';
|
||||
(fsRealpath as jest.Mock).mockImplementation((path: string) =>
|
||||
path === allowedPath ? actualPath : path,
|
||||
);
|
||||
expect(isFilePathBlocked(await resolvePath(allowedPath))).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle non-existent file when it is allowed', async () => {
|
||||
const filePath = '/non/existent/file';
|
||||
const error = new Error('ENOENT');
|
||||
// @ts-expect-error undefined property
|
||||
error.code = 'ENOENT';
|
||||
(fsRealpath as jest.Mock).mockRejectedValueOnce(error);
|
||||
expect(isFilePathBlocked(await resolvePath(filePath))).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle non-existent file when it is not allowed', async () => {
|
||||
const filePath = '/non/existent/file';
|
||||
const allowedPath = '/some/allowed/path';
|
||||
securityConfig.restrictFileAccessTo = allowedPath;
|
||||
const error = new Error('ENOENT');
|
||||
// @ts-expect-error undefined property
|
||||
error.code = 'ENOENT';
|
||||
(fsRealpath as jest.Mock).mockRejectedValueOnce(error);
|
||||
expect(isFilePathBlocked(await resolvePath(filePath))).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['.git', '/.git', '/tmp/.git', '/tmp/.git/config'])(
|
||||
'should per default block access to %s',
|
||||
async (path) => {
|
||||
expect(isFilePathBlocked(await resolvePath(path))).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('should allow access when pattern matching is disabled', async () => {
|
||||
securityConfig.blockFilePatterns = '';
|
||||
expect(isFilePathBlocked(await resolvePath('/tmp/.git'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should block all access when using invalid pattern', async () => {
|
||||
securityConfig.blockFilePatterns = '(';
|
||||
expect(isFilePathBlocked(await resolvePath('/tmp/xo'))).toBe(true);
|
||||
});
|
||||
|
||||
describe('cross-platform path handling', () => {
|
||||
beforeEach(() => {
|
||||
// Use default .git blocking pattern
|
||||
securityConfig.blockFilePatterns = '^(.*\\/)*\\.git(\\/.*)*$';
|
||||
});
|
||||
|
||||
it('should handle Windows-style paths for .git directory', async () => {
|
||||
const windowsGitPath = 'C:\\repo\\.git\\config';
|
||||
expect(isFilePathBlocked(await resolvePath(windowsGitPath))).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle nested Windows paths for .git subdirectories', async () => {
|
||||
const windowsGitPath = 'C:\\Users\\user\\project\\.git\\hooks\\pre-commit';
|
||||
expect(isFilePathBlocked(await resolvePath(windowsGitPath))).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle mixed path separators', async () => {
|
||||
const mixedPath = 'C:\\repo/.git\\objects\\abc123';
|
||||
expect(isFilePathBlocked(await resolvePath(mixedPath))).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow legitimate files with git-related extensions', async () => {
|
||||
const legitimatePath = 'C:\\repo\\somefile.txt';
|
||||
expect(isFilePathBlocked(await resolvePath(legitimatePath))).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle Windows absolute paths to .git', async () => {
|
||||
const windowsRootGit = 'C:\\.git';
|
||||
expect(isFilePathBlocked(await resolvePath(windowsRootGit))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when multiple file patterns are configured', () => {
|
||||
beforeEach(() => {
|
||||
securityConfig.blockFilePatterns = 'hello; \\/there$; ^where';
|
||||
});
|
||||
|
||||
it.each([
|
||||
'hello',
|
||||
'xhellox',
|
||||
'subpath/hello/',
|
||||
'/there',
|
||||
'/subpath/there',
|
||||
'where',
|
||||
'where-is/it',
|
||||
])('should block access to %s', async (path) => {
|
||||
expect(isFilePathBlocked(await resolvePath(path))).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['/there/is', '/where'])('should not block access to %s', async (path) => {
|
||||
expect(isFilePathBlocked(await resolvePath(path))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileSystemHelperFunctions', () => {
|
||||
const node = { type: 'TestNode' } as INode;
|
||||
const helperFunctions = getFileSystemHelperFunctions(node);
|
||||
|
||||
it('should create helper functions with correct context', () => {
|
||||
const expectedMethods = ['createReadStream', 'getStoragePath', 'writeContentToFile'] as const;
|
||||
|
||||
expectedMethods.forEach((method) => {
|
||||
expect(helperFunctions).toHaveProperty(method);
|
||||
expect(typeof helperFunctions[method]).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStoragePath', () => {
|
||||
it('returns correct path', () => {
|
||||
const expectedPath = join(instanceSettings.n8nFolder, `storage/${node.type}`);
|
||||
expect(helperFunctions.getStoragePath()).toBe(expectedPath);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createReadStream', () => {
|
||||
const mockFileStats = { dev: 123, ino: 456 };
|
||||
|
||||
it('should throw error for non-existent file', async () => {
|
||||
const filePath = '/non/existent/file';
|
||||
const error = new Error('ENOENT');
|
||||
// @ts-expect-error undefined property
|
||||
error.code = 'ENOENT';
|
||||
(fsStat as jest.Mock).mockResolvedValueOnce(mockFileStats);
|
||||
(fsAccess as jest.Mock).mockRejectedValueOnce(error);
|
||||
|
||||
await expect(
|
||||
helperFunctions.createReadStream(await helperFunctions.resolvePath(filePath)),
|
||||
).rejects.toThrow(`The file "${filePath}" could not be accessed.`);
|
||||
});
|
||||
|
||||
it('should throw when file access is blocked', async () => {
|
||||
securityConfig.restrictFileAccessTo = '/allowed/path';
|
||||
(fsStat as jest.Mock).mockResolvedValueOnce(mockFileStats);
|
||||
await expect(
|
||||
helperFunctions.createReadStream(await helperFunctions.resolvePath('/blocked/path')),
|
||||
).rejects.toThrow('Access to the file is not allowed');
|
||||
});
|
||||
|
||||
it('should not reveal if file exists if it is within restricted path', async () => {
|
||||
securityConfig.restrictFileAccessTo = '/allowed/path';
|
||||
(fsStat as jest.Mock).mockResolvedValueOnce(mockFileStats);
|
||||
|
||||
await expect(
|
||||
helperFunctions.createReadStream(await helperFunctions.resolvePath('/blocked/path')),
|
||||
).rejects.toThrow('Access to the file is not allowed');
|
||||
});
|
||||
|
||||
it('should create a read stream if file access is permitted', async () => {
|
||||
const filePath = '/allowed/path';
|
||||
const mockStream = { pipe: jest.fn() };
|
||||
const mockFileHandle = {
|
||||
stat: jest.fn().mockResolvedValue(mockFileStats),
|
||||
createReadStream: jest.fn().mockReturnValue(mockStream),
|
||||
};
|
||||
|
||||
(fsStat as jest.Mock).mockResolvedValueOnce(mockFileStats);
|
||||
(fsAccess as jest.Mock).mockResolvedValueOnce(undefined);
|
||||
(fsOpen as jest.Mock).mockResolvedValueOnce(mockFileHandle);
|
||||
|
||||
const result = await helperFunctions.createReadStream(
|
||||
await helperFunctions.resolvePath(filePath),
|
||||
);
|
||||
|
||||
expect(result).toBe(mockStream);
|
||||
expect(fsOpen).toHaveBeenCalledWith(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
});
|
||||
|
||||
it('should reject symlinks with ELOOP error', async () => {
|
||||
const filePath = '/allowed/path/file';
|
||||
const eloopError = new Error('ELOOP: too many symbolic links encountered');
|
||||
// @ts-expect-error undefined property
|
||||
eloopError.code = 'ELOOP';
|
||||
|
||||
(fsStat as jest.Mock).mockResolvedValueOnce(mockFileStats);
|
||||
(fsAccess as jest.Mock).mockResolvedValueOnce(undefined);
|
||||
(fsOpen as jest.Mock).mockRejectedValueOnce(eloopError);
|
||||
|
||||
await expect(
|
||||
helperFunctions.createReadStream(await helperFunctions.resolvePath(filePath)),
|
||||
).rejects.toThrow('Symlinks are not allowed.');
|
||||
});
|
||||
|
||||
it('should reject when file identity changes', async () => {
|
||||
const filePath = '/allowed/path/file';
|
||||
const differentStats = { dev: 999, ino: 888 };
|
||||
const mockFileHandle = {
|
||||
stat: jest.fn().mockResolvedValue(differentStats),
|
||||
createReadStream: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
(fsStat as jest.Mock).mockResolvedValueOnce(mockFileStats);
|
||||
(fsAccess as jest.Mock).mockResolvedValueOnce(undefined);
|
||||
(fsOpen as jest.Mock).mockResolvedValueOnce(mockFileHandle);
|
||||
|
||||
await expect(
|
||||
helperFunctions.createReadStream(await helperFunctions.resolvePath(filePath)),
|
||||
).rejects.toThrow('The file has changed and cannot be accessed.');
|
||||
expect(mockFileHandle.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeContentToFile', () => {
|
||||
const mockFileStats = { dev: 123, ino: 456, isFile: () => true };
|
||||
|
||||
it('should throw error for blocked file path', async () => {
|
||||
process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] = 'true';
|
||||
|
||||
await expect(
|
||||
helperFunctions.writeContentToFile(
|
||||
await helperFunctions.resolvePath(instanceSettings.n8nFolder + '/test.txt'),
|
||||
'content',
|
||||
constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC,
|
||||
),
|
||||
).rejects.toThrow('not writable');
|
||||
});
|
||||
|
||||
it('should reject symlinks with ELOOP error', async () => {
|
||||
const filePath = '/allowed/path/file';
|
||||
const eloopError = new Error('ELOOP: too many symbolic links encountered');
|
||||
// @ts-expect-error undefined property
|
||||
eloopError.code = 'ELOOP';
|
||||
|
||||
(fsStat as jest.Mock).mockResolvedValueOnce(mockFileStats);
|
||||
(fsOpen as jest.Mock).mockRejectedValueOnce(eloopError);
|
||||
|
||||
await expect(
|
||||
helperFunctions.writeContentToFile(
|
||||
await helperFunctions.resolvePath(filePath),
|
||||
'test content',
|
||||
),
|
||||
).rejects.toThrow('Symlinks are not allowed.');
|
||||
});
|
||||
|
||||
it('should reject when file identity changes', async () => {
|
||||
const filePath = '/allowed/path/file';
|
||||
const differentStats = { dev: 999, ino: 888, isFile: () => true };
|
||||
const mockFileHandle = {
|
||||
stat: jest.fn().mockResolvedValue(differentStats),
|
||||
truncate: jest.fn(),
|
||||
write: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
(fsStat as jest.Mock).mockResolvedValueOnce(mockFileStats);
|
||||
(fsOpen as jest.Mock).mockResolvedValueOnce(mockFileHandle);
|
||||
|
||||
await expect(
|
||||
helperFunctions.writeContentToFile(
|
||||
await helperFunctions.resolvePath(filePath),
|
||||
'test content',
|
||||
),
|
||||
).rejects.toThrow('The file has changed and cannot be written.');
|
||||
|
||||
expect(mockFileHandle.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should successfully write to file when identity matches', async () => {
|
||||
const filePath = '/allowed/path/file';
|
||||
const mockFileHandle = {
|
||||
stat: jest.fn().mockResolvedValue(mockFileStats),
|
||||
truncate: jest.fn().mockResolvedValue(undefined),
|
||||
writeFile: jest.fn().mockResolvedValue(undefined),
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
(fsStat as jest.Mock).mockResolvedValueOnce(mockFileStats);
|
||||
(fsOpen as jest.Mock).mockResolvedValueOnce(mockFileHandle);
|
||||
|
||||
await helperFunctions.writeContentToFile(
|
||||
await helperFunctions.resolvePath(filePath),
|
||||
'test content',
|
||||
);
|
||||
|
||||
expect(fsOpen).toHaveBeenCalledWith(
|
||||
filePath,
|
||||
constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW,
|
||||
);
|
||||
expect(mockFileHandle.truncate).toHaveBeenCalledWith(0);
|
||||
expect(mockFileHandle.writeFile).toHaveBeenCalledWith('test content', { encoding: 'binary' });
|
||||
expect(mockFileHandle.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should successfully create and write to new file', async () => {
|
||||
const filePath = '/allowed/path/newfile';
|
||||
const enoentError = new Error('ENOENT');
|
||||
// @ts-expect-error undefined property
|
||||
enoentError.code = 'ENOENT';
|
||||
|
||||
const newFileStats = { dev: 123, ino: 789, isFile: () => true };
|
||||
const mockFileHandle = {
|
||||
stat: jest.fn().mockResolvedValue(newFileStats),
|
||||
truncate: jest.fn().mockResolvedValue(undefined),
|
||||
writeFile: jest.fn().mockResolvedValue(undefined),
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
(fsStat as jest.Mock).mockRejectedValueOnce(enoentError);
|
||||
(fsStat as jest.Mock).mockResolvedValueOnce(newFileStats);
|
||||
(fsOpen as jest.Mock).mockResolvedValueOnce(mockFileHandle);
|
||||
|
||||
await helperFunctions.writeContentToFile(
|
||||
await helperFunctions.resolvePath(filePath),
|
||||
'new content',
|
||||
);
|
||||
|
||||
expect(mockFileHandle.truncate).toHaveBeenCalledWith(0);
|
||||
expect(mockFileHandle.writeFile).toHaveBeenCalledWith('new content', { encoding: 'binary' });
|
||||
expect(mockFileHandle.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should strip O_TRUNC flag from user flags', async () => {
|
||||
const filePath = '/allowed/path/file';
|
||||
const mockFileHandle = {
|
||||
stat: jest.fn().mockResolvedValue(mockFileStats),
|
||||
truncate: jest.fn().mockResolvedValue(undefined),
|
||||
writeFile: jest.fn().mockResolvedValue(undefined),
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
(fsStat as jest.Mock).mockResolvedValueOnce(mockFileStats);
|
||||
(fsOpen as jest.Mock).mockResolvedValueOnce(mockFileHandle);
|
||||
|
||||
await helperFunctions.writeContentToFile(
|
||||
await helperFunctions.resolvePath(filePath),
|
||||
'test content',
|
||||
constants.O_TRUNC, // This should be stripped
|
||||
);
|
||||
|
||||
// Verify O_TRUNC was not passed to fsOpen
|
||||
expect(fsOpen).toHaveBeenCalledWith(
|
||||
filePath,
|
||||
constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW,
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject non-regular files (directories)', async () => {
|
||||
const filePath = '/allowed/path/directory';
|
||||
const dirStats = { dev: 123, ino: 456, isFile: () => false };
|
||||
const mockFileHandle = {
|
||||
stat: jest.fn().mockResolvedValue(dirStats),
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
(fsStat as jest.Mock).mockResolvedValueOnce(dirStats);
|
||||
(fsOpen as jest.Mock).mockResolvedValueOnce(mockFileHandle);
|
||||
|
||||
await expect(
|
||||
helperFunctions.writeContentToFile(
|
||||
await helperFunctions.resolvePath(filePath),
|
||||
'test content',
|
||||
),
|
||||
).rejects.toThrow('The path is not a regular file.');
|
||||
|
||||
expect(mockFileHandle.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { LoggerProxy } from 'n8n-workflow';
|
||||
import type { IDataObject, IRunExecutionData, IWorkflowExecuteAdditionalData } from 'n8n-workflow';
|
||||
|
||||
import { PLACEHOLDER_EMPTY_EXECUTION_ID } from '@/constants';
|
||||
import type { ExternalSecretsProxy } from '@/execution-engine/external-secrets-proxy';
|
||||
|
||||
import { getAdditionalKeys } from '../get-additional-keys';
|
||||
|
||||
describe('getAdditionalKeys', () => {
|
||||
const externalSecretsProxy = mock<ExternalSecretsProxy>();
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||
executionId: '123',
|
||||
webhookWaitingBaseUrl: 'https://webhook.test',
|
||||
formWaitingBaseUrl: 'https://form.test',
|
||||
variables: { testVar: 'value' },
|
||||
externalSecretsProxy,
|
||||
});
|
||||
|
||||
const runExecutionData = mock<IRunExecutionData>({
|
||||
resultData: {
|
||||
runData: {},
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
LoggerProxy.init(mock());
|
||||
externalSecretsProxy.hasProvider.mockReturnValue(true);
|
||||
externalSecretsProxy.hasSecret.mockReturnValue(true);
|
||||
externalSecretsProxy.getSecret.mockReturnValue('secret-value');
|
||||
externalSecretsProxy.listSecrets.mockReturnValue(['secret1']);
|
||||
externalSecretsProxy.listProviders.mockReturnValue(['provider1']);
|
||||
});
|
||||
|
||||
it('should use placeholder execution ID when none provided', () => {
|
||||
const noIdData = { ...additionalData, executionId: undefined };
|
||||
const result = getAdditionalKeys(noIdData, 'manual', null);
|
||||
|
||||
expect(result.$execution?.id).toBe(PLACEHOLDER_EMPTY_EXECUTION_ID);
|
||||
});
|
||||
|
||||
it('should return production mode when not manual', () => {
|
||||
const result = getAdditionalKeys(additionalData, 'internal', null);
|
||||
|
||||
expect(result.$execution?.mode).toBe('production');
|
||||
});
|
||||
|
||||
it('should include customData methods when runExecutionData is provided', () => {
|
||||
const result = getAdditionalKeys(additionalData, 'manual', runExecutionData);
|
||||
|
||||
expect(result.$execution?.customData).toBeDefined();
|
||||
expect(typeof result.$execution?.customData?.set).toBe('function');
|
||||
expect(typeof result.$execution?.customData?.setAll).toBe('function');
|
||||
expect(typeof result.$execution?.customData?.get).toBe('function');
|
||||
expect(typeof result.$execution?.customData?.getAll).toBe('function');
|
||||
});
|
||||
|
||||
it('should handle customData operations correctly', () => {
|
||||
const result = getAdditionalKeys(additionalData, 'manual', runExecutionData);
|
||||
const customData = result.$execution?.customData;
|
||||
|
||||
customData?.set('testKey', 'testValue');
|
||||
expect(customData?.get('testKey')).toBe('testValue');
|
||||
|
||||
customData?.setAll({ key1: 'value1', key2: 'value2' });
|
||||
const allData = customData?.getAll();
|
||||
expect(allData).toEqual({
|
||||
testKey: 'testValue',
|
||||
key1: 'value1',
|
||||
key2: 'value2',
|
||||
});
|
||||
});
|
||||
|
||||
it('should include secrets when enabled', () => {
|
||||
const result = getAdditionalKeys(additionalData, 'manual', null, { secretsEnabled: true });
|
||||
|
||||
expect(result.$secrets).toBeDefined();
|
||||
expect((result.$secrets?.provider1 as IDataObject).secret1).toEqual('secret-value');
|
||||
});
|
||||
|
||||
it('should not include secrets when disabled', () => {
|
||||
const result = getAdditionalKeys(additionalData, 'manual', null, { secretsEnabled: false });
|
||||
|
||||
expect(result.$secrets).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw errors in manual mode', () => {
|
||||
const result = getAdditionalKeys(additionalData, 'manual', runExecutionData);
|
||||
|
||||
expect(() => {
|
||||
result.$execution?.customData?.set('invalid*key', 'value');
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should correctly set resume URLs', () => {
|
||||
const result = getAdditionalKeys(additionalData, 'manual', null);
|
||||
|
||||
expect(result.$execution?.resumeUrl).toBe('https://webhook.test/123');
|
||||
expect(result.$execution?.resumeFormUrl).toBe('https://form.test/123');
|
||||
expect(result.$resumeWebhookUrl).toBe('https://webhook.test/123'); // Test deprecated property
|
||||
});
|
||||
|
||||
it('should return test mode when manual', () => {
|
||||
const result = getAdditionalKeys(additionalData, 'manual', null);
|
||||
|
||||
expect(result.$execution?.mode).toBe('test');
|
||||
});
|
||||
|
||||
it('should return variables from additionalData', () => {
|
||||
const result = getAdditionalKeys(additionalData, 'manual', null);
|
||||
expect(result.$vars?.testVar).toEqual('value');
|
||||
});
|
||||
|
||||
it('should handle errors in non-manual mode without throwing', () => {
|
||||
const result = getAdditionalKeys(additionalData, 'internal', runExecutionData);
|
||||
const customData = result.$execution?.customData;
|
||||
|
||||
expect(() => {
|
||||
customData?.set('invalid*key', 'value');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should return undefined customData when runExecutionData is null', () => {
|
||||
const result = getAdditionalKeys(additionalData, 'manual', null);
|
||||
|
||||
expect(result.$execution?.customData).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should respect metadata KV limit', () => {
|
||||
const result = getAdditionalKeys(additionalData, 'manual', runExecutionData);
|
||||
const customData = result.$execution?.customData;
|
||||
|
||||
// Add 11 key-value pairs (exceeding the limit of 10)
|
||||
for (let i = 0; i < 11; i++) {
|
||||
customData?.set(`key${i}`, `value${i}`);
|
||||
}
|
||||
|
||||
const allData = customData?.getAll() ?? {};
|
||||
expect(Object.keys(allData)).toHaveLength(10);
|
||||
});
|
||||
});
|
||||
+1569
File diff suppressed because it is too large
Load Diff
+110
@@ -0,0 +1,110 @@
|
||||
import { ApplicationError } from '@n8n/errors';
|
||||
import type { IBinaryData, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import { normalizeItems } from '../normalize-items';
|
||||
|
||||
describe('normalizeItems', () => {
|
||||
describe('should handle', () => {
|
||||
const successTests: Array<{
|
||||
description: string;
|
||||
input: INodeExecutionData | INodeExecutionData[];
|
||||
expected: INodeExecutionData[];
|
||||
}> = [
|
||||
{
|
||||
description: 'single object without json key',
|
||||
input: { key: 'value' } as unknown as INodeExecutionData,
|
||||
expected: [{ json: { key: 'value' } }],
|
||||
},
|
||||
{
|
||||
description: 'array of objects without json key',
|
||||
input: [{ key1: 'value1' }, { key2: 'value2' }] as unknown as INodeExecutionData[],
|
||||
expected: [{ json: { key1: 'value1' } }, { json: { key2: 'value2' } }],
|
||||
},
|
||||
{
|
||||
description: 'single object with json key',
|
||||
input: { json: { key: 'value' } } as INodeExecutionData,
|
||||
expected: [{ json: { key: 'value' } }],
|
||||
},
|
||||
{
|
||||
description: 'array of objects with json key',
|
||||
input: [{ json: { key1: 'value1' } }, { json: { key2: 'value2' } }] as INodeExecutionData[],
|
||||
expected: [{ json: { key1: 'value1' } }, { json: { key2: 'value2' } }],
|
||||
},
|
||||
{
|
||||
description: 'array of objects with binary data',
|
||||
input: [
|
||||
{ json: {}, binary: { data: { data: 'binary1', mimeType: 'mime1' } } },
|
||||
{ json: {}, binary: { data: { data: 'binary2', mimeType: 'mime2' } } },
|
||||
],
|
||||
expected: [
|
||||
{ json: {}, binary: { data: { data: 'binary1', mimeType: 'mime1' } } },
|
||||
{ json: {}, binary: { data: { data: 'binary2', mimeType: 'mime2' } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
description: 'object with null or undefined values',
|
||||
input: { key: null, another: undefined } as unknown as INodeExecutionData,
|
||||
expected: [{ json: { key: null, another: undefined } }],
|
||||
},
|
||||
{
|
||||
description: 'array with mixed non-standard objects',
|
||||
input: [{ custom: 'value1' }, { another: 'value2' }] as unknown as INodeExecutionData[],
|
||||
expected: [{ json: { custom: 'value1' } }, { json: { another: 'value2' } }],
|
||||
},
|
||||
{
|
||||
description: 'empty object',
|
||||
input: {} as INodeExecutionData,
|
||||
expected: [{ json: {} }],
|
||||
},
|
||||
{
|
||||
description: 'array with primitive values',
|
||||
input: [1, 'string', true] as unknown as INodeExecutionData[],
|
||||
expected: [
|
||||
{ json: 1 },
|
||||
{ json: 'string' },
|
||||
{ json: true },
|
||||
] as unknown as INodeExecutionData[],
|
||||
},
|
||||
];
|
||||
test.each(successTests)('$description', ({ input, expected }) => {
|
||||
const result = normalizeItems(input);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should throw error', () => {
|
||||
const errorTests: Array<{
|
||||
description: string;
|
||||
input: INodeExecutionData[];
|
||||
}> = [
|
||||
{
|
||||
description: 'for inconsistent items with some having json key',
|
||||
input: [{ json: { key1: 'value1' } }, { key2: 'value2' } as unknown as INodeExecutionData],
|
||||
},
|
||||
{
|
||||
description: 'for inconsistent items with some having binary key',
|
||||
input: [
|
||||
{ json: {}, binary: { data: { data: 'binary1', mimeType: 'mime1' } } },
|
||||
{ key: 'value' } as unknown as INodeExecutionData,
|
||||
],
|
||||
},
|
||||
{
|
||||
description: 'when mixing json and non-json objects with non-json properties',
|
||||
input: [
|
||||
{ json: { key1: 'value1' } },
|
||||
{ other: 'value', custom: 'prop' } as unknown as INodeExecutionData,
|
||||
],
|
||||
},
|
||||
{
|
||||
description: 'when mixing binary and non-binary objects',
|
||||
input: [
|
||||
{ json: {}, binary: { data: { data: 'binarydata' } as IBinaryData } },
|
||||
{ custom: 'value' } as unknown as INodeExecutionData,
|
||||
],
|
||||
},
|
||||
];
|
||||
test.each(errorTests)('$description', ({ input }) => {
|
||||
expect(() => normalizeItems(input)).toThrow(new ApplicationError('Inconsistent item format'));
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user