mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-03-31 22:53:07 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20ebfbb0fc |
12
CHANGELOG.md
12
CHANGELOG.md
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.41.4] - 2026-03-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`validate_workflow` misses `conditions.options` check for If/Switch nodes** (Issue #675): Added version-conditional validation — If v2.2+ and Switch v3.2+ now require `conditions.options` metadata, If v2.0-2.1 validates operator structures, and v1.x is left unchecked. Previously only caught by `n8n_create_workflow` pre-flight but not by offline `validate_workflow`.
|
||||||
|
|
||||||
|
- **False positive "Set node has no fields configured" for Set v3+** (Issue #676): The `validateSet` checker now recognizes `config.assignments.assignments` (v3+ schema) in addition to `config.values` (v1/v2 schema). Updated suggestion text to match current UI terminology.
|
||||||
|
|
||||||
|
- **Expression validator does not detect unwrapped n8n expressions** (Issue #677): Added heuristic pre-pass that detects bare `$json`, `$node`, `$input`, `$execution`, `$workflow`, `$prevNode`, `$env`, `$now`, `$today`, `$itemIndex`, and `$runIndex` references missing `={{ }}` wrappers. Uses anchored patterns to avoid false positives. Emits warnings, not errors.
|
||||||
|
|
||||||
|
Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
|
||||||
|
|
||||||
## [2.41.3] - 2026-03-27
|
## [2.41.3] - 2026-03-27
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "n8n-mcp",
|
"name": "n8n-mcp",
|
||||||
"version": "2.41.3",
|
"version": "2.41.4",
|
||||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ interface ExpressionContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ExpressionValidator {
|
export class ExpressionValidator {
|
||||||
|
// Bare n8n variable references missing {{ }} wrappers
|
||||||
|
private static readonly BARE_EXPRESSION_PATTERNS: Array<{ pattern: RegExp; name: string }> = [
|
||||||
|
{ pattern: /^\$json[.\[]/, name: '$json' },
|
||||||
|
{ pattern: /^\$node\[/, name: '$node' },
|
||||||
|
{ pattern: /^\$input\./, name: '$input' },
|
||||||
|
{ pattern: /^\$execution\./, name: '$execution' },
|
||||||
|
{ pattern: /^\$workflow\./, name: '$workflow' },
|
||||||
|
{ pattern: /^\$prevNode\./, name: '$prevNode' },
|
||||||
|
{ pattern: /^\$env\./, name: '$env' },
|
||||||
|
{ pattern: /^\$(now|today|itemIndex|runIndex)$/, name: 'built-in variable' },
|
||||||
|
];
|
||||||
|
|
||||||
// Common n8n expression patterns
|
// Common n8n expression patterns
|
||||||
private static readonly EXPRESSION_PATTERN = /\{\{([\s\S]+?)\}\}/g;
|
private static readonly EXPRESSION_PATTERN = /\{\{([\s\S]+?)\}\}/g;
|
||||||
private static readonly VARIABLE_PATTERNS = {
|
private static readonly VARIABLE_PATTERNS = {
|
||||||
@@ -288,6 +300,32 @@ export class ExpressionValidator {
|
|||||||
return combinedResult;
|
return combinedResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect bare n8n variable references missing {{ }} wrappers.
|
||||||
|
* Emits warnings since the value is technically valid as a literal string.
|
||||||
|
*/
|
||||||
|
private static checkBareExpression(
|
||||||
|
value: string,
|
||||||
|
path: string,
|
||||||
|
result: ExpressionValidationResult
|
||||||
|
): void {
|
||||||
|
if (value.includes('{{') || value.startsWith('=')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
for (const { pattern, name } of this.BARE_EXPRESSION_PATTERNS) {
|
||||||
|
if (pattern.test(trimmed)) {
|
||||||
|
result.warnings.push(
|
||||||
|
(path ? `${path}: ` : '') +
|
||||||
|
`Possible unwrapped expression: "${trimmed}" looks like an n8n ${name} reference. ` +
|
||||||
|
`Use "={{ ${trimmed} }}" to evaluate it as an expression.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively validate expressions in parameters
|
* Recursively validate expressions in parameters
|
||||||
*/
|
*/
|
||||||
@@ -307,6 +345,9 @@ export class ExpressionValidator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof obj === 'string') {
|
if (typeof obj === 'string') {
|
||||||
|
// Detect bare expressions missing {{ }} wrappers
|
||||||
|
this.checkBareExpression(obj, path, result);
|
||||||
|
|
||||||
if (obj.includes('{{')) {
|
if (obj.includes('{{')) {
|
||||||
const validation = this.validateExpression(obj, context);
|
const validation = this.validateExpression(obj, context);
|
||||||
|
|
||||||
|
|||||||
@@ -344,10 +344,10 @@ export function validateWorkflowStructure(workflow: Partial<Workflow>): string[]
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate filter-based nodes (IF v2.2+, Switch v3.2+) have complete metadata
|
// Validate If/Switch condition structures (version-conditional)
|
||||||
if (workflow.nodes) {
|
if (workflow.nodes) {
|
||||||
workflow.nodes.forEach((node, index) => {
|
workflow.nodes.forEach((node, index) => {
|
||||||
const filterErrors = validateFilterBasedNodeMetadata(node);
|
const filterErrors = validateConditionNodeStructure(node);
|
||||||
if (filterErrors.length > 0) {
|
if (filterErrors.length > 0) {
|
||||||
errors.push(...filterErrors.map(err => `Node "${node.name}" (index ${index}): ${err}`));
|
errors.push(...filterErrors.map(err => `Node "${node.name}" (index ${index}): ${err}`));
|
||||||
}
|
}
|
||||||
@@ -488,106 +488,81 @@ export function hasWebhookTrigger(workflow: Workflow): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate filter-based node metadata (IF v2.2+, Switch v3.2+)
|
* Validate If/Switch node conditions structure for ANY version.
|
||||||
* Returns array of error messages
|
* Version-conditional: validates the correct structure per version.
|
||||||
*/
|
*/
|
||||||
export function validateFilterBasedNodeMetadata(node: WorkflowNode): string[] {
|
export function validateConditionNodeStructure(node: WorkflowNode): string[] {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
const typeVersion = node.typeVersion || 1;
|
||||||
|
|
||||||
// Check if node is filter-based
|
if (node.type === 'n8n-nodes-base.if') {
|
||||||
const isIFNode = node.type === 'n8n-nodes-base.if' && node.typeVersion >= 2.2;
|
if (typeVersion >= 2.2) {
|
||||||
const isSwitchNode = node.type === 'n8n-nodes-base.switch' && node.typeVersion >= 3.2;
|
errors.push(...validateFilterOptionsRequired(node.parameters?.conditions, 'conditions'));
|
||||||
|
errors.push(...validateFilterConditionOperators(node.parameters?.conditions, 'conditions'));
|
||||||
if (!isIFNode && !isSwitchNode) {
|
} else if (typeVersion >= 2) {
|
||||||
return errors; // Not a filter-based node
|
// v2 has conditions but no options requirement; just validate operators
|
||||||
}
|
errors.push(...validateFilterConditionOperators(node.parameters?.conditions as any, 'conditions'));
|
||||||
|
}
|
||||||
// Validate IF node
|
} else if (node.type === 'n8n-nodes-base.switch') {
|
||||||
if (isIFNode) {
|
if (typeVersion >= 3.2) {
|
||||||
const conditions = (node.parameters.conditions as any);
|
const rules = node.parameters?.rules as any;
|
||||||
|
if (rules?.rules && Array.isArray(rules.rules)) {
|
||||||
// Check conditions.options exists
|
rules.rules.forEach((rule: any, i: number) => {
|
||||||
if (!conditions?.options) {
|
errors.push(...validateFilterOptionsRequired(rule.conditions, `rules.rules[${i}].conditions`));
|
||||||
errors.push(
|
errors.push(...validateFilterConditionOperators(rule.conditions, `rules.rules[${i}].conditions`));
|
||||||
'Missing required "conditions.options". ' +
|
});
|
||||||
'IF v2.2+ requires: {version: 2, leftValue: "", caseSensitive: true, typeValidation: "strict"}'
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Validate required fields
|
|
||||||
const requiredFields = {
|
|
||||||
version: 2,
|
|
||||||
leftValue: '',
|
|
||||||
caseSensitive: 'boolean',
|
|
||||||
typeValidation: 'strict'
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const [field, expectedValue] of Object.entries(requiredFields)) {
|
|
||||||
if (!(field in conditions.options)) {
|
|
||||||
errors.push(
|
|
||||||
`Missing required field "conditions.options.${field}". ` +
|
|
||||||
`Expected value: ${typeof expectedValue === 'string' ? `"${expectedValue}"` : expectedValue}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate operators in conditions
|
|
||||||
if (conditions?.conditions && Array.isArray(conditions.conditions)) {
|
|
||||||
conditions.conditions.forEach((condition: any, i: number) => {
|
|
||||||
const operatorErrors = validateOperatorStructure(condition.operator, `conditions.conditions[${i}].operator`);
|
|
||||||
errors.push(...operatorErrors);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate Switch node
|
|
||||||
if (isSwitchNode) {
|
|
||||||
const rules = (node.parameters.rules as any);
|
|
||||||
|
|
||||||
if (rules?.rules && Array.isArray(rules.rules)) {
|
|
||||||
rules.rules.forEach((rule: any, ruleIndex: number) => {
|
|
||||||
// Check rule.conditions.options
|
|
||||||
if (!rule.conditions?.options) {
|
|
||||||
errors.push(
|
|
||||||
`Missing required "rules.rules[${ruleIndex}].conditions.options". ` +
|
|
||||||
'Switch v3.2+ requires: {version: 2, leftValue: "", caseSensitive: true, typeValidation: "strict"}'
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Validate required fields
|
|
||||||
const requiredFields = {
|
|
||||||
version: 2,
|
|
||||||
leftValue: '',
|
|
||||||
caseSensitive: 'boolean',
|
|
||||||
typeValidation: 'strict'
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const [field, expectedValue] of Object.entries(requiredFields)) {
|
|
||||||
if (!(field in rule.conditions.options)) {
|
|
||||||
errors.push(
|
|
||||||
`Missing required field "rules.rules[${ruleIndex}].conditions.options.${field}". ` +
|
|
||||||
`Expected value: ${typeof expectedValue === 'string' ? `"${expectedValue}"` : expectedValue}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate operators in rule conditions
|
|
||||||
if (rule.conditions?.conditions && Array.isArray(rule.conditions.conditions)) {
|
|
||||||
rule.conditions.conditions.forEach((condition: any, condIndex: number) => {
|
|
||||||
const operatorErrors = validateOperatorStructure(
|
|
||||||
condition.operator,
|
|
||||||
`rules.rules[${ruleIndex}].conditions.conditions[${condIndex}].operator`
|
|
||||||
);
|
|
||||||
errors.push(...operatorErrors);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateFilterOptionsRequired(conditions: any, path: string): string[] {
|
||||||
|
const errors: string[] = [];
|
||||||
|
if (!conditions || typeof conditions !== 'object') return errors;
|
||||||
|
|
||||||
|
if (!conditions.options) {
|
||||||
|
errors.push(
|
||||||
|
`Missing required "${path}.options". ` +
|
||||||
|
'Filter-based nodes require: {version: 2, leftValue: "", caseSensitive: true, typeValidation: "strict"}'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const requiredFields: [string, string][] = [
|
||||||
|
['version', '2'],
|
||||||
|
['leftValue', '""'],
|
||||||
|
['caseSensitive', 'true'],
|
||||||
|
['typeValidation', '"strict"'],
|
||||||
|
];
|
||||||
|
for (const [field, display] of requiredFields) {
|
||||||
|
if (!(field in conditions.options)) {
|
||||||
|
errors.push(
|
||||||
|
`Missing required field "${path}.options.${field}". Expected value: ${display}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateFilterConditionOperators(conditions: any, path: string): string[] {
|
||||||
|
const errors: string[] = [];
|
||||||
|
if (!conditions?.conditions || !Array.isArray(conditions.conditions)) return errors;
|
||||||
|
|
||||||
|
conditions.conditions.forEach((condition: any, i: number) => {
|
||||||
|
errors.push(...validateOperatorStructure(
|
||||||
|
condition.operator,
|
||||||
|
`${path}.conditions[${i}].operator`
|
||||||
|
));
|
||||||
|
});
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use validateConditionNodeStructure instead */
|
||||||
|
export function validateFilterBasedNodeMetadata(node: WorkflowNode): string[] {
|
||||||
|
return validateConditionNodeStructure(node);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate operator structure
|
* Validate operator structure
|
||||||
* Ensures operator has correct format: {type, operation, singleValue?}
|
* Ensures operator has correct format: {type, operation, singleValue?}
|
||||||
|
|||||||
@@ -1713,12 +1713,16 @@ export class NodeSpecificValidators {
|
|||||||
// Validate mode-specific requirements
|
// Validate mode-specific requirements
|
||||||
if (config.mode === 'manual') {
|
if (config.mode === 'manual') {
|
||||||
// In manual mode, at least one field should be defined
|
// In manual mode, at least one field should be defined
|
||||||
const hasFields = config.values && Object.keys(config.values).length > 0;
|
const hasFieldsViaValues = config.values && Object.keys(config.values).length > 0;
|
||||||
|
const hasFieldsViaAssignments = config.assignments?.assignments
|
||||||
|
&& Array.isArray(config.assignments.assignments)
|
||||||
|
&& config.assignments.assignments.length > 0;
|
||||||
|
const hasFields = hasFieldsViaValues || hasFieldsViaAssignments;
|
||||||
if (!hasFields && !config.jsonOutput) {
|
if (!hasFields && !config.jsonOutput) {
|
||||||
warnings.push({
|
warnings.push({
|
||||||
type: 'missing_common',
|
type: 'missing_common',
|
||||||
message: 'Set node has no fields configured - will output empty items',
|
message: 'Set node has no fields configured - will output empty items',
|
||||||
suggestion: 'Add fields in the Values section or use JSON mode'
|
suggestion: 'Add field assignments or use JSON mode'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { validateAISpecificNodes, hasAINodes, AI_CONNECTION_TYPES } from './ai-n
|
|||||||
import { isAIToolSubNode } from './ai-tool-validators';
|
import { isAIToolSubNode } from './ai-tool-validators';
|
||||||
import { isTriggerNode } from '../utils/node-type-utils';
|
import { isTriggerNode } from '../utils/node-type-utils';
|
||||||
import { isNonExecutableNode } from '../utils/node-classification';
|
import { isNonExecutableNode } from '../utils/node-classification';
|
||||||
|
import { validateConditionNodeStructure } from './n8n-validation';
|
||||||
import { ToolVariantGenerator } from './tool-variant-generator';
|
import { ToolVariantGenerator } from './tool-variant-generator';
|
||||||
const logger = new Logger({ prefix: '[WorkflowValidator]' });
|
const logger = new Logger({ prefix: '[WorkflowValidator]' });
|
||||||
|
|
||||||
@@ -579,6 +580,19 @@ export class WorkflowValidator {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Validate If/Switch conditions structure (version-conditional)
|
||||||
|
if (node.type === 'n8n-nodes-base.if' || node.type === 'n8n-nodes-base.switch') {
|
||||||
|
const conditionErrors = validateConditionNodeStructure(node as any);
|
||||||
|
for (const err of conditionErrors) {
|
||||||
|
result.errors.push({
|
||||||
|
type: 'error',
|
||||||
|
nodeId: node.id,
|
||||||
|
nodeName: node.name,
|
||||||
|
message: err
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
result.errors.push({
|
result.errors.push({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
|
|||||||
@@ -105,6 +105,78 @@ describe('ExpressionValidator', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('bare expression detection', () => {
|
||||||
|
it('should warn on bare $json.name', () => {
|
||||||
|
const params = { value: '$json.name' };
|
||||||
|
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
|
||||||
|
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn on bare $node["Webhook"].json', () => {
|
||||||
|
const params = { value: '$node["Webhook"].json' };
|
||||||
|
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
|
||||||
|
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn on bare $now', () => {
|
||||||
|
const params = { value: '$now' };
|
||||||
|
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
|
||||||
|
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn on bare $execution.id', () => {
|
||||||
|
const params = { value: '$execution.id' };
|
||||||
|
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
|
||||||
|
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn on bare $env.API_KEY', () => {
|
||||||
|
const params = { value: '$env.API_KEY' };
|
||||||
|
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
|
||||||
|
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn on bare $input.item.json.field', () => {
|
||||||
|
const params = { value: '$input.item.json.field' };
|
||||||
|
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
|
||||||
|
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT warn on properly wrapped ={{ $json.name }}', () => {
|
||||||
|
const params = { value: '={{ $json.name }}' };
|
||||||
|
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
|
||||||
|
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT warn on properly wrapped {{ $json.name }}', () => {
|
||||||
|
const params = { value: '{{ $json.name }}' };
|
||||||
|
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
|
||||||
|
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT warn when $json appears mid-string', () => {
|
||||||
|
const params = { value: 'The $json data is ready' };
|
||||||
|
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
|
||||||
|
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT warn on plain text', () => {
|
||||||
|
const params = { value: 'Hello World' };
|
||||||
|
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
|
||||||
|
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect bare expression in nested structure', () => {
|
||||||
|
const params = {
|
||||||
|
assignments: {
|
||||||
|
assignments: [{ value: '$json.name' }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
|
||||||
|
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('edge cases', () => {
|
describe('edge cases', () => {
|
||||||
it('should handle empty expressions', () => {
|
it('should handle empty expressions', () => {
|
||||||
const result = ExpressionValidator.validateExpression('{{ }}', defaultContext);
|
const result = ExpressionValidator.validateExpression('{{ }}', defaultContext);
|
||||||
|
|||||||
@@ -2384,6 +2384,73 @@ return [{"json": {"result": result}}]
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('validateSet', () => {
|
||||||
|
it('should not warn when Set v3 has populated assignments', () => {
|
||||||
|
context.config = {
|
||||||
|
mode: 'manual',
|
||||||
|
assignments: {
|
||||||
|
assignments: [
|
||||||
|
{ id: '1', name: 'status', value: 'active', type: 'string' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
NodeSpecificValidators.validateSet(context);
|
||||||
|
|
||||||
|
const fieldWarnings = context.warnings.filter(w => w.message.includes('no fields configured'));
|
||||||
|
expect(fieldWarnings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not warn when Set v2 has populated values', () => {
|
||||||
|
context.config = {
|
||||||
|
mode: 'manual',
|
||||||
|
values: {
|
||||||
|
string: [{ name: 'field', value: 'val' }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
NodeSpecificValidators.validateSet(context);
|
||||||
|
|
||||||
|
const fieldWarnings = context.warnings.filter(w => w.message.includes('no fields configured'));
|
||||||
|
expect(fieldWarnings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn when Set v3 has empty assignments array', () => {
|
||||||
|
context.config = {
|
||||||
|
mode: 'manual',
|
||||||
|
assignments: { assignments: [] }
|
||||||
|
};
|
||||||
|
|
||||||
|
NodeSpecificValidators.validateSet(context);
|
||||||
|
|
||||||
|
const fieldWarnings = context.warnings.filter(w => w.message.includes('no fields configured'));
|
||||||
|
expect(fieldWarnings).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn when Set manual mode has no values or assignments', () => {
|
||||||
|
context.config = {
|
||||||
|
mode: 'manual'
|
||||||
|
};
|
||||||
|
|
||||||
|
NodeSpecificValidators.validateSet(context);
|
||||||
|
|
||||||
|
const fieldWarnings = context.warnings.filter(w => w.message.includes('no fields configured'));
|
||||||
|
expect(fieldWarnings).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not warn when Set manual mode has jsonOutput', () => {
|
||||||
|
context.config = {
|
||||||
|
mode: 'manual',
|
||||||
|
jsonOutput: '{"key":"value"}'
|
||||||
|
};
|
||||||
|
|
||||||
|
NodeSpecificValidators.validateSet(context);
|
||||||
|
|
||||||
|
const fieldWarnings = context.warnings.filter(w => w.message.includes('no fields configured'));
|
||||||
|
expect(fieldWarnings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('validateAIAgent', () => {
|
describe('validateAIAgent', () => {
|
||||||
let context: NodeValidationContext;
|
let context: NodeValidationContext;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { NodeRepository } from '@/database/node-repository';
|
|||||||
import { EnhancedConfigValidator } from '@/services/enhanced-config-validator';
|
import { EnhancedConfigValidator } from '@/services/enhanced-config-validator';
|
||||||
import { ExpressionValidator } from '@/services/expression-validator';
|
import { ExpressionValidator } from '@/services/expression-validator';
|
||||||
import { createWorkflow } from '@tests/utils/builders/workflow.builder';
|
import { createWorkflow } from '@tests/utils/builders/workflow.builder';
|
||||||
|
import { validateConditionNodeStructure } from '@/services/n8n-validation';
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
vi.mock('@/database/node-repository');
|
vi.mock('@/database/node-repository');
|
||||||
@@ -743,4 +744,156 @@ describe('WorkflowValidator', () => {
|
|||||||
expect(result.statistics.validConnections).toBe(3);
|
expect(result.statistics.validConnections).toBe(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── If/Switch conditions validation ──────────────────────────────
|
||||||
|
|
||||||
|
describe('If/Switch conditions validation (validateConditionNodeStructure)', () => {
|
||||||
|
it('If v2.3 missing conditions.options → error', () => {
|
||||||
|
const node = {
|
||||||
|
id: '1', name: 'IF', type: 'n8n-nodes-base.if', typeVersion: 2.3,
|
||||||
|
position: [0, 0] as [number, number],
|
||||||
|
parameters: {
|
||||||
|
conditions: {
|
||||||
|
conditions: [{ leftValue: '={{ $json.x }}', rightValue: 'a', operator: { type: 'string', operation: 'equals' } }],
|
||||||
|
combinator: 'and'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const errors = validateConditionNodeStructure(node);
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
expect(errors.some(e => e.includes('options'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('If v2.3 with complete options → no error', () => {
|
||||||
|
const node = {
|
||||||
|
id: '1', name: 'IF', type: 'n8n-nodes-base.if', typeVersion: 2.3,
|
||||||
|
position: [0, 0] as [number, number],
|
||||||
|
parameters: {
|
||||||
|
conditions: {
|
||||||
|
options: { version: 2, leftValue: '', caseSensitive: true, typeValidation: 'strict' },
|
||||||
|
conditions: [{ leftValue: '={{ $json.x }}', rightValue: 'a', operator: { type: 'string', operation: 'equals' } }],
|
||||||
|
combinator: 'and'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const errors = validateConditionNodeStructure(node);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('If v2.0 without options → no error', () => {
|
||||||
|
const node = {
|
||||||
|
id: '1', name: 'IF', type: 'n8n-nodes-base.if', typeVersion: 2.0,
|
||||||
|
position: [0, 0] as [number, number],
|
||||||
|
parameters: {
|
||||||
|
conditions: {
|
||||||
|
conditions: [{ leftValue: '={{ $json.x }}', rightValue: 'a', operator: { type: 'string', operation: 'equals' } }],
|
||||||
|
combinator: 'and'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const errors = validateConditionNodeStructure(node);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('If v2.0 with bad operator (missing type) → operator error', () => {
|
||||||
|
const node = {
|
||||||
|
id: '1', name: 'IF', type: 'n8n-nodes-base.if', typeVersion: 2.0,
|
||||||
|
position: [0, 0] as [number, number],
|
||||||
|
parameters: {
|
||||||
|
conditions: {
|
||||||
|
conditions: [{ leftValue: '={{ $json.x }}', rightValue: 'a', operator: { operation: 'equals' } }],
|
||||||
|
combinator: 'and'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const errors = validateConditionNodeStructure(node);
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
expect(errors.some(e => e.includes('type'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('If v1 with old format → no errors', () => {
|
||||||
|
const node = {
|
||||||
|
id: '1', name: 'IF', type: 'n8n-nodes-base.if', typeVersion: 1,
|
||||||
|
position: [0, 0] as [number, number],
|
||||||
|
parameters: {
|
||||||
|
conditions: { string: [{ value1: '={{ $json.x }}', value2: 'a', operation: 'equals' }] }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const errors = validateConditionNodeStructure(node);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Switch v3.2 missing rule options → error', () => {
|
||||||
|
const node = {
|
||||||
|
id: '1', name: 'Switch', type: 'n8n-nodes-base.switch', typeVersion: 3.2,
|
||||||
|
position: [0, 0] as [number, number],
|
||||||
|
parameters: {
|
||||||
|
rules: {
|
||||||
|
rules: [{
|
||||||
|
conditions: {
|
||||||
|
conditions: [{ leftValue: '={{ $json.x }}', rightValue: 'a', operator: { type: 'string', operation: 'equals' } }],
|
||||||
|
combinator: 'and'
|
||||||
|
},
|
||||||
|
outputKey: 'Branch 1'
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const errors = validateConditionNodeStructure(node);
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
expect(errors.some(e => e.includes('rules.rules[0].conditions.options'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Switch v3.2 with complete options → no error', () => {
|
||||||
|
const node = {
|
||||||
|
id: '1', name: 'Switch', type: 'n8n-nodes-base.switch', typeVersion: 3.2,
|
||||||
|
position: [0, 0] as [number, number],
|
||||||
|
parameters: {
|
||||||
|
rules: {
|
||||||
|
rules: [{
|
||||||
|
conditions: {
|
||||||
|
options: { version: 2, leftValue: '', caseSensitive: true, typeValidation: 'strict' },
|
||||||
|
conditions: [{ leftValue: '={{ $json.x }}', rightValue: 'a', operator: { type: 'string', operation: 'equals' } }],
|
||||||
|
combinator: 'and'
|
||||||
|
},
|
||||||
|
outputKey: 'Branch 1'
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const errors = validateConditionNodeStructure(node);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('If v2.2 with empty parameters (missing conditions) → no error (graceful)', () => {
|
||||||
|
const node = {
|
||||||
|
id: '1', name: 'IF', type: 'n8n-nodes-base.if', typeVersion: 2.2,
|
||||||
|
position: [0, 0] as [number, number],
|
||||||
|
parameters: {}
|
||||||
|
};
|
||||||
|
const errors = validateConditionNodeStructure(node);
|
||||||
|
// Empty parameters are allowed — draft/incomplete nodes are valid at this level
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Switch v3.0 without options → no error', () => {
|
||||||
|
const node = {
|
||||||
|
id: '1', name: 'Switch', type: 'n8n-nodes-base.switch', typeVersion: 3.0,
|
||||||
|
position: [0, 0] as [number, number],
|
||||||
|
parameters: {
|
||||||
|
rules: {
|
||||||
|
rules: [{
|
||||||
|
conditions: {
|
||||||
|
conditions: [{ leftValue: '={{ $json.x }}', rightValue: 'a', operator: { type: 'string', operation: 'equals' } }],
|
||||||
|
combinator: 'and'
|
||||||
|
},
|
||||||
|
outputKey: 'Branch 1'
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const errors = validateConditionNodeStructure(node);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user