workflow-validator-comprehensive.test.ts•61.9 kB
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
import { WorkflowValidator } from '@/services/workflow-validator';
import { NodeRepository } from '@/database/node-repository';
import { EnhancedConfigValidator } from '@/services/enhanced-config-validator';
import { ExpressionValidator } from '@/services/expression-validator';
import { createWorkflow } from '@tests/utils/builders/workflow.builder';
import type { WorkflowNode, Workflow } from '@/types/n8n-api';
// Mock dependencies
vi.mock('@/database/node-repository');
vi.mock('@/services/enhanced-config-validator');
vi.mock('@/services/expression-validator');
vi.mock('@/utils/logger');
describe('WorkflowValidator - Comprehensive Tests', () => {
let validator: WorkflowValidator;
let mockNodeRepository: NodeRepository;
let mockEnhancedConfigValidator: typeof EnhancedConfigValidator;
beforeEach(() => {
vi.clearAllMocks();
// Create mock instances
mockNodeRepository = new NodeRepository({} as any) as any;
mockEnhancedConfigValidator = EnhancedConfigValidator as any;
// Ensure the mock repository has all necessary methods
if (!mockNodeRepository.getAllNodes) {
mockNodeRepository.getAllNodes = vi.fn();
}
if (!mockNodeRepository.getNode) {
mockNodeRepository.getNode = vi.fn();
}
// Mock common node types data
const nodeTypes: Record<string, any> = {
'nodes-base.webhook': {
type: 'nodes-base.webhook',
displayName: 'Webhook',
package: 'n8n-nodes-base',
version: 2,
isVersioned: true,
properties: [],
category: 'trigger'
},
'nodes-base.httpRequest': {
type: 'nodes-base.httpRequest',
displayName: 'HTTP Request',
package: 'n8n-nodes-base',
version: 4,
isVersioned: true,
properties: [],
category: 'network'
},
'nodes-base.set': {
type: 'nodes-base.set',
displayName: 'Set',
package: 'n8n-nodes-base',
version: 3,
isVersioned: true,
properties: [],
category: 'data'
},
'nodes-base.code': {
type: 'nodes-base.code',
displayName: 'Code',
package: 'n8n-nodes-base',
version: 2,
isVersioned: true,
properties: [],
category: 'code'
},
'nodes-base.manualTrigger': {
type: 'nodes-base.manualTrigger',
displayName: 'Manual Trigger',
package: 'n8n-nodes-base',
version: 1,
isVersioned: true,
properties: [],
category: 'trigger'
},
'nodes-base.if': {
type: 'nodes-base.if',
displayName: 'IF',
package: 'n8n-nodes-base',
version: 2,
isVersioned: true,
properties: [],
category: 'logic'
},
'nodes-base.slack': {
type: 'nodes-base.slack',
displayName: 'Slack',
package: 'n8n-nodes-base',
version: 2,
isVersioned: true,
properties: [],
category: 'communication'
},
'nodes-base.googleSheets': {
type: 'nodes-base.googleSheets',
displayName: 'Google Sheets',
package: 'n8n-nodes-base',
version: 4,
isVersioned: true,
properties: [],
category: 'data'
},
'nodes-langchain.agent': {
type: 'nodes-langchain.agent',
displayName: 'AI Agent',
package: '@n8n/n8n-nodes-langchain',
version: 1,
isVersioned: true,
properties: [],
isAITool: true,
category: 'ai'
},
'nodes-base.postgres': {
type: 'nodes-base.postgres',
displayName: 'Postgres',
package: 'n8n-nodes-base',
version: 2,
isVersioned: true,
properties: [],
category: 'database'
},
'community.customNode': {
type: 'community.customNode',
displayName: 'Custom Node',
package: 'n8n-nodes-custom',
version: 1,
isVersioned: false,
properties: [],
isAITool: false,
category: 'custom'
}
};
// Set up default mock behaviors
vi.mocked(mockNodeRepository.getNode).mockImplementation((nodeType: string) => {
// Handle normalization for custom nodes
if (nodeType === 'n8n-nodes-custom.customNode') {
return {
type: 'n8n-nodes-custom.customNode',
displayName: 'Custom Node',
package: 'n8n-nodes-custom',
version: 1,
isVersioned: false,
properties: [],
isAITool: false
};
}
return nodeTypes[nodeType] || null;
});
// Mock getAllNodes for NodeSimilarityService
vi.mocked(mockNodeRepository.getAllNodes).mockReturnValue(Object.values(nodeTypes));
vi.mocked(mockEnhancedConfigValidator.validateWithMode).mockReturnValue({
errors: [],
warnings: [],
suggestions: [],
mode: 'operation' as const,
valid: true,
visibleProperties: [],
hiddenProperties: []
} as any);
vi.mocked(ExpressionValidator.validateNodeExpressions).mockReturnValue({
valid: true,
errors: [],
warnings: [],
usedVariables: new Set(),
usedNodes: new Set()
});
// Create validator instance
validator = new WorkflowValidator(mockNodeRepository, mockEnhancedConfigValidator);
});
describe('validateWorkflow', () => {
it('should validate a minimal valid workflow', async () => {
const workflow = createWorkflow('Test Workflow')
.addWebhookNode({ name: 'Webhook' })
.build();
const result = await validator.validateWorkflow(workflow as any);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.statistics.totalNodes).toBe(1);
expect(result.statistics.enabledNodes).toBe(1);
expect(result.statistics.triggerNodes).toBe(1);
});
it('should validate a workflow with all options disabled', async () => {
const workflow = createWorkflow('Test Workflow')
.addWebhookNode({ name: 'Webhook' })
.build();
const result = await validator.validateWorkflow(workflow as any, {
validateNodes: false,
validateConnections: false,
validateExpressions: false
});
expect(result.valid).toBe(true);
expect(mockNodeRepository.getNode).not.toHaveBeenCalled();
expect(ExpressionValidator.validateNodeExpressions).not.toHaveBeenCalled();
});
it('should handle validation errors gracefully', async () => {
const workflow = createWorkflow('Test Workflow')
.addWebhookNode({ name: 'Webhook' })
.build();
// Make the validation throw an error
vi.mocked(mockNodeRepository.getNode).mockImplementation(() => {
throw new Error('Database error');
});
const result = await validator.validateWorkflow(workflow as any);
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
expect(result.errors.some(e => e.message.includes('Database error'))).toBe(true);
});
it('should use different validation profiles', async () => {
const workflow = createWorkflow('Test Workflow')
.addWebhookNode({ name: 'Webhook' })
.build();
const profiles = ['minimal', 'runtime', 'ai-friendly', 'strict'] as const;
for (const profile of profiles) {
const result = await validator.validateWorkflow(workflow as any, { profile });
expect(result).toBeDefined();
expect(mockEnhancedConfigValidator.validateWithMode).toHaveBeenCalledWith(
expect.any(String),
expect.any(Object),
expect.any(Array),
'operation',
profile
);
}
});
});
describe('validateWorkflowStructure', () => {
it('should error when nodes array is missing', async () => {
const workflow = { connections: {} } as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.message === 'Workflow must have a nodes array')).toBe(true);
});
it('should error when connections object is missing', async () => {
const workflow = { nodes: [] } as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.message === 'Workflow must have a connections object')).toBe(true);
});
it('should warn when workflow has no nodes', async () => {
const workflow = { nodes: [], connections: {} } as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.valid).toBe(true); // Empty workflows are valid but get a warning
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0].message).toBe('Workflow is empty - no nodes defined');
});
it('should error for single non-webhook node workflow', async () => {
const workflow = {
nodes: [{
id: '1',
name: 'Set',
type: 'n8n-nodes-base.set',
position: [100, 100],
parameters: {}
}],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.message.includes('Single-node workflows are only valid for webhook endpoints'))).toBe(true);
});
it('should warn for webhook without connections', async () => {
const workflow = {
nodes: [{
id: '1',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [100, 100],
parameters: {},
typeVersion: 2
}],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.valid).toBe(true);
expect(result.warnings.some(w => w.message.includes('Webhook node has no connections'))).toBe(true);
});
it('should error for multi-node workflow without connections', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [100, 100],
parameters: {}
},
{
id: '2',
name: 'Set',
type: 'n8n-nodes-base.set',
position: [300, 100],
parameters: {}
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.message.includes('Multi-node workflow has no connections'))).toBe(true);
});
it('should detect duplicate node names', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [100, 100],
parameters: {}
},
{
id: '2',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [300, 100],
parameters: {}
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.errors.some(e => e.message.includes('Duplicate node name: "Webhook"'))).toBe(true);
});
it('should detect duplicate node IDs', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Webhook1',
type: 'n8n-nodes-base.webhook',
position: [100, 100],
parameters: {}
},
{
id: '1',
name: 'Webhook2',
type: 'n8n-nodes-base.webhook',
position: [300, 100],
parameters: {}
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.errors.some(e => e.message.includes('Duplicate node ID: "1"'))).toBe(true);
});
it('should count trigger nodes correctly', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [100, 100],
parameters: {}
},
{
id: '2',
name: 'Schedule',
type: 'n8n-nodes-base.scheduleTrigger',
position: [100, 300],
parameters: {}
},
{
id: '3',
name: 'Manual',
type: 'n8n-nodes-base.manualTrigger',
position: [100, 500],
parameters: {}
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.statistics.triggerNodes).toBe(3);
});
it('should warn when no trigger nodes exist', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Set',
type: 'n8n-nodes-base.set',
position: [100, 100],
parameters: {}
},
{
id: '2',
name: 'Code',
type: 'n8n-nodes-base.code',
position: [300, 100],
parameters: {}
}
],
connections: {
'Set': {
main: [[{ node: 'Code', type: 'main', index: 0 }]]
}
}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.warnings.some(w => w.message.includes('Workflow has no trigger nodes'))).toBe(true);
});
it('should not count disabled nodes in enabledNodes count', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [100, 100],
parameters: {},
disabled: true
},
{
id: '2',
name: 'Set',
type: 'n8n-nodes-base.set',
position: [300, 100],
parameters: {}
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.statistics.totalNodes).toBe(2);
expect(result.statistics.enabledNodes).toBe(1);
});
});
describe('validateAllNodes', () => {
it('should skip disabled nodes', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [100, 100],
parameters: {},
disabled: true
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(mockNodeRepository.getNode).not.toHaveBeenCalled();
});
it('should accept both nodes-base and n8n-nodes-base prefixes as valid', async () => {
// This test verifies the fix for false positives - both prefixes are valid
const workflow = {
nodes: [
{
id: '1',
name: 'Webhook',
type: 'nodes-base.webhook', // This is now valid (normalized internally)
position: [100, 100],
parameters: {}
}
],
connections: {}
} as any;
// Mock the normalized node lookup
(mockNodeRepository.getNode as any) = vi.fn((type: string) => {
if (type === 'nodes-base.webhook') {
return {
nodeType: 'nodes-base.webhook',
displayName: 'Webhook',
properties: [],
isVersioned: false
};
}
return null;
});
const result = await validator.validateWorkflow(workflow as any);
// Should NOT error for nodes-base prefix - it's valid!
expect(result.valid).toBe(true);
expect(result.errors.some(e => e.message.includes('Invalid node type'))).toBe(false);
});
it.skip('should handle unknown node types with suggestions', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'HTTP',
type: 'httpRequest', // Missing package prefix
position: [100, 100],
parameters: {}
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.message.includes('Unknown node type: "httpRequest"'))).toBe(true);
expect(result.errors.some(e => e.message.includes('Did you mean "n8n-nodes-base.httpRequest"?'))).toBe(true);
});
it('should try normalized types for n8n-nodes-base', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [100, 100],
parameters: {}
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(mockNodeRepository.getNode).toHaveBeenCalledWith('nodes-base.webhook');
});
it('should validate typeVersion but skip parameter validation for langchain nodes', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 1,
position: [100, 100],
parameters: {}
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
// After v2.17.4 fix: Langchain nodes SHOULD call getNode for typeVersion validation
// This prevents invalid typeVersion values from bypassing validation
// But they skip parameter validation (handled by dedicated AI validators)
expect(mockNodeRepository.getNode).toHaveBeenCalledWith('nodes-langchain.agent');
// Should not have typeVersion validation errors (other AI-specific errors may exist)
const typeVersionErrors = result.errors.filter(e => e.message.includes('typeVersion'));
expect(typeVersionErrors).toEqual([]);
});
it('should catch invalid typeVersion for langchain nodes (v2.17.4 bug fix)', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 99999, // Invalid - exceeds maximum
position: [100, 100],
parameters: {}
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
// Critical: Before v2.17.4, this would pass validation but fail at runtime
// After v2.17.4: Invalid typeVersion is caught during validation
expect(result.valid).toBe(false);
expect(result.errors.some(e =>
e.message.includes('typeVersion 99999 exceeds maximum')
)).toBe(true);
});
it('should validate typeVersion for versioned nodes', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [100, 100],
parameters: {}
// Missing typeVersion
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.errors.some(e => e.message.includes('Missing required property \'typeVersion\''))).toBe(true);
});
it('should error for invalid typeVersion', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [100, 100],
parameters: {},
typeVersion: 'invalid' as any
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.errors.some(e => e.message.includes('Invalid typeVersion: invalid'))).toBe(true);
});
it('should warn for outdated typeVersion', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [100, 100],
parameters: {},
typeVersion: 1 // Current version is 2
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.warnings.some(w => w.message.includes('Outdated typeVersion: 1. Latest is 2'))).toBe(true);
});
it('should error for typeVersion exceeding maximum', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [100, 100],
parameters: {},
typeVersion: 10 // Max is 2
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.errors.some(e => e.message.includes('typeVersion 10 exceeds maximum supported version 2'))).toBe(true);
});
it('should add node validation errors and warnings', async () => {
vi.mocked(mockEnhancedConfigValidator.validateWithMode).mockReturnValue({
errors: [{ type: 'missing_required', property: 'url', message: 'Missing required field: url' }],
warnings: [{ type: 'security', property: 'url', message: 'Consider using HTTPS' }],
suggestions: [],
mode: 'operation' as const,
valid: false,
visibleProperties: [],
hiddenProperties: []
} as any);
const workflow = {
nodes: [
{
id: '1',
name: 'HTTP',
type: 'n8n-nodes-base.httpRequest',
position: [100, 100],
parameters: {},
typeVersion: 4
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.errors.some(e => e.message.includes('Missing required field: url'))).toBe(true);
expect(result.warnings.some(w => w.message.includes('Consider using HTTPS'))).toBe(true);
});
it('should handle node validation failures gracefully', async () => {
vi.mocked(mockEnhancedConfigValidator.validateWithMode).mockImplementation(() => {
throw new Error('Validation error');
});
const workflow = {
nodes: [
{
id: '1',
name: 'HTTP',
type: 'n8n-nodes-base.httpRequest',
position: [100, 100],
parameters: {},
typeVersion: 4
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.errors.some(e => e.message.includes('Failed to validate node: Validation error'))).toBe(true);
});
});
describe('validateConnections', () => {
it('should validate valid connections', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [100, 100],
parameters: {}
},
{
id: '2',
name: 'Set',
type: 'n8n-nodes-base.set',
position: [300, 100],
parameters: {}
}
],
connections: {
'Webhook': {
main: [[{ node: 'Set', type: 'main', index: 0 }]]
}
}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.statistics.validConnections).toBe(1);
expect(result.statistics.invalidConnections).toBe(0);
});
it('should error for connection from non-existent node', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [100, 100],
parameters: {}
}
],
connections: {
'NonExistent': {
main: [[{ node: 'Webhook', type: 'main', index: 0 }]]
}
}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.errors.some(e => e.message.includes('Connection from non-existent node: "NonExistent"'))).toBe(true);
expect(result.statistics.invalidConnections).toBe(1);
});
it('should error when using node ID instead of name in source', async () => {
const workflow = {
nodes: [
{
id: 'webhook-id',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [100, 100],
parameters: {}
},
{
id: 'set-id',
name: 'Set',
type: 'n8n-nodes-base.set',
position: [300, 100],
parameters: {}
}
],
connections: {
'webhook-id': { // Using ID instead of name
main: [[{ node: 'Set', type: 'main', index: 0 }]]
}
}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.errors.some(e => e.message.includes('Connection uses node ID \'webhook-id\' instead of node name \'Webhook\''))).toBe(true);
});
it('should error for connection to non-existent node', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [100, 100],
parameters: {}
}
],
connections: {
'Webhook': {
main: [[{ node: 'NonExistent', type: 'main', index: 0 }]]
}
}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.errors.some(e => e.message.includes('Connection to non-existent node: "NonExistent"'))).toBe(true);
expect(result.statistics.invalidConnections).toBe(1);
});
it('should error when using node ID instead of name in target', async () => {
const workflow = {
nodes: [
{
id: 'webhook-id',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [100, 100],
parameters: {}
},
{
id: 'set-id',
name: 'Set',
type: 'n8n-nodes-base.set',
position: [300, 100],
parameters: {}
}
],
connections: {
'Webhook': {
main: [[{ node: 'set-id', type: 'main', index: 0 }]] // Using ID instead of name
}
}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.errors.some(e => e.message.includes('Connection target uses node ID \'set-id\' instead of node name \'Set\''))).toBe(true);
});
it('should warn for connection to disabled node', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [100, 100],
parameters: {}
},
{
id: '2',
name: 'Set',
type: 'n8n-nodes-base.set',
position: [300, 100],
parameters: {},
disabled: true
}
],
connections: {
'Webhook': {
main: [[{ node: 'Set', type: 'main', index: 0 }]]
}
}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.warnings.some(w => w.message.includes('Connection to disabled node: "Set"'))).toBe(true);
});
it('should validate error outputs', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'HTTP',
type: 'n8n-nodes-base.httpRequest',
position: [100, 100],
parameters: {}
},
{
id: '2',
name: 'Error Handler',
type: 'n8n-nodes-base.set',
position: [300, 100],
parameters: {}
}
],
connections: {
'HTTP': {
error: [[{ node: 'Error Handler', type: 'main', index: 0 }]]
}
}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.statistics.validConnections).toBe(1);
});
it('should validate AI tool connections', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Agent',
type: '@n8n/n8n-nodes-langchain.agent',
position: [100, 100],
parameters: {}
},
{
id: '2',
name: 'Tool',
type: 'n8n-nodes-base.httpRequest',
position: [300, 100],
parameters: {}
}
],
connections: {
'Agent': {
ai_tool: [[{ node: 'Tool', type: 'main', index: 0 }]]
}
}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.statistics.validConnections).toBe(1);
});
it('should warn for community nodes used as AI tools', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Agent',
type: '@n8n/n8n-nodes-langchain.agent',
position: [100, 100],
parameters: {},
typeVersion: 1
},
{
id: '2',
name: 'CustomTool',
type: 'n8n-nodes-custom.customNode',
position: [300, 100],
parameters: {},
typeVersion: 1
}
],
connections: {
'Agent': {
ai_tool: [[{ node: 'CustomTool', type: 'main', index: 0 }]]
}
}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.warnings.some(w => w.message.includes('Community node "CustomTool" is being used as an AI tool'))).toBe(true);
});
it('should warn for orphaned nodes', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [100, 100],
parameters: {}
},
{
id: '2',
name: 'Set',
type: 'n8n-nodes-base.set',
position: [300, 100],
parameters: {}
},
{
id: '3',
name: 'Orphaned',
type: 'n8n-nodes-base.code',
position: [500, 100],
parameters: {}
}
],
connections: {
'Webhook': {
main: [[{ node: 'Set', type: 'main', index: 0 }]]
}
}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.warnings.some(w => w.message.includes('Node is not connected to any other nodes') && w.nodeName === 'Orphaned')).toBe(true);
});
it('should detect cycles in workflow', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Node1',
type: 'n8n-nodes-base.set',
position: [100, 100],
parameters: {}
},
{
id: '2',
name: 'Node2',
type: 'n8n-nodes-base.set',
position: [300, 100],
parameters: {}
},
{
id: '3',
name: 'Node3',
type: 'n8n-nodes-base.set',
position: [500, 100],
parameters: {}
}
],
connections: {
'Node1': {
main: [[{ node: 'Node2', type: 'main', index: 0 }]]
},
'Node2': {
main: [[{ node: 'Node3', type: 'main', index: 0 }]]
},
'Node3': {
main: [[{ node: 'Node1', type: 'main', index: 0 }]] // Creates cycle
}
}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.errors.some(e => e.message.includes('Workflow contains a cycle'))).toBe(true);
});
it('should handle null connections properly', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'IF',
type: 'n8n-nodes-base.if',
position: [100, 100],
parameters: {},
typeVersion: 2
},
{
id: '2',
name: 'True Branch',
type: 'n8n-nodes-base.set',
position: [300, 50],
parameters: {},
typeVersion: 3
}
],
connections: {
'IF': {
main: [
[{ node: 'True Branch', type: 'main', index: 0 }],
null // False branch not connected
]
}
}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.statistics.validConnections).toBe(1);
expect(result.valid).toBe(true);
});
});
describe('validateExpressions', () => {
it('should validate expressions in node parameters', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [100, 100],
parameters: {}
},
{
id: '2',
name: 'Set',
type: 'n8n-nodes-base.set',
position: [300, 100],
parameters: {
values: {
string: [
{
name: 'field',
value: '={{ $json.data }}'
}
]
}
}
}
],
connections: {
'Webhook': {
main: [[{ node: 'Set', type: 'main', index: 0 }]]
}
}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(ExpressionValidator.validateNodeExpressions).toHaveBeenCalledWith(
expect.objectContaining({ values: expect.any(Object) }),
expect.objectContaining({
availableNodes: expect.arrayContaining(['Webhook']),
currentNodeName: 'Set',
hasInputData: true
})
);
});
it('should add expression errors to result', async () => {
vi.mocked(ExpressionValidator.validateNodeExpressions).mockReturnValue({
valid: false,
errors: ['Invalid expression syntax'],
warnings: ['Deprecated variable usage'],
usedVariables: new Set(['$json']),
usedNodes: new Set()
});
const workflow = {
nodes: [
{
id: '1',
name: 'Set',
type: 'n8n-nodes-base.set',
position: [100, 100],
parameters: {
value: '={{ invalid }}'
}
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.errors.some(e => e.message.includes('Expression error: Invalid expression syntax'))).toBe(true);
expect(result.warnings.some(w => w.message.includes('Expression warning: Deprecated variable usage'))).toBe(true);
expect(result.statistics.expressionsValidated).toBe(1);
});
it('should skip expression validation for disabled nodes', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Set',
type: 'n8n-nodes-base.set',
position: [100, 100],
parameters: {
value: '={{ $json.data }}'
},
disabled: true
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(ExpressionValidator.validateNodeExpressions).not.toHaveBeenCalled();
});
});
describe('checkWorkflowPatterns', () => {
it('should suggest error handling for large workflows', async () => {
const builder = createWorkflow('Large Workflow');
// Add more than 3 nodes
for (let i = 0; i < 5; i++) {
builder.addCustomNode('n8n-nodes-base.set', 3, {}, { name: `Set${i}` });
}
const workflow = builder.build() as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.warnings.some(w => w.message.includes('Consider adding error handling'))).toBe(true);
});
it('should warn about long linear chains', async () => {
const builder = createWorkflow('Linear Workflow');
// Create a chain of 12 nodes
const nodeNames: string[] = [];
for (let i = 0; i < 12; i++) {
const nodeName = `Node${i}`;
builder.addCustomNode('n8n-nodes-base.set', 3, {}, { name: nodeName });
nodeNames.push(nodeName);
}
// Connect them sequentially
builder.connectSequentially(nodeNames);
const workflow = builder.build() as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.warnings.some(w => w.message.includes('Long linear chain detected'))).toBe(true);
});
it('should warn about missing credentials', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Slack',
type: 'n8n-nodes-base.slack',
position: [100, 100],
parameters: {},
credentials: {
slackApi: {} // Missing id
}
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.warnings.some(w => w.message.includes('Missing credentials configuration for slackApi'))).toBe(true);
});
it('should warn about AI agents without tools', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Agent',
type: '@n8n/n8n-nodes-langchain.agent',
position: [100, 100],
parameters: {}
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.warnings.some(w => w.message.includes('AI Agent has no tools connected'))).toBe(true);
});
it('should suggest community package setting for AI tools', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Agent',
type: '@n8n/n8n-nodes-langchain.agent',
position: [100, 100],
parameters: {}
},
{
id: '2',
name: 'Tool',
type: 'n8n-nodes-base.httpRequest',
position: [300, 100],
parameters: {}
}
],
connections: {
'Agent': {
ai_tool: [[{ node: 'Tool', type: 'main', index: 0 }]]
}
}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.suggestions.some(s => s.includes('N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE'))).toBe(true);
});
});
describe('checkNodeErrorHandling', () => {
it('should error when node-level properties are inside parameters', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'HTTP',
type: 'n8n-nodes-base.httpRequest',
position: [100, 100],
typeVersion: 4,
parameters: {
url: 'https://api.example.com',
onError: 'continueRegularOutput', // Wrong location!
retryOnFail: true, // Wrong location!
credentials: {} // Wrong location!
}
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.errors.some(e => e.message.includes('Node-level properties onError, retryOnFail, credentials are in the wrong location'))).toBe(true);
expect(result.errors.some(e => e.details?.fix?.includes('Move these properties from node.parameters to the node level'))).toBe(true);
});
it('should validate onError property values', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'HTTP',
type: 'n8n-nodes-base.httpRequest',
position: [100, 100],
parameters: {},
onError: 'invalidValue' as any
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.errors.some(e => e.message.includes('Invalid onError value: "invalidValue"'))).toBe(true);
});
it('should warn about deprecated continueOnFail', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'HTTP',
type: 'n8n-nodes-base.httpRequest',
position: [100, 100],
parameters: {},
continueOnFail: true
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.warnings.some(w => w.message.includes('Using deprecated "continueOnFail: true"'))).toBe(true);
});
it('should error for conflicting error handling properties', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'HTTP',
type: 'n8n-nodes-base.httpRequest',
position: [100, 100],
parameters: {},
continueOnFail: true,
onError: 'continueRegularOutput'
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.errors.some(e => e.message.includes('Cannot use both "continueOnFail" and "onError" properties'))).toBe(true);
});
it('should validate retry configuration', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'HTTP',
type: 'n8n-nodes-base.httpRequest',
position: [100, 100],
parameters: {},
retryOnFail: true,
maxTries: 'invalid' as any,
waitBetweenTries: -1000
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.errors.some(e => e.message.includes('maxTries must be a positive number'))).toBe(true);
expect(result.errors.some(e => e.message.includes('waitBetweenTries must be a non-negative number'))).toBe(true);
});
it('should warn about excessive retry values', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'HTTP',
type: 'n8n-nodes-base.httpRequest',
position: [100, 100],
parameters: {},
retryOnFail: true,
maxTries: 15,
waitBetweenTries: 400000
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.warnings.some(w => w.message.includes('maxTries is set to 15'))).toBe(true);
expect(result.warnings.some(w => w.message.includes('waitBetweenTries is set to 400000ms'))).toBe(true);
});
it('should warn about retryOnFail without maxTries', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'HTTP',
type: 'n8n-nodes-base.httpRequest',
position: [100, 100],
parameters: {},
retryOnFail: true
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.warnings.some(w => w.message.includes('retryOnFail is enabled but maxTries is not specified'))).toBe(true);
});
it('should validate other node-level properties', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Set',
type: 'n8n-nodes-base.set',
position: [100, 100],
parameters: {},
typeVersion: 3,
alwaysOutputData: 'invalid' as any,
executeOnce: 'invalid' as any,
disabled: 'invalid' as any,
notesInFlow: 'invalid' as any,
notes: 123 as any
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.errors.some(e => e.message.includes('alwaysOutputData must be a boolean'))).toBe(true);
expect(result.errors.some(e => e.message.includes('executeOnce must be a boolean'))).toBe(true);
expect(result.errors.some(e => e.message.includes('disabled must be a boolean'))).toBe(true);
expect(result.errors.some(e => e.message.includes('notesInFlow must be a boolean'))).toBe(true);
expect(result.errors.some(e => e.message.includes('notes must be a string'))).toBe(true);
});
it('should warn about executeOnce', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Set',
type: 'n8n-nodes-base.set',
position: [100, 100],
parameters: {},
executeOnce: true
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.warnings.some(w => w.message.includes('executeOnce is enabled'))).toBe(true);
});
it('should warn error-prone nodes without error handling', async () => {
const errorProneNodes = [
{ type: 'n8n-nodes-base.httpRequest', message: 'HTTP Request', version: 4 },
{ type: 'n8n-nodes-base.webhook', message: 'Webhook', version: 2 },
{ type: 'n8n-nodes-base.postgres', message: 'Database operation', version: 2 },
{ type: 'n8n-nodes-base.slack', message: 'slack node', version: 2 }
];
for (const nodeInfo of errorProneNodes) {
const workflow = {
nodes: [
{
id: '1',
name: 'Node',
type: nodeInfo.type,
position: [100, 100],
parameters: {},
typeVersion: nodeInfo.version
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.warnings.some(w => w.message.includes(nodeInfo.message) && w.message.includes('without error handling'))).toBe(true);
}
});
it('should warn about conflicting error handling', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'HTTP',
type: 'n8n-nodes-base.httpRequest',
position: [100, 100],
parameters: {},
continueOnFail: true,
retryOnFail: true
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.warnings.some(w => w.message.includes('Both continueOnFail and retryOnFail are enabled'))).toBe(true);
});
it('should suggest alwaysOutputData for debugging', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'HTTP',
type: 'n8n-nodes-base.httpRequest',
position: [100, 100],
parameters: {},
retryOnFail: true
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.suggestions.some(s => s.includes('Consider enabling alwaysOutputData'))).toBe(true);
});
it('should provide general error handling suggestions', async () => {
const builder = createWorkflow('No Error Handling');
// Add 6 nodes without error handling
for (let i = 0; i < 6; i++) {
builder.addCustomNode('n8n-nodes-base.httpRequest', 4, {}, { name: `HTTP${i}` });
}
const workflow = builder.build() as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.suggestions.some(s => s.includes('Most nodes lack error handling'))).toBe(true);
});
it('should suggest replacing deprecated error handling', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'HTTP',
type: 'n8n-nodes-base.httpRequest',
position: [100, 100],
parameters: {},
continueOnFail: true
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.suggestions.some(s => s.includes('Replace "continueOnFail: true" with "onError:'))).toBe(true);
});
});
describe('generateSuggestions', () => {
it('should suggest adding trigger for workflows without triggers', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Set',
type: 'n8n-nodes-base.set',
position: [100, 100],
parameters: {}
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.suggestions.some(s => s.includes('Add a trigger node'))).toBe(true);
});
it('should provide connection examples for connection errors', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [100, 100],
parameters: {}
},
{
id: '2',
name: 'Set',
type: 'n8n-nodes-base.set',
position: [300, 100],
parameters: {}
}
],
connections: {} // Missing connections
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.suggestions.some(s => s.includes('Example connection structure'))).toBe(true);
expect(result.suggestions.some(s => s.includes('Use node NAMES (not IDs) in connections'))).toBe(true);
});
it('should suggest error handling when missing', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [100, 100],
parameters: {}
},
{
id: '2',
name: 'HTTP',
type: 'n8n-nodes-base.httpRequest',
position: [300, 100],
parameters: {}
}
],
connections: {
'Webhook': {
main: [[{ node: 'HTTP', type: 'main', index: 0 }]]
}
}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.suggestions.some(s => s.includes('Add error handling'))).toBe(true);
});
it('should suggest breaking up large workflows', async () => {
const builder = createWorkflow('Large Workflow');
// Add 25 nodes
for (let i = 0; i < 25; i++) {
builder.addCustomNode('n8n-nodes-base.set', 3, {}, { name: `Node${i}` });
}
const workflow = builder.build() as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.suggestions.some(s => s.includes('Consider breaking this workflow into smaller sub-workflows'))).toBe(true);
});
it('should suggest Code node for complex expressions', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Complex',
type: 'n8n-nodes-base.set',
position: [100, 100],
parameters: {
field1: '={{ $json.a }}',
field2: '={{ $json.b }}',
field3: '={{ $json.c }}',
field4: '={{ $json.d }}',
field5: '={{ $json.e }}',
field6: '={{ $json.f }}'
}
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.suggestions.some(s => s.includes('Consider using a Code node for complex data transformations'))).toBe(true);
});
it('should suggest minimal workflow structure', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Set',
type: 'n8n-nodes-base.set',
position: [100, 100],
parameters: {}
}
],
connections: {}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.suggestions.some(s => s.includes('A minimal workflow needs'))).toBe(true);
});
});
describe('findSimilarNodeTypes', () => {
it.skip('should find similar node types for common mistakes', async () => {
// Test that webhook without prefix gets suggestions
const webhookWorkflow = {
nodes: [
{
id: '1',
name: 'Node',
type: 'webhook',
position: [100, 100],
parameters: {}
}
],
connections: {}
} as any;
const webhookResult = await validator.validateWorkflow(webhookWorkflow);
// Check that we get an unknown node error with suggestions
const unknownNodeError = webhookResult.errors.find(e =>
e.message && e.message.includes('Unknown node type')
);
expect(unknownNodeError).toBeDefined();
// For webhook, it should definitely suggest nodes-base.webhook
expect(unknownNodeError?.message).toContain('nodes-base.webhook');
// Test that slack without prefix gets suggestions
const slackWorkflow = {
nodes: [
{
id: '1',
name: 'Node',
type: 'slack',
position: [100, 100],
parameters: {}
}
],
connections: {}
} as any;
const slackResult = await validator.validateWorkflow(slackWorkflow);
const slackError = slackResult.errors.find(e =>
e.message && e.message.includes('Unknown node type')
);
expect(slackError).toBeDefined();
expect(slackError?.message).toContain('nodes-base.slack');
});
});
describe('Integration Tests', () => {
it('should validate a complex workflow with multiple issues', async () => {
const workflow = {
nodes: [
// Valid trigger
{
id: '1',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [100, 100],
parameters: {},
typeVersion: 2
},
// Node with valid alternative prefix (no longer an error)
{
id: '2',
name: 'HTTP1',
type: 'nodes-base.httpRequest', // Valid prefix (normalized internally)
position: [300, 100],
parameters: {}
},
// Node with missing typeVersion
{
id: '3',
name: 'Slack',
type: 'n8n-nodes-base.slack',
position: [500, 100],
parameters: {}
},
// Disabled node
{
id: '4',
name: 'Disabled',
type: 'n8n-nodes-base.set',
position: [700, 100],
parameters: {},
disabled: true
},
// Node with error handling in wrong place
{
id: '5',
name: 'HTTP2',
type: 'n8n-nodes-base.httpRequest',
position: [900, 100],
parameters: {
onError: 'continueRegularOutput'
},
typeVersion: 4
},
// Orphaned node
{
id: '6',
name: 'Orphaned',
type: 'n8n-nodes-base.code',
position: [1100, 100],
parameters: {},
typeVersion: 2
},
// AI Agent without tools
{
id: '7',
name: 'Agent',
type: '@n8n/n8n-nodes-langchain.agent',
position: [100, 300],
parameters: {},
typeVersion: 1
}
],
connections: {
'Webhook': {
main: [[{ node: 'HTTP1', type: 'main', index: 0 }]]
},
'HTTP1': {
main: [[{ node: 'Slack', type: 'main', index: 0 }]]
},
'Slack': {
main: [[{ node: 'Disabled', type: 'main', index: 0 }]]
},
// Using ID instead of name
'5': {
main: [[{ node: 'Agent', type: 'main', index: 0 }]]
}
}
} as any;
const result = await validator.validateWorkflow(workflow as any);
// Should have multiple errors (but not for the nodes-base prefix)
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(2); // Reduced by 1 since nodes-base prefix is now valid
// Specific errors (removed the invalid node type error as it's no longer invalid)
expect(result.errors.some(e => e.message.includes('Missing required property \'typeVersion\''))).toBe(true);
expect(result.errors.some(e => e.message.includes('Node-level properties onError are in the wrong location'))).toBe(true);
expect(result.errors.some(e => e.message.includes('Connection uses node ID \'5\' instead of node name'))).toBe(true);
// Warnings
expect(result.warnings.some(w => w.message.includes('Connection to disabled node'))).toBe(true);
expect(result.warnings.some(w => w.message.includes('Node is not connected') && w.nodeName === 'Orphaned')).toBe(true);
expect(result.warnings.some(w => w.message.includes('AI Agent has no tools connected'))).toBe(true);
// Statistics
expect(result.statistics.totalNodes).toBe(7);
expect(result.statistics.enabledNodes).toBe(6);
expect(result.statistics.triggerNodes).toBe(1);
expect(result.statistics.invalidConnections).toBeGreaterThan(0);
// Suggestions
expect(result.suggestions.length).toBeGreaterThan(0);
});
it('should validate a perfect workflow', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
position: [250, 300],
parameters: {},
typeVersion: 1
},
{
id: '2',
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
position: [450, 300],
parameters: {
url: 'https://api.example.com/data',
method: 'GET'
},
typeVersion: 4,
onError: 'continueErrorOutput',
retryOnFail: true,
maxTries: 3,
waitBetweenTries: 1000
},
{
id: '3',
name: 'Process Data',
type: 'n8n-nodes-base.code',
position: [650, 300],
parameters: {
jsCode: 'return items;'
},
typeVersion: 2
},
{
id: '4',
name: 'Error Handler',
type: 'n8n-nodes-base.set',
position: [650, 500],
parameters: {
values: {
string: [
{
name: 'error',
value: 'An error occurred'
}
]
}
},
typeVersion: 3
}
],
connections: {
'Manual Trigger': {
main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]]
},
'HTTP Request': {
main: [
[{ node: 'Process Data', type: 'main', index: 0 }],
[{ node: 'Error Handler', type: 'main', index: 0 }]
]
}
}
} as any;
const result = await validator.validateWorkflow(workflow as any);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.warnings).toHaveLength(0);
expect(result.statistics.validConnections).toBe(3);
expect(result.statistics.invalidConnections).toBe(0);
});
});
});