diff --git a/CHANGELOG.md b/CHANGELOG.md index 439d849..1af5360 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,64 @@ 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//chat` suffix. + +- **Root Cause**: `buildTriggerUrl()` used same pattern for webhooks and chat triggers +- **Fix**: Chat triggers now correctly use `/webhook//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 diff --git a/package-lock.json b/package-lock.json index 0804073..16404eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e427b16..773e44c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-mcp", - "version": "2.28.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", diff --git a/src/triggers/handlers/form-handler.ts b/src/triggers/handlers/form-handler.ts index 5fced9c..b24d23e 100644 --- a/src/triggers/handlers/form-handler.ts +++ b/src/triggers/handlers/form-handler.ts @@ -2,14 +2,15 @@ * Form trigger handler * * Handles form-based workflow triggers: - * - POST to /form/ or /form-test/ - * - Passes form fields as request body + * - POST to /form/ 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 { Workflow, WebhookRequest } from '../../types/n8n-api'; +import FormData from 'form-data'; +import { Workflow, WorkflowNode } from '../../types/n8n-api'; import { TriggerType, TriggerResponse, @@ -32,6 +33,188 @@ const formInputSchema = z.object({ 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 | 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 */ @@ -52,20 +235,27 @@ export class FormHandler extends BaseTriggerHandler { ): Promise { 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); + return this.errorResponse(input, 'Cannot determine n8n base URL', startTime, { + details: { + formFields: formFieldDefs, + hint: generateFormUsageHint(formFieldDefs), + }, + }); } - // Form triggers use /form/ endpoint - // The path can be from trigger info or workflow ID - const formPath = triggerInfo?.node?.parameters?.path || input.workflowId; + // Form triggers use /form/ 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 formFields = { + const inputFields = { ...input.data, ...input.formData, }; @@ -77,15 +267,142 @@ export class FormHandler extends BaseTriggerHandler { 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: { - 'Content-Type': 'application/json', + ...formData.getHeaders(), ...input.headers, }, - data: formFields, + data: formData, timeout: input.timeout || (input.waitForResponse !== false ? 120000 : 30000), validateStatus: (status) => status < 500, }; @@ -93,13 +410,29 @@ export class FormHandler extends BaseTriggerHandler { // Make the request const response = await axios.request(config); - return this.normalizeResponse(response.data, input, startTime, { + 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'; @@ -110,7 +443,17 @@ export class FormHandler extends BaseTriggerHandler { return this.errorResponse(input, errorMessage, startTime, { executionId, code: (error as any)?.code, - details: errorDetails, + details: { + ...errorDetails, + formFields: formFieldDefs.map(f => ({ + name: f.fieldName, + label: f.label, + type: f.type, + required: f.required, + options: f.options, + })), + hint: generateFormUsageHint(formFieldDefs), + }, }); } } diff --git a/src/triggers/trigger-detector.ts b/src/triggers/trigger-detector.ts index b90b6c2..d6afcb2 100644 --- a/src/triggers/trigger-detector.ts +++ b/src/triggers/trigger-detector.ts @@ -119,7 +119,7 @@ function detectWebhookTrigger(node: WorkflowNode): DetectedTrigger | null { // Extract webhook path from parameters const params = node.parameters || {}; - const webhookPath = extractWebhookPath(params, node.id); + const webhookPath = extractWebhookPath(params, node.id, node.webhookId); const httpMethod = extractHttpMethod(params); return { @@ -148,10 +148,12 @@ function detectFormTrigger(node: WorkflowNode): DetectedTrigger | 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, }; } @@ -174,7 +176,7 @@ function detectChatTrigger(node: WorkflowNode): DetectedTrigger | null { // Extract chat configuration const params = node.parameters || {}; const responseMode = (params.options as any)?.responseMode || 'lastNode'; - const webhookPath = extractWebhookPath(params, node.id); + const webhookPath = extractWebhookPath(params, node.id, node.webhookId); return { type: 'chat', @@ -188,8 +190,14 @@ function detectChatTrigger(node: WorkflowNode): DetectedTrigger | null { /** * 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, nodeId: string): string { +function extractWebhookPath(params: Record, nodeId: string, webhookId?: string): string { // Check for explicit path parameter if (typeof params.path === 'string' && params.path) { return params.path; @@ -203,6 +211,11 @@ function extractWebhookPath(params: Record, nodeId: string): st } } + // 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; } @@ -262,17 +275,24 @@ export function buildTriggerUrl( const cleanBaseUrl = baseUrl.replace(/\/+$/, ''); // Remove trailing slashes switch (trigger.type) { - case 'webhook': - case 'chat': { + 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//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/ endpoint + // Form triggers use /form/ endpoint const prefix = mode === 'test' ? 'form-test' : 'form'; - return `${cleanBaseUrl}/${prefix}/${trigger.node.id}`; + const path = trigger.webhookPath || trigger.node.id; + return `${cleanBaseUrl}/${prefix}/${path}`; } default: diff --git a/src/types/n8n-api.ts b/src/types/n8n-api.ts index be54d28..66c7254 100644 --- a/src/types/n8n-api.ts +++ b/src/types/n8n-api.ts @@ -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 { diff --git a/tests/unit/triggers/handlers/form-handler.test.ts b/tests/unit/triggers/handlers/form-handler.test.ts index acebca2..769185e 100644 --- a/tests/unit/triggers/handlers/form-handler.test.ts +++ b/tests/unit/triggers/handlers/form-handler.test.ts @@ -8,6 +8,7 @@ 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', () => ({ @@ -156,7 +157,7 @@ describe('FormHandler', () => { }); describe('execute', () => { - it('should execute form with provided formData', async () => { + it('should execute form with provided formData using multipart/form-data', async () => { const input = { workflowId: 'workflow-123', triggerType: 'form' as const, @@ -178,11 +179,15 @@ describe('FormHandler', () => { expect(axios.request).toHaveBeenCalledWith( expect.objectContaining({ method: 'POST', - data: { - name: 'Jane Doe', - email: 'jane@example.com', - message: 'Hello', - }, + }) + ); + // 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'), }) ); }); @@ -253,15 +258,9 @@ describe('FormHandler', () => { await handler.execute(input, workflow, triggerInfo); - expect(axios.request).toHaveBeenCalledWith( - expect.objectContaining({ - data: { - field1: 'from data', - field2: 'from formData', - field3: 'from formData', - }, - }) - ); + // 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 () => { @@ -303,7 +302,7 @@ describe('FormHandler', () => { expect(response.error).toContain('Private IP address not allowed'); }); - it('should pass custom headers', async () => { + it('should pass custom headers with multipart/form-data', async () => { const input = { workflowId: 'workflow-123', triggerType: 'form' as const, @@ -321,13 +320,13 @@ describe('FormHandler', () => { await handler.execute(input, workflow, triggerInfo); - expect(axios.request).toHaveBeenCalledWith( + const config = vi.mocked(axios.request).mock.calls[0][0]; + expect(config.headers).toEqual( expect.objectContaining({ - headers: expect.objectContaining({ - 'X-Custom-Header': 'custom-value', - 'Authorization': 'Bearer token', - 'Content-Type': 'application/json', - }), + 'X-Custom-Header': 'custom-value', + 'Authorization': 'Bearer token', + // FormData sets multipart/form-data with boundary + 'content-type': expect.stringContaining('multipart/form-data'), }) ); }); @@ -466,10 +465,15 @@ describe('FormHandler', () => { expect(response.success).toBe(false); expect(response.executionId).toBe('exec-111'); - expect(response.details).toEqual({ - id: 'exec-111', - error: 'Validation failed', - }); + // 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 () => { @@ -535,14 +539,12 @@ describe('FormHandler', () => { const response = await handler.execute(input, workflow, triggerInfo); expect(response.success).toBe(true); - expect(axios.request).toHaveBeenCalledWith( - expect.objectContaining({ - data: {}, - }) - ); + // 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', async () => { + it('should handle complex form data types via FormData', async () => { const input = { workflowId: 'workflow-123', triggerType: 'form' as const, @@ -562,17 +564,9 @@ describe('FormHandler', () => { await handler.execute(input, workflow, triggerInfo); - expect(axios.request).toHaveBeenCalledWith( - expect.objectContaining({ - data: { - name: 'Test User', - age: 30, - active: true, - tags: ['tag1', 'tag2'], - metadata: { key: 'value' }, - }, - }) - ); + // Complex data types are serialized in FormData + const config = vi.mocked(axios.request).mock.calls[0][0]; + expect(config.data).toBeInstanceOf(FormData); }); }); }); diff --git a/tests/unit/triggers/trigger-detector.test.ts b/tests/unit/triggers/trigger-detector.test.ts index f9619df..585b278 100644 --- a/tests/unit/triggers/trigger-detector.test.ts +++ b/tests/unit/triggers/trigger-detector.test.ts @@ -242,7 +242,7 @@ describe('Trigger Detector', () => { expect(url).toContain('/form/'); }); - it('should build chat URL correctly', () => { + it('should build chat URL correctly with /chat suffix', () => { const baseUrl = 'https://n8n.example.com'; const trigger = { type: 'chat' as const, @@ -259,7 +259,8 @@ describe('Trigger Detector', () => { const url = buildTriggerUrl(baseUrl, trigger, 'production'); - expect(url).toBe('https://n8n.example.com/webhook/ai-chat'); + // Chat triggers use /webhook//chat endpoint + expect(url).toBe('https://n8n.example.com/webhook/ai-chat/chat'); }); });