fix(Execute Workflow Node): Prevent invalid input mappings (#35767)

This commit is contained in:
Predrag Ristic 2026-08-10 10:56:39 +02:00 committed by GitHub
parent 21b52fbddf
commit 34ca40bde0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 461 additions and 0 deletions

View File

@ -6,6 +6,7 @@
* composite handling, and serialization.
*/
import { expr } from './expression';
import type { NodeInstance, WorkflowJSON, IfElseComposite, GraphNode } from './types/base';
import { workflow } from './workflow-builder';
import { splitInBatches } from './workflow-builder/control-flow-builders/split-in-batches';
@ -51,6 +52,84 @@ describe('WorkflowBuilder plugin integration', () => {
});
describe('validate() with plugins', () => {
it('blocks invalid Execute Workflow input mappings through the default registry', () => {
registerDefaultPlugins(testRegistry);
const createWorkflow = (workflowInputs?: unknown) => {
const executeWorkflow = node({
type: 'n8n-nodes-base.executeWorkflow',
version: 1.3,
config: {
name: 'Process Order',
parameters: {
source: 'database',
workflowId: { __rl: true, mode: 'id', value: 'process-order' },
mode: 'each',
...(workflowInputs === undefined ? {} : { workflowInputs }),
},
},
});
return workflow('orders', 'Orders', { registry: testRegistry }).add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1,
config: { name: 'Start' },
}).to(executeWorkflow),
);
};
const invalidResult = createWorkflow({
mappingMode: 'defineBelow',
value: null,
}).validate();
expect(invalidResult.valid).toBe(false);
expect(invalidResult.errors).toContainEqual(
expect.objectContaining({ code: 'EXECUTE_WORKFLOW_INVALID_INPUT_MAPPING' }),
);
const passThroughResult = createWorkflow().validate();
expect(passThroughResult.valid).toBe(true);
expect(passThroughResult.errors).not.toContainEqual(
expect.objectContaining({ code: 'EXECUTE_WORKFLOW_INVALID_INPUT_MAPPING' }),
);
const mappedResult = createWorkflow({
mappingMode: 'defineBelow',
value: {
orderId: expr('{{ $json.id }}'),
amount: expr('{{ $json.total }}'),
},
matchingColumns: [],
schema: [
{
id: 'orderId',
displayName: 'orderId',
required: false,
defaultMatch: false,
display: true,
canBeUsedToMatch: true,
type: 'string',
},
{
id: 'amount',
displayName: 'amount',
required: false,
defaultMatch: false,
display: true,
canBeUsedToMatch: true,
type: 'number',
},
],
attemptToConvertTypes: false,
convertFieldsToString: true,
}).validate();
expect(mappedResult.valid).toBe(true);
expect(mappedResult.errors).not.toContainEqual(
expect.objectContaining({ code: 'EXECUTE_WORKFLOW_INVALID_INPUT_MAPPING' }),
);
});
it('runs registered validators for matching node types', () => {
const mockValidateNode = vi.fn().mockReturnValue([]);
const mockValidator = createMockValidator(

View File

@ -20,6 +20,7 @@ describe('Default Plugins', () => {
expect(validatorIds).toContain('core:disconnected-node');
expect(validatorIds).toContain('core:agent');
expect(validatorIds).toContain('core:http-request');
expect(validatorIds).toContain('core:execute-workflow');
expect(validatorIds).toContain('core:memory-session-key');
expect(validatorIds).toContain('core:unknown-config-keys');
});

View File

@ -16,6 +16,7 @@ import {
disconnectedNodeValidator,
expressionPathValidator,
expressionPrefixValidator,
executeWorkflowValidator,
filterNodeValidator,
fromAiValidator,
httpRequestValidator,
@ -60,6 +61,7 @@ const coreValidators: ValidatorPlugin[] = [
setNodeValidator,
mergeNodeValidator,
filterNodeValidator,
executeWorkflowValidator,
// Expression validators (lower priority)
expressionPrefixValidator,

View File

@ -0,0 +1,220 @@
import type { GraphNode, NodeInstance } from '../../../types/base';
import type { PluginContext } from '../types';
import { executeWorkflowValidator } from './execute-workflow-validator';
function createMockNode(
parameters: unknown,
version = '1.2',
): NodeInstance<string, string, unknown> {
return {
type: 'n8n-nodes-base.executeWorkflow',
name: 'Run Sub-workflow',
version,
config: { parameters },
} as NodeInstance<string, string, unknown>;
}
function validate(parameters: unknown, version = '1.2') {
const node = createMockNode(parameters, version);
const graphNode: GraphNode = { instance: node, connections: new Map() };
const ctx: PluginContext = {
nodes: new Map([[node.name, graphNode]]),
workflowId: 'test-workflow',
workflowName: 'Test Workflow',
settings: {},
};
return executeWorkflowValidator.validateNode(node, graphNode, ctx);
}
describe('executeWorkflowValidator', () => {
describe('metadata', () => {
it('targets Execute Workflow nodes at priority 40', () => {
expect(executeWorkflowValidator).toEqual(
expect.objectContaining({
id: 'core:execute-workflow',
name: 'Execute Workflow Validator',
nodeTypes: ['n8n-nodes-base.executeWorkflow'],
priority: 40,
}),
);
});
});
describe('defineBelow mappings', () => {
it.each([
['null', null],
['an array', []],
['a string', 'order'],
['a number', 1],
['a boolean', true],
])('rejects value when it is %s', (_description, value) => {
const issues = validate({
source: 'database',
workflowInputs: { mappingMode: 'defineBelow', value },
});
expect(issues).toHaveLength(1);
expect(issues[0]).toEqual(
expect.objectContaining({
code: 'EXECUTE_WORKFLOW_INVALID_INPUT_MAPPING',
severity: 'error',
violationLevel: 'major',
nodeName: 'Run Sub-workflow',
parameterPath: 'parameters.workflowInputs.value',
}),
);
});
it.each([
['null', null],
['an array', []],
['a string', 'inputs'],
])('rejects workflowInputs when it is %s', (_description, workflowInputs) => {
const issues = validate({ source: 'database', workflowInputs });
expect(issues).toHaveLength(1);
expect(issues[0]?.code).toBe('EXECUTE_WORKFLOW_INVALID_INPUT_MAPPING');
});
it('allows workflowInputs to be omitted for sub-workflows that accept all data', () => {
const issues = validate({ source: 'database' });
expect(issues).toHaveLength(0);
});
it.each([
['workflowInputs.value', { mappingMode: 'defineBelow' }],
['mappingMode and value', {}],
])('allows workflowInputs with omitted %s', (_description, workflowInputs) => {
const issues = validate({ source: 'database', workflowInputs });
expect(issues).toHaveLength(0);
});
it('treats an omitted mappingMode as defineBelow', () => {
const issues = validate({
source: 'database',
workflowInputs: { value: null },
});
expect(issues).toHaveLength(1);
expect(issues[0]?.code).toBe('EXECUTE_WORKFLOW_INVALID_INPUT_MAPPING');
});
it('includes the failing path and a complete valid mapping example', () => {
const issues = validate({
source: 'database',
workflowInputs: { mappingMode: 'defineBelow', value: null },
});
expect(issues[0]?.message).toContain('parameters.workflowInputs.value');
expect(issues[0]?.message).toContain('accepts all data');
expect(issues[0]?.message).toContain('omit parameters.workflowInputs');
expect(issues[0]?.message).toContain("orderId: expr('{{ $json.id }}')");
expect(issues[0]?.message).toContain("amount: expr('{{ $json.total }}')");
expect(issues[0]?.message).toContain("id: 'orderId'");
expect(issues[0]?.message).toContain("type: 'string'");
expect(issues[0]?.message).toContain("id: 'amount'");
expect(issues[0]?.message).toContain("type: 'number'");
expect(issues[0]?.message).toContain('matchingColumns: []');
});
it('allows a populated value object', () => {
const issues = validate({
source: 'database',
workflowInputs: {
mappingMode: 'defineBelow',
value: {
orderId: '={{ $json.id }}',
amount: '={{ $json.total }}',
},
matchingColumns: [],
schema: [
{
id: 'orderId',
displayName: 'orderId',
required: false,
defaultMatch: false,
display: true,
canBeUsedToMatch: true,
type: 'string',
},
{
id: 'amount',
displayName: 'amount',
required: false,
defaultMatch: false,
display: true,
canBeUsedToMatch: true,
type: 'number',
},
],
attemptToConvertTypes: false,
convertFieldsToString: true,
},
});
expect(issues).toHaveLength(0);
});
it('allows an empty value object for sub-workflows with no declared inputs', () => {
const issues = validate({
source: 'database',
workflowInputs: { mappingMode: 'defineBelow', value: {} },
});
expect(issues).toHaveLength(0);
});
it('allows an omitted mappingMode when value is an object', () => {
const issues = validate({
source: 'database',
workflowInputs: { value: { order: '={{ $json }}' } },
});
expect(issues).toHaveLength(0);
});
});
describe('applicability', () => {
it('treats an omitted source as database', () => {
const issues = validate({
workflowInputs: { mappingMode: 'defineBelow', value: null },
});
expect(issues).toHaveLength(1);
expect(issues[0]?.code).toBe('EXECUTE_WORKFLOW_INVALID_INPUT_MAPPING');
});
it.each(['1', '1.1'])('skips legacy node version %s', (version) => {
const issues = validate(
{
source: 'database',
workflowInputs: { mappingMode: 'defineBelow', value: null },
},
version,
);
expect(issues).toHaveLength(0);
});
it('skips non-database sources', () => {
const issues = validate({
source: 'parameter',
workflowInputs: { mappingMode: 'defineBelow', value: null },
});
expect(issues).toHaveLength(0);
});
it('skips mapping modes other than defineBelow', () => {
const issues = validate({
source: 'database',
workflowInputs: { mappingMode: 'autoMapInputData', value: null },
});
expect(issues).toHaveLength(0);
});
});
});

View File

@ -0,0 +1,83 @@
/**
* Execute Workflow Node Validator Plugin
*
* Validates that database-backed Execute Workflow nodes provide executable
* input mappings instead of persisting the Resource Mapper's temporary null value.
*/
import { isRecord } from '@n8n/utils/is-record';
import type { GraphNode, NodeInstance } from '../../../types/base';
import { parseVersion } from '../../string-utils';
import {
type PluginContext,
type ValidationIssue,
type ValidatorPlugin,
findMapKey,
formatNodeRef,
isAutoRenamed,
} from '../types';
const MIN_WORKFLOW_INPUTS_VERSION = 1.2;
const DATABASE_SOURCE = 'database';
const DEFINE_BELOW_MODE = 'defineBelow';
const WORKFLOW_INPUTS_VALUE_PATH = 'parameters.workflowInputs.value';
const VALID_MAPPING_EXAMPLE =
"{ mappingMode: 'defineBelow', value: { orderId: expr('{{ $json.id }}'), amount: expr('{{ $json.total }}') }, matchingColumns: [], schema: [{ id: 'orderId', displayName: 'orderId', required: false, defaultMatch: false, display: true, canBeUsedToMatch: true, type: 'string' }, { id: 'amount', displayName: 'amount', required: false, defaultMatch: false, display: true, canBeUsedToMatch: true, type: 'number' }], attemptToConvertTypes: false, convertFieldsToString: true }";
export const executeWorkflowValidator: ValidatorPlugin = {
id: 'core:execute-workflow',
name: 'Execute Workflow Validator',
nodeTypes: ['n8n-nodes-base.executeWorkflow'],
priority: 40,
validateNode(
node: NodeInstance<string, string, unknown>,
graphNode: GraphNode,
ctx: PluginContext,
): ValidationIssue[] {
if (parseVersion(node.version) < MIN_WORKFLOW_INPUTS_VERSION) {
return [];
}
const parameters = isRecord(node.config?.parameters) ? node.config.parameters : {};
const source = parameters.source ?? DATABASE_SOURCE;
if (source !== DATABASE_SOURCE) {
return [];
}
const workflowInputs = parameters.workflowInputs;
if (workflowInputs === undefined) {
return [];
}
if (isRecord(workflowInputs)) {
const mappingMode = workflowInputs.mappingMode ?? DEFINE_BELOW_MODE;
const value = workflowInputs.value;
if (mappingMode !== DEFINE_BELOW_MODE || value === undefined || isRecord(value)) return [];
}
const mapKey = findMapKey(graphNode, ctx);
const originalName = node.name;
const renamed = isAutoRenamed(mapKey, originalName);
const displayName = renamed ? mapKey : originalName;
const origForWarning = renamed ? originalName : undefined;
const nodeRef = formatNodeRef(displayName, origForWarning, node.type);
return [
{
code: 'EXECUTE_WORKFLOW_INVALID_INPUT_MAPPING',
message:
`${nodeRef} has an invalid ${WORKFLOW_INPUTS_VALUE_PATH}. ` +
`When parameters.workflowInputs.mappingMode is '${DEFINE_BELOW_MODE}', an explicitly provided ${WORKFLOW_INPUTS_VALUE_PATH} must be an object. ` +
`If the selected sub-workflow accepts all data, omit parameters.workflowInputs. Otherwise set parameters.workflowInputs to a full Resource Mapper mapping whose value and schema match the declared inputs, such as ${VALID_MAPPING_EXAMPLE}.`,
severity: 'error',
violationLevel: 'major',
nodeName: displayName,
parameterPath: WORKFLOW_INPUTS_VALUE_PATH,
originalName: origForWarning,
},
];
},
};

View File

@ -11,6 +11,7 @@ export { disconnectedNodeValidator } from './disconnected-node-validator';
export { expressionPathValidator } from './expression-path-validator';
export { subnodeConnectionValidator } from './subnode-connection-validator';
export { expressionPrefixValidator } from './expression-prefix-validator';
export { executeWorkflowValidator } from './execute-workflow-validator';
export { filterNodeValidator } from './filter-node-validator';
export { fromAiValidator } from './from-ai-validator';
export { httpRequestValidator } from './http-request-validator';

View File

@ -29,6 +29,32 @@ describe('ExecuteWorkflow', () => {
} as unknown as IWorkflowDataProxyData);
});
test('should document pass-through and declared workflow input mappings for the builder', () => {
const workflowInputs = executeWorkflow.description.properties.find(
(property) => property.name === 'workflowInputs',
);
const propertyHint = workflowInputs?.builderHint?.propertyHint;
const mappingPatterns = executeWorkflow.description.builderHint?.extraTypeDefContent
?.map(({ content }) => content)
.join('\n');
expect(propertyHint).toContain('temporary UI initialization state');
expect(propertyHint).toContain('must never be emitted');
expect(propertyHint).toContain("trigger is set to 'Accept all data'");
expect(propertyHint).toContain('Omit workflowInputs');
expect(propertyHint).toContain('value and schema fields exactly match');
expect(mappingPatterns).toContain('Omit workflowInputs from parameters');
expect(mappingPatterns).toContain("orderId: expr('{{ $json.id }}')");
expect(mappingPatterns).toContain("amount: expr('{{ $json.total }}')");
expect(mappingPatterns).toContain("id: 'orderId'");
expect(mappingPatterns).toContain("type: 'string'");
expect(mappingPatterns).toContain("id: 'amount'");
expect(mappingPatterns).toContain("type: 'number'");
expect(mappingPatterns).toContain('matchingColumns: []');
expect(mappingPatterns).toContain('attemptToConvertTypes: false');
expect(mappingPatterns).toContain('convertFieldsToString: true');
});
test('should execute workflow in "each" mode and wait for sub-workflow completion', async () => {
executeFunctions.getNodeParameter
.mockReturnValueOnce('database') // source

View File

@ -28,6 +28,51 @@ export class ExecuteWorkflow implements INodeType {
},
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
builderHint: {
extraTypeDefContent: [
{
displayOptions: { show: { mode: ['once', 'each'] } },
content: `<patterns>
These workflowInputs patterns apply to Execute Workflow node versions 1.2 and newer.
<pattern title="Child accepts all data">
Omit workflowInputs from parameters.
</pattern>
<pattern title="Child declares inputs">
workflowInputs: {
mappingMode: 'defineBelow',
value: {
orderId: expr('{{ $json.id }}'),
amount: expr('{{ $json.total }}'),
},
matchingColumns: [],
schema: [
{
id: 'orderId',
displayName: 'orderId',
required: false,
defaultMatch: false,
display: true,
canBeUsedToMatch: true,
type: 'string',
},
{
id: 'amount',
displayName: 'amount',
required: false,
defaultMatch: false,
display: true,
canBeUsedToMatch: true,
type: 'number',
},
],
attemptToConvertTypes: false,
convertFieldsToString: true,
}
</pattern>
</patterns>`,
},
],
},
properties: [
{
displayName: 'Operation',
@ -218,6 +263,10 @@ export class ExecuteWorkflow implements INodeType {
value: null,
},
required: true,
builderHint: {
propertyHint:
"The default { mappingMode: 'defineBelow', value: null } is only a temporary UI initialization state and must never be emitted in a workflow. Omit workflowInputs when the selected sub-workflow's trigger is set to 'Accept all data'. When the trigger declares inputs, pass the full Resource Mapper object and make the value and schema fields exactly match the declared input names and types.",
},
typeOptions: {
loadOptionsDependsOn: ['workflowId.value'],
resourceMapper: {