fix: resolve issue #90 - prevent 'propertyValues[itemName] is not iterable' error

- Add validation for invalid fixedCollection structures in Switch, If, and Filter nodes
- Detect and prevent nested 'conditions.values' patterns that cause n8n UI crashes
- Support both 'n8n-nodes-base.x' and 'nodes-base.x' node type formats
- Provide auto-fix suggestions for invalid structures
- Add comprehensive test coverage for all edge cases

This prevents AI agents from creating invalid node configurations that break n8n's UI.
This commit is contained in:
czlonkowski
2025-08-02 00:42:25 +02:00
parent 6c7033bb45
commit 6b78c19545
3 changed files with 1131 additions and 2 deletions

View File

@@ -0,0 +1,450 @@
/**
* Fixed Collection Validation Tests
* Tests for the fix of issue #90: "propertyValues[itemName] is not iterable" error
*
* This ensures AI agents cannot create invalid fixedCollection structures that break n8n UI
*/
import { describe, test, expect } from 'vitest';
import { EnhancedConfigValidator } from '../../../src/services/enhanced-config-validator';
describe('FixedCollection Validation', () => {
describe('Switch Node v2/v3 Validation', () => {
test('should detect invalid nested conditions structure', () => {
const invalidConfig = {
rules: {
conditions: {
values: [
{
value1: '={{$json.status}}',
operation: 'equals',
value2: 'active'
}
]
}
}
};
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.switch',
invalidConfig,
[],
'operation',
'ai-friendly'
);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].type).toBe('invalid_value');
expect(result.errors[0].property).toBe('rules');
expect(result.errors[0].message).toContain('propertyValues[itemName] is not iterable');
expect(result.errors[0].fix).toContain('{ "rules": { "values": [{ "conditions": {...}, "outputKey": "output1" }] } }');
});
test('should detect direct conditions in rules (another invalid pattern)', () => {
const invalidConfig = {
rules: {
conditions: {
value1: '={{$json.status}}',
operation: 'equals',
value2: 'active'
}
}
};
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.switch',
invalidConfig,
[],
'operation',
'ai-friendly'
);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toContain('Invalid structure for nodes-base.switch node');
});
test('should provide auto-fix for invalid switch structure', () => {
const invalidConfig = {
rules: {
conditions: {
values: [
{
value1: '={{$json.status}}',
operation: 'equals',
value2: 'active'
}
]
}
}
};
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.switch',
invalidConfig,
[],
'operation',
'ai-friendly'
);
expect(result.autofix).toBeDefined();
expect(result.autofix!.rules).toBeDefined();
expect(result.autofix!.rules.values).toBeInstanceOf(Array);
expect(result.autofix!.rules.values).toHaveLength(1);
expect(result.autofix!.rules.values[0]).toHaveProperty('conditions');
expect(result.autofix!.rules.values[0]).toHaveProperty('outputKey');
});
test('should accept valid switch structure', () => {
const validConfig = {
rules: {
values: [
{
conditions: {
value1: '={{$json.status}}',
operation: 'equals',
value2: 'active'
},
outputKey: 'active'
}
]
}
};
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.switch',
validConfig,
[],
'operation',
'ai-friendly'
);
// Should not have the specific fixedCollection error
const hasFixedCollectionError = result.errors.some(e =>
e.message.includes('propertyValues[itemName] is not iterable')
);
expect(hasFixedCollectionError).toBe(false);
});
test('should warn about missing outputKey in valid structure', () => {
const configMissingOutputKey = {
rules: {
values: [
{
conditions: {
value1: '={{$json.status}}',
operation: 'equals',
value2: 'active'
}
// Missing outputKey
}
]
}
};
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.switch',
configMissingOutputKey,
[],
'operation',
'ai-friendly'
);
const hasOutputKeyWarning = result.warnings.some(w =>
w.message.includes('missing "outputKey" property')
);
expect(hasOutputKeyWarning).toBe(true);
});
});
describe('If Node Validation', () => {
test('should detect invalid nested values structure', () => {
const invalidConfig = {
conditions: {
values: [
{
value1: '={{$json.age}}',
operation: 'largerEqual',
value2: 18
}
]
}
};
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.if',
invalidConfig,
[],
'operation',
'ai-friendly'
);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].type).toBe('invalid_value');
expect(result.errors[0].property).toBe('conditions');
expect(result.errors[0].message).toContain('Invalid structure for nodes-base.if node');
expect(result.errors[0].fix).toBe('Use: { "conditions": {...} } or { "conditions": [...] } directly, not nested under "values"');
});
test('should provide auto-fix for invalid if structure', () => {
const invalidConfig = {
conditions: {
values: [
{
value1: '={{$json.age}}',
operation: 'largerEqual',
value2: 18
}
]
}
};
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.if',
invalidConfig,
[],
'operation',
'ai-friendly'
);
expect(result.autofix).toBeDefined();
expect(result.autofix!.conditions).toEqual(invalidConfig.conditions.values);
});
test('should accept valid if structure', () => {
const validConfig = {
conditions: {
value1: '={{$json.age}}',
operation: 'largerEqual',
value2: 18
}
};
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.if',
validConfig,
[],
'operation',
'ai-friendly'
);
// Should not have the specific structure error
const hasStructureError = result.errors.some(e =>
e.message.includes('should be a filter object/array directly')
);
expect(hasStructureError).toBe(false);
});
});
describe('Filter Node Validation', () => {
test('should detect invalid nested values structure', () => {
const invalidConfig = {
conditions: {
values: [
{
value1: '={{$json.score}}',
operation: 'larger',
value2: 80
}
]
}
};
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.filter',
invalidConfig,
[],
'operation',
'ai-friendly'
);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].type).toBe('invalid_value');
expect(result.errors[0].property).toBe('conditions');
expect(result.errors[0].message).toContain('Invalid structure for nodes-base.filter node');
});
test('should accept valid filter structure', () => {
const validConfig = {
conditions: {
value1: '={{$json.score}}',
operation: 'larger',
value2: 80
}
};
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.filter',
validConfig,
[],
'operation',
'ai-friendly'
);
// Should not have the specific structure error
const hasStructureError = result.errors.some(e =>
e.message.includes('should be a filter object/array directly')
);
expect(hasStructureError).toBe(false);
});
});
describe('Edge Cases', () => {
test('should not validate non-problematic nodes', () => {
const config = {
someProperty: {
conditions: {
values: ['should', 'not', 'trigger', 'validation']
}
}
};
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.httpRequest',
config,
[],
'operation',
'ai-friendly'
);
// Should not have fixedCollection errors for non-problematic nodes
const hasFixedCollectionError = result.errors.some(e =>
e.message.includes('propertyValues[itemName] is not iterable')
);
expect(hasFixedCollectionError).toBe(false);
});
test('should handle empty config gracefully', () => {
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.switch',
{},
[],
'operation',
'ai-friendly'
);
// Should not crash or produce false positives
expect(result).toBeDefined();
expect(result.errors).toBeInstanceOf(Array);
});
test('should handle non-object property values', () => {
const config = {
rules: 'not an object'
};
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.switch',
config,
[],
'operation',
'ai-friendly'
);
// Should not crash on non-object values
expect(result).toBeDefined();
expect(result.errors).toBeInstanceOf(Array);
});
});
describe('Real-world AI Agent Patterns', () => {
test('should catch common ChatGPT/Claude switch patterns', () => {
// This is a pattern commonly generated by AI agents
const aiGeneratedConfig = {
rules: {
conditions: {
values: [
{
"value1": "={{$json.status}}",
"operation": "equals",
"value2": "active"
},
{
"value1": "={{$json.priority}}",
"operation": "equals",
"value2": "high"
}
]
}
}
};
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.switch',
aiGeneratedConfig,
[],
'operation',
'ai-friendly'
);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toContain('propertyValues[itemName] is not iterable');
// Check auto-fix generates correct structure
expect(result.autofix!.rules.values).toHaveLength(2);
result.autofix!.rules.values.forEach((rule: any) => {
expect(rule).toHaveProperty('conditions');
expect(rule).toHaveProperty('outputKey');
});
});
test('should catch common AI if/filter patterns', () => {
const aiGeneratedIfConfig = {
conditions: {
values: {
"value1": "={{$json.age}}",
"operation": "largerEqual",
"value2": 21
}
}
};
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.if',
aiGeneratedIfConfig,
[],
'operation',
'ai-friendly'
);
expect(result.valid).toBe(false);
expect(result.errors[0].message).toContain('Invalid structure for nodes-base.if node');
});
});
describe('Version Compatibility', () => {
test('should work across different validation profiles', () => {
const invalidConfig = {
rules: {
conditions: {
values: [{ value1: 'test', operation: 'equals', value2: 'test' }]
}
}
};
const profiles: Array<'strict' | 'runtime' | 'ai-friendly' | 'minimal'> =
['strict', 'runtime', 'ai-friendly', 'minimal'];
profiles.forEach(profile => {
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.switch',
invalidConfig,
[],
'operation',
profile
);
// All profiles should catch this critical error
const hasCriticalError = result.errors.some(e =>
e.message.includes('propertyValues[itemName] is not iterable')
);
expect(hasCriticalError, `Profile ${profile} should catch critical fixedCollection error`).toBe(true);
});
});
});
});

View File

@@ -0,0 +1,413 @@
/**
* Workflow Fixed Collection Validation Tests
* Tests that workflow validation catches fixedCollection structure errors at the workflow level
*/
import { describe, test, expect, beforeEach, vi } from 'vitest';
import { WorkflowValidator } from '../../../src/services/workflow-validator';
import { EnhancedConfigValidator } from '../../../src/services/enhanced-config-validator';
import { NodeRepository } from '../../../src/database/node-repository';
describe('Workflow FixedCollection Validation', () => {
let validator: WorkflowValidator;
let mockNodeRepository: any;
beforeEach(() => {
// Create mock repository that returns basic node info for common nodes
mockNodeRepository = {
getNode: vi.fn().mockImplementation((type: string) => {
const normalizedType = type.replace('n8n-nodes-base.', '').replace('nodes-base.', '');
switch (normalizedType) {
case 'webhook':
return {
nodeType: 'nodes-base.webhook',
displayName: 'Webhook',
properties: [
{ name: 'path', type: 'string', required: true },
{ name: 'httpMethod', type: 'options' }
]
};
case 'switch':
return {
nodeType: 'nodes-base.switch',
displayName: 'Switch',
properties: [
{ name: 'rules', type: 'fixedCollection', required: true }
]
};
case 'if':
return {
nodeType: 'nodes-base.if',
displayName: 'If',
properties: [
{ name: 'conditions', type: 'filter', required: true }
]
};
case 'filter':
return {
nodeType: 'nodes-base.filter',
displayName: 'Filter',
properties: [
{ name: 'conditions', type: 'filter', required: true }
]
};
default:
return null;
}
})
};
validator = new WorkflowValidator(mockNodeRepository, EnhancedConfigValidator);
});
test('should catch invalid Switch node structure in workflow validation', async () => {
const workflow = {
name: 'Test Workflow with Invalid Switch',
nodes: [
{
id: 'webhook',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [0, 0] as [number, number],
parameters: {
path: 'test-webhook'
}
},
{
id: 'switch',
name: 'Switch',
type: 'n8n-nodes-base.switch',
position: [200, 0] as [number, number],
parameters: {
// This is the problematic structure that causes "propertyValues[itemName] is not iterable"
rules: {
conditions: {
values: [
{
value1: '={{$json.status}}',
operation: 'equals',
value2: 'active'
}
]
}
}
}
}
],
connections: {
Webhook: {
main: [[{ node: 'Switch', type: 'main', index: 0 }]]
}
}
};
const result = await validator.validateWorkflow(workflow, {
validateNodes: true,
profile: 'ai-friendly'
});
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
const switchError = result.errors.find(e => e.nodeId === 'switch');
expect(switchError).toBeDefined();
expect(switchError!.message).toContain('propertyValues[itemName] is not iterable');
expect(switchError!.message).toContain('Invalid structure for nodes-base.switch node');
});
test('should catch invalid If node structure in workflow validation', async () => {
const workflow = {
name: 'Test Workflow with Invalid If',
nodes: [
{
id: 'webhook',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [0, 0] as [number, number],
parameters: {
path: 'test-webhook'
}
},
{
id: 'if',
name: 'If',
type: 'n8n-nodes-base.if',
position: [200, 0] as [number, number],
parameters: {
// This is the problematic structure
conditions: {
values: [
{
value1: '={{$json.age}}',
operation: 'largerEqual',
value2: 18
}
]
}
}
}
],
connections: {
Webhook: {
main: [[{ node: 'If', type: 'main', index: 0 }]]
}
}
};
const result = await validator.validateWorkflow(workflow, {
validateNodes: true,
profile: 'ai-friendly'
});
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
const ifError = result.errors.find(e => e.nodeId === 'if');
expect(ifError).toBeDefined();
expect(ifError!.message).toContain('Invalid structure for nodes-base.if node');
});
test('should accept valid Switch node structure in workflow validation', async () => {
const workflow = {
name: 'Test Workflow with Valid Switch',
nodes: [
{
id: 'webhook',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [0, 0] as [number, number],
parameters: {
path: 'test-webhook'
}
},
{
id: 'switch',
name: 'Switch',
type: 'n8n-nodes-base.switch',
position: [200, 0] as [number, number],
parameters: {
// This is the correct structure
rules: {
values: [
{
conditions: {
value1: '={{$json.status}}',
operation: 'equals',
value2: 'active'
},
outputKey: 'active'
}
]
}
}
}
],
connections: {
Webhook: {
main: [[{ node: 'Switch', type: 'main', index: 0 }]]
}
}
};
const result = await validator.validateWorkflow(workflow, {
validateNodes: true,
profile: 'ai-friendly'
});
// Should not have fixedCollection structure errors
const hasFixedCollectionError = result.errors.some(e =>
e.message.includes('propertyValues[itemName] is not iterable')
);
expect(hasFixedCollectionError).toBe(false);
});
test('should catch multiple fixedCollection errors in a single workflow', async () => {
const workflow = {
name: 'Test Workflow with Multiple Invalid Structures',
nodes: [
{
id: 'webhook',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [0, 0] as [number, number],
parameters: {
path: 'test-webhook'
}
},
{
id: 'switch',
name: 'Switch',
type: 'n8n-nodes-base.switch',
position: [200, 0] as [number, number],
parameters: {
rules: {
conditions: {
values: [{ value1: 'test', operation: 'equals', value2: 'test' }]
}
}
}
},
{
id: 'if',
name: 'If',
type: 'n8n-nodes-base.if',
position: [400, 0] as [number, number],
parameters: {
conditions: {
values: [{ value1: 'test', operation: 'equals', value2: 'test' }]
}
}
},
{
id: 'filter',
name: 'Filter',
type: 'n8n-nodes-base.filter',
position: [600, 0] as [number, number],
parameters: {
conditions: {
values: [{ value1: 'test', operation: 'equals', value2: 'test' }]
}
}
}
],
connections: {
Webhook: {
main: [[{ node: 'Switch', type: 'main', index: 0 }]]
},
Switch: {
main: [
[{ node: 'If', type: 'main', index: 0 }],
[{ node: 'Filter', type: 'main', index: 0 }]
]
}
}
};
const result = await validator.validateWorkflow(workflow, {
validateNodes: true,
profile: 'ai-friendly'
});
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThanOrEqual(3); // At least one error for each problematic node
// Check that each problematic node has an error
const switchError = result.errors.find(e => e.nodeId === 'switch');
const ifError = result.errors.find(e => e.nodeId === 'if');
const filterError = result.errors.find(e => e.nodeId === 'filter');
expect(switchError).toBeDefined();
expect(ifError).toBeDefined();
expect(filterError).toBeDefined();
});
test('should provide helpful statistics about fixedCollection errors', async () => {
const workflow = {
name: 'Test Workflow Statistics',
nodes: [
{
id: 'webhook',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [0, 0] as [number, number],
parameters: { path: 'test' }
},
{
id: 'bad-switch',
name: 'Bad Switch',
type: 'n8n-nodes-base.switch',
position: [200, 0] as [number, number],
parameters: {
rules: {
conditions: { values: [{ value1: 'test', operation: 'equals', value2: 'test' }] }
}
}
},
{
id: 'good-switch',
name: 'Good Switch',
type: 'n8n-nodes-base.switch',
position: [400, 0] as [number, number],
parameters: {
rules: {
values: [{ conditions: { value1: 'test', operation: 'equals', value2: 'test' }, outputKey: 'out' }]
}
}
}
],
connections: {
Webhook: {
main: [
[{ node: 'Bad Switch', type: 'main', index: 0 }],
[{ node: 'Good Switch', type: 'main', index: 0 }]
]
}
}
};
const result = await validator.validateWorkflow(workflow, {
validateNodes: true,
profile: 'ai-friendly'
});
expect(result.statistics.totalNodes).toBe(3);
expect(result.statistics.enabledNodes).toBe(3);
expect(result.valid).toBe(false); // Should be invalid due to the bad switch
// Should have at least one error for the bad switch
const badSwitchError = result.errors.find(e => e.nodeId === 'bad-switch');
expect(badSwitchError).toBeDefined();
// Should not have errors for the good switch or webhook
const goodSwitchError = result.errors.find(e => e.nodeId === 'good-switch');
const webhookError = result.errors.find(e => e.nodeId === 'webhook');
// These might have other validation errors, but not fixedCollection errors
if (goodSwitchError) {
expect(goodSwitchError.message).not.toContain('propertyValues[itemName] is not iterable');
}
if (webhookError) {
expect(webhookError.message).not.toContain('propertyValues[itemName] is not iterable');
}
});
test('should work with different validation profiles', async () => {
const workflow = {
name: 'Test Profile Compatibility',
nodes: [
{
id: 'switch',
name: 'Switch',
type: 'n8n-nodes-base.switch',
position: [0, 0] as [number, number],
parameters: {
rules: {
conditions: {
values: [{ value1: 'test', operation: 'equals', value2: 'test' }]
}
}
}
}
],
connections: {}
};
const profiles: Array<'strict' | 'runtime' | 'ai-friendly' | 'minimal'> =
['strict', 'runtime', 'ai-friendly', 'minimal'];
for (const profile of profiles) {
const result = await validator.validateWorkflow(workflow, {
validateNodes: true,
profile
});
// All profiles should catch this critical error
const hasCriticalError = result.errors.some(e =>
e.message.includes('propertyValues[itemName] is not iterable')
);
expect(hasCriticalError, `Profile ${profile} should catch critical fixedCollection error`).toBe(true);
expect(result.valid, `Profile ${profile} should mark workflow as invalid`).toBe(false);
}
});
});