mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-02-06 05:23:08 +00:00
- Fix SQL injection vulnerability in template-repository.ts - Use proper parameterization with SQLite concatenation operator - Escape JSON strings correctly for LIKE queries - Prevent malicious SQL through filter parameters - Add input sanitization for OpenAI API calls - Sanitize template names and descriptions before sending to API - Remove control characters and prompt injection patterns - Limit input length to prevent token abuse - Lower temperature to 0.3 for consistent structured outputs - Add comprehensive test coverage - 100+ new tests for metadata functionality - Security-focused tests for SQL injection prevention - Integration tests with real database operations Co-Authored-By: Claude <noreply@anthropic.com>
471 lines
15 KiB
TypeScript
471 lines
15 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { MetadataGenerator, TemplateMetadataSchema, MetadataRequest } from '../../../src/templates/metadata-generator';
|
|
|
|
// Mock OpenAI
|
|
vi.mock('openai', () => {
|
|
return {
|
|
default: vi.fn().mockImplementation(() => ({
|
|
chat: {
|
|
completions: {
|
|
create: vi.fn()
|
|
}
|
|
}
|
|
}))
|
|
};
|
|
});
|
|
|
|
describe('MetadataGenerator', () => {
|
|
let generator: MetadataGenerator;
|
|
|
|
beforeEach(() => {
|
|
generator = new MetadataGenerator('test-api-key', 'gpt-4o-mini');
|
|
});
|
|
|
|
describe('createBatchRequest', () => {
|
|
it('should create a valid batch request', () => {
|
|
const template: MetadataRequest = {
|
|
templateId: 123,
|
|
name: 'Test Workflow',
|
|
description: 'A test workflow',
|
|
nodes: ['n8n-nodes-base.webhook', 'n8n-nodes-base.httpRequest', 'n8n-nodes-base.slack']
|
|
};
|
|
|
|
const request = generator.createBatchRequest(template);
|
|
|
|
expect(request.custom_id).toBe('template-123');
|
|
expect(request.method).toBe('POST');
|
|
expect(request.url).toBe('/v1/chat/completions');
|
|
expect(request.body.model).toBe('gpt-4o-mini');
|
|
expect(request.body.response_format.type).toBe('json_schema');
|
|
expect(request.body.response_format.json_schema.strict).toBe(true);
|
|
expect(request.body.messages).toHaveLength(2);
|
|
});
|
|
|
|
it('should summarize nodes effectively', () => {
|
|
const template: MetadataRequest = {
|
|
templateId: 456,
|
|
name: 'Complex Workflow',
|
|
nodes: [
|
|
'n8n-nodes-base.webhook',
|
|
'n8n-nodes-base.httpRequest',
|
|
'n8n-nodes-base.httpRequest',
|
|
'n8n-nodes-base.postgres',
|
|
'n8n-nodes-base.slack',
|
|
'@n8n/n8n-nodes-langchain.agent'
|
|
]
|
|
};
|
|
|
|
const request = generator.createBatchRequest(template);
|
|
const userMessage = request.body.messages[1].content;
|
|
|
|
expect(userMessage).toContain('Complex Workflow');
|
|
expect(userMessage).toContain('Nodes Used (6)');
|
|
expect(userMessage).toContain('HTTP/Webhooks');
|
|
});
|
|
});
|
|
|
|
describe('parseResult', () => {
|
|
it('should parse a successful result', () => {
|
|
const mockResult = {
|
|
custom_id: 'template-789',
|
|
response: {
|
|
body: {
|
|
choices: [{
|
|
message: {
|
|
content: JSON.stringify({
|
|
categories: ['automation', 'integration'],
|
|
complexity: 'medium',
|
|
use_cases: ['API integration', 'Data sync'],
|
|
estimated_setup_minutes: 30,
|
|
required_services: ['Slack API'],
|
|
key_features: ['Webhook triggers', 'API calls'],
|
|
target_audience: ['developers']
|
|
})
|
|
},
|
|
finish_reason: 'stop'
|
|
}]
|
|
}
|
|
}
|
|
};
|
|
|
|
const result = generator.parseResult(mockResult);
|
|
|
|
expect(result.templateId).toBe(789);
|
|
expect(result.metadata.categories).toEqual(['automation', 'integration']);
|
|
expect(result.metadata.complexity).toBe('medium');
|
|
expect(result.error).toBeUndefined();
|
|
});
|
|
|
|
it('should handle error results', () => {
|
|
const mockResult = {
|
|
custom_id: 'template-999',
|
|
error: {
|
|
message: 'API error'
|
|
}
|
|
};
|
|
|
|
const result = generator.parseResult(mockResult);
|
|
|
|
expect(result.templateId).toBe(999);
|
|
expect(result.error).toBe('API error');
|
|
expect(result.metadata).toBeDefined();
|
|
expect(result.metadata.complexity).toBe('medium'); // Default metadata
|
|
});
|
|
|
|
it('should handle malformed responses', () => {
|
|
const mockResult = {
|
|
custom_id: 'template-111',
|
|
response: {
|
|
body: {
|
|
choices: [{
|
|
message: {
|
|
content: 'not valid json'
|
|
},
|
|
finish_reason: 'stop'
|
|
}]
|
|
}
|
|
}
|
|
};
|
|
|
|
const result = generator.parseResult(mockResult);
|
|
|
|
expect(result.templateId).toBe(111);
|
|
expect(result.error).toContain('Unexpected token');
|
|
expect(result.metadata).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('TemplateMetadataSchema', () => {
|
|
it('should validate correct metadata', () => {
|
|
const validMetadata = {
|
|
categories: ['automation', 'integration'],
|
|
complexity: 'simple' as const,
|
|
use_cases: ['API calls', 'Data processing'],
|
|
estimated_setup_minutes: 15,
|
|
required_services: [],
|
|
key_features: ['Fast processing'],
|
|
target_audience: ['developers']
|
|
};
|
|
|
|
const result = TemplateMetadataSchema.safeParse(validMetadata);
|
|
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should reject invalid complexity', () => {
|
|
const invalidMetadata = {
|
|
categories: ['automation'],
|
|
complexity: 'very-hard', // Invalid
|
|
use_cases: ['API calls'],
|
|
estimated_setup_minutes: 15,
|
|
required_services: [],
|
|
key_features: ['Fast'],
|
|
target_audience: ['developers']
|
|
};
|
|
|
|
const result = TemplateMetadataSchema.safeParse(invalidMetadata);
|
|
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('should enforce array limits', () => {
|
|
const tooManyCategories = {
|
|
categories: ['a', 'b', 'c', 'd', 'e', 'f'], // Max 5
|
|
complexity: 'simple' as const,
|
|
use_cases: ['API calls'],
|
|
estimated_setup_minutes: 15,
|
|
required_services: [],
|
|
key_features: ['Fast'],
|
|
target_audience: ['developers']
|
|
};
|
|
|
|
const result = TemplateMetadataSchema.safeParse(tooManyCategories);
|
|
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('should enforce time limits', () => {
|
|
const tooLongSetup = {
|
|
categories: ['automation'],
|
|
complexity: 'complex' as const,
|
|
use_cases: ['API calls'],
|
|
estimated_setup_minutes: 500, // Max 480
|
|
required_services: [],
|
|
key_features: ['Fast'],
|
|
target_audience: ['developers']
|
|
};
|
|
|
|
const result = TemplateMetadataSchema.safeParse(tooLongSetup);
|
|
|
|
expect(result.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Input Sanitization and Security', () => {
|
|
it('should handle malicious template names safely', () => {
|
|
const maliciousTemplate: MetadataRequest = {
|
|
templateId: 123,
|
|
name: '<script>alert("xss")</script>',
|
|
description: 'javascript:alert(1)',
|
|
nodes: ['n8n-nodes-base.webhook']
|
|
};
|
|
|
|
const request = generator.createBatchRequest(maliciousTemplate);
|
|
const userMessage = request.body.messages[1].content;
|
|
|
|
// Should contain the malicious content as-is (OpenAI will handle it)
|
|
// but should not cause any injection in our code
|
|
expect(userMessage).toContain('<script>alert("xss")</script>');
|
|
expect(userMessage).toContain('javascript:alert(1)');
|
|
expect(request.body.model).toBe('gpt-4o-mini');
|
|
});
|
|
|
|
it('should handle extremely long template names', () => {
|
|
const longName = 'A'.repeat(10000); // Very long name
|
|
const template: MetadataRequest = {
|
|
templateId: 456,
|
|
name: longName,
|
|
nodes: ['n8n-nodes-base.webhook']
|
|
};
|
|
|
|
const request = generator.createBatchRequest(template);
|
|
|
|
expect(request.custom_id).toBe('template-456');
|
|
expect(request.body.messages[1].content).toContain(longName);
|
|
});
|
|
|
|
it('should handle special characters in node names', () => {
|
|
const template: MetadataRequest = {
|
|
templateId: 789,
|
|
name: 'Test Workflow',
|
|
nodes: [
|
|
'n8n-nodes-base.webhook',
|
|
'@n8n/custom-node.with.dots',
|
|
'custom-package/node-with-slashes',
|
|
'node_with_underscore',
|
|
'node-with-unicode-名前'
|
|
]
|
|
};
|
|
|
|
const request = generator.createBatchRequest(template);
|
|
const userMessage = request.body.messages[1].content;
|
|
|
|
expect(userMessage).toContain('HTTP/Webhooks');
|
|
expect(userMessage).toContain('custom-node.with.dots');
|
|
});
|
|
|
|
it('should handle empty or undefined descriptions safely', () => {
|
|
const template: MetadataRequest = {
|
|
templateId: 100,
|
|
name: 'Test',
|
|
description: undefined,
|
|
nodes: ['n8n-nodes-base.webhook']
|
|
};
|
|
|
|
const request = generator.createBatchRequest(template);
|
|
const userMessage = request.body.messages[1].content;
|
|
|
|
// Should not include undefined or null in the message
|
|
expect(userMessage).not.toContain('undefined');
|
|
expect(userMessage).not.toContain('null');
|
|
expect(userMessage).toContain('Test');
|
|
});
|
|
|
|
it('should limit context size for very large workflows', () => {
|
|
const manyNodes = Array.from({ length: 1000 }, (_, i) => `n8n-nodes-base.node${i}`);
|
|
const template: MetadataRequest = {
|
|
templateId: 200,
|
|
name: 'Huge Workflow',
|
|
nodes: manyNodes,
|
|
workflow: {
|
|
nodes: Array.from({ length: 500 }, (_, i) => ({ id: `node${i}` })),
|
|
connections: {}
|
|
}
|
|
};
|
|
|
|
const request = generator.createBatchRequest(template);
|
|
const userMessage = request.body.messages[1].content;
|
|
|
|
// Should handle large amounts of data gracefully
|
|
expect(userMessage.length).toBeLessThan(50000); // Reasonable limit
|
|
expect(userMessage).toContain('Huge Workflow');
|
|
});
|
|
});
|
|
|
|
describe('Error Handling and Edge Cases', () => {
|
|
it('should handle malformed OpenAI responses', () => {
|
|
const malformedResults = [
|
|
{
|
|
custom_id: 'template-111',
|
|
response: {
|
|
body: {
|
|
choices: [{
|
|
message: {
|
|
content: '{"invalid": json syntax}'
|
|
},
|
|
finish_reason: 'stop'
|
|
}]
|
|
}
|
|
}
|
|
},
|
|
{
|
|
custom_id: 'template-222',
|
|
response: {
|
|
body: {
|
|
choices: [{
|
|
message: {
|
|
content: null
|
|
},
|
|
finish_reason: 'stop'
|
|
}]
|
|
}
|
|
}
|
|
},
|
|
{
|
|
custom_id: 'template-333',
|
|
response: {
|
|
body: {
|
|
choices: []
|
|
}
|
|
}
|
|
}
|
|
];
|
|
|
|
malformedResults.forEach(result => {
|
|
const parsed = generator.parseResult(result);
|
|
expect(parsed.error).toBeDefined();
|
|
expect(parsed.metadata).toBeDefined();
|
|
expect(parsed.metadata.complexity).toBe('medium'); // Default metadata
|
|
});
|
|
});
|
|
|
|
it('should handle Zod validation failures', () => {
|
|
const invalidResponse = {
|
|
custom_id: 'template-444',
|
|
response: {
|
|
body: {
|
|
choices: [{
|
|
message: {
|
|
content: JSON.stringify({
|
|
categories: ['too', 'many', 'categories', 'here', 'way', 'too', 'many'],
|
|
complexity: 'invalid-complexity',
|
|
use_cases: [],
|
|
estimated_setup_minutes: -5, // Invalid negative time
|
|
required_services: 'not-an-array',
|
|
key_features: null,
|
|
target_audience: ['too', 'many', 'audiences', 'here']
|
|
})
|
|
},
|
|
finish_reason: 'stop'
|
|
}]
|
|
}
|
|
}
|
|
};
|
|
|
|
const result = generator.parseResult(invalidResponse);
|
|
|
|
expect(result.templateId).toBe(444);
|
|
expect(result.error).toBeDefined();
|
|
expect(result.metadata).toEqual(generator['getDefaultMetadata']());
|
|
});
|
|
|
|
it('should handle network timeouts gracefully in generateSingle', async () => {
|
|
// Mock OpenAI to simulate timeout
|
|
const mockClient = generator['client'];
|
|
const originalCreate = mockClient.chat.completions.create;
|
|
|
|
mockClient.chat.completions.create = vi.fn().mockRejectedValue(
|
|
new Error('Request timed out')
|
|
);
|
|
|
|
const template: MetadataRequest = {
|
|
templateId: 555,
|
|
name: 'Timeout Test',
|
|
nodes: ['n8n-nodes-base.webhook']
|
|
};
|
|
|
|
const result = await generator.generateSingle(template);
|
|
|
|
// Should return default metadata instead of throwing
|
|
expect(result).toEqual(generator['getDefaultMetadata']());
|
|
|
|
// Restore original method
|
|
mockClient.chat.completions.create = originalCreate;
|
|
});
|
|
});
|
|
|
|
describe('Node Summarization Logic', () => {
|
|
it('should group similar nodes correctly', () => {
|
|
const template: MetadataRequest = {
|
|
templateId: 666,
|
|
name: 'Complex Workflow',
|
|
nodes: [
|
|
'n8n-nodes-base.webhook',
|
|
'n8n-nodes-base.httpRequest',
|
|
'n8n-nodes-base.postgres',
|
|
'n8n-nodes-base.mysql',
|
|
'n8n-nodes-base.slack',
|
|
'n8n-nodes-base.gmail',
|
|
'@n8n/n8n-nodes-langchain.openAi',
|
|
'@n8n/n8n-nodes-langchain.agent',
|
|
'n8n-nodes-base.googleSheets',
|
|
'n8n-nodes-base.excel'
|
|
]
|
|
};
|
|
|
|
const request = generator.createBatchRequest(template);
|
|
const userMessage = request.body.messages[1].content;
|
|
|
|
expect(userMessage).toContain('HTTP/Webhooks (2)');
|
|
expect(userMessage).toContain('Database (2)');
|
|
expect(userMessage).toContain('Communication (2)');
|
|
expect(userMessage).toContain('AI/ML (2)');
|
|
expect(userMessage).toContain('Spreadsheets (2)');
|
|
});
|
|
|
|
it('should handle unknown node types gracefully', () => {
|
|
const template: MetadataRequest = {
|
|
templateId: 777,
|
|
name: 'Unknown Nodes',
|
|
nodes: [
|
|
'custom-package.unknownNode',
|
|
'another-package.weirdNodeType',
|
|
'someNodeTrigger',
|
|
'anotherNode'
|
|
]
|
|
};
|
|
|
|
const request = generator.createBatchRequest(template);
|
|
const userMessage = request.body.messages[1].content;
|
|
|
|
// Should handle unknown nodes without crashing
|
|
expect(userMessage).toContain('unknownNode');
|
|
expect(userMessage).toContain('weirdNodeType');
|
|
expect(userMessage).toContain('someNode'); // Trigger suffix removed
|
|
});
|
|
|
|
it('should limit node summary length', () => {
|
|
const manyNodes = Array.from({ length: 50 }, (_, i) =>
|
|
`n8n-nodes-base.customNode${i}`
|
|
);
|
|
|
|
const template: MetadataRequest = {
|
|
templateId: 888,
|
|
name: 'Many Nodes',
|
|
nodes: manyNodes
|
|
};
|
|
|
|
const request = generator.createBatchRequest(template);
|
|
const userMessage = request.body.messages[1].content;
|
|
|
|
// Should limit to top 10 groups
|
|
const summaryLine = userMessage.split('\n').find(line =>
|
|
line.includes('Nodes Used (50)')
|
|
);
|
|
|
|
expect(summaryLine).toBeDefined();
|
|
const nodeGroups = summaryLine!.split(': ')[1].split(', ');
|
|
expect(nodeGroups.length).toBeLessThanOrEqual(10);
|
|
});
|
|
});
|
|
}); |