From e94cda38371bdb3a5e521f34bfa4996141db1b32 Mon Sep 17 00:00:00 2001 From: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Date: Wed, 9 Oct 2024 17:31:45 +0300 Subject: [PATCH] feat: Add support for $env in the js task runner (no-changelog) (#11177) --- packages/@n8n/task-runner/package.json | 3 + .../__tests__/js-task-runner.test.ts | 228 +++++++++++++++--- .../src/js-task-runner/__tests__/test-data.ts | 30 ++- .../src/js-task-runner/js-task-runner.ts | 9 + .../src/runners/task-managers/task-manager.ts | 32 ++- .../src/workflow-execute-additional-data.ts | 17 +- packages/core/src/Agent/index.ts | 2 + packages/workflow/src/Interfaces.ts | 2 + packages/workflow/src/WorkflowDataProxy.ts | 43 +--- .../src/WorkflowDataProxyEnvProvider.ts | 75 ++++++ packages/workflow/src/index.ts | 1 + .../test/WorkflowDataProxyEnvProvider.test.ts | 87 +++++++ pnpm-lock.yaml | 4 + 13 files changed, 445 insertions(+), 88 deletions(-) create mode 100644 packages/workflow/src/WorkflowDataProxyEnvProvider.ts create mode 100644 packages/workflow/test/WorkflowDataProxyEnvProvider.test.ts diff --git a/packages/@n8n/task-runner/package.json b/packages/@n8n/task-runner/package.json index af5e650fce1fa..85b695b359207 100644 --- a/packages/@n8n/task-runner/package.json +++ b/packages/@n8n/task-runner/package.json @@ -26,5 +26,8 @@ "n8n-core": "workspace:*", "nanoid": "^3.3.6", "ws": "^8.18.0" + }, + "devDependencies": { + "luxon": "catalog:" } } diff --git a/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts b/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts index f654216b5a746..fec14acc8ce6e 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts @@ -1,3 +1,4 @@ +import { DateTime } from 'luxon'; import type { CodeExecutionMode, IDataObject } from 'n8n-workflow'; import { ValidationError } from '@/js-task-runner/errors/validation-error'; @@ -30,6 +31,36 @@ describe('JsTaskRunner', () => { jest.restoreAllMocks(); }); + const executeForAllItems = async ({ + code, + inputItems, + settings, + }: { code: string; inputItems: IDataObject[]; settings?: Partial }) => { + return await execTaskWithParams({ + task: newTaskWithSettings({ + code, + nodeMode: 'runOnceForAllItems', + ...settings, + }), + taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson)), + }); + }; + + const executeForEachItem = async ({ + code, + inputItems, + settings, + }: { code: string; inputItems: IDataObject[]; settings?: Partial }) => { + return await execTaskWithParams({ + task: newTaskWithSettings({ + code, + nodeMode: 'runOnceForEachItem', + ...settings, + }), + taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson)), + }); + }; + describe('console', () => { test.each<[CodeExecutionMode]>([['runOnceForAllItems'], ['runOnceForEachItem']])( 'should make an rpc call for console log in %s mode', @@ -52,22 +83,178 @@ describe('JsTaskRunner', () => { ); }); - describe('runOnceForAllItems', () => { - const executeForAllItems = async ({ - code, - inputItems, - settings, - }: { code: string; inputItems: IDataObject[]; settings?: Partial }) => { - return await execTaskWithParams({ - task: newTaskWithSettings({ - code, - nodeMode: 'runOnceForAllItems', - ...settings, - }), - taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson)), + describe('built-in methods and variables available in the context', () => { + const inputItems = [{ a: 1 }]; + + const testExpressionForAllItems = async ( + expression: string, + expected: IDataObject | string | number | boolean, + ) => { + const needsWrapping = typeof expected !== 'object'; + const outcome = await executeForAllItems({ + code: needsWrapping ? `return { val: ${expression} }` : `return ${expression}`, + inputItems, }); + + expect(outcome.result).toEqual([wrapIntoJson(needsWrapping ? { val: expected } : expected)]); }; + const testExpressionForEachItem = async ( + expression: string, + expected: IDataObject | string | number | boolean, + ) => { + const needsWrapping = typeof expected !== 'object'; + const outcome = await executeForEachItem({ + code: needsWrapping ? `return { val: ${expression} }` : `return ${expression}`, + inputItems, + }); + + expect(outcome.result).toEqual([ + withPairedItem(0, wrapIntoJson(needsWrapping ? { val: expected } : expected)), + ]); + }; + + const testGroups = { + // https://docs.n8n.io/code/builtin/current-node-input/ + 'current node input': [ + ['$input.first()', inputItems[0]], + ['$input.last()', inputItems[inputItems.length - 1]], + ['$input.params', { manualTriggerParam: 'empty' }], + ], + // https://docs.n8n.io/code/builtin/output-other-nodes/ + 'output of other nodes': [ + ['$("Trigger").first()', inputItems[0]], + ['$("Trigger").last()', inputItems[inputItems.length - 1]], + ['$("Trigger").params', { manualTriggerParam: 'empty' }], + ], + // https://docs.n8n.io/code/builtin/date-time/ + 'date and time': [ + ['$now', expect.any(DateTime)], + ['$today', expect.any(DateTime)], + ['{dt: DateTime}', { dt: expect.any(Function) }], + ], + // https://docs.n8n.io/code/builtin/jmespath/ + JMESPath: [['{ val: $jmespath([{ f: 1 },{ f: 2 }], "[*].f") }', { val: [1, 2] }]], + // https://docs.n8n.io/code/builtin/n8n-metadata/ + 'n8n metadata': [ + [ + '$execution', + { + id: 'exec-id', + mode: 'test', + resumeFormUrl: 'http://formWaitingBaseUrl/exec-id', + resumeUrl: 'http://webhookWaitingBaseUrl/exec-id', + customData: { + get: expect.any(Function), + getAll: expect.any(Function), + set: expect.any(Function), + setAll: expect.any(Function), + }, + }, + ], + ['$("Trigger").isExecuted', true], + ['$nodeVersion', 2], + ['$prevNode.name', 'Trigger'], + ['$prevNode.outputIndex', 0], + ['$runIndex', 0], + ['{ wf: $workflow }', { wf: { active: true, id: '1', name: 'Test Workflow' } }], + ['$vars', { var: 'value' }], + ], + }; + + for (const [groupName, tests] of Object.entries(testGroups)) { + describe(`${groupName} runOnceForAllItems`, () => { + test.each(tests)( + 'should have the %s available in the context', + async (expression, expected) => { + await testExpressionForAllItems(expression, expected); + }, + ); + }); + + describe(`${groupName} runOnceForEachItem`, () => { + test.each(tests)( + 'should have the %s available in the context', + async (expression, expected) => { + await testExpressionForEachItem(expression, expected); + }, + ); + }); + } + + describe('$env', () => { + it('should have the env available in context when access has not been blocked', async () => { + const outcome = await execTaskWithParams({ + task: newTaskWithSettings({ + code: 'return { val: $env.VAR1 }', + nodeMode: 'runOnceForAllItems', + }), + taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson), { + envProviderState: { + isEnvAccessBlocked: false, + isProcessAvailable: true, + env: { VAR1: 'value' }, + }, + }), + }); + + expect(outcome.result).toEqual([wrapIntoJson({ val: 'value' })]); + }); + + it('should be possible to access env if it has been blocked', async () => { + await expect( + execTaskWithParams({ + task: newTaskWithSettings({ + code: 'return { val: $env.VAR1 }', + nodeMode: 'runOnceForAllItems', + }), + taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson), { + envProviderState: { + isEnvAccessBlocked: true, + isProcessAvailable: true, + env: { VAR1: 'value' }, + }, + }), + }), + ).rejects.toThrow('access to env vars denied'); + }); + + it('should not be possible to iterate $env', async () => { + const outcome = await execTaskWithParams({ + task: newTaskWithSettings({ + code: 'return Object.values($env).concat(Object.keys($env))', + nodeMode: 'runOnceForAllItems', + }), + taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson), { + envProviderState: { + isEnvAccessBlocked: false, + isProcessAvailable: true, + env: { VAR1: '1', VAR2: '2', VAR3: '3' }, + }, + }), + }); + + expect(outcome.result).toEqual([]); + }); + + it("should not expose task runner's env variables even if no env state is received", async () => { + process.env.N8N_RUNNERS_N8N_URI = 'http://127.0.0.1:5679'; + const outcome = await execTaskWithParams({ + task: newTaskWithSettings({ + code: 'return { val: $env.N8N_RUNNERS_N8N_URI }', + nodeMode: 'runOnceForAllItems', + }), + taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson), { + envProviderState: undefined, + }), + }); + + expect(outcome.result).toEqual([wrapIntoJson({ val: undefined })]); + }); + }); + }); + + describe('runOnceForAllItems', () => { describe('continue on fail', () => { it('should return an item with the error if continueOnFail is true', async () => { const outcome = await executeForAllItems({ @@ -181,21 +368,6 @@ describe('JsTaskRunner', () => { }); describe('runForEachItem', () => { - const executeForEachItem = async ({ - code, - inputItems, - settings, - }: { code: string; inputItems: IDataObject[]; settings?: Partial }) => { - return await execTaskWithParams({ - task: newTaskWithSettings({ - code, - nodeMode: 'runOnceForEachItem', - ...settings, - }), - taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson)), - }); - }; - describe('continue on fail', () => { it('should return an item with the error if continueOnFail is true', async () => { const outcome = await executeForEachItem({ diff --git a/packages/@n8n/task-runner/src/js-task-runner/__tests__/test-data.ts b/packages/@n8n/task-runner/src/js-task-runner/__tests__/test-data.ts index 9f9d64df02d84..b157094619b61 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/__tests__/test-data.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/__tests__/test-data.ts @@ -65,6 +65,9 @@ export const newAllCodeTaskData = ( const manualTriggerNode = newNode({ name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', + parameters: { + manualTriggerParam: 'empty', + }, }); return { @@ -116,15 +119,32 @@ export const newAllCodeTaskData = ( siblingParameters: {}, mode: 'manual', selfData: {}, + envProviderState: { + env: {}, + isEnvAccessBlocked: true, + isProcessAvailable: true, + }, additionalData: { - formWaitingBaseUrl: '', + executionId: 'exec-id', instanceBaseUrl: '', restartExecutionId: '', restApiUrl: '', - webhookBaseUrl: '', - webhookTestBaseUrl: '', - webhookWaitingBaseUrl: '', - variables: {}, + formWaitingBaseUrl: 'http://formWaitingBaseUrl', + webhookBaseUrl: 'http://webhookBaseUrl', + webhookTestBaseUrl: 'http://webhookTestBaseUrl', + webhookWaitingBaseUrl: 'http://webhookWaitingBaseUrl', + variables: { + var: 'value', + }, + }, + executeData: { + node: codeNode, + data: { + main: [codeNodeInputData], + }, + source: { + main: [{ previousNode: manualTriggerNode.name }], + }, }, ...opts, }; diff --git a/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts b/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts index 907569eb6ed38..3547e35b6cb17 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts @@ -17,6 +17,7 @@ import type { INodeParameters, IRunExecutionData, WorkflowExecuteMode, + EnvProviderState, } from 'n8n-workflow'; import * as a from 'node:assert'; import { runInNewContext, type Context } from 'node:vm'; @@ -63,6 +64,7 @@ export interface AllCodeTaskData { connectionInputData: INodeExecutionData[]; siblingParameters: INodeParameters; mode: WorkflowExecuteMode; + envProviderState?: EnvProviderState; executeData?: IExecuteData; defaultReturnRunIndex: number; selfData: IDataObject; @@ -262,6 +264,13 @@ export class JsTaskRunner extends TaskRunner { allData.defaultReturnRunIndex, allData.selfData, allData.contextNodeName, + // Make sure that even if we don't receive the envProviderState for + // whatever reason, we don't expose the task runner's env to the code + allData.envProviderState ?? { + env: {}, + isEnvAccessBlocked: false, + isProcessAvailable: true, + }, ).getDataProxy(); } diff --git a/packages/cli/src/runners/task-managers/task-manager.ts b/packages/cli/src/runners/task-managers/task-manager.ts index 9f7e492fbe652..b2ca87bc9c161 100644 --- a/packages/cli/src/runners/task-managers/task-manager.ts +++ b/packages/cli/src/runners/task-managers/task-manager.ts @@ -1,16 +1,17 @@ -import { - type IExecuteFunctions, - type Workflow, - type IRunExecutionData, - type INodeExecutionData, - type ITaskDataConnections, - type INode, - type WorkflowParameters, - type INodeParameters, - type WorkflowExecuteMode, - type IExecuteData, - type IDataObject, - type IWorkflowExecuteAdditionalData, +import type { + EnvProviderState, + IExecuteFunctions, + Workflow, + IRunExecutionData, + INodeExecutionData, + ITaskDataConnections, + INode, + WorkflowParameters, + INodeParameters, + WorkflowExecuteMode, + IExecuteData, + IDataObject, + IWorkflowExecuteAdditionalData, } from 'n8n-workflow'; import { nanoid } from 'nanoid'; @@ -42,6 +43,7 @@ export interface TaskData { connectionInputData: INodeExecutionData[]; siblingParameters: INodeParameters; mode: WorkflowExecuteMode; + envProviderState: EnvProviderState; executeData?: IExecuteData; defaultReturnRunIndex: number; selfData: IDataObject; @@ -76,6 +78,7 @@ export interface AllCodeTaskData { connectionInputData: INodeExecutionData[]; siblingParameters: INodeParameters; mode: WorkflowExecuteMode; + envProviderState: EnvProviderState; executeData?: IExecuteData; defaultReturnRunIndex: number; selfData: IDataObject; @@ -137,6 +140,7 @@ export class TaskManager { connectionInputData: INodeExecutionData[], siblingParameters: INodeParameters, mode: WorkflowExecuteMode, + envProviderState: EnvProviderState, executeData?: IExecuteData, defaultReturnRunIndex = -1, selfData: IDataObject = {}, @@ -153,6 +157,7 @@ export class TaskManager { itemIndex, siblingParameters, mode, + envProviderState, executeData, defaultReturnRunIndex, selfData, @@ -311,6 +316,7 @@ export class TaskManager { contextNodeName: jd.contextNodeName, defaultReturnRunIndex: jd.defaultReturnRunIndex, mode: jd.mode, + envProviderState: jd.envProviderState, node: jd.node, runExecutionData: jd.runExecutionData, runIndex: jd.runIndex, diff --git a/packages/cli/src/workflow-execute-additional-data.ts b/packages/cli/src/workflow-execute-additional-data.ts index f357bbc01889e..2deae842fc4c7 100644 --- a/packages/cli/src/workflow-execute-additional-data.ts +++ b/packages/cli/src/workflow-execute-additional-data.ts @@ -6,6 +6,13 @@ import type { PushType } from '@n8n/api-types'; import { GlobalConfig } from '@n8n/config'; import { WorkflowExecute } from 'n8n-core'; +import { + ApplicationError, + ErrorReporterProxy as ErrorReporter, + NodeOperationError, + Workflow, + WorkflowHooks, +} from 'n8n-workflow'; import type { IDataObject, IExecuteData, @@ -28,13 +35,7 @@ import type { ITaskDataConnections, ExecuteWorkflowOptions, IWorkflowExecutionDataProcess, -} from 'n8n-workflow'; -import { - ApplicationError, - ErrorReporterProxy as ErrorReporter, - NodeOperationError, - Workflow, - WorkflowHooks, + EnvProviderState, } from 'n8n-workflow'; import { Container } from 'typedi'; @@ -1008,6 +1009,7 @@ export async function getBase( connectionInputData: INodeExecutionData[], siblingParameters: INodeParameters, mode: WorkflowExecuteMode, + envProviderState: EnvProviderState, executeData?: IExecuteData, defaultReturnRunIndex?: number, selfData?: IDataObject, @@ -1028,6 +1030,7 @@ export async function getBase( connectionInputData, siblingParameters, mode, + envProviderState, executeData, defaultReturnRunIndex, selfData, diff --git a/packages/core/src/Agent/index.ts b/packages/core/src/Agent/index.ts index 75195b6acfe38..1d4e916d331bc 100644 --- a/packages/core/src/Agent/index.ts +++ b/packages/core/src/Agent/index.ts @@ -11,6 +11,7 @@ import type { IExecuteData, IDataObject, } from 'n8n-workflow'; +import { createEnvProviderState } from 'n8n-workflow'; export const createAgentStartJob = ( additionalData: IWorkflowExecuteAdditionalData, @@ -49,6 +50,7 @@ export const createAgentStartJob = ( connectionInputData, siblingParameters, mode, + createEnvProviderState(), executeData, defaultReturnRunIndex, selfData, diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index f0903dd6cef7c..d1f95ab51f596 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -22,6 +22,7 @@ import type { WorkflowActivationError } from './errors/workflow-activation.error import type { WorkflowOperationError } from './errors/workflow-operation.error'; import type { ExecutionStatus } from './ExecutionStatus'; import type { Workflow } from './Workflow'; +import type { EnvProviderState } from './WorkflowDataProxyEnvProvider'; import type { WorkflowHooks } from './WorkflowHooks'; export interface IAdditionalCredentialOptions { @@ -2256,6 +2257,7 @@ export interface IWorkflowExecuteAdditionalData { connectionInputData: INodeExecutionData[], siblingParameters: INodeParameters, mode: WorkflowExecuteMode, + envProviderState: EnvProviderState, executeData?: IExecuteData, defaultReturnRunIndex?: number, selfData?: IDataObject, diff --git a/packages/workflow/src/WorkflowDataProxy.ts b/packages/workflow/src/WorkflowDataProxy.ts index 1efede36a560a..c596076845e15 100644 --- a/packages/workflow/src/WorkflowDataProxy.ts +++ b/packages/workflow/src/WorkflowDataProxy.ts @@ -30,6 +30,8 @@ import { import * as NodeHelpers from './NodeHelpers'; import { deepCopy } from './utils'; import type { Workflow } from './Workflow'; +import type { EnvProviderState } from './WorkflowDataProxyEnvProvider'; +import { createEnvProvider, createEnvProviderState } from './WorkflowDataProxyEnvProvider'; import { getPinDataIfManualExecution } from './WorkflowDataProxyHelpers'; export function isResourceLocatorValue(value: unknown): value is INodeParameterResourceLocator { @@ -66,6 +68,7 @@ export class WorkflowDataProxy { private defaultReturnRunIndex = -1, private selfData: IDataObject = {}, private contextNodeName: string = activeNodeName, + private envProviderState?: EnvProviderState, ) { this.runExecutionData = isScriptingNode(this.contextNodeName, workflow) ? runExecutionData !== null @@ -487,40 +490,6 @@ export class WorkflowDataProxy { ); } - /** - * Returns a proxy to query data from the environment - * - * @private - */ - private envGetter() { - const that = this; - return new Proxy( - {}, - { - has: () => true, - get(_, name) { - if (name === 'isProxy') return true; - - if (typeof process === 'undefined') { - throw new ExpressionError('not accessible via UI, please run node', { - runIndex: that.runIndex, - itemIndex: that.itemIndex, - }); - } - if (process.env.N8N_BLOCK_ENV_ACCESS_IN_NODE === 'true') { - throw new ExpressionError('access to env vars denied', { - causeDetailed: - 'If you need access please contact the administrator to remove the environment variable ‘N8N_BLOCK_ENV_ACCESS_IN_NODE‘', - runIndex: that.runIndex, - itemIndex: that.itemIndex, - }); - } - return process.env[name.toString()]; - }, - }, - ); - } - private prevNodeGetter() { const allowedValues = ['name', 'outputIndex', 'runIndex']; const that = this; @@ -1303,7 +1272,11 @@ export class WorkflowDataProxy { $binary: {}, // Placeholder $data: {}, // Placeholder - $env: this.envGetter(), + $env: createEnvProvider( + that.runIndex, + that.itemIndex, + that.envProviderState ?? createEnvProviderState(), + ), $evaluateExpression: (expression: string, itemIndex?: number) => { itemIndex = itemIndex || that.itemIndex; return that.workflow.expression.getParameterValue( diff --git a/packages/workflow/src/WorkflowDataProxyEnvProvider.ts b/packages/workflow/src/WorkflowDataProxyEnvProvider.ts new file mode 100644 index 0000000000000..b41c8b8e0927e --- /dev/null +++ b/packages/workflow/src/WorkflowDataProxyEnvProvider.ts @@ -0,0 +1,75 @@ +import { ExpressionError } from './errors/expression.error'; + +export type EnvProviderState = { + isProcessAvailable: boolean; + isEnvAccessBlocked: boolean; + env: Record; +}; + +/** + * Captures a snapshot of the environment variables and configuration + * that can be used to initialize an environment provider. + */ +export function createEnvProviderState(): EnvProviderState { + const isProcessAvailable = typeof process !== 'undefined'; + const isEnvAccessBlocked = isProcessAvailable + ? process.env.N8N_BLOCK_ENV_ACCESS_IN_NODE === 'true' + : false; + const env: Record = + !isProcessAvailable || isEnvAccessBlocked ? {} : (process.env as Record); + + return { + isProcessAvailable, + isEnvAccessBlocked, + env, + }; +} + +/** + * Creates a proxy that provides access to the environment variables + * in the `WorkflowDataProxy`. Use the `createEnvProviderState` to + * create the default state object that is needed for the proxy, + * unless you need something specific. + * + * @example + * createEnvProvider( + * runIndex, + * itemIndex, + * createEnvProviderState(), + * ) + */ +export function createEnvProvider( + runIndex: number, + itemIndex: number, + providerState: EnvProviderState, +): Record { + return new Proxy( + {}, + { + has() { + return true; + }, + + get(_, name) { + if (name === 'isProxy') return true; + + if (!providerState.isProcessAvailable) { + throw new ExpressionError('not accessible via UI, please run node', { + runIndex, + itemIndex, + }); + } + if (providerState.isEnvAccessBlocked) { + throw new ExpressionError('access to env vars denied', { + causeDetailed: + 'If you need access please contact the administrator to remove the environment variable ‘N8N_BLOCK_ENV_ACCESS_IN_NODE‘', + runIndex, + itemIndex, + }); + } + + return providerState.env[name.toString()]; + }, + }, + ); +} diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts index 61dae7afaed99..be5a7ca98ede6 100644 --- a/packages/workflow/src/index.ts +++ b/packages/workflow/src/index.ts @@ -18,6 +18,7 @@ export * from './NodeHelpers'; export * from './RoutingNode'; export * from './Workflow'; export * from './WorkflowDataProxy'; +export * from './WorkflowDataProxyEnvProvider'; export * from './WorkflowHooks'; export * from './VersionedNodeType'; export * from './TypeValidation'; diff --git a/packages/workflow/test/WorkflowDataProxyEnvProvider.test.ts b/packages/workflow/test/WorkflowDataProxyEnvProvider.test.ts new file mode 100644 index 0000000000000..15ce485140bc4 --- /dev/null +++ b/packages/workflow/test/WorkflowDataProxyEnvProvider.test.ts @@ -0,0 +1,87 @@ +import { ExpressionError } from '../src/errors/expression.error'; +import { createEnvProvider, createEnvProviderState } from '../src/WorkflowDataProxyEnvProvider'; + +describe('createEnvProviderState', () => { + afterEach(() => { + delete process.env.N8N_BLOCK_ENV_ACCESS_IN_NODE; + }); + + it('should return the state with process available and env access allowed', () => { + expect(createEnvProviderState()).toEqual({ + isProcessAvailable: true, + isEnvAccessBlocked: false, + env: process.env, + }); + }); + + it('should block env access when N8N_BLOCK_ENV_ACCESS_IN_NODE is set to "true"', () => { + process.env.N8N_BLOCK_ENV_ACCESS_IN_NODE = 'true'; + + expect(createEnvProviderState()).toEqual({ + isProcessAvailable: true, + isEnvAccessBlocked: true, + env: {}, + }); + }); + + it('should handle process not being available', () => { + const originalProcess = global.process; + try { + // @ts-expect-error process is read-only + global.process = undefined; + + expect(createEnvProviderState()).toEqual({ + isProcessAvailable: false, + isEnvAccessBlocked: false, + env: {}, + }); + } finally { + global.process = originalProcess; + } + }); +}); + +describe('createEnvProvider', () => { + it('should return true when checking for a property using "has"', () => { + const proxy = createEnvProvider(0, 0, createEnvProviderState()); + expect('someProperty' in proxy).toBe(true); + }); + + it('should return the value from process.env if access is allowed', () => { + process.env.TEST_ENV_VAR = 'test_value'; + const proxy = createEnvProvider(0, 0, createEnvProviderState()); + expect(proxy.TEST_ENV_VAR).toBe('test_value'); + }); + + it('should throw ExpressionError when process is unavailable', () => { + const originalProcess = global.process; + // @ts-expect-error process is read-only + global.process = undefined; + try { + const proxy = createEnvProvider(1, 1, createEnvProviderState()); + + expect(() => proxy.someEnvVar).toThrowError( + new ExpressionError('not accessible via UI, please run node', { + runIndex: 1, + itemIndex: 1, + }), + ); + } finally { + global.process = originalProcess; + } + }); + + it('should throw ExpressionError when env access is blocked', () => { + process.env.N8N_BLOCK_ENV_ACCESS_IN_NODE = 'true'; + const proxy = createEnvProvider(1, 1, createEnvProviderState()); + + expect(() => proxy.someEnvVar).toThrowError( + new ExpressionError('access to env vars denied', { + causeDetailed: + 'If you need access please contact the administrator to remove the environment variable ‘N8N_BLOCK_ENV_ACCESS_IN_NODE‘', + runIndex: 1, + itemIndex: 1, + }), + ); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fef1cc6f55440..c27a56d54cdde 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -641,6 +641,10 @@ importers: ws: specifier: '>=8.17.1' version: 8.17.1 + devDependencies: + luxon: + specifier: 'catalog:' + version: 3.4.4 packages/@n8n_io/eslint-config: devDependencies: