mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-01-30 06:22:04 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef9b6f6341 | ||
|
|
3188d209b7 | ||
|
|
33690c5650 | ||
|
|
ddf9556759 |
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: n8n-mcp-tester
|
||||
description: Use this agent when you need to test n8n-mcp functionality after implementing fixes or new features. This agent should be invoked proactively after code changes to validate that the MCP tools work correctly with real n8n workflows. The agent tests the actual MCP server tools, not just unit tests. Examples:\n\n<example>\nContext: Developer has just fixed a validation bug in the n8n-mcp server\nuser: "I've fixed the validation issue for HTTP Request nodes"\nassistant: "I've completed the fix. Now let me build the project and then test it."\n<builds project>\nassistant: "Project built successfully. Please reload the MCP server in Claude Desktop to apply the changes."\nuser: "Reloaded"\nassistant: "Great! Now let me invoke the n8n-mcp-tester agent to verify the fix works correctly."\n<commentary>\nSince a fix has been implemented and the MCP server reloaded, use the n8n-mcp-tester agent to validate the fix works in real-world usage.\n</commentary>\n</example>\n\n<example>\nContext: Developer has added a new MCP tool for workflow validation\nuser: "I've implemented the new workflow validation tool"\nassistant: "Implementation complete. Let me build and prepare for testing."\n<builds project>\nassistant: "Build successful. Please reload the MCP server to load the new tool."\nuser: "Done"\nassistant: "Perfect! I'll now use the n8n-mcp-tester agent to test the new workflow validation tool."\n<commentary>\nAfter implementing new MCP functionality and reloading the server, invoke n8n-mcp-tester to verify it works correctly.\n</commentary>\n</example>
|
||||
tools: Glob, Grep, LS, Read, WebFetch, TodoWrite, WebSearch, mcp__puppeteer__puppeteer_navigate, mcp__puppeteer__puppeteer_screenshot, mcp__puppeteer__puppeteer_click, mcp__puppeteer__puppeteer_fill, mcp__puppeteer__puppeteer_select, mcp__puppeteer__puppeteer_hover, mcp__puppeteer__puppeteer_evaluate, ListMcpResourcesTool, ReadMcpResourceTool, mcp__supabase__list_organizations, mcp__supabase__get_organization, mcp__supabase__list_projects, mcp__supabase__get_project, mcp__supabase__get_cost, mcp__supabase__confirm_cost, mcp__supabase__create_project, mcp__supabase__pause_project, mcp__supabase__restore_project, mcp__supabase__create_branch, mcp__supabase__list_branches, mcp__supabase__delete_branch, mcp__supabase__merge_branch, mcp__supabase__reset_branch, mcp__supabase__rebase_branch, mcp__supabase__list_tables, mcp__supabase__list_extensions, mcp__supabase__list_migrations, mcp__supabase__apply_migration, mcp__supabase__execute_sql, mcp__supabase__get_logs, mcp__supabase__get_advisors, mcp__supabase__get_project_url, mcp__supabase__get_anon_key, mcp__supabase__generate_typescript_types, mcp__supabase__search_docs, mcp__supabase__list_edge_functions, mcp__supabase__deploy_edge_function, mcp__n8n-mcp__tools_documentation, mcp__n8n-mcp__list_nodes, mcp__n8n-mcp__get_node_info, mcp__n8n-mcp__search_nodes, mcp__n8n-mcp__list_ai_tools, mcp__n8n-mcp__get_node_documentation, mcp__n8n-mcp__get_database_statistics, mcp__n8n-mcp__get_node_essentials, mcp__n8n-mcp__search_node_properties, mcp__n8n-mcp__get_node_for_task, mcp__n8n-mcp__list_tasks, mcp__n8n-mcp__validate_node_operation, mcp__n8n-mcp__validate_node_minimal, mcp__n8n-mcp__get_property_dependencies, mcp__n8n-mcp__get_node_as_tool_info, mcp__n8n-mcp__list_node_templates, mcp__n8n-mcp__get_template, mcp__n8n-mcp__search_templates, mcp__n8n-mcp__get_templates_for_task, mcp__n8n-mcp__validate_workflow, mcp__n8n-mcp__validate_workflow_connections, mcp__n8n-mcp__validate_workflow_expressions, mcp__n8n-mcp__n8n_create_workflow, mcp__n8n-mcp__n8n_get_workflow, mcp__n8n-mcp__n8n_get_workflow_details, mcp__n8n-mcp__n8n_get_workflow_structure, mcp__n8n-mcp__n8n_get_workflow_minimal, mcp__n8n-mcp__n8n_update_full_workflow, mcp__n8n-mcp__n8n_update_partial_workflow, mcp__n8n-mcp__n8n_delete_workflow, mcp__n8n-mcp__n8n_list_workflows, mcp__n8n-mcp__n8n_validate_workflow, mcp__n8n-mcp__n8n_trigger_webhook_workflow, mcp__n8n-mcp__n8n_get_execution, mcp__n8n-mcp__n8n_list_executions, mcp__n8n-mcp__n8n_delete_execution, mcp__n8n-mcp__n8n_health_check, mcp__n8n-mcp__n8n_list_available_tools, mcp__n8n-mcp__n8n_diagnostic
|
||||
tools: Glob, Grep, Read, WebFetch, TodoWrite, WebSearch, mcp__supabase__create_branch, mcp__supabase__list_branches, mcp__supabase__delete_branch, mcp__supabase__merge_branch, mcp__supabase__reset_branch, mcp__supabase__rebase_branch, mcp__supabase__list_tables, mcp__supabase__list_extensions, mcp__supabase__list_migrations, mcp__supabase__apply_migration, mcp__supabase__execute_sql, mcp__supabase__get_logs, mcp__supabase__get_advisors, mcp__supabase__get_project_url, mcp__supabase__generate_typescript_types, mcp__supabase__search_docs, mcp__supabase__list_edge_functions, mcp__supabase__deploy_edge_function, mcp__n8n-mcp__tools_documentation, mcp__n8n-mcp__search_nodes, mcp__n8n-mcp__get_template, mcp__n8n-mcp__search_templates, mcp__n8n-mcp__validate_workflow, mcp__n8n-mcp__n8n_create_workflow, mcp__n8n-mcp__n8n_get_workflow, mcp__n8n-mcp__n8n_update_full_workflow, mcp__n8n-mcp__n8n_update_partial_workflow, mcp__n8n-mcp__n8n_delete_workflow, mcp__n8n-mcp__n8n_list_workflows, mcp__n8n-mcp__n8n_validate_workflow, mcp__n8n-mcp__n8n_trigger_webhook_workflow, mcp__n8n-mcp__n8n_health_check, mcp__brightdata-mcp__search_engine, mcp__brightdata-mcp__scrape_as_markdown, mcp__brightdata-mcp__search_engine_batch, mcp__brightdata-mcp__scrape_batch, mcp__supabase__get_publishable_keys, mcp__supabase__get_edge_function, mcp__n8n-mcp__get_node, mcp__n8n-mcp__validate_node, mcp__n8n-mcp__n8n_autofix_workflow, mcp__n8n-mcp__n8n_executions, mcp__n8n-mcp__n8n_workflow_versions, mcp__n8n-mcp__n8n_deploy_template, mcp__ide__getDiagnostics, mcp__ide__executeCode
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
|
||||
263
CHANGELOG.md
263
CHANGELOG.md
@@ -7,6 +7,269 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.28.2] - 2025-12-01
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
**n8n_test_workflow: webhookId Resolution**
|
||||
|
||||
Fixed critical bug where trigger handlers used `node.id` instead of `node.webhookId` for building webhook URLs. This caused chat/form/webhook triggers to fail with 404 errors when nodes had custom IDs.
|
||||
|
||||
- **Root Cause**: `extractWebhookPath()` in `trigger-detector.ts` fell back to `node.id` instead of checking `node.webhookId` first
|
||||
- **Fix**: Added `webhookId` to `WorkflowNode` type and updated priority: `params.path` > `webhookId` > `node.id`
|
||||
- **Files**: `src/triggers/trigger-detector.ts`, `src/types/n8n-api.ts`
|
||||
|
||||
**n8n_test_workflow: Chat Trigger URL Pattern**
|
||||
|
||||
Fixed chat triggers using wrong URL pattern. n8n chat triggers require `/webhook/<id>/chat` suffix.
|
||||
|
||||
- **Root Cause**: `buildTriggerUrl()` used same pattern for webhooks and chat triggers
|
||||
- **Fix**: Chat triggers now correctly use `/webhook/<webhookId>/chat` endpoint
|
||||
- **Files**: `src/triggers/trigger-detector.ts:284-289`
|
||||
|
||||
**n8n_test_workflow: Form Trigger Content-Type**
|
||||
|
||||
Fixed form triggers failing with "Expected multipart/form-data" error.
|
||||
|
||||
- **Root Cause**: Form handler sent `application/json` but n8n requires `multipart/form-data`
|
||||
- **Fix**: Switched to `form-data` library for proper multipart encoding
|
||||
- **Files**: `src/triggers/handlers/form-handler.ts`
|
||||
|
||||
### Enhancements
|
||||
|
||||
**Form Handler: Complete Field Type Support**
|
||||
|
||||
Enhanced form handler to support all n8n form field types with intelligent handling:
|
||||
|
||||
- **Supported Types**: text, textarea, email, number, password, date, dropdown, checkbox, file, hidden, html
|
||||
- **Checkbox Arrays**: Automatically converts arrays to `field[]` format required by n8n
|
||||
- **File Uploads**: Supports base64 content or sends empty placeholder for required files
|
||||
- **Helpful Warnings**: Reports missing required fields with field names and labels
|
||||
- **Error Hints**: On failure, provides complete field structure with usage examples
|
||||
|
||||
```javascript
|
||||
// Example with all field types
|
||||
n8n_test_workflow({
|
||||
workflowId: "abc123",
|
||||
data: {
|
||||
"field-0": "text value",
|
||||
"field-1": ["checkbox1", "checkbox2"], // Array for checkboxes
|
||||
"field-2": "dropdown_option",
|
||||
"field-3": "2025-01-15", // Date format
|
||||
"field-4": "user@example.com",
|
||||
"field-5": 42, // Number
|
||||
"field-6": "password123"
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Conceived by Romuald Członkowski - [AiAdvisors](https://www.aiadvisors.pl/en)**
|
||||
|
||||
## [2.28.1] - 2025-12-01
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
**Issue #458: AI Connection Type Propagation**
|
||||
|
||||
Fixed `addConnection` operation in workflow diff engine defaulting `targetInput` to "main" instead of preserving the source output type. This caused AI tool connections to be created with incorrect type.
|
||||
|
||||
- **Root Cause**: `targetInput` defaulted to `'main'` regardless of `sourceOutput` type
|
||||
- **Fix**: Changed default to `sourceOutput` to preserve connection type (ai_tool, ai_memory, ai_languageModel)
|
||||
- **Files**: `src/services/workflow-diff-engine.ts:760`
|
||||
|
||||
**AI Agent Validation False Positive**
|
||||
|
||||
Fixed false positive "AI Agent has no tools connected" warning when tools were properly connected.
|
||||
|
||||
- **Root Cause**: Validation checked connections FROM agent instead of TO agent
|
||||
- **Fix**: Search all connections where target node is the agent
|
||||
- **Files**: `src/services/workflow-validator.ts:1148-1163`
|
||||
|
||||
### ✨ Enhancements
|
||||
|
||||
**get_node: expectedFormat for resourceLocator Properties**
|
||||
|
||||
Added `expectedFormat` field to resourceLocator properties in `get_node` output. This helps AI models understand the correct format for these complex property types.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "model",
|
||||
"type": "resourceLocator",
|
||||
"expectedFormat": {
|
||||
"structure": { "mode": "string", "value": "string" },
|
||||
"modes": ["list", "id"],
|
||||
"example": { "mode": "id", "value": "gpt-4o-mini" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**get_node: versionNotice Field**
|
||||
|
||||
Added `versionNotice` field to make typeVersion more prominent in get_node output, reducing the chance of AI models using outdated versions.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.3",
|
||||
"versionNotice": "⚠️ Use typeVersion: 1.3 when creating this node"
|
||||
}
|
||||
```
|
||||
|
||||
**Conceived by Romuald Członkowski - [AiAdvisors](https://www.aiadvisors.pl/en)**
|
||||
|
||||
## [2.28.0] - 2025-12-01
|
||||
|
||||
### ✨ Features
|
||||
|
||||
**n8n_test_workflow: Unified Workflow Trigger Tool**
|
||||
|
||||
Replaced `n8n_trigger_webhook_workflow` with a new unified `n8n_test_workflow` tool that supports multiple trigger types with auto-detection.
|
||||
|
||||
#### Key Features
|
||||
|
||||
1. **Auto-Detection of Trigger Type**
|
||||
- Automatically analyzes workflow to detect trigger type (webhook, form, or chat)
|
||||
- No need to specify triggerType unless you want to override detection
|
||||
|
||||
2. **Multi-Trigger Support**
|
||||
- **Webhook**: HTTP-based triggers (GET/POST/PUT/DELETE) with custom headers and data
|
||||
- **Form**: Form submission triggers with form field data
|
||||
- **Chat**: AI chat triggers with message and session continuity
|
||||
|
||||
3. **SSRF Protection**
|
||||
- All trigger handlers include SSRF URL validation
|
||||
- Blocks requests to private networks, cloud metadata endpoints
|
||||
- Configurable security modes (strict/moderate/permissive)
|
||||
|
||||
4. **Extensible Handler Architecture**
|
||||
- Plugin-based trigger handler system
|
||||
- Registry pattern for easy extension
|
||||
- Clean separation of concerns
|
||||
|
||||
#### Usage
|
||||
|
||||
```javascript
|
||||
// Auto-detect trigger type (recommended)
|
||||
n8n_test_workflow({workflowId: "123"})
|
||||
|
||||
// Webhook with data
|
||||
n8n_test_workflow({
|
||||
workflowId: "123",
|
||||
triggerType: "webhook",
|
||||
httpMethod: "POST",
|
||||
data: {name: "John", email: "john@example.com"}
|
||||
})
|
||||
|
||||
// Chat trigger
|
||||
n8n_test_workflow({
|
||||
workflowId: "123",
|
||||
triggerType: "chat",
|
||||
message: "Hello AI assistant",
|
||||
sessionId: "conversation-123"
|
||||
})
|
||||
|
||||
// Form submission
|
||||
n8n_test_workflow({
|
||||
workflowId: "123",
|
||||
triggerType: "form",
|
||||
data: {email: "test@example.com", name: "Test User"}
|
||||
})
|
||||
```
|
||||
|
||||
#### Breaking Changes
|
||||
|
||||
- **Removed**: `n8n_trigger_webhook_workflow` tool
|
||||
- **Replaced by**: `n8n_test_workflow` with enhanced capabilities
|
||||
- **Migration**: Change tool name and add `workflowId` parameter (previously `webhookUrl`)
|
||||
|
||||
#### Technical Details
|
||||
|
||||
**New Files:**
|
||||
- `src/triggers/` - Complete trigger system module
|
||||
- `types.ts` - Type definitions for all trigger types
|
||||
- `trigger-detector.ts` - Auto-detection logic
|
||||
- `trigger-registry.ts` - Handler registration
|
||||
- `handlers/` - Individual handler implementations
|
||||
|
||||
**Modified Files:**
|
||||
- `src/mcp/handlers-n8n-manager.ts` - New `handleTestWorkflow` function
|
||||
- `src/mcp/tools-n8n-manager.ts` - Updated tool definition
|
||||
- `src/mcp/tool-docs/workflow_management/` - New documentation
|
||||
|
||||
**Test Coverage:**
|
||||
- 32 unit tests for trigger detection and registry
|
||||
- 30 unit tests for SSRF protection
|
||||
- All parameter validation tests updated
|
||||
|
||||
**Conceived by Romuald Członkowski - [AiAdvisors](https://www.aiadvisors.pl/en)**
|
||||
|
||||
## [2.27.2] - 2025-11-29
|
||||
|
||||
### ✨ Enhanced Features
|
||||
|
||||
**n8n_deploy_template: Deploy-First with Auto-Fix**
|
||||
|
||||
Improved the template deployment tool to deploy first, then automatically fix common issues. This change dramatically improves deployment success rates for templates with expression format issues.
|
||||
|
||||
#### Key Changes
|
||||
|
||||
1. **Deploy-First Behavior**
|
||||
- Templates are now deployed first without pre-validation
|
||||
- Auto-fix runs automatically after deployment (configurable via `autoFix` parameter)
|
||||
- Returns `fixesApplied` array showing all corrections made
|
||||
|
||||
2. **Fixed Expression Validator False Positive**
|
||||
- Fixed "nested expressions" detection that incorrectly flagged valid patterns
|
||||
- Multiple expressions in one string like `={{ $a }} text {{ $b }}` now correctly pass validation
|
||||
- Only truly nested patterns like `{{ {{ $json }} }}` are flagged as errors
|
||||
|
||||
3. **Fixed Zod Schema Validation**
|
||||
- Added missing `typeversion-upgrade` and `version-migration` fix types to autofix schema
|
||||
- Prevents silent validation failures when autofix runs
|
||||
|
||||
#### Usage
|
||||
|
||||
```javascript
|
||||
// Deploy with auto-fix (default behavior)
|
||||
n8n_deploy_template({
|
||||
templateId: 2776,
|
||||
name: "My Workflow"
|
||||
})
|
||||
|
||||
// Deploy without auto-fix (not recommended)
|
||||
n8n_deploy_template({
|
||||
templateId: 2776,
|
||||
autoFix: false
|
||||
})
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"workflowId": "abc123",
|
||||
"name": "My Workflow",
|
||||
"fixesApplied": [
|
||||
{
|
||||
"node": "HTTP Request",
|
||||
"field": "url",
|
||||
"type": "expression-format",
|
||||
"before": "https://api.com/{{ $json.id }}",
|
||||
"after": "=https://api.com/{{ $json.id }}",
|
||||
"confidence": "high"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Testing Results
|
||||
|
||||
- 87% deployment success rate across 15 diverse templates
|
||||
- Auto-fix correctly adds `=` prefix to expressions missing it
|
||||
- Auto-fix correctly upgrades outdated typeVersions
|
||||
- Failed deployments are legitimate issues (missing community nodes, incomplete templates)
|
||||
|
||||
**Conceived by Romuald Członkowski - [AiAdvisors](https://www.aiadvisors.pl/en)**
|
||||
|
||||
## [2.27.1] - 2025-11-29
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
@@ -598,7 +598,7 @@ ALWAYS explicitly configure ALL parameters that control node behavior.
|
||||
- `n8n_create_workflow(workflow)` - Deploy
|
||||
- `n8n_validate_workflow({id})` - Post-deployment check
|
||||
- `n8n_update_partial_workflow({id, operations: [...]})` - Batch updates
|
||||
- `n8n_trigger_webhook_workflow()` - Test webhooks
|
||||
- `n8n_test_workflow({workflowId})` - Test workflow execution
|
||||
|
||||
## Critical Warnings
|
||||
|
||||
@@ -974,10 +974,13 @@ These tools require `N8N_API_URL` and `N8N_API_KEY` in your configuration.
|
||||
- **`n8n_validate_workflow`** - Validate workflows in n8n by ID
|
||||
- **`n8n_autofix_workflow`** - Automatically fix common workflow errors
|
||||
- **`n8n_workflow_versions`** - Manage version history and rollback
|
||||
- **`n8n_deploy_template`** - Deploy templates from n8n.io directly to your instance (NEW!)
|
||||
- **`n8n_deploy_template`** - Deploy templates from n8n.io directly to your instance with auto-fix
|
||||
|
||||
#### Execution Management
|
||||
- **`n8n_trigger_webhook_workflow`** - Trigger workflows via webhook URL
|
||||
- **`n8n_test_workflow`** - Test/trigger workflow execution:
|
||||
- Auto-detects trigger type (webhook, form, chat) from workflow
|
||||
- Supports custom data, headers, and HTTP methods for webhooks
|
||||
- Chat triggers support message and sessionId for conversations
|
||||
- **`n8n_executions`** - Unified execution management (v2.26.0):
|
||||
- `action: 'list'` - List executions with status filtering
|
||||
- `action: 'get'` - Get execution details by ID
|
||||
|
||||
470
package-lock.json
generated
470
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "n8n-mcp",
|
||||
"version": "2.27.0",
|
||||
"version": "2.28.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "n8n-mcp",
|
||||
"version": "2.27.0",
|
||||
"version": "2.28.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "1.20.1",
|
||||
@@ -15,6 +15,7 @@
|
||||
"dotenv": "^16.5.0",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"form-data": "^4.0.5",
|
||||
"lru-cache": "^11.2.1",
|
||||
"n8n": "^1.121.2",
|
||||
"n8n-core": "^1.120.1",
|
||||
@@ -6654,6 +6655,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@getzep/zep-cloud/node_modules/form-data": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
|
||||
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/@getzep/zep-js": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@getzep/zep-js/-/zep-js-0.9.0.tgz",
|
||||
@@ -7060,23 +7075,6 @@
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@ibm-cloud/watsonx-ai/node_modules/form-data": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/@ibm-cloud/watsonx-ai/node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
@@ -9088,6 +9086,20 @@
|
||||
"reflect-metadata": "0.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@n8n/ai-workflow-builder/node_modules/form-data": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
|
||||
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/@n8n/ai-workflow-builder/node_modules/n8n-workflow": {
|
||||
"version": "1.118.1",
|
||||
"resolved": "https://registry.npmjs.org/n8n-workflow/-/n8n-workflow-1.118.1.tgz",
|
||||
@@ -9141,6 +9153,20 @@
|
||||
"zod-class": "0.0.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@n8n/api-types/node_modules/form-data": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
|
||||
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/@n8n/api-types/node_modules/n8n-workflow": {
|
||||
"version": "1.118.1",
|
||||
"resolved": "https://registry.npmjs.org/n8n-workflow/-/n8n-workflow-1.118.1.tgz",
|
||||
@@ -9292,22 +9318,6 @@
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@n8n/client-oauth2/node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/@n8n/config": {
|
||||
"version": "1.64.0",
|
||||
"resolved": "https://registry.npmjs.org/@n8n/config/-/config-1.64.0.tgz",
|
||||
@@ -9665,6 +9675,41 @@
|
||||
"@supabase/storage-js": "2.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@n8n/n8n-nodes-langchain/node_modules/form-data": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
|
||||
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/@n8n/n8n-nodes-langchain/node_modules/form-data/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@n8n/n8n-nodes-langchain/node_modules/form-data/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@n8n/n8n-nodes-langchain/node_modules/mime-db": {
|
||||
"version": "1.54.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
@@ -10297,43 +10342,6 @@
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@n8n/task-runner/node_modules/axios/node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/@n8n/task-runner/node_modules/axios/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@n8n/task-runner/node_modules/axios/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@n8n/task-runner/node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
@@ -10449,6 +10457,41 @@
|
||||
"n8n-generate-translations": "bin/generate-translations"
|
||||
}
|
||||
},
|
||||
"node_modules/@n8n/task-runner/node_modules/n8n-core/node_modules/form-data": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
|
||||
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/@n8n/task-runner/node_modules/n8n-core/node_modules/form-data/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@n8n/task-runner/node_modules/n8n-core/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@n8n/task-runner/node_modules/n8n-workflow": {
|
||||
"version": "1.118.1",
|
||||
"resolved": "https://registry.npmjs.org/n8n-workflow/-/n8n-workflow-1.118.1.tgz",
|
||||
@@ -10474,6 +10517,41 @@
|
||||
"zod": "3.25.67"
|
||||
}
|
||||
},
|
||||
"node_modules/@n8n/task-runner/node_modules/n8n-workflow/node_modules/form-data": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
|
||||
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/@n8n/task-runner/node_modules/n8n-workflow/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@n8n/task-runner/node_modules/n8n-workflow/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@n8n/task-runner/node_modules/picocolors": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
|
||||
@@ -14545,22 +14623,6 @@
|
||||
"form-data": "^4.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-fetch/node_modules/form-data": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.6.1.tgz",
|
||||
@@ -15803,22 +15865,6 @@
|
||||
"axios": "0.x || 1.x"
|
||||
}
|
||||
},
|
||||
"node_modules/axios/node_modules/form-data": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-jest": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
|
||||
@@ -19301,13 +19347,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
|
||||
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
@@ -20579,23 +20627,6 @@
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ibm-cloud-sdk-core/node_modules/form-data": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/ibm-cloud-sdk-core/node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
@@ -24863,6 +24894,41 @@
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/n8n-core/node_modules/form-data": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
|
||||
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/n8n-core/node_modules/form-data/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/n8n-core/node_modules/form-data/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/n8n-core/node_modules/htmlparser2": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz",
|
||||
@@ -25351,6 +25417,20 @@
|
||||
"zod": "3.25.67"
|
||||
}
|
||||
},
|
||||
"node_modules/n8n-workflow/node_modules/form-data": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
|
||||
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/n8n-workflow/node_modules/zod": {
|
||||
"version": "3.25.67",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz",
|
||||
@@ -25515,6 +25595,41 @@
|
||||
"zod-to-json-schema": "3.23.3"
|
||||
}
|
||||
},
|
||||
"node_modules/n8n/node_modules/@n8n/n8n-nodes-langchain/node_modules/form-data": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
|
||||
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/n8n/node_modules/@n8n/n8n-nodes-langchain/node_modules/form-data/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/n8n/node_modules/@n8n/n8n-nodes-langchain/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/n8n/node_modules/@n8n/n8n-nodes-langchain/node_modules/openai": {
|
||||
"version": "5.12.2",
|
||||
"resolved": "https://registry.npmjs.org/openai/-/openai-5.12.2.tgz",
|
||||
@@ -26134,43 +26249,6 @@
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/n8n/node_modules/axios/node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/n8n/node_modules/axios/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/n8n/node_modules/axios/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/n8n/node_modules/cheerio-select": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-1.6.0.tgz",
|
||||
@@ -26493,6 +26571,41 @@
|
||||
"n8n-generate-translations": "bin/generate-translations"
|
||||
}
|
||||
},
|
||||
"node_modules/n8n/node_modules/n8n-core/node_modules/form-data": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
|
||||
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/n8n/node_modules/n8n-core/node_modules/form-data/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/n8n/node_modules/n8n-core/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/n8n/node_modules/n8n-nodes-base": {
|
||||
"version": "1.119.1",
|
||||
"resolved": "https://registry.npmjs.org/n8n-nodes-base/-/n8n-nodes-base-1.119.1.tgz",
|
||||
@@ -26683,6 +26796,41 @@
|
||||
"zod": "3.25.67"
|
||||
}
|
||||
},
|
||||
"node_modules/n8n/node_modules/n8n-workflow/node_modules/form-data": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
|
||||
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/n8n/node_modules/n8n-workflow/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/n8n/node_modules/n8n-workflow/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/n8n/node_modules/open": {
|
||||
"version": "7.4.2",
|
||||
"resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n8n-mcp",
|
||||
"version": "2.27.1",
|
||||
"version": "2.28.2",
|
||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -146,6 +146,7 @@
|
||||
"dotenv": "^16.5.0",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"form-data": "^4.0.5",
|
||||
"lru-cache": "^11.2.1",
|
||||
"n8n": "^1.121.2",
|
||||
"n8n-core": "^1.120.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n8n-mcp-runtime",
|
||||
"version": "2.27.1",
|
||||
"version": "2.28.0",
|
||||
"description": "n8n MCP Server Runtime Dependencies Only",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ExecutionFilterOptions,
|
||||
ExecutionMode
|
||||
} from '../types/n8n-api';
|
||||
import type { TriggerType, TestWorkflowInput } from '../triggers/types';
|
||||
import {
|
||||
validateWorkflowStructure,
|
||||
hasWebhookTrigger,
|
||||
@@ -85,6 +86,31 @@ interface CloudPlatformGuide {
|
||||
troubleshooting: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Applied Fix from Auto-Fix Operation
|
||||
*/
|
||||
interface AppliedFix {
|
||||
node: string;
|
||||
field: string;
|
||||
type: string;
|
||||
before: string;
|
||||
after: string;
|
||||
confidence: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-Fix Result Data from handleAutofixWorkflow
|
||||
*/
|
||||
interface AutofixResultData {
|
||||
fixesApplied?: number;
|
||||
fixes?: AppliedFix[];
|
||||
workflowId?: string;
|
||||
workflowName?: string;
|
||||
message?: string;
|
||||
summary?: string;
|
||||
stats?: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow Validation Response Data
|
||||
*/
|
||||
@@ -396,17 +422,25 @@ const autofixWorkflowSchema = z.object({
|
||||
'typeversion-correction',
|
||||
'error-output-config',
|
||||
'node-type-correction',
|
||||
'webhook-missing-path'
|
||||
'webhook-missing-path',
|
||||
'typeversion-upgrade',
|
||||
'version-migration'
|
||||
])).optional(),
|
||||
confidenceThreshold: z.enum(['high', 'medium', 'low']).optional().default('medium'),
|
||||
maxFixes: z.number().optional().default(50)
|
||||
});
|
||||
|
||||
const triggerWebhookSchema = z.object({
|
||||
webhookUrl: z.string().url(),
|
||||
// Schema for n8n_test_workflow tool
|
||||
const testWorkflowSchema = z.object({
|
||||
workflowId: z.string(),
|
||||
triggerType: z.enum(['webhook', 'form', 'chat']).optional(),
|
||||
httpMethod: z.enum(['GET', 'POST', 'PUT', 'DELETE']).optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
sessionId: z.string().optional(),
|
||||
data: z.record(z.unknown()).optional(),
|
||||
headers: z.record(z.string()).optional(),
|
||||
timeout: z.number().optional(),
|
||||
waitForResponse: z.boolean().optional(),
|
||||
});
|
||||
|
||||
@@ -1208,74 +1242,160 @@ export async function handleAutofixWorkflow(
|
||||
|
||||
// Execution Management Handlers
|
||||
|
||||
export async function handleTriggerWebhookWorkflow(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
/**
|
||||
* Handler for n8n_test_workflow tool
|
||||
* Triggers workflow execution via auto-detected or specified trigger type
|
||||
*/
|
||||
export async function handleTestWorkflow(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const input = triggerWebhookSchema.parse(args);
|
||||
const input = testWorkflowSchema.parse(args);
|
||||
|
||||
const webhookRequest: WebhookRequest = {
|
||||
webhookUrl: input.webhookUrl,
|
||||
httpMethod: input.httpMethod || 'POST',
|
||||
// Import trigger system (lazy to avoid circular deps)
|
||||
const {
|
||||
detectTriggerFromWorkflow,
|
||||
ensureRegistryInitialized,
|
||||
TriggerRegistry,
|
||||
} = await import('../triggers');
|
||||
|
||||
// Ensure registry is initialized
|
||||
await ensureRegistryInitialized();
|
||||
|
||||
// Fetch the workflow to analyze its trigger
|
||||
const workflow = await client.getWorkflow(input.workflowId);
|
||||
|
||||
// Determine trigger type
|
||||
let triggerType: TriggerType | undefined = input.triggerType as TriggerType | undefined;
|
||||
let triggerInfo;
|
||||
|
||||
// Auto-detect from workflow
|
||||
const detection = detectTriggerFromWorkflow(workflow);
|
||||
|
||||
if (!triggerType) {
|
||||
if (detection.detected && detection.trigger) {
|
||||
triggerType = detection.trigger.type;
|
||||
triggerInfo = detection.trigger;
|
||||
} else {
|
||||
// No externally-triggerable trigger found
|
||||
return {
|
||||
success: false,
|
||||
error: 'Workflow cannot be triggered externally',
|
||||
details: {
|
||||
workflowId: input.workflowId,
|
||||
reason: detection.reason,
|
||||
hint: 'Only workflows with webhook, form, or chat triggers can be executed via the API. Add one of these trigger nodes to your workflow.',
|
||||
},
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// User specified a trigger type, verify it matches workflow
|
||||
if (detection.detected && detection.trigger?.type === triggerType) {
|
||||
triggerInfo = detection.trigger;
|
||||
} else if (!detection.detected || detection.trigger?.type !== triggerType) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Workflow does not have a ${triggerType} trigger`,
|
||||
details: {
|
||||
workflowId: input.workflowId,
|
||||
requestedTrigger: triggerType,
|
||||
detectedTrigger: detection.trigger?.type || 'none',
|
||||
hint: detection.detected
|
||||
? `Workflow has a ${detection.trigger?.type} trigger. Either use that type or omit triggerType for auto-detection.`
|
||||
: 'Workflow has no externally-triggerable triggers (webhook, form, or chat).',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Get handler for trigger type
|
||||
const handler = TriggerRegistry.getHandler(triggerType, client, context);
|
||||
if (!handler) {
|
||||
return {
|
||||
success: false,
|
||||
error: `No handler registered for trigger type: ${triggerType}`,
|
||||
details: {
|
||||
supportedTypes: TriggerRegistry.getRegisteredTypes(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Check if workflow is active (if required by handler)
|
||||
if (handler.capabilities.requiresActiveWorkflow && !workflow.active) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Workflow must be active to trigger via this method',
|
||||
details: {
|
||||
workflowId: input.workflowId,
|
||||
triggerType,
|
||||
hint: 'Activate the workflow in n8n using n8n_update_partial_workflow with [{type: "activateWorkflow"}]',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Validate chat trigger has message
|
||||
if (triggerType === 'chat' && !input.message) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Chat trigger requires a message parameter',
|
||||
details: {
|
||||
hint: 'Provide message="your message" for chat triggers',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Build trigger-specific input
|
||||
const triggerInput = {
|
||||
workflowId: input.workflowId,
|
||||
triggerType,
|
||||
httpMethod: input.httpMethod,
|
||||
webhookPath: input.webhookPath,
|
||||
message: input.message || '',
|
||||
sessionId: input.sessionId,
|
||||
data: input.data,
|
||||
formData: input.data, // For form triggers
|
||||
headers: input.headers,
|
||||
waitForResponse: input.waitForResponse ?? true
|
||||
timeout: input.timeout,
|
||||
waitForResponse: input.waitForResponse,
|
||||
};
|
||||
|
||||
const response = await client.triggerWebhook(webhookRequest);
|
||||
// Execute the trigger
|
||||
const response = await handler.execute(triggerInput as any, workflow, triggerInfo);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response,
|
||||
message: 'Webhook triggered successfully'
|
||||
success: response.success,
|
||||
data: response.data,
|
||||
message: response.success
|
||||
? `Workflow triggered successfully via ${triggerType}`
|
||||
: response.error,
|
||||
executionId: response.executionId,
|
||||
workflowId: input.workflowId,
|
||||
details: {
|
||||
triggerType,
|
||||
metadata: response.metadata,
|
||||
...(response.details || {}),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid input',
|
||||
details: { errors: error.errors }
|
||||
details: { errors: error.errors },
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof N8nApiError) {
|
||||
// Try to extract execution context from error response
|
||||
const errorData = error.details as any;
|
||||
const executionId = errorData?.executionId || errorData?.id || errorData?.execution?.id;
|
||||
const workflowId = errorData?.workflowId || errorData?.workflow?.id;
|
||||
|
||||
// If we have execution ID, provide specific guidance with n8n_get_execution
|
||||
if (executionId) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatExecutionError(executionId, workflowId),
|
||||
code: error.code,
|
||||
executionId,
|
||||
workflowId: workflowId || undefined
|
||||
};
|
||||
}
|
||||
|
||||
// No execution ID available - workflow likely didn't start
|
||||
// Provide guidance to check recent executions
|
||||
if (error.code === 'SERVER_ERROR' || error.statusCode && error.statusCode >= 500) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatNoExecutionError(),
|
||||
code: error.code
|
||||
};
|
||||
}
|
||||
|
||||
// For other errors (auth, validation, etc), use standard message
|
||||
return {
|
||||
success: false,
|
||||
error: getUserFriendlyErrorMessage(error),
|
||||
code: error.code,
|
||||
details: error.details as Record<string, unknown> | undefined
|
||||
details: error.details as Record<string, unknown> | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2199,7 +2319,7 @@ const deployTemplateSchema = z.object({
|
||||
templateId: z.number().positive().int(),
|
||||
name: z.string().optional(),
|
||||
autoUpgradeVersions: z.boolean().default(true),
|
||||
validate: z.boolean().default(true),
|
||||
autoFix: z.boolean().default(true), // Auto-apply fixes after deployment
|
||||
stripCredentials: z.boolean().default(true)
|
||||
});
|
||||
|
||||
@@ -2318,32 +2438,6 @@ export async function handleDeployTemplate(
|
||||
}
|
||||
}
|
||||
|
||||
// Validate workflow if requested
|
||||
if (input.validate) {
|
||||
const validator = new WorkflowValidator(repository, EnhancedConfigValidator);
|
||||
const validationResult = await validator.validateWorkflow(workflow, {
|
||||
validateNodes: true,
|
||||
validateConnections: true,
|
||||
validateExpressions: true,
|
||||
profile: 'runtime'
|
||||
});
|
||||
|
||||
if (validationResult.errors.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Workflow validation failed',
|
||||
details: {
|
||||
errors: validationResult.errors.map(e => ({
|
||||
node: e.nodeName,
|
||||
message: e.message
|
||||
})),
|
||||
warnings: validationResult.warnings.length,
|
||||
hint: 'Use validate=false to skip validation, or fix the template issues'
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Identify trigger type
|
||||
const triggerNode = workflow.nodes.find((n: any) =>
|
||||
n.type?.includes('Trigger') ||
|
||||
@@ -2353,6 +2447,7 @@ export async function handleDeployTemplate(
|
||||
const triggerType = triggerNode?.type?.split('.').pop() || 'manual';
|
||||
|
||||
// Create workflow via API (always creates inactive)
|
||||
// Deploy first, then fix - this ensures the workflow exists before we modify it
|
||||
const createdWorkflow = await client.createWorkflow({
|
||||
name: workflowName,
|
||||
nodes: workflow.nodes,
|
||||
@@ -2364,6 +2459,44 @@ export async function handleDeployTemplate(
|
||||
const apiConfig = context ? getN8nApiConfigFromContext(context) : getN8nApiConfig();
|
||||
const baseUrl = apiConfig?.baseUrl?.replace('/api/v1', '') || '';
|
||||
|
||||
// Auto-fix common issues after deployment (expression format, etc.)
|
||||
let fixesApplied: AppliedFix[] = [];
|
||||
let fixSummary = '';
|
||||
let autoFixStatus: 'success' | 'failed' | 'skipped' = 'skipped';
|
||||
|
||||
if (input.autoFix) {
|
||||
try {
|
||||
// Run autofix on the deployed workflow
|
||||
const autofixResult = await handleAutofixWorkflow(
|
||||
{
|
||||
id: createdWorkflow.id,
|
||||
applyFixes: true,
|
||||
fixTypes: ['expression-format', 'typeversion-upgrade'],
|
||||
confidenceThreshold: 'medium'
|
||||
},
|
||||
repository,
|
||||
context
|
||||
);
|
||||
|
||||
if (autofixResult.success && autofixResult.data) {
|
||||
const fixData = autofixResult.data as AutofixResultData;
|
||||
autoFixStatus = 'success';
|
||||
if (fixData.fixesApplied && fixData.fixesApplied > 0) {
|
||||
fixesApplied = fixData.fixes || [];
|
||||
fixSummary = ` Auto-fixed ${fixData.fixesApplied} issue(s).`;
|
||||
}
|
||||
}
|
||||
} catch (fixError) {
|
||||
// Log but don't fail - autofix is best-effort
|
||||
autoFixStatus = 'failed';
|
||||
logger.warn('Auto-fix failed after template deployment', {
|
||||
workflowId: createdWorkflow.id,
|
||||
error: fixError instanceof Error ? fixError.message : 'Unknown error'
|
||||
});
|
||||
fixSummary = ' Auto-fix failed (workflow deployed successfully).';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
@@ -2375,9 +2508,11 @@ export async function handleDeployTemplate(
|
||||
requiredCredentials: requiredCredentials.length > 0 ? requiredCredentials : undefined,
|
||||
url: baseUrl ? `${baseUrl}/workflow/${createdWorkflow.id}` : undefined,
|
||||
templateId: input.templateId,
|
||||
templateUrl: template.url || `https://n8n.io/workflows/${input.templateId}`
|
||||
templateUrl: template.url || `https://n8n.io/workflows/${input.templateId}`,
|
||||
autoFixStatus,
|
||||
fixesApplied: fixesApplied.length > 0 ? fixesApplied : undefined
|
||||
},
|
||||
message: `Workflow "${createdWorkflow.name}" deployed successfully from template ${input.templateId}. ${
|
||||
message: `Workflow "${createdWorkflow.name}" deployed successfully from template ${input.templateId}.${fixSummary} ${
|
||||
requiredCredentials.length > 0
|
||||
? `Configure ${requiredCredentials.length} credential(s) in n8n to activate.`
|
||||
: ''
|
||||
@@ -2407,3 +2542,84 @@ export async function handleDeployTemplate(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compatible webhook trigger handler
|
||||
*
|
||||
* @deprecated Use handleTestWorkflow instead. This function is kept for
|
||||
* backward compatibility with existing integration tests.
|
||||
*/
|
||||
export async function handleTriggerWebhookWorkflow(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
const triggerWebhookSchema = z.object({
|
||||
webhookUrl: z.string().url(),
|
||||
httpMethod: z.enum(['GET', 'POST', 'PUT', 'DELETE']).optional(),
|
||||
data: z.record(z.unknown()).optional(),
|
||||
headers: z.record(z.string()).optional(),
|
||||
waitForResponse: z.boolean().optional(),
|
||||
});
|
||||
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const input = triggerWebhookSchema.parse(args);
|
||||
|
||||
const webhookRequest: WebhookRequest = {
|
||||
webhookUrl: input.webhookUrl,
|
||||
httpMethod: input.httpMethod || 'POST',
|
||||
data: input.data,
|
||||
headers: input.headers,
|
||||
waitForResponse: input.waitForResponse ?? true
|
||||
};
|
||||
|
||||
const response = await client.triggerWebhook(webhookRequest);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response,
|
||||
message: 'Webhook triggered successfully'
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid input',
|
||||
details: { errors: error.errors }
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof N8nApiError) {
|
||||
const errorData = error.details as any;
|
||||
const executionId = errorData?.executionId || errorData?.id || errorData?.execution?.id;
|
||||
const workflowId = errorData?.workflowId || errorData?.workflow?.id;
|
||||
|
||||
if (executionId) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatExecutionError(executionId, workflowId),
|
||||
code: error.code,
|
||||
executionId,
|
||||
workflowId: workflowId || undefined
|
||||
};
|
||||
}
|
||||
|
||||
if (error.code === 'SERVER_ERROR' || error.statusCode && error.statusCode >= 500) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatNoExecutionError(),
|
||||
code: error.code
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: getUserFriendlyErrorMessage(error),
|
||||
code: error.code,
|
||||
details: error.details as Record<string, unknown> | undefined
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1176,9 +1176,9 @@ export class N8NDocumentationMCPServer {
|
||||
await this.ensureInitialized();
|
||||
if (!this.repository) throw new Error('Repository not initialized');
|
||||
return n8nHandlers.handleAutofixWorkflow(args, this.repository, this.instanceContext);
|
||||
case 'n8n_trigger_webhook_workflow':
|
||||
this.validateToolParams(name, args, ['webhookUrl']);
|
||||
return n8nHandlers.handleTriggerWebhookWorkflow(args, this.instanceContext);
|
||||
case 'n8n_test_workflow':
|
||||
this.validateToolParams(name, args, ['workflowId']);
|
||||
return n8nHandlers.handleTestWorkflow(args, this.instanceContext);
|
||||
case 'n8n_executions': {
|
||||
this.validateToolParams(name, args, ['action']);
|
||||
const execAction = args.action;
|
||||
@@ -2203,14 +2203,19 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
|
||||
// Get operations (already parsed by repository)
|
||||
const operations = node.operations || [];
|
||||
|
||||
// Get the latest version - this is important for AI to use correct typeVersion
|
||||
const latestVersion = node.version ?? '1';
|
||||
|
||||
const result = {
|
||||
nodeType: node.nodeType,
|
||||
workflowNodeType: getWorkflowNodeType(node.package ?? 'n8n-nodes-base', node.nodeType),
|
||||
displayName: node.displayName,
|
||||
description: node.description,
|
||||
category: node.category,
|
||||
version: node.version ?? '1',
|
||||
version: latestVersion,
|
||||
isVersioned: node.isVersioned ?? false,
|
||||
// Prominent warning to use the correct typeVersion
|
||||
versionNotice: `⚠️ Use typeVersion: ${latestVersion} when creating this node`,
|
||||
requiredProperties: essentials.required,
|
||||
commonProperties: essentials.common,
|
||||
operations: operations.map((op: any) => ({
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
n8nListWorkflowsDoc,
|
||||
n8nValidateWorkflowDoc,
|
||||
n8nAutofixWorkflowDoc,
|
||||
n8nTriggerWebhookWorkflowDoc,
|
||||
n8nTestWorkflowDoc,
|
||||
n8nExecutionsDoc,
|
||||
n8nWorkflowVersionsDoc,
|
||||
n8nDeployTemplateDoc
|
||||
@@ -57,7 +57,7 @@ export const toolsDocumentation: Record<string, ToolDocumentation> = {
|
||||
n8n_list_workflows: n8nListWorkflowsDoc,
|
||||
n8n_validate_workflow: n8nValidateWorkflowDoc,
|
||||
n8n_autofix_workflow: n8nAutofixWorkflowDoc,
|
||||
n8n_trigger_webhook_workflow: n8nTriggerWebhookWorkflowDoc,
|
||||
n8n_test_workflow: n8nTestWorkflowDoc,
|
||||
n8n_executions: n8nExecutionsDoc,
|
||||
n8n_workflow_versions: n8nWorkflowVersionsDoc,
|
||||
n8n_deploy_template: n8nDeployTemplateDoc
|
||||
|
||||
@@ -6,7 +6,7 @@ export { n8nDeleteWorkflowDoc } from './n8n-delete-workflow';
|
||||
export { n8nListWorkflowsDoc } from './n8n-list-workflows';
|
||||
export { n8nValidateWorkflowDoc } from './n8n-validate-workflow';
|
||||
export { n8nAutofixWorkflowDoc } from './n8n-autofix-workflow';
|
||||
export { n8nTriggerWebhookWorkflowDoc } from './n8n-trigger-webhook-workflow';
|
||||
export { n8nTestWorkflowDoc } from './n8n-test-workflow';
|
||||
export { n8nExecutionsDoc } from './n8n-executions';
|
||||
export { n8nWorkflowVersionsDoc } from './n8n-workflow-versions';
|
||||
export { n8nDeployTemplateDoc } from './n8n-deploy-template';
|
||||
|
||||
@@ -84,7 +84,7 @@ n8n_create_workflow({
|
||||
'Validate with validate_workflow first',
|
||||
'Use unique node IDs',
|
||||
'Position nodes for readability',
|
||||
'Test with n8n_trigger_webhook_workflow'
|
||||
'Test with n8n_test_workflow'
|
||||
],
|
||||
pitfalls: [
|
||||
'**REQUIRES N8N_API_URL and N8N_API_KEY environment variables** - tool unavailable without n8n API access',
|
||||
@@ -95,6 +95,6 @@ n8n_create_workflow({
|
||||
'**Auto-sanitization runs on creation**: All nodes sanitized before workflow created (operator structures fixed, missing metadata added)',
|
||||
'**Auto-sanitization cannot prevent all failures**: Broken connections or invalid node configurations may still cause creation to fail'
|
||||
],
|
||||
relatedTools: ['validate_workflow', 'n8n_update_partial_workflow', 'n8n_trigger_webhook_workflow']
|
||||
relatedTools: ['validate_workflow', 'n8n_update_partial_workflow', 'n8n_test_workflow']
|
||||
}
|
||||
};
|
||||
@@ -4,39 +4,39 @@ export const n8nDeployTemplateDoc: ToolDocumentation = {
|
||||
name: 'n8n_deploy_template',
|
||||
category: 'workflow_management',
|
||||
essentials: {
|
||||
description: 'Deploy a workflow template from n8n.io directly to your n8n instance. Fetches template, optionally upgrades node versions and validates, then creates workflow.',
|
||||
keyParameters: ['templateId', 'name', 'autoUpgradeVersions', 'validate', 'stripCredentials'],
|
||||
description: 'Deploy a workflow template from n8n.io directly to your n8n instance. Deploys first, then auto-fixes common issues (expression format, typeVersions).',
|
||||
keyParameters: ['templateId', 'name', 'autoUpgradeVersions', 'autoFix', 'stripCredentials'],
|
||||
example: 'n8n_deploy_template({templateId: 2776, name: "My Deployed Template"})',
|
||||
performance: 'Network-dependent',
|
||||
tips: [
|
||||
'Auto-fixes expression format issues after deployment',
|
||||
'Workflow created inactive - configure credentials in n8n UI first',
|
||||
'Returns list of required credentials',
|
||||
'Use search_templates to find template IDs',
|
||||
'Templates are upgraded to latest node versions by default'
|
||||
'Returns list of required credentials and fixes applied',
|
||||
'Use search_templates to find template IDs'
|
||||
]
|
||||
},
|
||||
full: {
|
||||
description: 'Deploys a workflow template from n8n.io directly to your n8n instance. This tool combines fetching a template and creating a workflow in a single operation. Templates are stored locally and fetched from the database. The workflow is always created in an inactive state, allowing you to configure credentials before activation.',
|
||||
description: 'Deploys a workflow template from n8n.io directly to your n8n instance. This tool deploys first, then automatically fixes common issues like missing expression prefixes (=) and outdated typeVersions. Templates are stored locally and fetched from the database. The workflow is always created in an inactive state, allowing you to configure credentials before activation.',
|
||||
parameters: {
|
||||
templateId: { type: 'number', required: true, description: 'Template ID from n8n.io (find via search_templates)' },
|
||||
name: { type: 'string', description: 'Custom workflow name (default: template name)' },
|
||||
autoUpgradeVersions: { type: 'boolean', description: 'Upgrade node typeVersions to latest supported (default: true)' },
|
||||
validate: { type: 'boolean', description: 'Validate workflow before deployment (default: true)' },
|
||||
autoFix: { type: 'boolean', description: 'Auto-apply fixes after deployment for expression format issues, missing = prefix, etc. (default: true)' },
|
||||
stripCredentials: { type: 'boolean', description: 'Remove credential references - user configures in n8n UI (default: true)' }
|
||||
},
|
||||
returns: 'Object with workflowId, name, nodeCount, triggerType, requiredCredentials array, url, templateId, templateUrl',
|
||||
returns: 'Object with workflowId, name, nodeCount, triggerType, requiredCredentials array, url, templateId, templateUrl, autoFixStatus (success/failed/skipped), and fixesApplied array',
|
||||
examples: [
|
||||
`// Deploy template with default settings
|
||||
`// Deploy template with default settings (auto-fix enabled)
|
||||
n8n_deploy_template({templateId: 2776})`,
|
||||
`// Deploy with custom name
|
||||
n8n_deploy_template({
|
||||
templateId: 2776,
|
||||
name: "My Google Drive to Airtable Sync"
|
||||
})`,
|
||||
`// Deploy without validation (faster, use for trusted templates)
|
||||
`// Deploy without auto-fix (not recommended)
|
||||
n8n_deploy_template({
|
||||
templateId: 2776,
|
||||
validate: false
|
||||
autoFix: false
|
||||
})`,
|
||||
`// Keep original node versions (useful for compatibility)
|
||||
n8n_deploy_template({
|
||||
@@ -50,10 +50,12 @@ n8n_deploy_template({
|
||||
'Bootstrap new projects with proven workflows',
|
||||
'Deploy templates found via search_templates'
|
||||
],
|
||||
performance: 'Network-dependent - Typically 200-500ms (template fetch + workflow creation)',
|
||||
performance: 'Network-dependent - Typically 300-800ms (template fetch + workflow creation + autofix)',
|
||||
bestPractices: [
|
||||
'Use search_templates to find templates by use case',
|
||||
'Review required credentials in the response',
|
||||
'Check autoFixStatus in response - "success", "failed", or "skipped"',
|
||||
'Check fixesApplied in response to see what was automatically corrected',
|
||||
'Configure credentials in n8n UI before activating',
|
||||
'Test workflow before connecting to production systems'
|
||||
],
|
||||
@@ -62,8 +64,8 @@ n8n_deploy_template({
|
||||
'Workflows created in INACTIVE state - must configure credentials and activate in n8n',
|
||||
'Templates may reference services you do not have (Slack, Google, etc.)',
|
||||
'Template database must be populated - run npm run fetch:templates if templates not found',
|
||||
'Validation may fail for templates with outdated node configurations'
|
||||
'Some issues may not be auto-fixable (e.g., missing required fields that need user input)'
|
||||
],
|
||||
relatedTools: ['search_templates', 'get_template', 'n8n_create_workflow', 'n8n_validate_workflow']
|
||||
relatedTools: ['search_templates', 'get_template', 'n8n_create_workflow', 'n8n_autofix_workflow']
|
||||
}
|
||||
};
|
||||
|
||||
@@ -77,6 +77,6 @@ export const n8nExecutionsDoc: ToolDocumentation = {
|
||||
'Execution must exist or returns 404',
|
||||
'Delete is permanent - cannot undo'
|
||||
],
|
||||
relatedTools: ['n8n_get_workflow', 'n8n_trigger_webhook_workflow', 'n8n_validate_workflow']
|
||||
relatedTools: ['n8n_get_workflow', 'n8n_test_workflow', 'n8n_validate_workflow']
|
||||
}
|
||||
};
|
||||
|
||||
138
src/mcp/tool-docs/workflow_management/n8n-test-workflow.ts
Normal file
138
src/mcp/tool-docs/workflow_management/n8n-test-workflow.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { ToolDocumentation } from '../types';
|
||||
|
||||
export const n8nTestWorkflowDoc: ToolDocumentation = {
|
||||
name: 'n8n_test_workflow',
|
||||
category: 'workflow_management',
|
||||
essentials: {
|
||||
description: 'Test/trigger workflow execution. Auto-detects trigger type (webhook/form/chat). Only workflows with these triggers can be executed externally.',
|
||||
keyParameters: ['workflowId', 'triggerType', 'data', 'message'],
|
||||
example: 'n8n_test_workflow({workflowId: "123"}) - auto-detect trigger',
|
||||
performance: 'Immediate trigger, response time depends on workflow complexity',
|
||||
tips: [
|
||||
'Auto-detects trigger type from workflow if not specified',
|
||||
'Workflow must have a webhook, form, or chat trigger to be executable',
|
||||
'For chat triggers, message is required',
|
||||
'All trigger types require the workflow to be ACTIVE'
|
||||
]
|
||||
},
|
||||
full: {
|
||||
description: `Test and trigger n8n workflows through HTTP-based methods. This unified tool supports multiple trigger types:
|
||||
|
||||
**Trigger Types:**
|
||||
- **webhook**: HTTP-based triggers (GET/POST/PUT/DELETE)
|
||||
- **form**: Form submission triggers
|
||||
- **chat**: AI chat triggers with conversation support
|
||||
|
||||
**Important:** n8n's public API does not support direct workflow execution. Only workflows with webhook, form, or chat triggers can be executed externally. Workflows with schedule, manual, or other trigger types cannot be triggered via this API.
|
||||
|
||||
The tool auto-detects the appropriate trigger type by analyzing the workflow's trigger node. You can override this with the triggerType parameter.`,
|
||||
parameters: {
|
||||
workflowId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Workflow ID to execute'
|
||||
},
|
||||
triggerType: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
enum: ['webhook', 'form', 'chat'],
|
||||
description: 'Trigger type. Auto-detected if not specified. Workflow must have matching trigger node.'
|
||||
},
|
||||
httpMethod: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
enum: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||
description: 'For webhook: HTTP method (default: from workflow config or POST)'
|
||||
},
|
||||
webhookPath: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'For webhook: override the webhook path'
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'For chat: message to send (required for chat triggers)'
|
||||
},
|
||||
sessionId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'For chat: session ID for conversation continuity'
|
||||
},
|
||||
data: {
|
||||
type: 'object',
|
||||
required: false,
|
||||
description: 'Input data/payload for webhook or form fields'
|
||||
},
|
||||
headers: {
|
||||
type: 'object',
|
||||
required: false,
|
||||
description: 'Custom HTTP headers'
|
||||
},
|
||||
timeout: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
description: 'Timeout in ms (default: 120000)'
|
||||
},
|
||||
waitForResponse: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
description: 'Wait for workflow completion (default: true)'
|
||||
}
|
||||
},
|
||||
returns: `Execution response including:
|
||||
- success: boolean
|
||||
- data: workflow output data
|
||||
- executionId: for tracking/debugging
|
||||
- triggerType: detected or specified trigger type
|
||||
- metadata: timing and request details`,
|
||||
examples: [
|
||||
'n8n_test_workflow({workflowId: "123"}) - Auto-detect and trigger',
|
||||
'n8n_test_workflow({workflowId: "123", triggerType: "webhook", data: {name: "John"}}) - Webhook with data',
|
||||
'n8n_test_workflow({workflowId: "123", triggerType: "chat", message: "Hello AI"}) - Chat trigger',
|
||||
'n8n_test_workflow({workflowId: "123", triggerType: "form", data: {email: "test@example.com"}}) - Form submission'
|
||||
],
|
||||
useCases: [
|
||||
'Test workflows during development',
|
||||
'Trigger AI chat workflows with messages',
|
||||
'Submit form data to form-triggered workflows',
|
||||
'Integrate n8n workflows with external systems via webhooks'
|
||||
],
|
||||
performance: `Performance varies based on workflow complexity and waitForResponse setting:
|
||||
- Webhook: Immediate trigger, depends on workflow
|
||||
- Form: Immediate trigger, depends on workflow
|
||||
- Chat: May have additional AI processing time`,
|
||||
errorHandling: `**Error Response with Execution Guidance**
|
||||
|
||||
When execution fails, the response includes guidance for debugging:
|
||||
|
||||
**With Execution ID** (workflow started but failed):
|
||||
- Use n8n_executions({action: 'get', id: executionId, mode: 'preview'}) to investigate
|
||||
|
||||
**Without Execution ID** (workflow didn't start):
|
||||
- Use n8n_executions({action: 'list', workflowId: 'wf_id'}) to find recent executions
|
||||
|
||||
**Common Errors:**
|
||||
- "Workflow not found" - Check workflow ID exists
|
||||
- "Workflow not active" - Activate workflow (required for all trigger types)
|
||||
- "Workflow cannot be triggered externally" - Workflow has no webhook/form/chat trigger
|
||||
- "Chat message required" - Provide message parameter for chat triggers
|
||||
- "SSRF protection" - URL validation failed`,
|
||||
bestPractices: [
|
||||
'Let auto-detection choose the trigger type when possible',
|
||||
'Ensure workflow has a webhook, form, or chat trigger before testing',
|
||||
'For chat workflows, provide sessionId for multi-turn conversations',
|
||||
'Use mode="preview" with n8n_executions for efficient debugging',
|
||||
'Test with small data payloads first',
|
||||
'Activate workflows before testing (use n8n_update_partial_workflow with activateWorkflow)'
|
||||
],
|
||||
pitfalls: [
|
||||
'All trigger types require the workflow to be ACTIVE',
|
||||
'Workflows without webhook/form/chat triggers cannot be executed externally',
|
||||
'Chat trigger requires message parameter',
|
||||
'Form data must match expected form fields',
|
||||
'Webhook method must match node configuration'
|
||||
],
|
||||
relatedTools: ['n8n_executions', 'n8n_get_workflow', 'n8n_create_workflow', 'n8n_validate_workflow']
|
||||
}
|
||||
};
|
||||
@@ -1,118 +0,0 @@
|
||||
import { ToolDocumentation } from '../types';
|
||||
|
||||
export const n8nTriggerWebhookWorkflowDoc: ToolDocumentation = {
|
||||
name: 'n8n_trigger_webhook_workflow',
|
||||
category: 'workflow_management',
|
||||
essentials: {
|
||||
description: 'Trigger workflow via webhook. Must be ACTIVE with Webhook node. Method must match config.',
|
||||
keyParameters: ['webhookUrl', 'httpMethod', 'data'],
|
||||
example: 'n8n_trigger_webhook_workflow({webhookUrl: "https://n8n.example.com/webhook/abc-def-ghi"})',
|
||||
performance: 'Immediate trigger, response time depends on workflow complexity',
|
||||
tips: [
|
||||
'Workflow MUST be active and contain a Webhook node for triggering',
|
||||
'HTTP method must match webhook node configuration (often GET)',
|
||||
'Use waitForResponse:false for async execution without waiting'
|
||||
]
|
||||
},
|
||||
full: {
|
||||
description: `Triggers a workflow execution via its webhook URL. This is the primary method for external systems to start n8n workflows. The target workflow must be active and contain a properly configured Webhook node as the trigger. The HTTP method used must match the webhook configuration.`,
|
||||
parameters: {
|
||||
webhookUrl: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Full webhook URL from n8n workflow (e.g., https://n8n.example.com/webhook/abc-def-ghi)'
|
||||
},
|
||||
httpMethod: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
enum: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||
description: 'HTTP method (must match webhook configuration, often GET). Defaults to GET if not specified'
|
||||
},
|
||||
data: {
|
||||
type: 'object',
|
||||
required: false,
|
||||
description: 'Data to send with the webhook request. For GET requests, becomes query parameters'
|
||||
},
|
||||
headers: {
|
||||
type: 'object',
|
||||
required: false,
|
||||
description: 'Additional HTTP headers to include in the request'
|
||||
},
|
||||
waitForResponse: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
description: 'Wait for workflow completion and return results (default: true). Set to false for fire-and-forget'
|
||||
}
|
||||
},
|
||||
returns: `Webhook response data if waitForResponse is true, or immediate acknowledgment if false. Response format depends on webhook node configuration.`,
|
||||
examples: [
|
||||
'n8n_trigger_webhook_workflow({webhookUrl: "https://n8n.example.com/webhook/order-process"}) - Trigger with GET',
|
||||
'n8n_trigger_webhook_workflow({webhookUrl: "https://n8n.example.com/webhook/data-import", httpMethod: "POST", data: {name: "John", email: "john@example.com"}}) - POST with data',
|
||||
'n8n_trigger_webhook_workflow({webhookUrl: "https://n8n.example.com/webhook/async-job", waitForResponse: false}) - Fire and forget',
|
||||
'n8n_trigger_webhook_workflow({webhookUrl: "https://n8n.example.com/webhook/api", headers: {"API-Key": "secret"}}) - With auth headers'
|
||||
],
|
||||
useCases: [
|
||||
'Trigger data processing workflows from external applications',
|
||||
'Start scheduled jobs manually via webhook',
|
||||
'Integrate n8n workflows with third-party services',
|
||||
'Create REST API endpoints using n8n workflows',
|
||||
'Implement event-driven architectures with n8n'
|
||||
],
|
||||
performance: `Performance varies based on workflow complexity and waitForResponse setting. Synchronous calls (waitForResponse: true) block until workflow completes. For long-running workflows, use async mode (waitForResponse: false) and monitor execution separately.`,
|
||||
errorHandling: `**Enhanced Error Messages with Execution Guidance**
|
||||
|
||||
When a webhook trigger fails, the error response now includes specific guidance to help debug the issue:
|
||||
|
||||
**Error with Execution ID** (workflow started but failed):
|
||||
- Format: "Workflow {workflowId} execution {executionId} failed. Use n8n_executions({action: 'get', id: '{executionId}', mode: 'preview'}) to investigate the error."
|
||||
- Response includes: executionId and workflowId fields for direct access
|
||||
- Recommended action: Use n8n_executions with action='get' and mode='preview' for fast, efficient error inspection
|
||||
|
||||
**Error without Execution ID** (workflow didn't start):
|
||||
- Format: "Workflow failed to execute. Use n8n_executions({action: 'list'}) to find recent executions, then n8n_executions({action: 'get', mode: 'preview'}) to investigate."
|
||||
- Recommended action: Check recent executions with n8n_executions({action: 'list'})
|
||||
|
||||
**Why mode='preview'?**
|
||||
- Fast: <50ms response time
|
||||
- Efficient: ~500 tokens (vs 50K+ for full mode)
|
||||
- Safe: No timeout or token limit risks
|
||||
- Informative: Shows structure, counts, and error details
|
||||
- Provides recommendations for fetching more data if needed
|
||||
|
||||
**Example Error Responses**:
|
||||
\`\`\`json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Workflow wf_123 execution exec_456 failed. Use n8n_get_execution({id: 'exec_456', mode: 'preview'}) to investigate the error.",
|
||||
"executionId": "exec_456",
|
||||
"workflowId": "wf_123",
|
||||
"code": "SERVER_ERROR"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Investigation Workflow**:
|
||||
1. Trigger returns error with execution ID
|
||||
2. Call n8n_executions({action: 'get', id: executionId, mode: 'preview'}) to see structure and error
|
||||
3. Based on preview recommendation, fetch more data if needed
|
||||
4. Fix issues in workflow and retry`,
|
||||
bestPractices: [
|
||||
'Always verify workflow is active before attempting webhook triggers',
|
||||
'Match HTTP method exactly with webhook node configuration',
|
||||
'Use async mode (waitForResponse: false) for long-running workflows',
|
||||
'Include authentication headers when webhook requires them',
|
||||
'Test webhook URL manually first to ensure it works',
|
||||
'When errors occur, use n8n_executions with action="get" and mode="preview" first for efficient debugging',
|
||||
'Store execution IDs from error responses for later investigation'
|
||||
],
|
||||
pitfalls: [
|
||||
'Workflow must be ACTIVE - inactive workflows cannot be triggered',
|
||||
'HTTP method mismatch returns 404 even if URL is correct',
|
||||
'Webhook node must be the trigger node in the workflow',
|
||||
'Timeout errors occur with long workflows in sync mode',
|
||||
'Data format must match webhook node expectations',
|
||||
'Error messages always include n8n_executions guidance - follow the suggested steps for efficient debugging',
|
||||
'Execution IDs in error responses are crucial for debugging - always check for and use them'
|
||||
],
|
||||
relatedTools: ['n8n_executions', 'n8n_get_workflow', 'n8n_create_workflow']
|
||||
}
|
||||
};
|
||||
@@ -135,7 +135,7 @@ When working with Code nodes, always start by calling the relevant guide:
|
||||
- n8n_list_workflows - List workflows with filters
|
||||
- n8n_validate_workflow - Validate workflow by ID
|
||||
- n8n_autofix_workflow - Auto-fix common issues
|
||||
- n8n_trigger_webhook_workflow - Trigger via webhook
|
||||
- n8n_test_workflow - Test/trigger workflows (webhook, form, chat, execute)
|
||||
- n8n_executions - Unified execution management (action='get'/'list'/'delete')
|
||||
- n8n_health_check - Check n8n API connectivity
|
||||
- n8n_workflow_versions - Version history and rollback
|
||||
|
||||
@@ -276,34 +276,58 @@ export const n8nManagementTools: ToolDefinition[] = [
|
||||
|
||||
// Execution Management Tools
|
||||
{
|
||||
name: 'n8n_trigger_webhook_workflow',
|
||||
description: `Trigger workflow via webhook. Must be ACTIVE with Webhook node. Method must match config.`,
|
||||
name: 'n8n_test_workflow',
|
||||
description: `Test/trigger workflow execution. Auto-detects trigger type (webhook/form/chat). Supports: webhook (HTTP), form (fields), chat (message). Note: Only workflows with these trigger types can be executed externally.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
webhookUrl: {
|
||||
type: 'string',
|
||||
description: 'Full webhook URL from n8n workflow (e.g., https://n8n.example.com/webhook/abc-def-ghi)'
|
||||
workflowId: {
|
||||
type: 'string',
|
||||
description: 'Workflow ID to execute (required)'
|
||||
},
|
||||
httpMethod: {
|
||||
type: 'string',
|
||||
triggerType: {
|
||||
type: 'string',
|
||||
enum: ['webhook', 'form', 'chat'],
|
||||
description: 'Trigger type. Auto-detected if not specified. Workflow must have a matching trigger node.'
|
||||
},
|
||||
// Webhook options
|
||||
httpMethod: {
|
||||
type: 'string',
|
||||
enum: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||
description: 'HTTP method (must match webhook configuration, often GET)'
|
||||
description: 'For webhook: HTTP method (default: from workflow config or POST)'
|
||||
},
|
||||
data: {
|
||||
type: 'object',
|
||||
description: 'Data to send with the webhook request'
|
||||
webhookPath: {
|
||||
type: 'string',
|
||||
description: 'For webhook: override the webhook path'
|
||||
},
|
||||
headers: {
|
||||
type: 'object',
|
||||
description: 'Additional HTTP headers'
|
||||
// Chat options
|
||||
message: {
|
||||
type: 'string',
|
||||
description: 'For chat: message to send (required for chat triggers)'
|
||||
},
|
||||
waitForResponse: {
|
||||
type: 'boolean',
|
||||
description: 'Wait for workflow completion (default: true)'
|
||||
sessionId: {
|
||||
type: 'string',
|
||||
description: 'For chat: session ID for conversation continuity'
|
||||
},
|
||||
// Common options
|
||||
data: {
|
||||
type: 'object',
|
||||
description: 'Input data/payload for webhook, form fields, or execution data'
|
||||
},
|
||||
headers: {
|
||||
type: 'object',
|
||||
description: 'Custom HTTP headers'
|
||||
},
|
||||
timeout: {
|
||||
type: 'number',
|
||||
description: 'Timeout in ms (default: 120000)'
|
||||
},
|
||||
waitForResponse: {
|
||||
type: 'boolean',
|
||||
description: 'Wait for workflow completion (default: true)'
|
||||
}
|
||||
},
|
||||
required: ['webhookUrl']
|
||||
required: ['workflowId']
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -450,7 +474,7 @@ export const n8nManagementTools: ToolDefinition[] = [
|
||||
// Template Deployment Tool
|
||||
{
|
||||
name: 'n8n_deploy_template',
|
||||
description: `Deploy a workflow template from n8n.io directly to your n8n instance. Fetches template, optionally upgrades node versions and validates, then creates workflow. Returns workflow ID and required credentials list.`,
|
||||
description: `Deploy a workflow template from n8n.io directly to your n8n instance. Deploys first, then auto-fixes common issues (expression format, typeVersions). Returns workflow ID, required credentials, and fixes applied.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -467,10 +491,10 @@ export const n8nManagementTools: ToolDefinition[] = [
|
||||
default: true,
|
||||
description: 'Automatically upgrade node typeVersions to latest supported (default: true)'
|
||||
},
|
||||
validate: {
|
||||
autoFix: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Validate workflow before deployment (default: true)'
|
||||
description: 'Auto-apply fixes after deployment for expression format issues, missing = prefix, etc. (default: true)'
|
||||
},
|
||||
stripCredentials: {
|
||||
type: 'boolean',
|
||||
|
||||
@@ -97,12 +97,12 @@ export class ExpressionValidator {
|
||||
errors.push('Unmatched expression brackets {{ }}');
|
||||
}
|
||||
|
||||
// Check for nested expressions (not supported in n8n)
|
||||
if (expression.includes('{{') && expression.includes('{{', expression.indexOf('{{') + 2)) {
|
||||
const match = expression.match(/\{\{.*\{\{/);
|
||||
if (match) {
|
||||
errors.push('Nested expressions are not supported');
|
||||
}
|
||||
// Check for truly nested expressions (not supported in n8n)
|
||||
// This means {{ inside another {{ }}, like {{ {{ $json }} }}
|
||||
// NOT multiple expressions like {{ $json.a }} text {{ $json.b }} (which is valid)
|
||||
const nestedPattern = /\{\{[^}]*\{\{/;
|
||||
if (nestedPattern.test(expression)) {
|
||||
errors.push('Nested expressions are not supported (expression inside another expression)');
|
||||
}
|
||||
|
||||
// Check for empty expressions
|
||||
|
||||
@@ -16,6 +16,11 @@ export interface SimplifiedProperty {
|
||||
placeholder?: string;
|
||||
showWhen?: Record<string, any>;
|
||||
usageHint?: string;
|
||||
expectedFormat?: {
|
||||
structure: Record<string, string>;
|
||||
modes?: string[];
|
||||
example: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EssentialConfig {
|
||||
@@ -322,7 +327,18 @@ export class PropertyFilter {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Add expectedFormat for resourceLocator types - critical for correct configuration
|
||||
if (prop.type === 'resourceLocator') {
|
||||
const modes = prop.modes?.map((m: any) => m.name || m) || ['list', 'id'];
|
||||
const defaultValue = prop.default?.value || 'your-resource-id';
|
||||
simplified.expectedFormat = {
|
||||
structure: { mode: 'string', value: 'string' },
|
||||
modes,
|
||||
example: { mode: 'id', value: defaultValue }
|
||||
};
|
||||
}
|
||||
|
||||
// Include simple display conditions (max 2 conditions)
|
||||
if (prop.displayOptions?.show) {
|
||||
const conditions = Object.keys(prop.displayOptions.show);
|
||||
|
||||
@@ -757,7 +757,8 @@ export class WorkflowDiffEngine {
|
||||
const { sourceOutput, sourceIndex } = this.resolveSmartParameters(workflow, operation);
|
||||
|
||||
// Use nullish coalescing to properly handle explicit 0 values
|
||||
const targetInput = operation.targetInput ?? 'main';
|
||||
// Default targetInput to sourceOutput to preserve connection type for AI connections (ai_tool, ai_memory, etc.)
|
||||
const targetInput = operation.targetInput ?? sourceOutput;
|
||||
const targetIndex = operation.targetIndex ?? 0;
|
||||
|
||||
// Initialize source node connections object
|
||||
|
||||
@@ -1137,16 +1137,23 @@ export class WorkflowValidator {
|
||||
}
|
||||
|
||||
// Check for AI Agent workflows
|
||||
const aiAgentNodes = workflow.nodes.filter(n =>
|
||||
n.type.toLowerCase().includes('agent') ||
|
||||
const aiAgentNodes = workflow.nodes.filter(n =>
|
||||
n.type.toLowerCase().includes('agent') ||
|
||||
n.type.includes('langchain.agent')
|
||||
);
|
||||
|
||||
|
||||
if (aiAgentNodes.length > 0) {
|
||||
// Check if AI agents have tools connected
|
||||
// Tools connect TO the agent, so we need to find connections where the target is the agent
|
||||
for (const agentNode of aiAgentNodes) {
|
||||
const connections = workflow.connections[agentNode.name];
|
||||
if (!connections?.ai_tool || connections.ai_tool.flat().filter(c => c).length === 0) {
|
||||
// Search all connections to find ones targeting this agent via ai_tool
|
||||
const hasToolConnected = Object.values(workflow.connections).some(sourceOutputs => {
|
||||
const aiToolConnections = sourceOutputs.ai_tool;
|
||||
if (!aiToolConnections) return false;
|
||||
return aiToolConnections.flat().some(conn => conn && conn.node === agentNode.name);
|
||||
});
|
||||
|
||||
if (!hasToolConnected) {
|
||||
result.warnings.push({
|
||||
type: 'warning',
|
||||
nodeId: agentNode.id,
|
||||
|
||||
149
src/triggers/handlers/base-handler.ts
Normal file
149
src/triggers/handlers/base-handler.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Base trigger handler - abstract class for all trigger handlers
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { Workflow } from '../../types/n8n-api';
|
||||
import { InstanceContext } from '../../types/instance-context';
|
||||
import { N8nApiClient } from '../../services/n8n-api-client';
|
||||
import { getN8nApiConfig } from '../../config/n8n-api';
|
||||
import {
|
||||
TriggerType,
|
||||
TriggerResponse,
|
||||
TriggerHandlerCapabilities,
|
||||
DetectedTrigger,
|
||||
BaseTriggerInput,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* Constructor type for trigger handlers
|
||||
*/
|
||||
export type TriggerHandlerConstructor = new (
|
||||
client: N8nApiClient,
|
||||
context?: InstanceContext
|
||||
) => BaseTriggerHandler;
|
||||
|
||||
/**
|
||||
* Abstract base class for all trigger handlers
|
||||
*
|
||||
* Each handler implements:
|
||||
* - Input validation via Zod schema
|
||||
* - Capability declaration (active workflow required, etc.)
|
||||
* - Execution logic specific to the trigger type
|
||||
*/
|
||||
export abstract class BaseTriggerHandler<T extends BaseTriggerInput = BaseTriggerInput> {
|
||||
protected client: N8nApiClient;
|
||||
protected context?: InstanceContext;
|
||||
|
||||
/** The trigger type this handler supports */
|
||||
abstract readonly triggerType: TriggerType;
|
||||
|
||||
/** Handler capabilities */
|
||||
abstract readonly capabilities: TriggerHandlerCapabilities;
|
||||
|
||||
/** Zod schema for input validation */
|
||||
abstract readonly inputSchema: z.ZodSchema<T>;
|
||||
|
||||
constructor(client: N8nApiClient, context?: InstanceContext) {
|
||||
this.client = client;
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate input against schema
|
||||
* @throws ZodError if validation fails
|
||||
*/
|
||||
validate(input: unknown): T {
|
||||
return this.inputSchema.parse(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the trigger
|
||||
*
|
||||
* @param input - Validated trigger input
|
||||
* @param workflow - The workflow being triggered
|
||||
* @param triggerInfo - Detected trigger information (may be undefined for 'execute' type)
|
||||
*/
|
||||
abstract execute(
|
||||
input: T,
|
||||
workflow: Workflow,
|
||||
triggerInfo?: DetectedTrigger
|
||||
): Promise<TriggerResponse>;
|
||||
|
||||
/**
|
||||
* Get the n8n instance base URL from context or environment config
|
||||
*/
|
||||
protected getBaseUrl(): string | undefined {
|
||||
// First try context (for multi-tenant scenarios)
|
||||
if (this.context?.n8nApiUrl) {
|
||||
return this.context.n8nApiUrl.replace(/\/api\/v1\/?$/, '');
|
||||
}
|
||||
// Fallback to environment config
|
||||
const config = getN8nApiConfig();
|
||||
if (config?.baseUrl) {
|
||||
return config.baseUrl.replace(/\/api\/v1\/?$/, '');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the n8n API key from context or environment config
|
||||
*/
|
||||
protected getApiKey(): string | undefined {
|
||||
// First try context (for multi-tenant scenarios)
|
||||
if (this.context?.n8nApiKey) {
|
||||
return this.context.n8nApiKey;
|
||||
}
|
||||
// Fallback to environment config
|
||||
const config = getN8nApiConfig();
|
||||
return config?.apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize response to unified format
|
||||
*/
|
||||
protected normalizeResponse(
|
||||
result: unknown,
|
||||
input: T,
|
||||
startTime: number,
|
||||
extra?: Partial<TriggerResponse>
|
||||
): TriggerResponse {
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
triggerType: this.triggerType,
|
||||
workflowId: input.workflowId,
|
||||
data: result,
|
||||
metadata: {
|
||||
duration,
|
||||
},
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error response
|
||||
*/
|
||||
protected errorResponse(
|
||||
input: BaseTriggerInput,
|
||||
error: string,
|
||||
startTime: number,
|
||||
extra?: Partial<TriggerResponse>
|
||||
): TriggerResponse {
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
return {
|
||||
success: false,
|
||||
triggerType: this.triggerType,
|
||||
workflowId: input.workflowId,
|
||||
error,
|
||||
metadata: {
|
||||
duration,
|
||||
},
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
}
|
||||
141
src/triggers/handlers/chat-handler.ts
Normal file
141
src/triggers/handlers/chat-handler.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Chat trigger handler
|
||||
*
|
||||
* Handles chat-based workflow triggers:
|
||||
* - POST to webhook endpoint with chat payload
|
||||
* - Payload structure: { action: 'sendMessage', sessionId, chatInput }
|
||||
* - Sync mode only (no SSE streaming)
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import axios, { AxiosRequestConfig } from 'axios';
|
||||
import { Workflow } from '../../types/n8n-api';
|
||||
import {
|
||||
TriggerType,
|
||||
TriggerResponse,
|
||||
TriggerHandlerCapabilities,
|
||||
DetectedTrigger,
|
||||
ChatTriggerInput,
|
||||
} from '../types';
|
||||
import { BaseTriggerHandler } from './base-handler';
|
||||
import { buildTriggerUrl } from '../trigger-detector';
|
||||
|
||||
/**
|
||||
* Zod schema for chat input validation
|
||||
*/
|
||||
const chatInputSchema = z.object({
|
||||
workflowId: z.string(),
|
||||
triggerType: z.literal('chat'),
|
||||
message: z.string(),
|
||||
sessionId: z.string().optional(),
|
||||
data: z.record(z.unknown()).optional(),
|
||||
headers: z.record(z.string()).optional(),
|
||||
timeout: z.number().optional(),
|
||||
waitForResponse: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Generate a unique session ID
|
||||
*/
|
||||
function generateSessionId(): string {
|
||||
return `session_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat trigger handler
|
||||
*/
|
||||
export class ChatHandler extends BaseTriggerHandler<ChatTriggerInput> {
|
||||
readonly triggerType: TriggerType = 'chat';
|
||||
|
||||
readonly capabilities: TriggerHandlerCapabilities = {
|
||||
requiresActiveWorkflow: true,
|
||||
canPassInputData: true,
|
||||
};
|
||||
|
||||
readonly inputSchema = chatInputSchema;
|
||||
|
||||
async execute(
|
||||
input: ChatTriggerInput,
|
||||
workflow: Workflow,
|
||||
triggerInfo?: DetectedTrigger
|
||||
): Promise<TriggerResponse> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Build chat webhook URL
|
||||
const baseUrl = this.getBaseUrl();
|
||||
if (!baseUrl) {
|
||||
return this.errorResponse(input, 'Cannot determine n8n base URL', startTime);
|
||||
}
|
||||
|
||||
// Use trigger info to build URL or fallback to default pattern
|
||||
let chatUrl: string;
|
||||
if (triggerInfo?.webhookPath) {
|
||||
chatUrl = buildTriggerUrl(baseUrl, triggerInfo, 'production');
|
||||
} else {
|
||||
// Default chat webhook path pattern
|
||||
chatUrl = `${baseUrl.replace(/\/+$/, '')}/webhook/${input.workflowId}`;
|
||||
}
|
||||
|
||||
// SSRF protection
|
||||
const { SSRFProtection } = await import('../../utils/ssrf-protection');
|
||||
const validation = await SSRFProtection.validateWebhookUrl(chatUrl);
|
||||
if (!validation.valid) {
|
||||
return this.errorResponse(input, `SSRF protection: ${validation.reason}`, startTime);
|
||||
}
|
||||
|
||||
// Generate or use provided session ID
|
||||
const sessionId = input.sessionId || generateSessionId();
|
||||
|
||||
// Build chat payload
|
||||
const chatPayload = {
|
||||
action: 'sendMessage',
|
||||
sessionId,
|
||||
chatInput: input.message,
|
||||
// Include any additional data
|
||||
...input.data,
|
||||
};
|
||||
|
||||
// Build request config
|
||||
const config: AxiosRequestConfig = {
|
||||
method: 'POST',
|
||||
url: chatUrl,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...input.headers,
|
||||
},
|
||||
data: chatPayload,
|
||||
timeout: input.timeout || (input.waitForResponse !== false ? 120000 : 30000),
|
||||
validateStatus: (status) => status < 500,
|
||||
};
|
||||
|
||||
// Make the request (sync mode - no streaming)
|
||||
const response = await axios.request(config);
|
||||
|
||||
// Extract the chat response
|
||||
const chatResponse = response.data;
|
||||
|
||||
return this.normalizeResponse(chatResponse, input, startTime, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
metadata: {
|
||||
duration: Date.now() - startTime,
|
||||
sessionId,
|
||||
webhookPath: triggerInfo?.webhookPath,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
// Try to extract execution ID from error if available
|
||||
const errorDetails = (error as any)?.response?.data;
|
||||
const executionId = errorDetails?.executionId || errorDetails?.id;
|
||||
|
||||
return this.errorResponse(input, errorMessage, startTime, {
|
||||
executionId,
|
||||
code: (error as any)?.code,
|
||||
details: errorDetails,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
460
src/triggers/handlers/form-handler.ts
Normal file
460
src/triggers/handlers/form-handler.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* Form trigger handler
|
||||
*
|
||||
* Handles form-based workflow triggers:
|
||||
* - POST to /form/<webhookId> with multipart/form-data
|
||||
* - Supports all n8n form field types: text, textarea, email, number, password, date, dropdown, checkbox, file, hidden
|
||||
* - Workflow must be active (for production endpoint)
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import axios, { AxiosRequestConfig } from 'axios';
|
||||
import FormData from 'form-data';
|
||||
import { Workflow, WorkflowNode } from '../../types/n8n-api';
|
||||
import {
|
||||
TriggerType,
|
||||
TriggerResponse,
|
||||
TriggerHandlerCapabilities,
|
||||
DetectedTrigger,
|
||||
FormTriggerInput,
|
||||
} from '../types';
|
||||
import { BaseTriggerHandler } from './base-handler';
|
||||
|
||||
/**
|
||||
* Zod schema for form input validation
|
||||
*/
|
||||
const formInputSchema = z.object({
|
||||
workflowId: z.string(),
|
||||
triggerType: z.literal('form'),
|
||||
formData: z.record(z.unknown()).optional(),
|
||||
data: z.record(z.unknown()).optional(),
|
||||
headers: z.record(z.string()).optional(),
|
||||
timeout: z.number().optional(),
|
||||
waitForResponse: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Form field types supported by n8n
|
||||
*/
|
||||
const FORM_FIELD_TYPES = {
|
||||
TEXT: 'text',
|
||||
TEXTAREA: 'textarea',
|
||||
EMAIL: 'email',
|
||||
NUMBER: 'number',
|
||||
PASSWORD: 'password',
|
||||
DATE: 'date',
|
||||
DROPDOWN: 'dropdown',
|
||||
CHECKBOX: 'checkbox',
|
||||
FILE: 'file',
|
||||
HIDDEN: 'hiddenField',
|
||||
HTML: 'html',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Maximum file size for base64 uploads (10MB)
|
||||
*/
|
||||
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* n8n form field option structure
|
||||
*/
|
||||
interface FormFieldOption {
|
||||
option: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* n8n form field value structure from workflow parameters
|
||||
*/
|
||||
interface FormFieldValue {
|
||||
fieldType?: string;
|
||||
fieldLabel?: string;
|
||||
fieldName?: string;
|
||||
elementName?: string;
|
||||
requiredField?: boolean;
|
||||
fieldOptions?: {
|
||||
values?: FormFieldOption[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Form field definition extracted from workflow
|
||||
*/
|
||||
interface FormFieldDef {
|
||||
index: number;
|
||||
fieldName: string; // field-0, field-1, etc.
|
||||
label: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
options?: string[]; // For dropdown/checkbox
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is valid base64
|
||||
*/
|
||||
function isValidBase64(str: string): boolean {
|
||||
if (!str || str.length === 0) {
|
||||
return false;
|
||||
}
|
||||
// Check for valid base64 characters and proper padding
|
||||
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
|
||||
if (!base64Regex.test(str)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
// Verify round-trip encoding
|
||||
const decoded = Buffer.from(str, 'base64');
|
||||
return decoded.toString('base64') === str;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract form field definitions from workflow
|
||||
*/
|
||||
function extractFormFields(workflow: Workflow, triggerNode?: WorkflowNode): FormFieldDef[] {
|
||||
const node = triggerNode || workflow.nodes.find(n =>
|
||||
n.type.toLowerCase().includes('formtrigger')
|
||||
);
|
||||
|
||||
const params = node?.parameters as Record<string, unknown> | undefined;
|
||||
const formFields = params?.formFields as { values?: unknown[] } | undefined;
|
||||
|
||||
if (!formFields?.values) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fields: FormFieldDef[] = [];
|
||||
let fieldIndex = 0;
|
||||
|
||||
for (const rawField of formFields.values) {
|
||||
const field = rawField as FormFieldValue;
|
||||
const fieldType = field.fieldType || FORM_FIELD_TYPES.TEXT;
|
||||
|
||||
// HTML fields are rendered as hidden inputs but are display-only
|
||||
// They still get a field index
|
||||
const def: FormFieldDef = {
|
||||
index: fieldIndex,
|
||||
fieldName: `field-${fieldIndex}`,
|
||||
label: field.fieldLabel || field.fieldName || field.elementName || `field-${fieldIndex}`,
|
||||
type: fieldType,
|
||||
required: field.requiredField === true,
|
||||
};
|
||||
|
||||
// Extract options for dropdown/checkbox
|
||||
if (field.fieldOptions?.values) {
|
||||
def.options = field.fieldOptions.values.map((v: FormFieldOption) => v.option);
|
||||
}
|
||||
|
||||
fields.push(def);
|
||||
fieldIndex++;
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate helpful usage hint for form fields
|
||||
*/
|
||||
function generateFormUsageHint(fields: FormFieldDef[]): string {
|
||||
if (fields.length === 0) {
|
||||
return 'No form fields detected in workflow.';
|
||||
}
|
||||
|
||||
const lines: string[] = ['Form fields (use these keys in data parameter):'];
|
||||
|
||||
for (const field of fields) {
|
||||
let hint = ` "${field.fieldName}": `;
|
||||
|
||||
switch (field.type) {
|
||||
case FORM_FIELD_TYPES.CHECKBOX:
|
||||
hint += `["${field.options?.[0] || 'option1'}", ...]`;
|
||||
if (field.options) {
|
||||
hint += ` (options: ${field.options.join(', ')})`;
|
||||
}
|
||||
break;
|
||||
case FORM_FIELD_TYPES.DROPDOWN:
|
||||
hint += `"${field.options?.[0] || 'value'}"`;
|
||||
if (field.options) {
|
||||
hint += ` (options: ${field.options.join(', ')})`;
|
||||
}
|
||||
break;
|
||||
case FORM_FIELD_TYPES.DATE:
|
||||
hint += '"YYYY-MM-DD"';
|
||||
break;
|
||||
case FORM_FIELD_TYPES.EMAIL:
|
||||
hint += '"user@example.com"';
|
||||
break;
|
||||
case FORM_FIELD_TYPES.NUMBER:
|
||||
hint += '123';
|
||||
break;
|
||||
case FORM_FIELD_TYPES.FILE:
|
||||
hint += '{ filename: "test.txt", content: "base64..." } or skip (sends empty file)';
|
||||
break;
|
||||
case FORM_FIELD_TYPES.PASSWORD:
|
||||
hint += '"secret"';
|
||||
break;
|
||||
case FORM_FIELD_TYPES.TEXTAREA:
|
||||
hint += '"multi-line text..."';
|
||||
break;
|
||||
case FORM_FIELD_TYPES.HTML:
|
||||
hint += '"" (display-only, can be omitted)';
|
||||
break;
|
||||
case FORM_FIELD_TYPES.HIDDEN:
|
||||
hint += '"value" (hidden field)';
|
||||
break;
|
||||
default:
|
||||
hint += '"text value"';
|
||||
}
|
||||
|
||||
hint += field.required ? ' [REQUIRED]' : '';
|
||||
hint += ` // ${field.label}`;
|
||||
lines.push(hint);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Form trigger handler
|
||||
*/
|
||||
export class FormHandler extends BaseTriggerHandler<FormTriggerInput> {
|
||||
readonly triggerType: TriggerType = 'form';
|
||||
|
||||
readonly capabilities: TriggerHandlerCapabilities = {
|
||||
requiresActiveWorkflow: true,
|
||||
canPassInputData: true,
|
||||
};
|
||||
|
||||
readonly inputSchema = formInputSchema;
|
||||
|
||||
async execute(
|
||||
input: FormTriggerInput,
|
||||
workflow: Workflow,
|
||||
triggerInfo?: DetectedTrigger
|
||||
): Promise<TriggerResponse> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Extract form field definitions for helpful error messages
|
||||
const formFieldDefs = extractFormFields(workflow, triggerInfo?.node);
|
||||
|
||||
try {
|
||||
// Build form URL
|
||||
const baseUrl = this.getBaseUrl();
|
||||
if (!baseUrl) {
|
||||
return this.errorResponse(input, 'Cannot determine n8n base URL', startTime, {
|
||||
details: {
|
||||
formFields: formFieldDefs,
|
||||
hint: generateFormUsageHint(formFieldDefs),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Form triggers use /form/<webhookId> endpoint
|
||||
const formPath = triggerInfo?.webhookPath || triggerInfo?.node?.parameters?.path || input.workflowId;
|
||||
const formUrl = `${baseUrl.replace(/\/+$/, '')}/form/${formPath}`;
|
||||
|
||||
// Merge formData and data (formData takes precedence)
|
||||
const inputFields = {
|
||||
...input.data,
|
||||
...input.formData,
|
||||
};
|
||||
|
||||
// SSRF protection
|
||||
const { SSRFProtection } = await import('../../utils/ssrf-protection');
|
||||
const validation = await SSRFProtection.validateWebhookUrl(formUrl);
|
||||
if (!validation.valid) {
|
||||
return this.errorResponse(input, `SSRF protection: ${validation.reason}`, startTime);
|
||||
}
|
||||
|
||||
// Build multipart/form-data (required by n8n form triggers)
|
||||
const formData = new FormData();
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Process each defined form field
|
||||
for (const fieldDef of formFieldDefs) {
|
||||
const value = inputFields[fieldDef.fieldName];
|
||||
|
||||
switch (fieldDef.type) {
|
||||
case FORM_FIELD_TYPES.CHECKBOX:
|
||||
// Checkbox fields need array syntax with [] suffix
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
formData.append(`${fieldDef.fieldName}[]`, String(item ?? ''));
|
||||
}
|
||||
} else if (value !== undefined && value !== null) {
|
||||
// Single value provided, wrap in array
|
||||
formData.append(`${fieldDef.fieldName}[]`, String(value));
|
||||
} else if (fieldDef.required) {
|
||||
warnings.push(`Required checkbox field "${fieldDef.fieldName}" (${fieldDef.label}) not provided`);
|
||||
}
|
||||
break;
|
||||
|
||||
case FORM_FIELD_TYPES.FILE:
|
||||
// File fields - handle file upload or send empty placeholder
|
||||
if (value && typeof value === 'object' && 'content' in value) {
|
||||
// File object with content (base64 or buffer)
|
||||
const fileObj = value as { filename?: string; content: string | Buffer };
|
||||
let buffer: Buffer;
|
||||
|
||||
if (typeof fileObj.content === 'string') {
|
||||
// Validate base64 encoding
|
||||
if (!isValidBase64(fileObj.content)) {
|
||||
warnings.push(`Invalid base64 encoding for file field "${fieldDef.fieldName}" (${fieldDef.label})`);
|
||||
buffer = Buffer.from('');
|
||||
} else {
|
||||
buffer = Buffer.from(fileObj.content, 'base64');
|
||||
// Check file size
|
||||
if (buffer.length > MAX_FILE_SIZE_BYTES) {
|
||||
warnings.push(`File too large for "${fieldDef.fieldName}" (${fieldDef.label}): ${Math.round(buffer.length / 1024 / 1024)}MB exceeds ${MAX_FILE_SIZE_BYTES / 1024 / 1024}MB limit`);
|
||||
buffer = Buffer.from('');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
buffer = fileObj.content;
|
||||
// Check file size for Buffer input
|
||||
if (buffer.length > MAX_FILE_SIZE_BYTES) {
|
||||
warnings.push(`File too large for "${fieldDef.fieldName}" (${fieldDef.label}): ${Math.round(buffer.length / 1024 / 1024)}MB exceeds ${MAX_FILE_SIZE_BYTES / 1024 / 1024}MB limit`);
|
||||
buffer = Buffer.from('');
|
||||
}
|
||||
}
|
||||
|
||||
formData.append(fieldDef.fieldName, buffer, {
|
||||
filename: fileObj.filename || 'file.txt',
|
||||
contentType: 'application/octet-stream',
|
||||
});
|
||||
} else if (value && typeof value === 'string') {
|
||||
// String value - treat as base64 content
|
||||
if (!isValidBase64(value)) {
|
||||
warnings.push(`Invalid base64 encoding for file field "${fieldDef.fieldName}" (${fieldDef.label})`);
|
||||
formData.append(fieldDef.fieldName, Buffer.from(''), {
|
||||
filename: 'empty.txt',
|
||||
contentType: 'text/plain',
|
||||
});
|
||||
} else {
|
||||
const buffer = Buffer.from(value, 'base64');
|
||||
if (buffer.length > MAX_FILE_SIZE_BYTES) {
|
||||
warnings.push(`File too large for "${fieldDef.fieldName}" (${fieldDef.label}): ${Math.round(buffer.length / 1024 / 1024)}MB exceeds ${MAX_FILE_SIZE_BYTES / 1024 / 1024}MB limit`);
|
||||
formData.append(fieldDef.fieldName, Buffer.from(''), {
|
||||
filename: 'empty.txt',
|
||||
contentType: 'text/plain',
|
||||
});
|
||||
} else {
|
||||
formData.append(fieldDef.fieldName, buffer, {
|
||||
filename: 'file.txt',
|
||||
contentType: 'application/octet-stream',
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No file provided - send empty file as placeholder
|
||||
formData.append(fieldDef.fieldName, Buffer.from(''), {
|
||||
filename: 'empty.txt',
|
||||
contentType: 'text/plain',
|
||||
});
|
||||
if (fieldDef.required) {
|
||||
warnings.push(`Required file field "${fieldDef.fieldName}" (${fieldDef.label}) not provided - sending empty placeholder`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case FORM_FIELD_TYPES.HTML:
|
||||
// HTML is display-only, but n8n renders it as hidden input
|
||||
// Send empty string or provided value
|
||||
formData.append(fieldDef.fieldName, String(value ?? ''));
|
||||
break;
|
||||
|
||||
case FORM_FIELD_TYPES.HIDDEN:
|
||||
// Hidden fields
|
||||
formData.append(fieldDef.fieldName, String(value ?? ''));
|
||||
break;
|
||||
|
||||
default:
|
||||
// Standard fields: text, textarea, email, number, password, date, dropdown
|
||||
if (value !== undefined && value !== null) {
|
||||
formData.append(fieldDef.fieldName, String(value));
|
||||
} else if (fieldDef.required) {
|
||||
warnings.push(`Required field "${fieldDef.fieldName}" (${fieldDef.label}) not provided`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Also include any extra fields not in the form definition (for flexibility)
|
||||
const definedFieldNames = new Set(formFieldDefs.map(f => f.fieldName));
|
||||
for (const [key, value] of Object.entries(inputFields)) {
|
||||
if (!definedFieldNames.has(key)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
formData.append(`${key}[]`, String(item ?? ''));
|
||||
}
|
||||
} else {
|
||||
formData.append(key, String(value ?? ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build request config
|
||||
const config: AxiosRequestConfig = {
|
||||
method: 'POST',
|
||||
url: formUrl,
|
||||
headers: {
|
||||
...formData.getHeaders(),
|
||||
...input.headers,
|
||||
},
|
||||
data: formData,
|
||||
timeout: input.timeout || (input.waitForResponse !== false ? 120000 : 30000),
|
||||
validateStatus: (status) => status < 500,
|
||||
};
|
||||
|
||||
// Make the request
|
||||
const response = await axios.request(config);
|
||||
|
||||
const result = this.normalizeResponse(response.data, input, startTime, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
metadata: {
|
||||
duration: Date.now() - startTime,
|
||||
},
|
||||
});
|
||||
|
||||
// Add fields submitted count to details
|
||||
result.details = {
|
||||
...result.details,
|
||||
fieldsSubmitted: formFieldDefs.length,
|
||||
};
|
||||
|
||||
// Add warnings if any
|
||||
if (warnings.length > 0) {
|
||||
result.details = {
|
||||
...result.details,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
// Try to extract execution ID from error if available
|
||||
const errorDetails = (error as any)?.response?.data;
|
||||
const executionId = errorDetails?.executionId || errorDetails?.id;
|
||||
|
||||
return this.errorResponse(input, errorMessage, startTime, {
|
||||
executionId,
|
||||
code: (error as any)?.code,
|
||||
details: {
|
||||
...errorDetails,
|
||||
formFields: formFieldDefs.map(f => ({
|
||||
name: f.fieldName,
|
||||
label: f.label,
|
||||
type: f.type,
|
||||
required: f.required,
|
||||
options: f.options,
|
||||
})),
|
||||
hint: generateFormUsageHint(formFieldDefs),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
125
src/triggers/handlers/webhook-handler.ts
Normal file
125
src/triggers/handlers/webhook-handler.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Webhook trigger handler
|
||||
*
|
||||
* Handles webhook-based workflow triggers:
|
||||
* - Supports GET, POST, PUT, DELETE methods
|
||||
* - Passes data as body (POST/PUT/DELETE) or query params (GET)
|
||||
* - Includes SSRF protection
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { Workflow, WebhookRequest } from '../../types/n8n-api';
|
||||
import {
|
||||
TriggerType,
|
||||
TriggerResponse,
|
||||
TriggerHandlerCapabilities,
|
||||
DetectedTrigger,
|
||||
WebhookTriggerInput,
|
||||
} from '../types';
|
||||
import { BaseTriggerHandler } from './base-handler';
|
||||
import { buildTriggerUrl } from '../trigger-detector';
|
||||
|
||||
/**
|
||||
* Zod schema for webhook input validation
|
||||
*/
|
||||
const webhookInputSchema = z.object({
|
||||
workflowId: z.string(),
|
||||
triggerType: z.literal('webhook'),
|
||||
httpMethod: z.enum(['GET', 'POST', 'PUT', 'DELETE']).optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
data: z.record(z.unknown()).optional(),
|
||||
headers: z.record(z.string()).optional(),
|
||||
timeout: z.number().optional(),
|
||||
waitForResponse: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Webhook trigger handler
|
||||
*/
|
||||
export class WebhookHandler extends BaseTriggerHandler<WebhookTriggerInput> {
|
||||
readonly triggerType: TriggerType = 'webhook';
|
||||
|
||||
readonly capabilities: TriggerHandlerCapabilities = {
|
||||
requiresActiveWorkflow: true,
|
||||
supportedMethods: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||
canPassInputData: true,
|
||||
};
|
||||
|
||||
readonly inputSchema = webhookInputSchema;
|
||||
|
||||
async execute(
|
||||
input: WebhookTriggerInput,
|
||||
workflow: Workflow,
|
||||
triggerInfo?: DetectedTrigger
|
||||
): Promise<TriggerResponse> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Build webhook URL
|
||||
const baseUrl = this.getBaseUrl();
|
||||
if (!baseUrl) {
|
||||
return this.errorResponse(input, 'Cannot determine n8n base URL', startTime);
|
||||
}
|
||||
|
||||
// Use provided webhook path or extract from trigger info
|
||||
let webhookUrl: string;
|
||||
if (input.webhookPath) {
|
||||
// User provided explicit path
|
||||
webhookUrl = `${baseUrl.replace(/\/+$/, '')}/webhook/${input.webhookPath}`;
|
||||
} else if (triggerInfo?.webhookPath) {
|
||||
// Use detected path from workflow
|
||||
webhookUrl = buildTriggerUrl(baseUrl, triggerInfo, 'production');
|
||||
} else {
|
||||
return this.errorResponse(
|
||||
input,
|
||||
'No webhook path available. Provide webhookPath parameter or ensure workflow has a webhook trigger.',
|
||||
startTime
|
||||
);
|
||||
}
|
||||
|
||||
// Determine HTTP method
|
||||
const httpMethod = input.httpMethod || triggerInfo?.httpMethod || 'POST';
|
||||
|
||||
// SSRF protection - validate the webhook URL before making the request
|
||||
const { SSRFProtection } = await import('../../utils/ssrf-protection');
|
||||
const validation = await SSRFProtection.validateWebhookUrl(webhookUrl);
|
||||
if (!validation.valid) {
|
||||
return this.errorResponse(input, `SSRF protection: ${validation.reason}`, startTime);
|
||||
}
|
||||
|
||||
// Build webhook request
|
||||
const webhookRequest: WebhookRequest = {
|
||||
webhookUrl,
|
||||
httpMethod: httpMethod as 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||
data: input.data,
|
||||
headers: input.headers,
|
||||
waitForResponse: input.waitForResponse ?? true,
|
||||
};
|
||||
|
||||
// Trigger the webhook
|
||||
const response = await this.client.triggerWebhook(webhookRequest);
|
||||
|
||||
return this.normalizeResponse(response, input, startTime, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
metadata: {
|
||||
duration: Date.now() - startTime,
|
||||
webhookPath: input.webhookPath || triggerInfo?.webhookPath,
|
||||
httpMethod,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
// Try to extract execution ID from error if available
|
||||
const errorDetails = (error as any)?.details;
|
||||
const executionId = errorDetails?.executionId || errorDetails?.id;
|
||||
|
||||
return this.errorResponse(input, errorMessage, startTime, {
|
||||
executionId,
|
||||
code: (error as any)?.code,
|
||||
details: errorDetails,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
46
src/triggers/index.ts
Normal file
46
src/triggers/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Trigger system for n8n_test_workflow tool
|
||||
*
|
||||
* Provides extensible trigger handling for different n8n trigger types:
|
||||
* - webhook: HTTP-based triggers
|
||||
* - form: Form submission triggers
|
||||
* - chat: Chat/AI triggers
|
||||
*
|
||||
* Note: n8n's public API does not support direct workflow execution.
|
||||
* Only workflows with these trigger types can be triggered externally.
|
||||
*/
|
||||
|
||||
// Types
|
||||
export {
|
||||
TriggerType,
|
||||
BaseTriggerInput,
|
||||
WebhookTriggerInput,
|
||||
FormTriggerInput,
|
||||
ChatTriggerInput,
|
||||
TriggerInput,
|
||||
TriggerResponse,
|
||||
TriggerHandlerCapabilities,
|
||||
DetectedTrigger,
|
||||
TriggerDetectionResult,
|
||||
TestWorkflowInput,
|
||||
} from './types';
|
||||
|
||||
// Detector
|
||||
export {
|
||||
detectTriggerFromWorkflow,
|
||||
buildTriggerUrl,
|
||||
describeTrigger,
|
||||
} from './trigger-detector';
|
||||
|
||||
// Registry
|
||||
export {
|
||||
TriggerRegistry,
|
||||
initializeTriggerRegistry,
|
||||
ensureRegistryInitialized,
|
||||
} from './trigger-registry';
|
||||
|
||||
// Base handler
|
||||
export {
|
||||
BaseTriggerHandler,
|
||||
TriggerHandlerConstructor,
|
||||
} from './handlers/base-handler';
|
||||
321
src/triggers/trigger-detector.ts
Normal file
321
src/triggers/trigger-detector.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Trigger detector - analyzes workflows to detect trigger type
|
||||
*/
|
||||
|
||||
import { Workflow, WorkflowNode } from '../types/n8n-api';
|
||||
import { normalizeNodeType } from '../utils/node-type-utils';
|
||||
import { TriggerType, DetectedTrigger, TriggerDetectionResult } from './types';
|
||||
|
||||
/**
|
||||
* Node type patterns for each trigger type
|
||||
*/
|
||||
const WEBHOOK_PATTERNS = [
|
||||
'webhook',
|
||||
'webhooktrigger',
|
||||
];
|
||||
|
||||
const FORM_PATTERNS = [
|
||||
'formtrigger',
|
||||
'form',
|
||||
];
|
||||
|
||||
const CHAT_PATTERNS = [
|
||||
'chattrigger',
|
||||
];
|
||||
|
||||
/**
|
||||
* Detect the trigger type from a workflow
|
||||
*
|
||||
* Priority order:
|
||||
* 1. Webhook trigger (most common for API access)
|
||||
* 2. Chat trigger (AI-specific)
|
||||
* 3. Form trigger
|
||||
*
|
||||
* Note: n8n's public API does not support direct workflow execution.
|
||||
* Only workflows with webhook/form/chat triggers can be triggered externally.
|
||||
*/
|
||||
export function detectTriggerFromWorkflow(workflow: Workflow): TriggerDetectionResult {
|
||||
if (!workflow.nodes || workflow.nodes.length === 0) {
|
||||
return {
|
||||
detected: false,
|
||||
reason: 'Workflow has no nodes',
|
||||
};
|
||||
}
|
||||
|
||||
// Find all trigger nodes
|
||||
const triggerNodes = workflow.nodes.filter(node => !node.disabled && isTriggerNodeType(node.type));
|
||||
|
||||
if (triggerNodes.length === 0) {
|
||||
return {
|
||||
detected: false,
|
||||
reason: 'No trigger nodes found in workflow',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for specific trigger types in priority order
|
||||
for (const node of triggerNodes) {
|
||||
const webhookTrigger = detectWebhookTrigger(node);
|
||||
if (webhookTrigger) {
|
||||
return {
|
||||
detected: true,
|
||||
trigger: webhookTrigger,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of triggerNodes) {
|
||||
const chatTrigger = detectChatTrigger(node);
|
||||
if (chatTrigger) {
|
||||
return {
|
||||
detected: true,
|
||||
trigger: chatTrigger,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of triggerNodes) {
|
||||
const formTrigger = detectFormTrigger(node);
|
||||
if (formTrigger) {
|
||||
return {
|
||||
detected: true,
|
||||
trigger: formTrigger,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// No externally-triggerable trigger found
|
||||
return {
|
||||
detected: false,
|
||||
reason: `Workflow has trigger nodes but none support external triggering (found: ${triggerNodes.map(n => n.type).join(', ')}). Only webhook, form, and chat triggers can be triggered via the API.`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node type is a trigger
|
||||
*/
|
||||
function isTriggerNodeType(nodeType: string): boolean {
|
||||
const normalized = normalizeNodeType(nodeType).toLowerCase();
|
||||
return (
|
||||
normalized.includes('trigger') ||
|
||||
normalized.includes('webhook') ||
|
||||
normalized === 'nodes-base.start'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect webhook trigger and extract configuration
|
||||
*/
|
||||
function detectWebhookTrigger(node: WorkflowNode): DetectedTrigger | null {
|
||||
const normalized = normalizeNodeType(node.type).toLowerCase();
|
||||
const nodeName = normalized.split('.').pop() || '';
|
||||
|
||||
const isWebhook = WEBHOOK_PATTERNS.some(pattern =>
|
||||
nodeName === pattern || nodeName.includes(pattern)
|
||||
);
|
||||
|
||||
if (!isWebhook) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract webhook path from parameters
|
||||
const params = node.parameters || {};
|
||||
const webhookPath = extractWebhookPath(params, node.id, node.webhookId);
|
||||
const httpMethod = extractHttpMethod(params);
|
||||
|
||||
return {
|
||||
type: 'webhook',
|
||||
node,
|
||||
webhookPath,
|
||||
httpMethod,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect form trigger and extract configuration
|
||||
*/
|
||||
function detectFormTrigger(node: WorkflowNode): DetectedTrigger | null {
|
||||
const normalized = normalizeNodeType(node.type).toLowerCase();
|
||||
const nodeName = normalized.split('.').pop() || '';
|
||||
|
||||
const isForm = FORM_PATTERNS.some(pattern =>
|
||||
nodeName === pattern || nodeName.includes(pattern)
|
||||
);
|
||||
|
||||
if (!isForm) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract form fields from parameters
|
||||
const params = node.parameters || {};
|
||||
const formFields = extractFormFields(params);
|
||||
const webhookPath = extractWebhookPath(params, node.id, node.webhookId);
|
||||
|
||||
return {
|
||||
type: 'form',
|
||||
node,
|
||||
webhookPath,
|
||||
formFields,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect chat trigger and extract configuration
|
||||
*/
|
||||
function detectChatTrigger(node: WorkflowNode): DetectedTrigger | null {
|
||||
const normalized = normalizeNodeType(node.type).toLowerCase();
|
||||
const nodeName = normalized.split('.').pop() || '';
|
||||
|
||||
const isChat = CHAT_PATTERNS.some(pattern =>
|
||||
nodeName === pattern || nodeName.includes(pattern)
|
||||
);
|
||||
|
||||
if (!isChat) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract chat configuration
|
||||
const params = node.parameters || {};
|
||||
const responseMode = (params.options as any)?.responseMode || 'lastNode';
|
||||
const webhookPath = extractWebhookPath(params, node.id, node.webhookId);
|
||||
|
||||
return {
|
||||
type: 'chat',
|
||||
node,
|
||||
webhookPath,
|
||||
chatConfig: {
|
||||
responseMode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract webhook path from node parameters
|
||||
*
|
||||
* Priority:
|
||||
* 1. Explicit path parameter in node config
|
||||
* 2. HTTP method specific path
|
||||
* 3. webhookId on the node (n8n assigns this for all webhook-like triggers)
|
||||
* 4. Fallback to node ID
|
||||
*/
|
||||
function extractWebhookPath(params: Record<string, unknown>, nodeId: string, webhookId?: string): string {
|
||||
// Check for explicit path parameter
|
||||
if (typeof params.path === 'string' && params.path) {
|
||||
return params.path;
|
||||
}
|
||||
|
||||
// Check for httpMethod specific path
|
||||
if (typeof params.httpMethod === 'string') {
|
||||
const methodPath = params[`path_${params.httpMethod.toLowerCase()}`];
|
||||
if (typeof methodPath === 'string' && methodPath) {
|
||||
return methodPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Use webhookId if available (n8n assigns this for chat/form/webhook triggers)
|
||||
if (typeof webhookId === 'string' && webhookId) {
|
||||
return webhookId;
|
||||
}
|
||||
|
||||
// Default: use node ID as path (n8n default behavior)
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract HTTP method from webhook parameters
|
||||
*/
|
||||
function extractHttpMethod(params: Record<string, unknown>): string {
|
||||
if (typeof params.httpMethod === 'string') {
|
||||
return params.httpMethod.toUpperCase();
|
||||
}
|
||||
return 'POST'; // Default to POST
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract form field names from form trigger parameters
|
||||
*/
|
||||
function extractFormFields(params: Record<string, unknown>): string[] {
|
||||
const fields: string[] = [];
|
||||
|
||||
// Check for formFields parameter (common pattern)
|
||||
if (Array.isArray(params.formFields)) {
|
||||
for (const field of params.formFields) {
|
||||
if (field && typeof field.fieldLabel === 'string') {
|
||||
fields.push(field.fieldLabel);
|
||||
} else if (field && typeof field.fieldName === 'string') {
|
||||
fields.push(field.fieldName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for fields in options
|
||||
const options = params.options as Record<string, unknown> | undefined;
|
||||
if (options && Array.isArray(options.formFields)) {
|
||||
for (const field of options.formFields) {
|
||||
if (field && typeof field.fieldLabel === 'string') {
|
||||
fields.push(field.fieldLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the trigger URL based on detected trigger and n8n base URL
|
||||
*
|
||||
* @param baseUrl - n8n instance base URL (e.g., https://n8n.example.com)
|
||||
* @param trigger - Detected trigger information
|
||||
* @param mode - 'production' uses /webhook/, 'test' uses /webhook-test/
|
||||
*/
|
||||
export function buildTriggerUrl(
|
||||
baseUrl: string,
|
||||
trigger: DetectedTrigger,
|
||||
mode: 'production' | 'test' = 'production'
|
||||
): string {
|
||||
const cleanBaseUrl = baseUrl.replace(/\/+$/, ''); // Remove trailing slashes
|
||||
|
||||
switch (trigger.type) {
|
||||
case 'webhook': {
|
||||
const prefix = mode === 'test' ? 'webhook-test' : 'webhook';
|
||||
const path = trigger.webhookPath || trigger.node.id;
|
||||
return `${cleanBaseUrl}/${prefix}/${path}`;
|
||||
}
|
||||
|
||||
case 'chat': {
|
||||
// Chat triggers use /webhook/<webhookId>/chat endpoint
|
||||
const prefix = mode === 'test' ? 'webhook-test' : 'webhook';
|
||||
const path = trigger.webhookPath || trigger.node.id;
|
||||
return `${cleanBaseUrl}/${prefix}/${path}/chat`;
|
||||
}
|
||||
|
||||
case 'form': {
|
||||
// Form triggers use /form/<webhookId> endpoint
|
||||
const prefix = mode === 'test' ? 'form-test' : 'form';
|
||||
const path = trigger.webhookPath || trigger.node.id;
|
||||
return `${cleanBaseUrl}/${prefix}/${path}`;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Cannot build URL for trigger type: ${trigger.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable description of the detected trigger
|
||||
*/
|
||||
export function describeTrigger(trigger: DetectedTrigger): string {
|
||||
switch (trigger.type) {
|
||||
case 'webhook':
|
||||
return `Webhook trigger (${trigger.httpMethod || 'POST'} /${trigger.webhookPath || trigger.node.id})`;
|
||||
|
||||
case 'form':
|
||||
const fieldCount = trigger.formFields?.length || 0;
|
||||
return `Form trigger (${fieldCount} fields)`;
|
||||
|
||||
case 'chat':
|
||||
return `Chat trigger (${trigger.chatConfig?.responseMode || 'lastNode'} mode)`;
|
||||
|
||||
default:
|
||||
return 'Unknown trigger';
|
||||
}
|
||||
}
|
||||
118
src/triggers/trigger-registry.ts
Normal file
118
src/triggers/trigger-registry.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Trigger Registry - central registry for trigger handlers
|
||||
*
|
||||
* Uses the plugin pattern for extensibility:
|
||||
* - Register handlers at startup
|
||||
* - Get handler by trigger type
|
||||
* - List all registered types
|
||||
*/
|
||||
|
||||
import { N8nApiClient } from '../services/n8n-api-client';
|
||||
import { InstanceContext } from '../types/instance-context';
|
||||
import { TriggerType } from './types';
|
||||
import { BaseTriggerHandler, TriggerHandlerConstructor } from './handlers/base-handler';
|
||||
|
||||
/**
|
||||
* Central registry for trigger handlers
|
||||
*/
|
||||
export class TriggerRegistry {
|
||||
private static handlers: Map<TriggerType, TriggerHandlerConstructor> = new Map();
|
||||
private static initialized = false;
|
||||
|
||||
/**
|
||||
* Register a trigger handler
|
||||
*
|
||||
* @param type - The trigger type this handler supports
|
||||
* @param HandlerClass - The handler class constructor
|
||||
*/
|
||||
static register(type: TriggerType, HandlerClass: TriggerHandlerConstructor): void {
|
||||
this.handlers.set(type, HandlerClass);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a handler instance for a trigger type
|
||||
*
|
||||
* @param type - The trigger type
|
||||
* @param client - n8n API client
|
||||
* @param context - Optional instance context
|
||||
* @returns Handler instance or undefined if not registered
|
||||
*/
|
||||
static getHandler(
|
||||
type: TriggerType,
|
||||
client: N8nApiClient,
|
||||
context?: InstanceContext
|
||||
): BaseTriggerHandler | undefined {
|
||||
const HandlerClass = this.handlers.get(type);
|
||||
if (!HandlerClass) {
|
||||
return undefined;
|
||||
}
|
||||
return new HandlerClass(client, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a trigger type has a registered handler
|
||||
*/
|
||||
static hasHandler(type: TriggerType): boolean {
|
||||
return this.handlers.has(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered trigger types
|
||||
*/
|
||||
static getRegisteredTypes(): TriggerType[] {
|
||||
return Array.from(this.handlers.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all registered handlers (useful for testing)
|
||||
*/
|
||||
static clear(): void {
|
||||
this.handlers.clear();
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if registry is initialized
|
||||
*/
|
||||
static isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark registry as initialized
|
||||
*/
|
||||
static markInitialized(): void {
|
||||
this.initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the registry with all handlers
|
||||
* Called once at startup
|
||||
*/
|
||||
export async function initializeTriggerRegistry(): Promise<void> {
|
||||
if (TriggerRegistry.isInitialized()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Import handlers dynamically to avoid circular dependencies
|
||||
const { WebhookHandler } = await import('./handlers/webhook-handler');
|
||||
const { FormHandler } = await import('./handlers/form-handler');
|
||||
const { ChatHandler } = await import('./handlers/chat-handler');
|
||||
|
||||
// Register all handlers
|
||||
TriggerRegistry.register('webhook', WebhookHandler);
|
||||
TriggerRegistry.register('form', FormHandler);
|
||||
TriggerRegistry.register('chat', ChatHandler);
|
||||
|
||||
TriggerRegistry.markInitialized();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure registry is initialized (lazy initialization)
|
||||
*/
|
||||
export async function ensureRegistryInitialized(): Promise<void> {
|
||||
if (!TriggerRegistry.isInitialized()) {
|
||||
await initializeTriggerRegistry();
|
||||
}
|
||||
}
|
||||
137
src/triggers/types.ts
Normal file
137
src/triggers/types.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Trigger system types for n8n_test_workflow tool
|
||||
*
|
||||
* Supports 3 trigger categories (all input-capable):
|
||||
* - webhook: AI can pass HTTP body/headers/params
|
||||
* - form: AI can pass form field values
|
||||
* - chat: AI can pass message + sessionId
|
||||
*
|
||||
* Note: Direct workflow execution via API is not supported by n8n's public API.
|
||||
* Workflows must have webhook/form/chat triggers to be executable externally.
|
||||
*/
|
||||
|
||||
import { Workflow, WorkflowNode } from '../types/n8n-api';
|
||||
|
||||
/**
|
||||
* Supported trigger types (all input-capable)
|
||||
*/
|
||||
export type TriggerType = 'webhook' | 'form' | 'chat';
|
||||
|
||||
/**
|
||||
* Base input for all trigger handlers
|
||||
*/
|
||||
export interface BaseTriggerInput {
|
||||
workflowId: string;
|
||||
triggerType?: TriggerType;
|
||||
data?: Record<string, unknown>;
|
||||
headers?: Record<string, string>;
|
||||
timeout?: number;
|
||||
waitForResponse?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook-specific input
|
||||
*/
|
||||
export interface WebhookTriggerInput extends BaseTriggerInput {
|
||||
triggerType: 'webhook';
|
||||
httpMethod?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
webhookPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Form-specific input
|
||||
*/
|
||||
export interface FormTriggerInput extends BaseTriggerInput {
|
||||
triggerType: 'form';
|
||||
formData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat-specific input (sync mode only)
|
||||
*/
|
||||
export interface ChatTriggerInput extends BaseTriggerInput {
|
||||
triggerType: 'chat';
|
||||
message: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated union of all trigger inputs
|
||||
*/
|
||||
export type TriggerInput =
|
||||
| WebhookTriggerInput
|
||||
| FormTriggerInput
|
||||
| ChatTriggerInput;
|
||||
|
||||
/**
|
||||
* Unified response from all trigger handlers
|
||||
*/
|
||||
export interface TriggerResponse {
|
||||
success: boolean;
|
||||
triggerType: TriggerType;
|
||||
workflowId: string;
|
||||
executionId?: string;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
code?: string;
|
||||
details?: Record<string, unknown>;
|
||||
metadata: {
|
||||
duration: number;
|
||||
webhookPath?: string;
|
||||
sessionId?: string;
|
||||
httpMethod?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler capability flags
|
||||
*/
|
||||
export interface TriggerHandlerCapabilities {
|
||||
/** Whether workflow must be active for this trigger */
|
||||
requiresActiveWorkflow: boolean;
|
||||
/** Supported HTTP methods (for webhook) */
|
||||
supportedMethods?: string[];
|
||||
/** Whether this handler can pass input data to workflow */
|
||||
canPassInputData: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detected trigger information from workflow analysis
|
||||
*/
|
||||
export interface DetectedTrigger {
|
||||
type: TriggerType;
|
||||
node: WorkflowNode;
|
||||
webhookPath?: string;
|
||||
httpMethod?: string;
|
||||
formFields?: string[];
|
||||
chatConfig?: {
|
||||
responseMode?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of trigger detection
|
||||
*/
|
||||
export interface TriggerDetectionResult {
|
||||
detected: boolean;
|
||||
trigger?: DetectedTrigger;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for the MCP tool (before trigger type detection)
|
||||
*/
|
||||
export interface TestWorkflowInput {
|
||||
workflowId: string;
|
||||
triggerType?: TriggerType;
|
||||
httpMethod?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
webhookPath?: string;
|
||||
message?: string;
|
||||
sessionId?: string;
|
||||
data?: Record<string, unknown>;
|
||||
headers?: Record<string, string>;
|
||||
timeout?: number;
|
||||
waitForResponse?: boolean;
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export interface WorkflowNode {
|
||||
waitBetweenTries?: number;
|
||||
alwaysOutputData?: boolean;
|
||||
executeOnce?: boolean;
|
||||
webhookId?: string; // n8n assigns this for webhook/form/chat trigger nodes
|
||||
}
|
||||
|
||||
export interface WorkflowConnection {
|
||||
|
||||
@@ -10,7 +10,7 @@ const deployTemplateSchema = z.object({
|
||||
templateId: z.number().positive().int(),
|
||||
name: z.string().optional(),
|
||||
autoUpgradeVersions: z.boolean().default(true),
|
||||
validate: z.boolean().default(true),
|
||||
autoFix: z.boolean().default(true),
|
||||
stripCredentials: z.boolean().default(true)
|
||||
});
|
||||
|
||||
@@ -78,11 +78,11 @@ describe('handleDeployTemplate Schema Validation', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should default validate to true', () => {
|
||||
it('should default autoFix to true', () => {
|
||||
const result = deployTemplateSchema.safeParse({ templateId: 123 });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.validate).toBe(true);
|
||||
expect(result.data.autoFix).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -99,7 +99,7 @@ describe('handleDeployTemplate Schema Validation', () => {
|
||||
templateId: 2776,
|
||||
name: 'My Deployed Workflow',
|
||||
autoUpgradeVersions: false,
|
||||
validate: false,
|
||||
autoFix: false,
|
||||
stripCredentials: false
|
||||
});
|
||||
|
||||
@@ -108,7 +108,7 @@ describe('handleDeployTemplate Schema Validation', () => {
|
||||
expect(result.data.templateId).toBe(2776);
|
||||
expect(result.data.name).toBe('My Deployed Workflow');
|
||||
expect(result.data.autoUpgradeVersions).toBe(false);
|
||||
expect(result.data.validate).toBe(false);
|
||||
expect(result.data.autoFix).toBe(false);
|
||||
expect(result.data.stripCredentials).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -535,12 +535,12 @@ describe('Parameter Validation', () => {
|
||||
{ name: 'n8n_validate_workflow', args: {}, expected: 'n8n_validate_workflow: Validation failed:\n • id: id is required' },
|
||||
];
|
||||
|
||||
// n8n_update_partial_workflow and n8n_trigger_webhook_workflow use legacy validation
|
||||
// n8n_update_partial_workflow and n8n_test_workflow use legacy validation
|
||||
await expect(server.testExecuteTool('n8n_update_partial_workflow', {}))
|
||||
.rejects.toThrow('Missing required parameters for n8n_update_partial_workflow: id, operations');
|
||||
|
||||
await expect(server.testExecuteTool('n8n_trigger_webhook_workflow', {}))
|
||||
.rejects.toThrow('Missing required parameters for n8n_trigger_webhook_workflow: webhookUrl');
|
||||
|
||||
await expect(server.testExecuteTool('n8n_test_workflow', {}))
|
||||
.rejects.toThrow('Missing required parameters for n8n_test_workflow: workflowId');
|
||||
|
||||
for (const tool of n8nToolsWithRequiredParams) {
|
||||
await expect(server.testExecuteTool(tool.name, tool.args))
|
||||
|
||||
@@ -406,5 +406,74 @@ describe('PropertyFilter', () => {
|
||||
const complex = result.common.find(p => p.name === 'complex');
|
||||
expect(complex?.default).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should add expectedFormat for resourceLocator type properties', () => {
|
||||
const properties = [
|
||||
{
|
||||
name: 'channel',
|
||||
type: 'resourceLocator',
|
||||
displayName: 'Channel',
|
||||
description: 'The channel to send message to',
|
||||
modes: [
|
||||
{ name: 'list', displayName: 'From List' },
|
||||
{ name: 'id', displayName: 'By ID' },
|
||||
{ name: 'url', displayName: 'By URL' }
|
||||
],
|
||||
default: { mode: 'list', value: '' }
|
||||
}
|
||||
];
|
||||
|
||||
const result = PropertyFilter.getEssentials(properties, 'nodes-base.slack');
|
||||
|
||||
const channelProp = result.common.find(p => p.name === 'channel');
|
||||
expect(channelProp).toBeDefined();
|
||||
expect(channelProp?.expectedFormat).toBeDefined();
|
||||
expect(channelProp?.expectedFormat?.structure).toEqual({
|
||||
mode: 'string',
|
||||
value: 'string'
|
||||
});
|
||||
expect(channelProp?.expectedFormat?.modes).toEqual(['list', 'id', 'url']);
|
||||
expect(channelProp?.expectedFormat?.example).toBeDefined();
|
||||
expect(channelProp?.expectedFormat?.example.mode).toBe('id');
|
||||
expect(channelProp?.expectedFormat?.example.value).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle resourceLocator without modes array', () => {
|
||||
const properties = [
|
||||
{
|
||||
name: 'resource',
|
||||
type: 'resourceLocator',
|
||||
displayName: 'Resource',
|
||||
default: { mode: 'id', value: 'test-123' }
|
||||
}
|
||||
];
|
||||
|
||||
const result = PropertyFilter.getEssentials(properties, 'nodes-base.unknownNode');
|
||||
|
||||
const resourceProp = result.common.find(p => p.name === 'resource');
|
||||
expect(resourceProp?.expectedFormat).toBeDefined();
|
||||
// Should default to common modes
|
||||
expect(resourceProp?.expectedFormat?.modes).toEqual(['list', 'id']);
|
||||
expect(resourceProp?.expectedFormat?.example.value).toBe('test-123');
|
||||
});
|
||||
|
||||
it('should handle resourceLocator with no default value', () => {
|
||||
const properties = [
|
||||
{
|
||||
name: 'item',
|
||||
type: 'resourceLocator',
|
||||
displayName: 'Item',
|
||||
modes: [{ name: 'search' }, { name: 'id' }]
|
||||
}
|
||||
];
|
||||
|
||||
const result = PropertyFilter.getEssentials(properties, 'nodes-base.unknownNode');
|
||||
|
||||
const itemProp = result.common.find(p => p.name === 'item');
|
||||
expect(itemProp?.expectedFormat).toBeDefined();
|
||||
expect(itemProp?.expectedFormat?.modes).toEqual(['search', 'id']);
|
||||
// Should use fallback value
|
||||
expect(itemProp?.expectedFormat?.example.value).toBe('your-resource-id');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4665,4 +4665,223 @@ describe('WorkflowDiffEngine', () => {
|
||||
expect(result.errors![0].message).toContain('executeWorkflowTrigger cannot activate workflows');
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #458: AI connection type propagation
|
||||
describe('AI Connection Type Propagation (Issue #458)', () => {
|
||||
it('should propagate ai_tool connection type when targetInput is not specified', async () => {
|
||||
const workflowWithAI = {
|
||||
...baseWorkflow,
|
||||
nodes: [
|
||||
{
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
typeVersion: 2.1,
|
||||
position: [500, 300] as [number, number],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: 'tool1',
|
||||
name: 'Calculator',
|
||||
type: '@n8n/n8n-nodes-langchain.toolCalculator',
|
||||
typeVersion: 1,
|
||||
position: [300, 400] as [number, number],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const operation: AddConnectionOperation = {
|
||||
type: 'addConnection',
|
||||
source: 'Calculator',
|
||||
target: 'AI Agent',
|
||||
sourceOutput: 'ai_tool'
|
||||
// targetInput not specified - should default to sourceOutput ('ai_tool')
|
||||
};
|
||||
|
||||
const request: WorkflowDiffRequest = {
|
||||
id: 'test-workflow',
|
||||
operations: [operation]
|
||||
};
|
||||
|
||||
const result = await diffEngine.applyDiff(workflowWithAI as Workflow, request);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.workflow.connections['Calculator']).toBeDefined();
|
||||
expect(result.workflow.connections['Calculator']['ai_tool']).toBeDefined();
|
||||
// The inner type should be 'ai_tool', NOT 'main'
|
||||
expect(result.workflow.connections['Calculator']['ai_tool'][0][0].type).toBe('ai_tool');
|
||||
expect(result.workflow.connections['Calculator']['ai_tool'][0][0].node).toBe('AI Agent');
|
||||
});
|
||||
|
||||
it('should propagate ai_languageModel connection type', async () => {
|
||||
const workflowWithAI = {
|
||||
...baseWorkflow,
|
||||
nodes: [
|
||||
{
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
typeVersion: 2.1,
|
||||
position: [500, 300] as [number, number],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: 'llm1',
|
||||
name: 'OpenAI Chat Model',
|
||||
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
typeVersion: 1.2,
|
||||
position: [300, 200] as [number, number],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const operation: AddConnectionOperation = {
|
||||
type: 'addConnection',
|
||||
source: 'OpenAI Chat Model',
|
||||
target: 'AI Agent',
|
||||
sourceOutput: 'ai_languageModel'
|
||||
};
|
||||
|
||||
const request: WorkflowDiffRequest = {
|
||||
id: 'test-workflow',
|
||||
operations: [operation]
|
||||
};
|
||||
|
||||
const result = await diffEngine.applyDiff(workflowWithAI as Workflow, request);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.workflow.connections['OpenAI Chat Model']['ai_languageModel'][0][0].type).toBe('ai_languageModel');
|
||||
});
|
||||
|
||||
it('should propagate ai_memory connection type', async () => {
|
||||
const workflowWithAI = {
|
||||
...baseWorkflow,
|
||||
nodes: [
|
||||
{
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
typeVersion: 2.1,
|
||||
position: [500, 300] as [number, number],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: 'memory1',
|
||||
name: 'Window Buffer Memory',
|
||||
type: '@n8n/n8n-nodes-langchain.memoryBufferWindow',
|
||||
typeVersion: 1.3,
|
||||
position: [300, 500] as [number, number],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const operation: AddConnectionOperation = {
|
||||
type: 'addConnection',
|
||||
source: 'Window Buffer Memory',
|
||||
target: 'AI Agent',
|
||||
sourceOutput: 'ai_memory'
|
||||
};
|
||||
|
||||
const request: WorkflowDiffRequest = {
|
||||
id: 'test-workflow',
|
||||
operations: [operation]
|
||||
};
|
||||
|
||||
const result = await diffEngine.applyDiff(workflowWithAI as Workflow, request);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.workflow.connections['Window Buffer Memory']['ai_memory'][0][0].type).toBe('ai_memory');
|
||||
});
|
||||
|
||||
it('should allow explicit targetInput override for mixed connection types', async () => {
|
||||
const workflowWithNodes = {
|
||||
...baseWorkflow,
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
name: 'Source Node',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [300, 300] as [number, number],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: 'node2',
|
||||
name: 'Target Node',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [500, 300] as [number, number],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const operation: AddConnectionOperation = {
|
||||
type: 'addConnection',
|
||||
source: 'Source Node',
|
||||
target: 'Target Node',
|
||||
sourceOutput: 'main',
|
||||
targetInput: 'main' // Explicit override
|
||||
};
|
||||
|
||||
const request: WorkflowDiffRequest = {
|
||||
id: 'test-workflow',
|
||||
operations: [operation]
|
||||
};
|
||||
|
||||
const result = await diffEngine.applyDiff(workflowWithNodes as Workflow, request);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.workflow.connections['Source Node']['main'][0][0].type).toBe('main');
|
||||
});
|
||||
|
||||
it('should default to main for regular connections when sourceOutput is not specified', async () => {
|
||||
const workflowWithNodes = {
|
||||
...baseWorkflow,
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
name: 'Source Node',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [300, 300] as [number, number],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: 'node2',
|
||||
name: 'Target Node',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [500, 300] as [number, number],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const operation: AddConnectionOperation = {
|
||||
type: 'addConnection',
|
||||
source: 'Source Node',
|
||||
target: 'Target Node'
|
||||
// Neither sourceOutput nor targetInput specified - should default to 'main'
|
||||
};
|
||||
|
||||
const request: WorkflowDiffRequest = {
|
||||
id: 'test-workflow',
|
||||
operations: [operation]
|
||||
};
|
||||
|
||||
const result = await diffEngine.applyDiff(workflowWithNodes as Workflow, request);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.workflow.connections['Source Node']['main'][0][0].type).toBe('main');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1329,6 +1329,37 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
||||
expect(result.warnings.some(w => w.message.includes('AI Agent has no tools connected'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT warn about AI agents WITH tools properly connected', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Calculator Tool',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
position: [100, 100],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [300, 100],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Calculator Tool': {
|
||||
ai_tool: [[{ node: 'Agent', type: 'ai_tool', index: 0 }]]
|
||||
}
|
||||
}
|
||||
} as any;
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
// Should NOT have warning about missing tools
|
||||
expect(result.warnings.some(w => w.message.includes('AI Agent has no tools connected'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should suggest community package setting for AI tools', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
|
||||
335
tests/unit/triggers/handlers/base-handler.test.ts
Normal file
335
tests/unit/triggers/handlers/base-handler.test.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Unit tests for BaseTriggerHandler
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { BaseTriggerHandler } from '../../../../src/triggers/handlers/base-handler';
|
||||
import { N8nApiClient } from '../../../../src/services/n8n-api-client';
|
||||
import { InstanceContext } from '../../../../src/types/instance-context';
|
||||
import { Workflow } from '../../../../src/types/n8n-api';
|
||||
import { TriggerType, TriggerResponse, TriggerHandlerCapabilities, BaseTriggerInput } from '../../../../src/triggers/types';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Mock getN8nApiConfig
|
||||
vi.mock('../../../../src/config/n8n-api', () => ({
|
||||
getN8nApiConfig: vi.fn(() => ({
|
||||
baseUrl: 'https://env-n8n.example.com/api/v1',
|
||||
apiKey: 'env-api-key',
|
||||
})),
|
||||
}));
|
||||
|
||||
// Create a concrete implementation for testing
|
||||
class TestHandler extends BaseTriggerHandler {
|
||||
readonly triggerType: TriggerType = 'webhook';
|
||||
readonly capabilities: TriggerHandlerCapabilities = {
|
||||
requiresActiveWorkflow: true,
|
||||
canPassInputData: true,
|
||||
};
|
||||
readonly inputSchema = z.object({
|
||||
workflowId: z.string(),
|
||||
triggerType: z.literal('webhook'),
|
||||
});
|
||||
|
||||
async execute(
|
||||
input: BaseTriggerInput,
|
||||
workflow: Workflow
|
||||
): Promise<TriggerResponse> {
|
||||
return {
|
||||
success: true,
|
||||
triggerType: this.triggerType,
|
||||
workflowId: input.workflowId,
|
||||
data: { test: 'data' },
|
||||
metadata: { duration: 100 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
describe('BaseTriggerHandler', () => {
|
||||
let mockClient: N8nApiClient;
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient = createMockClient();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with client only', () => {
|
||||
const handler = new TestHandler(mockClient);
|
||||
expect(handler).toBeDefined();
|
||||
expect(handler.triggerType).toBe('webhook');
|
||||
});
|
||||
|
||||
it('should initialize with client and context', () => {
|
||||
const context: InstanceContext = {
|
||||
n8nApiUrl: 'https://test.n8n.com/api/v1',
|
||||
n8nApiKey: 'test-key',
|
||||
sessionId: 'test-session',
|
||||
};
|
||||
const handler = new TestHandler(mockClient, context);
|
||||
expect(handler).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate', () => {
|
||||
it('should validate correct input', () => {
|
||||
const handler = new TestHandler(mockClient);
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook',
|
||||
};
|
||||
|
||||
const result = handler.validate(input);
|
||||
expect(result).toEqual(input);
|
||||
});
|
||||
|
||||
it('should throw ZodError for invalid input', () => {
|
||||
const handler = new TestHandler(mockClient);
|
||||
const input = {
|
||||
workflowId: 123, // Wrong type
|
||||
triggerType: 'webhook',
|
||||
};
|
||||
|
||||
expect(() => handler.validate(input)).toThrow();
|
||||
});
|
||||
|
||||
it('should throw ZodError for missing required fields', () => {
|
||||
const handler = new TestHandler(mockClient);
|
||||
const input = {
|
||||
triggerType: 'webhook',
|
||||
// Missing workflowId
|
||||
};
|
||||
|
||||
expect(() => handler.validate(input)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBaseUrl', () => {
|
||||
it('should return base URL from context', () => {
|
||||
const context: InstanceContext = {
|
||||
n8nApiUrl: 'https://context.n8n.com/api/v1',
|
||||
n8nApiKey: 'context-key',
|
||||
sessionId: 'test-session',
|
||||
};
|
||||
const handler = new TestHandler(mockClient, context);
|
||||
|
||||
const baseUrl = (handler as any).getBaseUrl();
|
||||
expect(baseUrl).toBe('https://context.n8n.com');
|
||||
});
|
||||
|
||||
it('should strip trailing slash and /api/v1 from context URL', () => {
|
||||
const context: InstanceContext = {
|
||||
n8nApiUrl: 'https://context.n8n.com/api/v1/',
|
||||
n8nApiKey: 'context-key',
|
||||
sessionId: 'test-session',
|
||||
};
|
||||
const handler = new TestHandler(mockClient, context);
|
||||
|
||||
const baseUrl = (handler as any).getBaseUrl();
|
||||
expect(baseUrl).toBe('https://context.n8n.com');
|
||||
});
|
||||
|
||||
it('should return base URL from environment config when no context', () => {
|
||||
const handler = new TestHandler(mockClient);
|
||||
|
||||
const baseUrl = (handler as any).getBaseUrl();
|
||||
expect(baseUrl).toBe('https://env-n8n.example.com');
|
||||
});
|
||||
|
||||
it('should prefer context over environment config', () => {
|
||||
const context: InstanceContext = {
|
||||
n8nApiUrl: 'https://context.n8n.com/api/v1',
|
||||
n8nApiKey: 'context-key',
|
||||
sessionId: 'test-session',
|
||||
};
|
||||
const handler = new TestHandler(mockClient, context);
|
||||
|
||||
const baseUrl = (handler as any).getBaseUrl();
|
||||
expect(baseUrl).toBe('https://context.n8n.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getApiKey', () => {
|
||||
it('should return API key from context', () => {
|
||||
const context: InstanceContext = {
|
||||
n8nApiUrl: 'https://context.n8n.com/api/v1',
|
||||
n8nApiKey: 'context-api-key',
|
||||
sessionId: 'test-session',
|
||||
};
|
||||
const handler = new TestHandler(mockClient, context);
|
||||
|
||||
const apiKey = (handler as any).getApiKey();
|
||||
expect(apiKey).toBe('context-api-key');
|
||||
});
|
||||
|
||||
it('should return API key from environment config when no context', () => {
|
||||
const handler = new TestHandler(mockClient);
|
||||
|
||||
const apiKey = (handler as any).getApiKey();
|
||||
expect(apiKey).toBe('env-api-key');
|
||||
});
|
||||
|
||||
it('should prefer context over environment config', () => {
|
||||
const context: InstanceContext = {
|
||||
n8nApiUrl: 'https://context.n8n.com/api/v1',
|
||||
n8nApiKey: 'context-key',
|
||||
sessionId: 'test-session',
|
||||
};
|
||||
const handler = new TestHandler(mockClient, context);
|
||||
|
||||
const apiKey = (handler as any).getApiKey();
|
||||
expect(apiKey).toBe('context-key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeResponse', () => {
|
||||
it('should create normalized success response', () => {
|
||||
const handler = new TestHandler(mockClient);
|
||||
const input: BaseTriggerInput = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook',
|
||||
};
|
||||
const startTime = Date.now() - 150;
|
||||
const result = { data: 'test-result' };
|
||||
|
||||
const response = (handler as any).normalizeResponse(result, input, startTime);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.triggerType).toBe('webhook');
|
||||
expect(response.workflowId).toBe('workflow-123');
|
||||
expect(response.data).toEqual(result);
|
||||
expect(response.metadata.duration).toBeGreaterThanOrEqual(150);
|
||||
});
|
||||
|
||||
it('should merge extra fields into response', () => {
|
||||
const handler = new TestHandler(mockClient);
|
||||
const input: BaseTriggerInput = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook',
|
||||
};
|
||||
const startTime = Date.now();
|
||||
const result = { data: 'test' };
|
||||
const extra = {
|
||||
executionId: 'exec-123',
|
||||
status: 200,
|
||||
};
|
||||
|
||||
const response = (handler as any).normalizeResponse(result, input, startTime, extra);
|
||||
|
||||
expect(response.executionId).toBe('exec-123');
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should calculate duration correctly', () => {
|
||||
const handler = new TestHandler(mockClient);
|
||||
const input: BaseTriggerInput = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook',
|
||||
};
|
||||
const startTime = Date.now() - 500;
|
||||
|
||||
const response = (handler as any).normalizeResponse({}, input, startTime);
|
||||
|
||||
expect(response.metadata.duration).toBeGreaterThanOrEqual(500);
|
||||
expect(response.metadata.duration).toBeLessThan(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('errorResponse', () => {
|
||||
it('should create error response', () => {
|
||||
const handler = new TestHandler(mockClient);
|
||||
const input: BaseTriggerInput = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook',
|
||||
};
|
||||
const startTime = Date.now() - 200;
|
||||
|
||||
const response = (handler as any).errorResponse(
|
||||
input,
|
||||
'Test error message',
|
||||
startTime
|
||||
);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.triggerType).toBe('webhook');
|
||||
expect(response.workflowId).toBe('workflow-123');
|
||||
expect(response.error).toBe('Test error message');
|
||||
expect(response.metadata.duration).toBeGreaterThanOrEqual(200);
|
||||
});
|
||||
|
||||
it('should merge extra error details', () => {
|
||||
const handler = new TestHandler(mockClient);
|
||||
const input: BaseTriggerInput = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook',
|
||||
};
|
||||
const startTime = Date.now();
|
||||
const extra = {
|
||||
code: 'ERR_TEST',
|
||||
details: { reason: 'test reason' },
|
||||
};
|
||||
|
||||
const response = (handler as any).errorResponse(
|
||||
input,
|
||||
'Error',
|
||||
startTime,
|
||||
extra
|
||||
);
|
||||
|
||||
expect(response.code).toBe('ERR_TEST');
|
||||
expect(response.details).toEqual({ reason: 'test reason' });
|
||||
});
|
||||
|
||||
it('should calculate error duration correctly', () => {
|
||||
const handler = new TestHandler(mockClient);
|
||||
const input: BaseTriggerInput = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook',
|
||||
};
|
||||
const startTime = Date.now() - 750;
|
||||
|
||||
const response = (handler as any).errorResponse(input, 'Error', startTime);
|
||||
|
||||
expect(response.metadata.duration).toBeGreaterThanOrEqual(750);
|
||||
expect(response.metadata.duration).toBeLessThan(1500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should execute successfully', async () => {
|
||||
const handler = new TestHandler(mockClient);
|
||||
const input: BaseTriggerInput = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook',
|
||||
};
|
||||
const workflow: Workflow = {
|
||||
id: 'workflow-123',
|
||||
name: 'Test Workflow',
|
||||
active: true,
|
||||
nodes: [],
|
||||
connections: {},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
settings: {},
|
||||
staticData: undefined,
|
||||
} as Workflow;
|
||||
|
||||
const response = await handler.execute(input, workflow);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.workflowId).toBe('workflow-123');
|
||||
expect(response.data).toEqual({ test: 'data' });
|
||||
});
|
||||
});
|
||||
});
|
||||
569
tests/unit/triggers/handlers/chat-handler.test.ts
Normal file
569
tests/unit/triggers/handlers/chat-handler.test.ts
Normal file
@@ -0,0 +1,569 @@
|
||||
/**
|
||||
* Unit tests for ChatHandler
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ChatHandler } from '../../../../src/triggers/handlers/chat-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 buildTriggerUrl
|
||||
vi.mock('../../../../src/triggers/trigger-detector', () => ({
|
||||
buildTriggerUrl: vi.fn((baseUrl: string, trigger: any, mode: string) => {
|
||||
return `${baseUrl}/webhook/${trigger.webhookPath}`;
|
||||
}),
|
||||
}));
|
||||
|
||||
// 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: 'Chat Workflow',
|
||||
active: true,
|
||||
nodes: [
|
||||
{
|
||||
id: 'chat-node',
|
||||
name: 'Chat',
|
||||
type: '@n8n/n8n-nodes-langchain.chatTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
path: 'ai-chat',
|
||||
},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
settings: {},
|
||||
staticData: undefined,
|
||||
} as Workflow);
|
||||
|
||||
describe('ChatHandler', () => {
|
||||
let mockClient: N8nApiClient;
|
||||
let handler: ChatHandler;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockClient = createMockClient();
|
||||
handler = new ChatHandler(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: { response: 'Chat response' },
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should have correct trigger type', () => {
|
||||
expect(handler.triggerType).toBe('chat');
|
||||
});
|
||||
|
||||
it('should have correct capabilities', () => {
|
||||
expect(handler.capabilities.requiresActiveWorkflow).toBe(true);
|
||||
expect(handler.capabilities.canPassInputData).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('input validation', () => {
|
||||
it('should validate correct chat input', () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello AI!',
|
||||
sessionId: 'session-123',
|
||||
};
|
||||
|
||||
const result = handler.validate(input);
|
||||
expect(result).toEqual(input);
|
||||
});
|
||||
|
||||
it('should validate minimal input without sessionId', () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello AI!',
|
||||
};
|
||||
|
||||
const result = handler.validate(input);
|
||||
expect(result.workflowId).toBe('workflow-123');
|
||||
expect(result.message).toBe('Hello AI!');
|
||||
expect(result.sessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject invalid trigger type', () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook',
|
||||
message: 'Hello',
|
||||
};
|
||||
|
||||
expect(() => handler.validate(input)).toThrow();
|
||||
});
|
||||
|
||||
it('should reject missing message', () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat',
|
||||
};
|
||||
|
||||
expect(() => handler.validate(input)).toThrow();
|
||||
});
|
||||
|
||||
it('should accept optional fields', () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello',
|
||||
data: { context: 'value' },
|
||||
headers: { 'X-Custom': 'header' },
|
||||
timeout: 60000,
|
||||
waitForResponse: false,
|
||||
};
|
||||
|
||||
const result = handler.validate(input);
|
||||
expect(result.data).toEqual({ context: 'value' });
|
||||
expect(result.headers).toEqual({ 'X-Custom': 'header' });
|
||||
expect(result.timeout).toBe(60000);
|
||||
expect(result.waitForResponse).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should execute chat with provided sessionId', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello AI!',
|
||||
sessionId: 'custom-session',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'chat',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'ai-chat',
|
||||
};
|
||||
|
||||
const response = await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(axios.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
data: expect.objectContaining({
|
||||
action: 'sendMessage',
|
||||
sessionId: 'custom-session',
|
||||
chatInput: 'Hello AI!',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate sessionId when not provided', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello AI!',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'chat',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'ai-chat',
|
||||
};
|
||||
|
||||
const response = await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.metadata?.sessionId).toMatch(/^session_\d+_[a-z0-9]+$/);
|
||||
});
|
||||
|
||||
it('should use trigger info to build chat URL', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello AI!',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'chat',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'custom-chat',
|
||||
};
|
||||
|
||||
await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(axios.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: expect.stringContaining('/webhook/custom-chat'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use workflow ID as fallback when no trigger info', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello AI!',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
|
||||
await handler.execute(input, workflow);
|
||||
|
||||
expect(axios.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: expect.stringContaining('/webhook/workflow-123'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when base URL not available', async () => {
|
||||
const handlerNoContext = new ChatHandler(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: 'chat' as const,
|
||||
message: 'Hello',
|
||||
};
|
||||
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: 'chat' as const,
|
||||
message: 'Hello',
|
||||
};
|
||||
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 include additional data in payload', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello',
|
||||
data: {
|
||||
userId: 'user-456',
|
||||
context: 'support',
|
||||
},
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'chat',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'ai-chat',
|
||||
};
|
||||
|
||||
await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(axios.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: 'sendMessage',
|
||||
chatInput: 'Hello',
|
||||
userId: 'user-456',
|
||||
context: 'support',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass custom headers', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello',
|
||||
headers: {
|
||||
'X-Custom-Header': 'custom-value',
|
||||
'Authorization': 'Bearer token',
|
||||
},
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'chat',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'ai-chat',
|
||||
};
|
||||
|
||||
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: 'chat' as const,
|
||||
message: 'Hello',
|
||||
timeout: 90000,
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'chat',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'ai-chat',
|
||||
};
|
||||
|
||||
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: 'chat' as const,
|
||||
message: 'Hello',
|
||||
waitForResponse: true,
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'chat',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'ai-chat',
|
||||
};
|
||||
|
||||
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: 'chat' as const,
|
||||
message: 'Hello',
|
||||
waitForResponse: false,
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'chat',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'ai-chat',
|
||||
};
|
||||
|
||||
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: 'chat' as const,
|
||||
message: 'Hello AI!',
|
||||
sessionId: 'session-123',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'chat',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'ai-chat',
|
||||
};
|
||||
|
||||
vi.mocked(axios.request).mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: { response: 'AI reply', tokens: 150 },
|
||||
});
|
||||
|
||||
const response = await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.statusText).toBe('OK');
|
||||
expect(response.data).toEqual({ response: 'AI reply', tokens: 150 });
|
||||
expect(response.metadata?.duration).toBeGreaterThanOrEqual(0);
|
||||
expect(response.metadata?.sessionId).toBe('session-123');
|
||||
expect(response.metadata?.webhookPath).toBe('ai-chat');
|
||||
});
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'chat',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'ai-chat',
|
||||
};
|
||||
|
||||
const apiError = new Error('Chat execution failed');
|
||||
vi.mocked(axios.request).mockRejectedValue(apiError);
|
||||
|
||||
const response = await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBe('Chat execution failed');
|
||||
});
|
||||
|
||||
it('should extract execution ID from error response', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'chat',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'ai-chat',
|
||||
};
|
||||
|
||||
const apiError: any = new Error('Execution error');
|
||||
apiError.response = {
|
||||
data: {
|
||||
executionId: 'exec-789',
|
||||
error: 'Node 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-789');
|
||||
expect(response.details).toEqual({
|
||||
executionId: 'exec-789',
|
||||
error: 'Node failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle error with code', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'chat',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'ai-chat',
|
||||
};
|
||||
|
||||
const apiError: any = new Error('Timeout error');
|
||||
apiError.code = 'ETIMEDOUT';
|
||||
vi.mocked(axios.request).mockRejectedValue(apiError);
|
||||
|
||||
const response = await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.code).toBe('ETIMEDOUT');
|
||||
});
|
||||
|
||||
it('should validate status codes less than 500', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'chat',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'ai-chat',
|
||||
};
|
||||
|
||||
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!(404)).toBe(true);
|
||||
expect(config.validateStatus!(499)).toBe(true);
|
||||
expect(config.validateStatus!(500)).toBe(false);
|
||||
expect(config.validateStatus!(503)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
572
tests/unit/triggers/handlers/form-handler.test.ts
Normal file
572
tests/unit/triggers/handlers/form-handler.test.ts
Normal file
@@ -0,0 +1,572 @@
|
||||
/**
|
||||
* 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';
|
||||
import FormData from 'form-data';
|
||||
|
||||
// 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 using multipart/form-data', 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',
|
||||
})
|
||||
);
|
||||
// Verify FormData is used
|
||||
const config = vi.mocked(axios.request).mock.calls[0][0];
|
||||
expect(config.data).toBeInstanceOf(FormData);
|
||||
// Verify multipart/form-data content type is set via FormData headers
|
||||
expect(config.headers).toEqual(
|
||||
expect.objectContaining({
|
||||
'content-type': expect.stringContaining('multipart/form-data'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
// Verify FormData is used and contains merged data
|
||||
const config = vi.mocked(axios.request).mock.calls[0][0];
|
||||
expect(config.data).toBeInstanceOf(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 with multipart/form-data', 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);
|
||||
|
||||
const config = vi.mocked(axios.request).mock.calls[0][0];
|
||||
expect(config.headers).toEqual(
|
||||
expect.objectContaining({
|
||||
'X-Custom-Header': 'custom-value',
|
||||
'Authorization': 'Bearer token',
|
||||
// FormData sets multipart/form-data with boundary
|
||||
'content-type': expect.stringContaining('multipart/form-data'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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');
|
||||
// Details include original error data plus form field info and hint
|
||||
expect(response.details).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'exec-111',
|
||||
error: 'Validation failed',
|
||||
formFields: expect.any(Array),
|
||||
hint: expect.any(String),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
// Even empty formData is sent as FormData
|
||||
const config = vi.mocked(axios.request).mock.calls[0][0];
|
||||
expect(config.data).toBeInstanceOf(FormData);
|
||||
});
|
||||
|
||||
it('should handle complex form data types via FormData', 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);
|
||||
|
||||
// Complex data types are serialized in FormData
|
||||
const config = vi.mocked(axios.request).mock.calls[0][0];
|
||||
expect(config.data).toBeInstanceOf(FormData);
|
||||
});
|
||||
});
|
||||
});
|
||||
525
tests/unit/triggers/handlers/webhook-handler.test.ts
Normal file
525
tests/unit/triggers/handlers/webhook-handler.test.ts
Normal file
@@ -0,0 +1,525 @@
|
||||
/**
|
||||
* Unit tests for WebhookHandler
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { WebhookHandler } from '../../../../src/triggers/handlers/webhook-handler';
|
||||
import { N8nApiClient } from '../../../../src/services/n8n-api-client';
|
||||
import { InstanceContext } from '../../../../src/types/instance-context';
|
||||
import { Workflow, WebhookRequest } from '../../../../src/types/n8n-api';
|
||||
import { DetectedTrigger } from '../../../../src/triggers/types';
|
||||
|
||||
// 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 buildTriggerUrl
|
||||
vi.mock('../../../../src/triggers/trigger-detector', () => ({
|
||||
buildTriggerUrl: vi.fn((baseUrl: string, trigger: any, mode: string) => {
|
||||
return `${baseUrl}/webhook/${trigger.webhookPath}`;
|
||||
}),
|
||||
}));
|
||||
|
||||
// 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: 'Test Workflow',
|
||||
active: true,
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-node',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
path: 'test-webhook',
|
||||
httpMethod: 'POST',
|
||||
},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
settings: {},
|
||||
staticData: undefined,
|
||||
} as Workflow);
|
||||
|
||||
describe('WebhookHandler', () => {
|
||||
let mockClient: N8nApiClient;
|
||||
let handler: WebhookHandler;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockClient = createMockClient();
|
||||
handler = new WebhookHandler(mockClient);
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Import and reset mock
|
||||
const { SSRFProtection } = await import('../../../../src/utils/ssrf-protection');
|
||||
vi.mocked(SSRFProtection.validateWebhookUrl).mockResolvedValue({
|
||||
valid: true,
|
||||
reason: '',
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should have correct trigger type', () => {
|
||||
expect(handler.triggerType).toBe('webhook');
|
||||
});
|
||||
|
||||
it('should have correct capabilities', () => {
|
||||
expect(handler.capabilities.requiresActiveWorkflow).toBe(true);
|
||||
expect(handler.capabilities.canPassInputData).toBe(true);
|
||||
expect(handler.capabilities.supportedMethods).toEqual(['GET', 'POST', 'PUT', 'DELETE']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('input validation', () => {
|
||||
it('should validate correct webhook input', () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
httpMethod: 'POST' as const,
|
||||
webhookPath: 'test-path',
|
||||
};
|
||||
|
||||
const result = handler.validate(input);
|
||||
expect(result).toEqual(input);
|
||||
});
|
||||
|
||||
it('should validate minimal input', () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
};
|
||||
|
||||
const result = handler.validate(input);
|
||||
expect(result.workflowId).toBe('workflow-123');
|
||||
expect(result.triggerType).toBe('webhook');
|
||||
});
|
||||
|
||||
it('should reject invalid trigger type', () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat',
|
||||
};
|
||||
|
||||
expect(() => handler.validate(input)).toThrow();
|
||||
});
|
||||
|
||||
it('should reject invalid HTTP method', () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook',
|
||||
httpMethod: 'PATCH',
|
||||
};
|
||||
|
||||
expect(() => handler.validate(input)).toThrow();
|
||||
});
|
||||
|
||||
it('should accept optional fields', () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
data: { key: 'value' },
|
||||
headers: { 'X-Custom': 'header' },
|
||||
timeout: 60000,
|
||||
waitForResponse: false,
|
||||
};
|
||||
|
||||
const result = handler.validate(input);
|
||||
expect(result.data).toEqual({ key: 'value' });
|
||||
expect(result.headers).toEqual({ 'X-Custom': 'header' });
|
||||
expect(result.timeout).toBe(60000);
|
||||
expect(result.waitForResponse).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should execute webhook with provided path', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
webhookPath: 'custom-path',
|
||||
httpMethod: 'POST' as const,
|
||||
data: { test: 'data' },
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
|
||||
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: { result: 'success' },
|
||||
});
|
||||
|
||||
const response = await handler.execute(input, workflow);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(mockClient.triggerWebhook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
webhookUrl: expect.stringContaining('/webhook/custom-path'),
|
||||
httpMethod: 'POST',
|
||||
data: { test: 'data' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use trigger info when no explicit path provided', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'webhook',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'detected-path',
|
||||
httpMethod: 'GET',
|
||||
};
|
||||
|
||||
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: { result: 'success' },
|
||||
});
|
||||
|
||||
const response = await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(mockClient.triggerWebhook).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error when no webhook path available', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
|
||||
const response = await handler.execute(input, workflow);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toContain('No webhook path available');
|
||||
});
|
||||
|
||||
it('should return error when base URL not available', async () => {
|
||||
const handlerNoContext = new WebhookHandler(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: 'webhook' as const,
|
||||
webhookPath: 'test',
|
||||
};
|
||||
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: 'webhook' as const,
|
||||
webhookPath: 'test-path',
|
||||
};
|
||||
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 use default POST method when not specified', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
webhookPath: 'test-path',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
|
||||
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: {},
|
||||
});
|
||||
|
||||
await handler.execute(input, workflow);
|
||||
|
||||
expect(mockClient.triggerWebhook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
httpMethod: 'POST',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass custom headers', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
webhookPath: 'test-path',
|
||||
headers: {
|
||||
'X-Custom-Header': 'custom-value',
|
||||
'Authorization': 'Bearer token',
|
||||
},
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
|
||||
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: {},
|
||||
});
|
||||
|
||||
await handler.execute(input, workflow);
|
||||
|
||||
expect(mockClient.triggerWebhook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
'X-Custom-Header': 'custom-value',
|
||||
'Authorization': 'Bearer token',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should set waitForResponse from input', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
webhookPath: 'test-path',
|
||||
waitForResponse: false,
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
|
||||
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
|
||||
status: 202,
|
||||
statusText: 'Accepted',
|
||||
data: {},
|
||||
});
|
||||
|
||||
await handler.execute(input, workflow);
|
||||
|
||||
expect(mockClient.triggerWebhook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
waitForResponse: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should default waitForResponse to true', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
webhookPath: 'test-path',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
|
||||
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: {},
|
||||
});
|
||||
|
||||
await handler.execute(input, workflow);
|
||||
|
||||
expect(mockClient.triggerWebhook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
waitForResponse: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return response with status and metadata', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
webhookPath: 'test-path',
|
||||
httpMethod: 'POST' as const,
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
|
||||
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: { result: 'webhook response' },
|
||||
});
|
||||
|
||||
const response = await handler.execute(input, workflow);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.statusText).toBe('OK');
|
||||
expect(response.data).toEqual({ status: 200, statusText: 'OK', data: { result: 'webhook response' } });
|
||||
expect(response.metadata?.duration).toBeGreaterThanOrEqual(0);
|
||||
expect(response.metadata?.webhookPath).toBe('test-path');
|
||||
expect(response.metadata?.httpMethod).toBe('POST');
|
||||
});
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
webhookPath: 'test-path',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
|
||||
const apiError = new Error('Webhook execution failed');
|
||||
vi.mocked(mockClient.triggerWebhook).mockRejectedValue(apiError);
|
||||
|
||||
const response = await handler.execute(input, workflow);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBe('Webhook execution failed');
|
||||
});
|
||||
|
||||
it('should extract execution ID from error details', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
webhookPath: 'test-path',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
|
||||
const apiError: any = new Error('Execution error');
|
||||
apiError.details = {
|
||||
executionId: 'exec-456',
|
||||
message: 'Node execution failed',
|
||||
};
|
||||
vi.mocked(mockClient.triggerWebhook).mockRejectedValue(apiError);
|
||||
|
||||
const response = await handler.execute(input, workflow);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.executionId).toBe('exec-456');
|
||||
expect(response.details).toEqual({
|
||||
executionId: 'exec-456',
|
||||
message: 'Node execution failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('should support all HTTP methods', async () => {
|
||||
const workflow = createWorkflow();
|
||||
const methods: Array<'GET' | 'POST' | 'PUT' | 'DELETE'> = ['GET', 'POST', 'PUT', 'DELETE'];
|
||||
|
||||
for (const method of methods) {
|
||||
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: {},
|
||||
});
|
||||
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
webhookPath: 'test-path',
|
||||
httpMethod: method,
|
||||
};
|
||||
|
||||
const response = await handler.execute(input, workflow);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(mockClient.triggerWebhook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
httpMethod: method,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should use httpMethod from trigger info when not in input', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
webhookPath: 'test-path',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'webhook',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'detected-path',
|
||||
httpMethod: 'PUT',
|
||||
};
|
||||
|
||||
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: {},
|
||||
});
|
||||
|
||||
await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(mockClient.triggerWebhook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
httpMethod: 'PUT',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should prefer input httpMethod over trigger info', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
webhookPath: 'test-path',
|
||||
httpMethod: 'DELETE' as const,
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'webhook',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'detected-path',
|
||||
httpMethod: 'GET',
|
||||
};
|
||||
|
||||
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: {},
|
||||
});
|
||||
|
||||
await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(mockClient.triggerWebhook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
httpMethod: 'DELETE',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
331
tests/unit/triggers/trigger-detector.test.ts
Normal file
331
tests/unit/triggers/trigger-detector.test.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Unit tests for trigger detection
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { detectTriggerFromWorkflow, buildTriggerUrl, describeTrigger } from '../../../src/triggers/trigger-detector';
|
||||
import type { Workflow } from '../../../src/types/n8n-api';
|
||||
|
||||
// Helper to create a workflow with a specific trigger node
|
||||
function createWorkflowWithTrigger(triggerType: string, params: Record<string, unknown> = {}): Workflow {
|
||||
return {
|
||||
id: 'test-workflow',
|
||||
name: 'Test Workflow',
|
||||
active: true,
|
||||
nodes: [
|
||||
{
|
||||
id: 'trigger-node',
|
||||
name: 'Trigger',
|
||||
type: triggerType,
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: params,
|
||||
},
|
||||
{
|
||||
id: 'action-node',
|
||||
name: 'Action',
|
||||
type: 'n8n-nodes-base.noOp',
|
||||
typeVersion: 1,
|
||||
position: [200, 0],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
settings: {},
|
||||
staticData: undefined,
|
||||
} as Workflow;
|
||||
}
|
||||
|
||||
describe('Trigger Detector', () => {
|
||||
describe('detectTriggerFromWorkflow', () => {
|
||||
describe('webhook detection', () => {
|
||||
it('should detect n8n-nodes-base.webhook as webhook trigger', () => {
|
||||
const workflow = createWorkflowWithTrigger('n8n-nodes-base.webhook', {
|
||||
path: 'my-webhook',
|
||||
httpMethod: 'POST',
|
||||
});
|
||||
|
||||
const result = detectTriggerFromWorkflow(workflow);
|
||||
|
||||
expect(result.detected).toBe(true);
|
||||
expect(result.trigger?.type).toBe('webhook');
|
||||
expect(result.trigger?.webhookPath).toBe('my-webhook');
|
||||
expect(result.trigger?.httpMethod).toBe('POST');
|
||||
});
|
||||
|
||||
it('should detect webhook node with httpMethod from parameters', () => {
|
||||
const workflow = createWorkflowWithTrigger('n8n-nodes-base.webhook', {
|
||||
path: 'get-data',
|
||||
httpMethod: 'GET',
|
||||
});
|
||||
|
||||
const result = detectTriggerFromWorkflow(workflow);
|
||||
|
||||
expect(result.detected).toBe(true);
|
||||
expect(result.trigger?.type).toBe('webhook');
|
||||
expect(result.trigger?.httpMethod).toBe('GET');
|
||||
});
|
||||
|
||||
it('should default httpMethod to POST when not specified', () => {
|
||||
const workflow = createWorkflowWithTrigger('n8n-nodes-base.webhook', {
|
||||
path: 'test-path',
|
||||
});
|
||||
|
||||
const result = detectTriggerFromWorkflow(workflow);
|
||||
|
||||
expect(result.detected).toBe(true);
|
||||
expect(result.trigger?.type).toBe('webhook');
|
||||
// Default is POST when not specified
|
||||
expect(result.trigger?.httpMethod).toBe('POST');
|
||||
});
|
||||
});
|
||||
|
||||
describe('form detection', () => {
|
||||
it('should detect n8n-nodes-base.formTrigger as form trigger', () => {
|
||||
const workflow = createWorkflowWithTrigger('n8n-nodes-base.formTrigger', {
|
||||
path: 'my-form',
|
||||
});
|
||||
|
||||
const result = detectTriggerFromWorkflow(workflow);
|
||||
|
||||
expect(result.detected).toBe(true);
|
||||
expect(result.trigger?.type).toBe('form');
|
||||
expect(result.trigger?.node?.parameters?.path).toBe('my-form');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chat detection', () => {
|
||||
it('should detect @n8n/n8n-nodes-langchain.chatTrigger as chat trigger', () => {
|
||||
const workflow = createWorkflowWithTrigger('@n8n/n8n-nodes-langchain.chatTrigger', {
|
||||
path: 'chat-endpoint',
|
||||
});
|
||||
|
||||
const result = detectTriggerFromWorkflow(workflow);
|
||||
|
||||
expect(result.detected).toBe(true);
|
||||
expect(result.trigger?.type).toBe('chat');
|
||||
});
|
||||
|
||||
it('should detect n8n-nodes-langchain.chatTrigger as chat trigger', () => {
|
||||
const workflow = createWorkflowWithTrigger('n8n-nodes-langchain.chatTrigger', {
|
||||
webhookPath: 'ai-chat',
|
||||
});
|
||||
|
||||
const result = detectTriggerFromWorkflow(workflow);
|
||||
|
||||
expect(result.detected).toBe(true);
|
||||
expect(result.trigger?.type).toBe('chat');
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-triggerable workflows', () => {
|
||||
it('should return not detected for schedule trigger', () => {
|
||||
const workflow = createWorkflowWithTrigger('n8n-nodes-base.scheduleTrigger', {
|
||||
rule: { interval: [{ field: 'hours', value: 1 }] },
|
||||
});
|
||||
|
||||
const result = detectTriggerFromWorkflow(workflow);
|
||||
|
||||
expect(result.detected).toBe(false);
|
||||
// Fallback reason may be undefined for non-input triggers
|
||||
});
|
||||
|
||||
it('should return not detected for manual trigger', () => {
|
||||
const workflow = createWorkflowWithTrigger('n8n-nodes-base.manualTrigger', {});
|
||||
|
||||
const result = detectTriggerFromWorkflow(workflow);
|
||||
|
||||
expect(result.detected).toBe(false);
|
||||
});
|
||||
|
||||
it('should return not detected for email trigger', () => {
|
||||
const workflow = createWorkflowWithTrigger('n8n-nodes-base.emailReadImap', {
|
||||
mailbox: 'INBOX',
|
||||
});
|
||||
|
||||
const result = detectTriggerFromWorkflow(workflow);
|
||||
|
||||
expect(result.detected).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflows without triggers', () => {
|
||||
it('should return not detected for workflow with no trigger node', () => {
|
||||
const workflow: Workflow = {
|
||||
id: 'test-workflow',
|
||||
name: 'Test Workflow',
|
||||
active: true,
|
||||
nodes: [
|
||||
{
|
||||
id: 'action-node',
|
||||
name: 'Action',
|
||||
type: 'n8n-nodes-base.noOp',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
settings: {},
|
||||
staticData: undefined,
|
||||
} as Workflow;
|
||||
|
||||
const result = detectTriggerFromWorkflow(workflow);
|
||||
|
||||
expect(result.detected).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTriggerUrl', () => {
|
||||
it('should build webhook URL correctly', () => {
|
||||
const baseUrl = 'https://n8n.example.com';
|
||||
const trigger = {
|
||||
type: 'webhook' as const,
|
||||
node: {
|
||||
id: 'trigger',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: { path: 'my-webhook' },
|
||||
},
|
||||
webhookPath: 'my-webhook',
|
||||
};
|
||||
|
||||
const url = buildTriggerUrl(baseUrl, trigger, 'production');
|
||||
|
||||
expect(url).toBe('https://n8n.example.com/webhook/my-webhook');
|
||||
});
|
||||
|
||||
it('should build test webhook URL correctly', () => {
|
||||
const baseUrl = 'https://n8n.example.com/';
|
||||
const trigger = {
|
||||
type: 'webhook' as const,
|
||||
node: {
|
||||
id: 'trigger',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: { path: 'test-path' },
|
||||
},
|
||||
webhookPath: 'test-path',
|
||||
};
|
||||
|
||||
const url = buildTriggerUrl(baseUrl, trigger, 'test');
|
||||
|
||||
expect(url).toBe('https://n8n.example.com/webhook-test/test-path');
|
||||
});
|
||||
|
||||
it('should build form URL with node ID when webhookPath not set', () => {
|
||||
const baseUrl = 'https://n8n.example.com';
|
||||
const trigger = {
|
||||
type: 'form' as const,
|
||||
node: {
|
||||
id: 'trigger',
|
||||
name: 'Form',
|
||||
type: 'n8n-nodes-base.formTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: { path: 'my-form' },
|
||||
},
|
||||
// webhookPath is undefined - should use node.id
|
||||
};
|
||||
|
||||
const url = buildTriggerUrl(baseUrl, trigger, 'production');
|
||||
|
||||
// When webhookPath is not set, uses node.id as fallback
|
||||
expect(url).toContain('/form/');
|
||||
});
|
||||
|
||||
it('should build chat URL correctly with /chat suffix', () => {
|
||||
const baseUrl = 'https://n8n.example.com';
|
||||
const trigger = {
|
||||
type: 'chat' as const,
|
||||
node: {
|
||||
id: 'trigger',
|
||||
name: 'Chat',
|
||||
type: '@n8n/n8n-nodes-langchain.chatTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: { path: 'ai-chat' },
|
||||
},
|
||||
webhookPath: 'ai-chat',
|
||||
};
|
||||
|
||||
const url = buildTriggerUrl(baseUrl, trigger, 'production');
|
||||
|
||||
// Chat triggers use /webhook/<webhookId>/chat endpoint
|
||||
expect(url).toBe('https://n8n.example.com/webhook/ai-chat/chat');
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeTrigger', () => {
|
||||
it('should describe webhook trigger', () => {
|
||||
const trigger = {
|
||||
type: 'webhook' as const,
|
||||
node: {
|
||||
id: 'trigger',
|
||||
name: 'My Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: { path: 'my-webhook' },
|
||||
},
|
||||
webhookPath: 'my-webhook',
|
||||
httpMethod: 'POST' as const,
|
||||
};
|
||||
|
||||
const description = describeTrigger(trigger);
|
||||
|
||||
// Case-insensitive check
|
||||
expect(description.toLowerCase()).toContain('webhook');
|
||||
expect(description).toContain('POST');
|
||||
expect(description).toContain('my-webhook');
|
||||
});
|
||||
|
||||
it('should describe form trigger', () => {
|
||||
const trigger = {
|
||||
type: 'form' as const,
|
||||
node: {
|
||||
id: 'trigger',
|
||||
name: 'Contact Form',
|
||||
type: 'n8n-nodes-base.formTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: { path: 'contact' },
|
||||
},
|
||||
webhookPath: 'contact',
|
||||
};
|
||||
|
||||
const description = describeTrigger(trigger);
|
||||
|
||||
// Case-insensitive check
|
||||
expect(description.toLowerCase()).toContain('form');
|
||||
});
|
||||
|
||||
it('should describe chat trigger', () => {
|
||||
const trigger = {
|
||||
type: 'chat' as const,
|
||||
node: {
|
||||
id: 'trigger',
|
||||
name: 'AI Chat',
|
||||
type: '@n8n/n8n-nodes-langchain.chatTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {},
|
||||
},
|
||||
};
|
||||
|
||||
const description = describeTrigger(trigger);
|
||||
|
||||
// Case-insensitive check
|
||||
expect(description.toLowerCase()).toContain('chat');
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
156
tests/unit/triggers/trigger-registry.test.ts
Normal file
156
tests/unit/triggers/trigger-registry.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Unit tests for trigger registry
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { TriggerRegistry, initializeTriggerRegistry, ensureRegistryInitialized } from '../../../src/triggers/trigger-registry';
|
||||
import type { N8nApiClient } from '../../../src/services/n8n-api-client';
|
||||
|
||||
// Mock N8nApiClient
|
||||
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);
|
||||
|
||||
describe('TriggerRegistry', () => {
|
||||
describe('initialization', () => {
|
||||
it('should initialize with all handlers registered', async () => {
|
||||
await initializeTriggerRegistry();
|
||||
|
||||
const registeredTypes = TriggerRegistry.getRegisteredTypes();
|
||||
|
||||
expect(registeredTypes).toContain('webhook');
|
||||
expect(registeredTypes).toContain('form');
|
||||
expect(registeredTypes).toContain('chat');
|
||||
expect(registeredTypes.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should not register duplicate handlers on multiple init calls', async () => {
|
||||
await initializeTriggerRegistry();
|
||||
const firstTypes = TriggerRegistry.getRegisteredTypes();
|
||||
|
||||
await initializeTriggerRegistry();
|
||||
const secondTypes = TriggerRegistry.getRegisteredTypes();
|
||||
|
||||
expect(firstTypes.length).toBe(secondTypes.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasHandler', () => {
|
||||
beforeEach(async () => {
|
||||
await ensureRegistryInitialized();
|
||||
});
|
||||
|
||||
it('should return true for webhook handler', () => {
|
||||
expect(TriggerRegistry.hasHandler('webhook')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for form handler', () => {
|
||||
expect(TriggerRegistry.hasHandler('form')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for chat handler', () => {
|
||||
expect(TriggerRegistry.hasHandler('chat')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for unknown trigger type', () => {
|
||||
expect(TriggerRegistry.hasHandler('unknown' as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHandler', () => {
|
||||
let mockClient: N8nApiClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
await ensureRegistryInitialized();
|
||||
mockClient = createMockClient();
|
||||
});
|
||||
|
||||
it('should return a webhook handler', () => {
|
||||
const handler = TriggerRegistry.getHandler('webhook', mockClient);
|
||||
|
||||
expect(handler).toBeDefined();
|
||||
expect(handler?.triggerType).toBe('webhook');
|
||||
});
|
||||
|
||||
it('should return a form handler', () => {
|
||||
const handler = TriggerRegistry.getHandler('form', mockClient);
|
||||
|
||||
expect(handler).toBeDefined();
|
||||
expect(handler?.triggerType).toBe('form');
|
||||
});
|
||||
|
||||
it('should return a chat handler', () => {
|
||||
const handler = TriggerRegistry.getHandler('chat', mockClient);
|
||||
|
||||
expect(handler).toBeDefined();
|
||||
expect(handler?.triggerType).toBe('chat');
|
||||
});
|
||||
|
||||
it('should return undefined for unknown trigger type', () => {
|
||||
const handler = TriggerRegistry.getHandler('unknown' as any, mockClient);
|
||||
|
||||
expect(handler).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handler capabilities', () => {
|
||||
let mockClient: N8nApiClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
await ensureRegistryInitialized();
|
||||
mockClient = createMockClient();
|
||||
});
|
||||
|
||||
it('webhook handler should require active workflow', () => {
|
||||
const handler = TriggerRegistry.getHandler('webhook', mockClient);
|
||||
|
||||
expect(handler?.capabilities.requiresActiveWorkflow).toBe(true);
|
||||
expect(handler?.capabilities.canPassInputData).toBe(true);
|
||||
});
|
||||
|
||||
it('form handler should require active workflow', () => {
|
||||
const handler = TriggerRegistry.getHandler('form', mockClient);
|
||||
|
||||
expect(handler?.capabilities.requiresActiveWorkflow).toBe(true);
|
||||
expect(handler?.capabilities.canPassInputData).toBe(true);
|
||||
});
|
||||
|
||||
it('chat handler should require active workflow', () => {
|
||||
const handler = TriggerRegistry.getHandler('chat', mockClient);
|
||||
|
||||
expect(handler?.capabilities.requiresActiveWorkflow).toBe(true);
|
||||
expect(handler?.capabilities.canPassInputData).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureRegistryInitialized', () => {
|
||||
it('should be safe to call multiple times', async () => {
|
||||
await ensureRegistryInitialized();
|
||||
await ensureRegistryInitialized();
|
||||
await ensureRegistryInitialized();
|
||||
|
||||
const types = TriggerRegistry.getRegisteredTypes();
|
||||
expect(types.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle concurrent initialization calls', async () => {
|
||||
const promises = [
|
||||
ensureRegistryInitialized(),
|
||||
ensureRegistryInitialized(),
|
||||
ensureRegistryInitialized(),
|
||||
];
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
const types = TriggerRegistry.getRegisteredTypes();
|
||||
expect(types.length).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user