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

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -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);
}
}
}
+4
View File
@@ -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;
+74
View File
@@ -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;
};
}
+59
View File
@@ -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,
}),
};
+23
View File
@@ -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';
+82
View File
@@ -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();
});
});
});
+44
View File
@@ -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];
}
}
+1
View File
@@ -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);
}
}
+364
View File
@@ -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.`,
);
}
}
+12
View File
@@ -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();
});
});
});
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;
}
@@ -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);
});
});
});
@@ -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',
]);
});
});
});
@@ -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();
});
});
});
@@ -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();
});
});
});
@@ -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');
});
});
});
@@ -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);
}
}
}
});
});
});
@@ -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);
});
});
};
@@ -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);
});
});
});
@@ -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();
});
});
});
@@ -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;
}
}
@@ -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);
}
}
@@ -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');
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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 你好');
});
});
});
@@ -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');
});
});
});
@@ -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"),
);
});
});
@@ -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),
});
});
});
@@ -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();
});
});
});
@@ -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);
});
});
@@ -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