mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-01-29 22:12:05 +00:00
* feat: rename n8n_trigger_webhook_workflow to n8n_test_workflow with multi-trigger support - Rename tool from n8n_trigger_webhook_workflow to n8n_test_workflow - Add support for webhook, form, and chat triggers (auto-detection) - Implement modular trigger system with registry pattern - Add trigger detector for automatic trigger type inference - Remove execute trigger type (n8n public API limitation) - Add comprehensive tests for trigger detection and handlers The tool now auto-detects trigger type from workflow structure and supports all externally-triggerable workflows via n8n's public API. Note: Direct workflow execution (Schedule/Manual triggers) requires n8n's instance-level MCP access, not available via REST API. Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: add SSRF protection to webhook handler and update tests - Add SSRF URL validation to webhook-handler.ts (critical security fix) Aligns with existing SSRF protection in form-handler.ts and chat-handler.ts - Update parameter-validation.test.ts to use new n8n_test_workflow tool name Conceived by Romuald Członkowski - www.aiadvisors.pl/en 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: n8n_test_workflow unified trigger tool (v2.28.0) Added new `n8n_test_workflow` tool replacing `n8n_trigger_webhook_workflow`: Features: - Auto-detects trigger type (webhook/form/chat) from workflow - Supports multiple trigger types with type-specific parameters - SSRF protection for all trigger handlers - Extensible handler architecture with registry pattern Changes: - Fixed Zod schema to remove invalid 'execute' trigger type - Updated README.md tool documentation - Added CHANGELOG entry for v2.28.0 - Bumped version to 2.28.0 Conceived by Romuald Członkowski - www.aiadvisors.pl/en 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * test: add comprehensive unit tests for trigger handlers Added 87 unit tests across 4 test files to improve code coverage: - base-handler.test.ts (19 tests) - 100% coverage - webhook-handler.test.ts (22 tests) - 100% coverage - chat-handler.test.ts (23 tests) - 100% coverage - form-handler.test.ts (23 tests) - 100% coverage Tests cover: - Input validation and parameter handling - SSRF protection integration - HTTP method handling and URL building - Error response formatting - Execution paths for all trigger types Conceived by Romuald Członkowski - www.aiadvisors.pl/en 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
579 lines
16 KiB
TypeScript
579 lines
16 KiB
TypeScript
/**
|
|
* Unit tests for FormHandler
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { FormHandler } from '../../../../src/triggers/handlers/form-handler';
|
|
import { N8nApiClient } from '../../../../src/services/n8n-api-client';
|
|
import { InstanceContext } from '../../../../src/types/instance-context';
|
|
import { Workflow } from '../../../../src/types/n8n-api';
|
|
import { DetectedTrigger } from '../../../../src/triggers/types';
|
|
import axios from 'axios';
|
|
|
|
// Mock getN8nApiConfig
|
|
vi.mock('../../../../src/config/n8n-api', () => ({
|
|
getN8nApiConfig: vi.fn(() => ({
|
|
baseUrl: 'https://test.n8n.com/api/v1',
|
|
apiKey: 'test-api-key',
|
|
})),
|
|
}));
|
|
|
|
// Mock SSRFProtection
|
|
vi.mock('../../../../src/utils/ssrf-protection', () => ({
|
|
SSRFProtection: {
|
|
validateWebhookUrl: vi.fn(async () => ({ valid: true, reason: '' })),
|
|
},
|
|
}));
|
|
|
|
// Mock axios
|
|
vi.mock('axios');
|
|
|
|
// Create mock client
|
|
const createMockClient = (): N8nApiClient => ({
|
|
getWorkflow: vi.fn(),
|
|
listWorkflows: vi.fn(),
|
|
createWorkflow: vi.fn(),
|
|
updateWorkflow: vi.fn(),
|
|
deleteWorkflow: vi.fn(),
|
|
triggerWebhook: vi.fn(),
|
|
getExecution: vi.fn(),
|
|
listExecutions: vi.fn(),
|
|
deleteExecution: vi.fn(),
|
|
} as unknown as N8nApiClient);
|
|
|
|
// Create test workflow
|
|
const createWorkflow = (): Workflow => ({
|
|
id: 'workflow-123',
|
|
name: 'Form Workflow',
|
|
active: true,
|
|
nodes: [
|
|
{
|
|
id: 'form-node',
|
|
name: 'Form Trigger',
|
|
type: 'n8n-nodes-base.formTrigger',
|
|
typeVersion: 1,
|
|
position: [0, 0],
|
|
parameters: {
|
|
path: 'contact-form',
|
|
},
|
|
},
|
|
],
|
|
connections: {},
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
settings: {},
|
|
staticData: undefined,
|
|
} as Workflow);
|
|
|
|
describe('FormHandler', () => {
|
|
let mockClient: N8nApiClient;
|
|
let handler: FormHandler;
|
|
|
|
beforeEach(async () => {
|
|
mockClient = createMockClient();
|
|
handler = new FormHandler(mockClient);
|
|
vi.clearAllMocks();
|
|
|
|
// Reset SSRFProtection mock
|
|
const { SSRFProtection } = await import('../../../../src/utils/ssrf-protection');
|
|
vi.mocked(SSRFProtection.validateWebhookUrl).mockResolvedValue({
|
|
valid: true,
|
|
reason: '',
|
|
});
|
|
|
|
// Reset axios mock
|
|
vi.mocked(axios.request).mockResolvedValue({
|
|
status: 200,
|
|
statusText: 'OK',
|
|
data: { success: true, message: 'Form submitted' },
|
|
});
|
|
});
|
|
|
|
describe('initialization', () => {
|
|
it('should have correct trigger type', () => {
|
|
expect(handler.triggerType).toBe('form');
|
|
});
|
|
|
|
it('should have correct capabilities', () => {
|
|
expect(handler.capabilities.requiresActiveWorkflow).toBe(true);
|
|
expect(handler.capabilities.canPassInputData).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('input validation', () => {
|
|
it('should validate correct form input', () => {
|
|
const input = {
|
|
workflowId: 'workflow-123',
|
|
triggerType: 'form' as const,
|
|
formData: {
|
|
name: 'John Doe',
|
|
email: 'john@example.com',
|
|
},
|
|
};
|
|
|
|
const result = handler.validate(input);
|
|
expect(result).toEqual(input);
|
|
});
|
|
|
|
it('should validate minimal input without formData', () => {
|
|
const input = {
|
|
workflowId: 'workflow-123',
|
|
triggerType: 'form' as const,
|
|
};
|
|
|
|
const result = handler.validate(input);
|
|
expect(result.workflowId).toBe('workflow-123');
|
|
expect(result.triggerType).toBe('form');
|
|
expect(result.formData).toBeUndefined();
|
|
});
|
|
|
|
it('should reject invalid trigger type', () => {
|
|
const input = {
|
|
workflowId: 'workflow-123',
|
|
triggerType: 'webhook',
|
|
};
|
|
|
|
expect(() => handler.validate(input)).toThrow();
|
|
});
|
|
|
|
it('should accept optional fields', () => {
|
|
const input = {
|
|
workflowId: 'workflow-123',
|
|
triggerType: 'form' as const,
|
|
formData: { field: 'value' },
|
|
data: { extra: 'data' },
|
|
headers: { 'X-Custom': 'header' },
|
|
timeout: 60000,
|
|
waitForResponse: false,
|
|
};
|
|
|
|
const result = handler.validate(input);
|
|
expect(result.formData).toEqual({ field: 'value' });
|
|
expect(result.data).toEqual({ extra: 'data' });
|
|
expect(result.headers).toEqual({ 'X-Custom': 'header' });
|
|
expect(result.timeout).toBe(60000);
|
|
expect(result.waitForResponse).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('execute', () => {
|
|
it('should execute form with provided formData', async () => {
|
|
const input = {
|
|
workflowId: 'workflow-123',
|
|
triggerType: 'form' as const,
|
|
formData: {
|
|
name: 'Jane Doe',
|
|
email: 'jane@example.com',
|
|
message: 'Hello',
|
|
},
|
|
};
|
|
const workflow = createWorkflow();
|
|
const triggerInfo: DetectedTrigger = {
|
|
type: 'form',
|
|
node: workflow.nodes[0],
|
|
};
|
|
|
|
const response = await handler.execute(input, workflow, triggerInfo);
|
|
|
|
expect(response.success).toBe(true);
|
|
expect(axios.request).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
data: {
|
|
name: 'Jane Doe',
|
|
email: 'jane@example.com',
|
|
message: 'Hello',
|
|
},
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should use form path from trigger info', async () => {
|
|
const input = {
|
|
workflowId: 'workflow-123',
|
|
triggerType: 'form' as const,
|
|
formData: { field: 'value' },
|
|
};
|
|
const workflow = createWorkflow();
|
|
const triggerInfo: DetectedTrigger = {
|
|
type: 'form',
|
|
node: {
|
|
id: 'form-node',
|
|
name: 'Form',
|
|
type: 'n8n-nodes-base.formTrigger',
|
|
typeVersion: 1,
|
|
position: [0, 0],
|
|
parameters: { path: 'custom-form' },
|
|
},
|
|
};
|
|
|
|
await handler.execute(input, workflow, triggerInfo);
|
|
|
|
expect(axios.request).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
url: expect.stringContaining('/form/custom-form'),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should use workflow ID as fallback path', async () => {
|
|
const input = {
|
|
workflowId: 'workflow-456',
|
|
triggerType: 'form' as const,
|
|
formData: { field: 'value' },
|
|
};
|
|
const workflow = createWorkflow();
|
|
|
|
await handler.execute(input, workflow);
|
|
|
|
expect(axios.request).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
url: expect.stringContaining('/form/workflow-456'),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should merge formData and data with formData taking precedence', async () => {
|
|
const input = {
|
|
workflowId: 'workflow-123',
|
|
triggerType: 'form' as const,
|
|
data: {
|
|
field1: 'from data',
|
|
field2: 'from data',
|
|
},
|
|
formData: {
|
|
field2: 'from formData',
|
|
field3: 'from formData',
|
|
},
|
|
};
|
|
const workflow = createWorkflow();
|
|
const triggerInfo: DetectedTrigger = {
|
|
type: 'form',
|
|
node: workflow.nodes[0],
|
|
};
|
|
|
|
await handler.execute(input, workflow, triggerInfo);
|
|
|
|
expect(axios.request).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: {
|
|
field1: 'from data',
|
|
field2: 'from formData',
|
|
field3: 'from formData',
|
|
},
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should return error when base URL not available', async () => {
|
|
const handlerNoContext = new FormHandler(mockClient, {} as InstanceContext);
|
|
|
|
// Mock getN8nApiConfig to return null
|
|
const { getN8nApiConfig } = await import('../../../../src/config/n8n-api');
|
|
vi.mocked(getN8nApiConfig).mockReturnValue(null as any);
|
|
|
|
const input = {
|
|
workflowId: 'workflow-123',
|
|
triggerType: 'form' as const,
|
|
};
|
|
const workflow = createWorkflow();
|
|
|
|
const response = await handlerNoContext.execute(input, workflow);
|
|
|
|
expect(response.success).toBe(false);
|
|
expect(response.error).toContain('Cannot determine n8n base URL');
|
|
});
|
|
|
|
it('should handle SSRF protection rejection', async () => {
|
|
const { SSRFProtection } = await import('../../../../src/utils/ssrf-protection');
|
|
vi.mocked(SSRFProtection.validateWebhookUrl).mockResolvedValue({
|
|
valid: false,
|
|
reason: 'Private IP address not allowed',
|
|
});
|
|
|
|
const input = {
|
|
workflowId: 'workflow-123',
|
|
triggerType: 'form' as const,
|
|
};
|
|
const workflow = createWorkflow();
|
|
|
|
const response = await handler.execute(input, workflow);
|
|
|
|
expect(response.success).toBe(false);
|
|
expect(response.error).toContain('SSRF protection');
|
|
expect(response.error).toContain('Private IP address not allowed');
|
|
});
|
|
|
|
it('should pass custom headers', async () => {
|
|
const input = {
|
|
workflowId: 'workflow-123',
|
|
triggerType: 'form' as const,
|
|
formData: { field: 'value' },
|
|
headers: {
|
|
'X-Custom-Header': 'custom-value',
|
|
'Authorization': 'Bearer token',
|
|
},
|
|
};
|
|
const workflow = createWorkflow();
|
|
const triggerInfo: DetectedTrigger = {
|
|
type: 'form',
|
|
node: workflow.nodes[0],
|
|
};
|
|
|
|
await handler.execute(input, workflow, triggerInfo);
|
|
|
|
expect(axios.request).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
'X-Custom-Header': 'custom-value',
|
|
'Authorization': 'Bearer token',
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should use custom timeout when provided', async () => {
|
|
const input = {
|
|
workflowId: 'workflow-123',
|
|
triggerType: 'form' as const,
|
|
timeout: 90000,
|
|
};
|
|
const workflow = createWorkflow();
|
|
const triggerInfo: DetectedTrigger = {
|
|
type: 'form',
|
|
node: workflow.nodes[0],
|
|
};
|
|
|
|
await handler.execute(input, workflow, triggerInfo);
|
|
|
|
expect(axios.request).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
timeout: 90000,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should use default timeout of 120000ms when waiting for response', async () => {
|
|
const input = {
|
|
workflowId: 'workflow-123',
|
|
triggerType: 'form' as const,
|
|
waitForResponse: true,
|
|
};
|
|
const workflow = createWorkflow();
|
|
const triggerInfo: DetectedTrigger = {
|
|
type: 'form',
|
|
node: workflow.nodes[0],
|
|
};
|
|
|
|
await handler.execute(input, workflow, triggerInfo);
|
|
|
|
expect(axios.request).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
timeout: 120000,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should use timeout of 30000ms when not waiting for response', async () => {
|
|
const input = {
|
|
workflowId: 'workflow-123',
|
|
triggerType: 'form' as const,
|
|
waitForResponse: false,
|
|
};
|
|
const workflow = createWorkflow();
|
|
const triggerInfo: DetectedTrigger = {
|
|
type: 'form',
|
|
node: workflow.nodes[0],
|
|
};
|
|
|
|
await handler.execute(input, workflow, triggerInfo);
|
|
|
|
expect(axios.request).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
timeout: 30000,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should return response with status and metadata', async () => {
|
|
const input = {
|
|
workflowId: 'workflow-123',
|
|
triggerType: 'form' as const,
|
|
formData: { name: 'Test' },
|
|
};
|
|
const workflow = createWorkflow();
|
|
const triggerInfo: DetectedTrigger = {
|
|
type: 'form',
|
|
node: workflow.nodes[0],
|
|
};
|
|
|
|
vi.mocked(axios.request).mockResolvedValue({
|
|
status: 201,
|
|
statusText: 'Created',
|
|
data: { id: 'submission-123', status: 'processed' },
|
|
});
|
|
|
|
const response = await handler.execute(input, workflow, triggerInfo);
|
|
|
|
expect(response.success).toBe(true);
|
|
expect(response.status).toBe(201);
|
|
expect(response.statusText).toBe('Created');
|
|
expect(response.data).toEqual({ id: 'submission-123', status: 'processed' });
|
|
expect(response.metadata?.duration).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
it('should handle API errors gracefully', async () => {
|
|
const input = {
|
|
workflowId: 'workflow-123',
|
|
triggerType: 'form' as const,
|
|
};
|
|
const workflow = createWorkflow();
|
|
const triggerInfo: DetectedTrigger = {
|
|
type: 'form',
|
|
node: workflow.nodes[0],
|
|
};
|
|
|
|
const apiError = new Error('Form submission failed');
|
|
vi.mocked(axios.request).mockRejectedValue(apiError);
|
|
|
|
const response = await handler.execute(input, workflow, triggerInfo);
|
|
|
|
expect(response.success).toBe(false);
|
|
expect(response.error).toBe('Form submission failed');
|
|
});
|
|
|
|
it('should extract execution ID from error response', async () => {
|
|
const input = {
|
|
workflowId: 'workflow-123',
|
|
triggerType: 'form' as const,
|
|
};
|
|
const workflow = createWorkflow();
|
|
const triggerInfo: DetectedTrigger = {
|
|
type: 'form',
|
|
node: workflow.nodes[0],
|
|
};
|
|
|
|
const apiError: any = new Error('Execution error');
|
|
apiError.response = {
|
|
data: {
|
|
id: 'exec-111',
|
|
error: 'Validation failed',
|
|
},
|
|
};
|
|
vi.mocked(axios.request).mockRejectedValue(apiError);
|
|
|
|
const response = await handler.execute(input, workflow, triggerInfo);
|
|
|
|
expect(response.success).toBe(false);
|
|
expect(response.executionId).toBe('exec-111');
|
|
expect(response.details).toEqual({
|
|
id: 'exec-111',
|
|
error: 'Validation failed',
|
|
});
|
|
});
|
|
|
|
it('should handle error with code', async () => {
|
|
const input = {
|
|
workflowId: 'workflow-123',
|
|
triggerType: 'form' as const,
|
|
};
|
|
const workflow = createWorkflow();
|
|
const triggerInfo: DetectedTrigger = {
|
|
type: 'form',
|
|
node: workflow.nodes[0],
|
|
};
|
|
|
|
const apiError: any = new Error('Connection timeout');
|
|
apiError.code = 'ECONNABORTED';
|
|
vi.mocked(axios.request).mockRejectedValue(apiError);
|
|
|
|
const response = await handler.execute(input, workflow, triggerInfo);
|
|
|
|
expect(response.success).toBe(false);
|
|
expect(response.code).toBe('ECONNABORTED');
|
|
});
|
|
|
|
it('should validate status codes less than 500', async () => {
|
|
const input = {
|
|
workflowId: 'workflow-123',
|
|
triggerType: 'form' as const,
|
|
};
|
|
const workflow = createWorkflow();
|
|
const triggerInfo: DetectedTrigger = {
|
|
type: 'form',
|
|
node: workflow.nodes[0],
|
|
};
|
|
|
|
await handler.execute(input, workflow, triggerInfo);
|
|
|
|
expect(axios.request).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
validateStatus: expect.any(Function),
|
|
})
|
|
);
|
|
|
|
const config = vi.mocked(axios.request).mock.calls[0][0];
|
|
expect(config.validateStatus!(200)).toBe(true);
|
|
expect(config.validateStatus!(400)).toBe(true);
|
|
expect(config.validateStatus!(499)).toBe(true);
|
|
expect(config.validateStatus!(500)).toBe(false);
|
|
expect(config.validateStatus!(502)).toBe(false);
|
|
});
|
|
|
|
it('should handle empty formData', async () => {
|
|
const input = {
|
|
workflowId: 'workflow-123',
|
|
triggerType: 'form' as const,
|
|
formData: {},
|
|
};
|
|
const workflow = createWorkflow();
|
|
const triggerInfo: DetectedTrigger = {
|
|
type: 'form',
|
|
node: workflow.nodes[0],
|
|
};
|
|
|
|
const response = await handler.execute(input, workflow, triggerInfo);
|
|
|
|
expect(response.success).toBe(true);
|
|
expect(axios.request).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: {},
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should handle complex form data types', async () => {
|
|
const input = {
|
|
workflowId: 'workflow-123',
|
|
triggerType: 'form' as const,
|
|
formData: {
|
|
name: 'Test User',
|
|
age: 30,
|
|
active: true,
|
|
tags: ['tag1', 'tag2'],
|
|
metadata: { key: 'value' },
|
|
},
|
|
};
|
|
const workflow = createWorkflow();
|
|
const triggerInfo: DetectedTrigger = {
|
|
type: 'form',
|
|
node: workflow.nodes[0],
|
|
};
|
|
|
|
await handler.execute(input, workflow, triggerInfo);
|
|
|
|
expect(axios.request).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: {
|
|
name: 'Test User',
|
|
age: 30,
|
|
active: true,
|
|
tags: ['tag1', 'tag2'],
|
|
metadata: { key: 'value' },
|
|
},
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|