n8n/packages/workflow/test/node-validation.test.ts
Ricardo Espinoza 5e28ff8c23
fix(core): Block publishing workflows with unconnected required node inputs (#37265)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 07:56:07 +00:00

517 lines
14 KiB
TypeScript

import type { INode, INodeType, INodeTypeDescription } from '../src/interfaces';
import {
validateNodeCredentials,
getUnconnectedRequiredInputs,
isTriggerLikeNode,
type NodeCredentialIssue,
type WorkflowForInputValidation,
} from '../src/node-validation';
describe('node-validation', () => {
describe('validateNodeCredentials', () => {
const createNode = (
credentials?: Record<string, { id: string }>,
parameters?: Record<string, unknown>,
): INode => ({
name: 'Test Node',
type: 'n8n-nodes-base.test',
id: 'node-1',
typeVersion: 1,
position: [0, 0],
credentials: credentials as INode['credentials'],
parameters: (parameters || {}) as INode['parameters'],
});
const createNodeType = (credentials?: INodeTypeDescription['credentials']): INodeType => ({
description: {
displayName: 'Test Node',
name: 'test',
group: ['transform'],
version: 1,
description: 'Test node',
defaults: { name: 'Test Node' },
inputs: ['main'],
outputs: ['main'],
properties: [],
credentials: credentials || [],
},
});
it('should return no issues when node has all required credentials', () => {
const node = createNode({ testCredential: { id: 'cred-1' } });
const nodeType = createNodeType([
{
name: 'testCredential',
displayName: 'Test Credential',
required: true,
},
]);
const issues = validateNodeCredentials(node, nodeType);
expect(issues).toEqual([]);
});
it('should return missing issue when required credential is not set', () => {
const node = createNode();
const nodeType = createNodeType([
{
name: 'testCredential',
displayName: 'Test Credential',
required: true,
},
]);
const issues = validateNodeCredentials(node, nodeType);
expect(issues).toEqual([
{
type: 'missing',
displayName: 'Test Credential',
credentialName: 'testCredential',
},
]);
});
it('should return not-configured issue when credential has no ID', () => {
const node = createNode({ testCredential: { id: '' } });
const nodeType = createNodeType([
{
name: 'testCredential',
displayName: 'Test Credential',
required: true,
},
]);
const issues = validateNodeCredentials(node, nodeType);
expect(issues).toEqual([
{
type: 'not-configured',
displayName: 'Test Credential',
credentialName: 'testCredential',
},
]);
});
it('should skip optional credentials', () => {
const node = createNode();
const nodeType = createNodeType([
{
name: 'optionalCredential',
displayName: 'Optional Credential',
required: false,
},
]);
const issues = validateNodeCredentials(node, nodeType);
expect(issues).toEqual([]);
});
it('should respect displayOptions and skip hidden credentials', () => {
const node = createNode(undefined, { authentication: 'none' });
const nodeType = createNodeType([
{
name: 'basicAuth',
displayName: 'Basic Auth',
required: true,
displayOptions: {
show: {
authentication: ['basicAuth'],
},
},
},
]);
const issues = validateNodeCredentials(node, nodeType);
// Should be empty because basicAuth is hidden when authentication='none'
expect(issues).toEqual([]);
});
it('should validate credentials when displayOptions match', () => {
const node = createNode(undefined, { authentication: 'basicAuth' });
const nodeType = createNodeType([
{
name: 'basicAuth',
displayName: 'Basic Auth',
required: true,
displayOptions: {
show: {
authentication: ['basicAuth'],
},
},
},
]);
const issues = validateNodeCredentials(node, nodeType);
// Should have issue because basicAuth is shown but not set
expect(issues).toEqual([
{
type: 'missing',
displayName: 'Basic Auth',
credentialName: 'basicAuth',
},
]);
});
it('should return multiple issues for multiple missing credentials', () => {
const node = createNode();
const nodeType = createNodeType([
{
name: 'credential1',
displayName: 'Credential 1',
required: true,
},
{
name: 'credential2',
displayName: 'Credential 2',
required: true,
},
]);
const issues = validateNodeCredentials(node, nodeType);
expect(issues).toHaveLength(2);
expect(issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ credentialName: 'credential1' }),
expect.objectContaining({ credentialName: 'credential2' }),
]),
);
});
it('should use credential name as displayName fallback', () => {
const node = createNode();
const nodeType = createNodeType([
{
name: 'testCredential',
required: true,
// No displayName provided
} as any,
]);
const issues = validateNodeCredentials(node, nodeType);
expect(issues[0].displayName).toBe('testCredential');
});
});
describe('isTriggerLikeNode', () => {
it('should return true for node with trigger function', () => {
const nodeType: INodeType = {
description: {
displayName: 'Trigger Node',
name: 'trigger',
group: ['trigger'],
version: 1,
description: 'Test trigger',
defaults: { name: 'Trigger' },
inputs: [],
outputs: ['main'],
properties: [],
},
trigger: async () => ({
closeFunction: async () => {},
manualTriggerFunction: async () => {},
}),
};
expect(isTriggerLikeNode(nodeType)).toBe(true);
});
it('should return true for node with webhook function', () => {
const nodeType: INodeType = {
description: {
displayName: 'Webhook Node',
name: 'webhook',
group: ['trigger'],
version: 1,
description: 'Test webhook',
defaults: { name: 'Webhook' },
inputs: [],
outputs: ['main'],
properties: [],
},
webhook: async () => ({ workflowData: [[]] }),
};
expect(isTriggerLikeNode(nodeType)).toBe(true);
});
it('should return true for node with poll function', () => {
const nodeType: INodeType = {
description: {
displayName: 'Poll Node',
name: 'poll',
group: ['trigger'],
version: 1,
description: 'Test poll',
defaults: { name: 'Poll' },
inputs: [],
outputs: ['main'],
properties: [],
},
poll: async () => [[]],
};
expect(isTriggerLikeNode(nodeType)).toBe(true);
});
it('should return false for regular node', () => {
const nodeType: INodeType = {
description: {
displayName: 'Regular Node',
name: 'regular',
group: ['transform'],
version: 1,
description: 'Test regular node',
defaults: { name: 'Regular' },
inputs: ['main'],
outputs: ['main'],
properties: [],
},
execute: async () => [[]],
};
expect(isTriggerLikeNode(nodeType)).toBe(false);
});
it('should return false for node with only execute function', () => {
const nodeType: INodeType = {
description: {
displayName: 'Execute Node',
name: 'execute',
group: ['transform'],
version: 1,
description: 'Test execute node',
defaults: { name: 'Execute' },
inputs: ['main'],
outputs: ['main'],
properties: [],
},
execute: async () => [[]],
};
expect(isTriggerLikeNode(nodeType)).toBe(false);
});
});
describe('getUnconnectedRequiredInputs', () => {
const parser: INode = {
name: 'Parser',
type: 'parser',
id: 'parser-1',
typeVersion: 1,
position: [0, 0],
parameters: { autoFix: true },
};
const model: INode = {
name: 'Model',
type: 'model',
id: 'model-1',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
/** An expression, so inputs depend on the node's parameters. */
const description = {
inputs:
'={{ $parameter.autoFix ? [{ displayName: "Model", type: "ai_languageModel", required: true }] : [] }}',
} as unknown as INodeTypeDescription;
/**
* `sourcesByIndex` lists the nodes arriving at each `ai_languageModel`
* input, by input index — the same shape the engine indexes into, so a
* source on index 0 cannot stand in for the input on index 1.
*/
const makeWorkflow = (
sourcesByIndex: string[][],
nodes: Record<string, INode>,
): WorkflowForInputValidation =>
({
expression: {
// Mirrors the engine: an undefined `inputs` resolves to undefined
// rather than to a list.
getSimpleParameterValue: (node: INode, value: unknown) =>
value === undefined
? undefined
: (node.parameters as { autoFix?: boolean }).autoFix
? [{ displayName: 'Model', type: 'ai_languageModel', required: true }]
: [],
},
getNode: (name: string) => nodes[name] ?? null,
connectionsByDestinationNode: {
Parser: {
ai_languageModel: sourcesByIndex.map((names) =>
names.map((name) => ({ node: name, type: 'ai_languageModel', index: 0 })),
),
},
},
}) as unknown as WorkflowForInputValidation;
it('reports a required input with nothing connected', () => {
const result = getUnconnectedRequiredInputs(
makeWorkflow([], { Parser: parser }),
parser,
description,
);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('ai_languageModel');
});
it('reports nothing once an enabled node is connected', () => {
const result = getUnconnectedRequiredInputs(
makeWorkflow([['Model']], { Parser: parser, Model: model }),
parser,
description,
);
expect(result).toEqual([]);
});
it('treats a disabled source as not connected', () => {
const result = getUnconnectedRequiredInputs(
makeWorkflow([['Model']], { Parser: parser, Model: { ...model, disabled: true } }),
parser,
description,
);
expect(result).toHaveLength(1);
});
it('reports nothing when a failed inputs expression is treated as empty', () => {
const throwing = {
expression: {
getSimpleParameterValue: () => {
throw new Error('boom');
},
},
getNode: () => parser,
connectionsByDestinationNode: {},
} as unknown as WorkflowForInputValidation;
expect(getUnconnectedRequiredInputs(throwing, parser, description)).toEqual([]);
});
it('surfaces a failed inputs expression when asked to', () => {
const throwing = {
expression: {
getSimpleParameterValue: () => {
throw new Error('boom');
},
},
getNode: () => parser,
connectionsByDestinationNode: {},
} as unknown as WorkflowForInputValidation;
expect(() =>
getUnconnectedRequiredInputs(throwing, parser, description, {
throwOnExpressionError: true,
}),
).toThrow('boom');
});
// An expression that evaluates to a non-list is the normal state of an
// unconfigured node: the LangChain Code node maps over its `Inputs`
// collection, which is empty by default, and the engine swallows the
// resulting TypeError into `null`. The engine then reads that as "no
// inputs" and runs, so neither caller may treat it as an error — doing
// so blocks publishing a workflow the runtime is happy with.
describe('when the expression resolves to no list at all', () => {
const unresolved = {
expression: { getSimpleParameterValue: () => null },
getNode: () => parser,
connectionsByDestinationNode: {},
} as unknown as WorkflowForInputValidation;
it('reports nothing even when asked to surface expression errors', () => {
expect(
getUnconnectedRequiredInputs(unresolved, parser, description, {
throwOnExpressionError: true,
}),
).toEqual([]);
});
it('reports nothing when swallowing suits the caller', () => {
expect(getUnconnectedRequiredInputs(unresolved, parser, description)).toEqual([]);
});
});
it('reports nothing for a type that declares no inputs at all', () => {
// Nothing to resolve, so this is "requires nothing", not "unknown".
const noInputs = { properties: [] } as unknown as INodeTypeDescription;
expect(
getUnconnectedRequiredInputs(makeWorkflow([], { Parser: parser }), parser, noInputs, {
throwOnExpressionError: true,
}),
).toEqual([]);
});
// An agent with a fallback declares two required `ai_languageModel` inputs;
// the engine satisfies each on its own index.
describe('with two required inputs of the same type', () => {
const twoModels = {
inputs: [
{ displayName: 'Chat Model', type: 'ai_languageModel', required: true },
{ displayName: 'Fallback Model', type: 'ai_languageModel', required: true },
],
} as unknown as INodeTypeDescription;
it('does not let a source on the first index satisfy the second', () => {
const result = getUnconnectedRequiredInputs(
makeWorkflow([['Model']], { Parser: parser, Model: model }),
parser,
twoModels,
);
expect(result.map((input) => input.displayName)).toEqual(['Fallback Model']);
});
it('reports nothing once each index has its own source', () => {
const result = getUnconnectedRequiredInputs(
makeWorkflow([['Primary'], ['Fallback']], {
Parser: parser,
Primary: model,
Fallback: { ...model, name: 'Fallback' },
}),
parser,
twoModels,
);
expect(result).toEqual([]);
});
it('treats a disabled source on the second index as absent', () => {
const result = getUnconnectedRequiredInputs(
makeWorkflow([['Primary'], ['Fallback']], {
Parser: parser,
Primary: model,
Fallback: { ...model, name: 'Fallback', disabled: true },
}),
parser,
twoModels,
);
expect(result.map((input) => input.displayName)).toEqual(['Fallback Model']);
});
});
it('reports nothing when the parameters do not make the input required', () => {
const off = { ...parser, parameters: { autoFix: false } };
const result = getUnconnectedRequiredInputs(
makeWorkflow([], { Parser: off }),
off,
description,
);
expect(result).toEqual([]);
});
});
});