This is page 35 of 59. Use http://codebase.md/czlonkowski/n8n-mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── _config.yml ├── .claude │ └── agents │ ├── code-reviewer.md │ ├── context-manager.md │ ├── debugger.md │ ├── deployment-engineer.md │ ├── mcp-backend-engineer.md │ ├── n8n-mcp-tester.md │ ├── technical-researcher.md │ └── test-automator.md ├── .dockerignore ├── .env.docker ├── .env.example ├── .env.n8n.example ├── .env.test ├── .env.test.example ├── .github │ ├── ABOUT.md │ ├── BENCHMARK_THRESHOLDS.md │ ├── FUNDING.yml │ ├── gh-pages.yml │ ├── secret_scanning.yml │ └── workflows │ ├── benchmark-pr.yml │ ├── benchmark.yml │ ├── docker-build-fast.yml │ ├── docker-build-n8n.yml │ ├── docker-build.yml │ ├── release.yml │ ├── test.yml │ └── update-n8n-deps.yml ├── .gitignore ├── .npmignore ├── ATTRIBUTION.md ├── CHANGELOG.md ├── CLAUDE.md ├── codecov.yml ├── coverage.json ├── data │ ├── .gitkeep │ ├── nodes.db │ ├── nodes.db-shm │ ├── nodes.db-wal │ └── templates.db ├── deploy │ └── quick-deploy-n8n.sh ├── docker │ ├── docker-entrypoint.sh │ ├── n8n-mcp │ ├── parse-config.js │ └── README.md ├── docker-compose.buildkit.yml ├── docker-compose.extract.yml ├── docker-compose.n8n.yml ├── docker-compose.override.yml.example ├── docker-compose.test-n8n.yml ├── docker-compose.yml ├── Dockerfile ├── Dockerfile.railway ├── Dockerfile.test ├── docs │ ├── AUTOMATED_RELEASES.md │ ├── BENCHMARKS.md │ ├── CHANGELOG.md │ ├── CLAUDE_CODE_SETUP.md │ ├── CLAUDE_INTERVIEW.md │ ├── CODECOV_SETUP.md │ ├── CODEX_SETUP.md │ ├── CURSOR_SETUP.md │ ├── DEPENDENCY_UPDATES.md │ ├── DOCKER_README.md │ ├── DOCKER_TROUBLESHOOTING.md │ ├── FINAL_AI_VALIDATION_SPEC.md │ ├── FLEXIBLE_INSTANCE_CONFIGURATION.md │ ├── HTTP_DEPLOYMENT.md │ ├── img │ │ ├── cc_command.png │ │ ├── cc_connected.png │ │ ├── codex_connected.png │ │ ├── cursor_tut.png │ │ ├── Railway_api.png │ │ ├── Railway_server_address.png │ │ ├── vsc_ghcp_chat_agent_mode.png │ │ ├── vsc_ghcp_chat_instruction_files.png │ │ ├── vsc_ghcp_chat_thinking_tool.png │ │ └── windsurf_tut.png │ ├── INSTALLATION.md │ ├── LIBRARY_USAGE.md │ ├── local │ │ ├── DEEP_DIVE_ANALYSIS_2025-10-02.md │ │ ├── DEEP_DIVE_ANALYSIS_README.md │ │ ├── Deep_dive_p1_p2.md │ │ ├── integration-testing-plan.md │ │ ├── integration-tests-phase1-summary.md │ │ ├── N8N_AI_WORKFLOW_BUILDER_ANALYSIS.md │ │ ├── P0_IMPLEMENTATION_PLAN.md │ │ └── TEMPLATE_MINING_ANALYSIS.md │ ├── MCP_ESSENTIALS_README.md │ ├── MCP_QUICK_START_GUIDE.md │ ├── N8N_DEPLOYMENT.md │ ├── RAILWAY_DEPLOYMENT.md │ ├── README_CLAUDE_SETUP.md │ ├── README.md │ ├── tools-documentation-usage.md │ ├── VS_CODE_PROJECT_SETUP.md │ ├── WINDSURF_SETUP.md │ └── workflow-diff-examples.md ├── examples │ └── enhanced-documentation-demo.js ├── fetch_log.txt ├── LICENSE ├── MEMORY_N8N_UPDATE.md ├── MEMORY_TEMPLATE_UPDATE.md ├── monitor_fetch.sh ├── N8N_HTTP_STREAMABLE_SETUP.md ├── n8n-nodes.db ├── P0-R3-TEST-PLAN.md ├── package-lock.json ├── package.json ├── package.runtime.json ├── PRIVACY.md ├── railway.json ├── README.md ├── renovate.json ├── scripts │ ├── analyze-optimization.sh │ ├── audit-schema-coverage.ts │ ├── build-optimized.sh │ ├── compare-benchmarks.js │ ├── demo-optimization.sh │ ├── deploy-http.sh │ ├── deploy-to-vm.sh │ ├── export-webhook-workflows.ts │ ├── extract-changelog.js │ ├── extract-from-docker.js │ ├── extract-nodes-docker.sh │ ├── extract-nodes-simple.sh │ ├── format-benchmark-results.js │ ├── generate-benchmark-stub.js │ ├── generate-detailed-reports.js │ ├── generate-test-summary.js │ ├── http-bridge.js │ ├── mcp-http-client.js │ ├── migrate-nodes-fts.ts │ ├── migrate-tool-docs.ts │ ├── n8n-docs-mcp.service │ ├── nginx-n8n-mcp.conf │ ├── prebuild-fts5.ts │ ├── prepare-release.js │ ├── publish-npm-quick.sh │ ├── publish-npm.sh │ ├── quick-test.ts │ ├── run-benchmarks-ci.js │ ├── sync-runtime-version.js │ ├── test-ai-validation-debug.ts │ ├── test-code-node-enhancements.ts │ ├── test-code-node-fixes.ts │ ├── test-docker-config.sh │ ├── test-docker-fingerprint.ts │ ├── test-docker-optimization.sh │ ├── test-docker.sh │ ├── test-empty-connection-validation.ts │ ├── test-error-message-tracking.ts │ ├── test-error-output-validation.ts │ ├── test-error-validation.js │ ├── test-essentials.ts │ ├── test-expression-code-validation.ts │ ├── test-expression-format-validation.js │ ├── test-fts5-search.ts │ ├── test-fuzzy-fix.ts │ ├── test-fuzzy-simple.ts │ ├── test-helpers-validation.ts │ ├── test-http-search.ts │ ├── test-http.sh │ ├── test-jmespath-validation.ts │ ├── test-multi-tenant-simple.ts │ ├── test-multi-tenant.ts │ ├── test-n8n-integration.sh │ ├── test-node-info.js │ ├── test-node-type-validation.ts │ ├── test-nodes-base-prefix.ts │ ├── test-operation-validation.ts │ ├── test-optimized-docker.sh │ ├── test-release-automation.js │ ├── test-search-improvements.ts │ ├── test-security.ts │ ├── test-single-session.sh │ ├── test-sqljs-triggers.ts │ ├── test-telemetry-debug.ts │ ├── test-telemetry-direct.ts │ ├── test-telemetry-env.ts │ ├── test-telemetry-integration.ts │ ├── test-telemetry-no-select.ts │ ├── test-telemetry-security.ts │ ├── test-telemetry-simple.ts │ ├── test-typeversion-validation.ts │ ├── test-url-configuration.ts │ ├── test-user-id-persistence.ts │ ├── test-webhook-validation.ts │ ├── test-workflow-insert.ts │ ├── test-workflow-sanitizer.ts │ ├── test-workflow-tracking-debug.ts │ ├── update-and-publish-prep.sh │ ├── update-n8n-deps.js │ ├── update-readme-version.js │ ├── vitest-benchmark-json-reporter.js │ └── vitest-benchmark-reporter.ts ├── SECURITY.md ├── src │ ├── config │ │ └── n8n-api.ts │ ├── data │ │ └── canonical-ai-tool-examples.json │ ├── database │ │ ├── database-adapter.ts │ │ ├── migrations │ │ │ └── add-template-node-configs.sql │ │ ├── node-repository.ts │ │ ├── nodes.db │ │ ├── schema-optimized.sql │ │ └── schema.sql │ ├── errors │ │ └── validation-service-error.ts │ ├── http-server-single-session.ts │ ├── http-server.ts │ ├── index.ts │ ├── loaders │ │ └── node-loader.ts │ ├── mappers │ │ └── docs-mapper.ts │ ├── mcp │ │ ├── handlers-n8n-manager.ts │ │ ├── handlers-workflow-diff.ts │ │ ├── index.ts │ │ ├── server.ts │ │ ├── stdio-wrapper.ts │ │ ├── tool-docs │ │ │ ├── configuration │ │ │ │ ├── get-node-as-tool-info.ts │ │ │ │ ├── get-node-documentation.ts │ │ │ │ ├── get-node-essentials.ts │ │ │ │ ├── get-node-info.ts │ │ │ │ ├── get-property-dependencies.ts │ │ │ │ ├── index.ts │ │ │ │ └── search-node-properties.ts │ │ │ ├── discovery │ │ │ │ ├── get-database-statistics.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-ai-tools.ts │ │ │ │ ├── list-nodes.ts │ │ │ │ └── search-nodes.ts │ │ │ ├── guides │ │ │ │ ├── ai-agents-guide.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── system │ │ │ │ ├── index.ts │ │ │ │ ├── n8n-diagnostic.ts │ │ │ │ ├── n8n-health-check.ts │ │ │ │ ├── n8n-list-available-tools.ts │ │ │ │ └── tools-documentation.ts │ │ │ ├── templates │ │ │ │ ├── get-template.ts │ │ │ │ ├── get-templates-for-task.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-node-templates.ts │ │ │ │ ├── list-tasks.ts │ │ │ │ ├── search-templates-by-metadata.ts │ │ │ │ └── search-templates.ts │ │ │ ├── types.ts │ │ │ ├── validation │ │ │ │ ├── index.ts │ │ │ │ ├── validate-node-minimal.ts │ │ │ │ ├── validate-node-operation.ts │ │ │ │ ├── validate-workflow-connections.ts │ │ │ │ ├── validate-workflow-expressions.ts │ │ │ │ └── validate-workflow.ts │ │ │ └── workflow_management │ │ │ ├── index.ts │ │ │ ├── n8n-autofix-workflow.ts │ │ │ ├── n8n-create-workflow.ts │ │ │ ├── n8n-delete-execution.ts │ │ │ ├── n8n-delete-workflow.ts │ │ │ ├── n8n-get-execution.ts │ │ │ ├── n8n-get-workflow-details.ts │ │ │ ├── n8n-get-workflow-minimal.ts │ │ │ ├── n8n-get-workflow-structure.ts │ │ │ ├── n8n-get-workflow.ts │ │ │ ├── n8n-list-executions.ts │ │ │ ├── n8n-list-workflows.ts │ │ │ ├── n8n-trigger-webhook-workflow.ts │ │ │ ├── n8n-update-full-workflow.ts │ │ │ ├── n8n-update-partial-workflow.ts │ │ │ └── n8n-validate-workflow.ts │ │ ├── tools-documentation.ts │ │ ├── tools-n8n-friendly.ts │ │ ├── tools-n8n-manager.ts │ │ ├── tools.ts │ │ └── workflow-examples.ts │ ├── mcp-engine.ts │ ├── mcp-tools-engine.ts │ ├── n8n │ │ ├── MCPApi.credentials.ts │ │ └── MCPNode.node.ts │ ├── parsers │ │ ├── node-parser.ts │ │ ├── property-extractor.ts │ │ └── simple-parser.ts │ ├── scripts │ │ ├── debug-http-search.ts │ │ ├── extract-from-docker.ts │ │ ├── fetch-templates-robust.ts │ │ ├── fetch-templates.ts │ │ ├── rebuild-database.ts │ │ ├── rebuild-optimized.ts │ │ ├── rebuild.ts │ │ ├── sanitize-templates.ts │ │ ├── seed-canonical-ai-examples.ts │ │ ├── test-autofix-documentation.ts │ │ ├── test-autofix-workflow.ts │ │ ├── test-execution-filtering.ts │ │ ├── test-node-suggestions.ts │ │ ├── test-protocol-negotiation.ts │ │ ├── test-summary.ts │ │ ├── test-webhook-autofix.ts │ │ ├── validate.ts │ │ └── validation-summary.ts │ ├── services │ │ ├── ai-node-validator.ts │ │ ├── ai-tool-validators.ts │ │ ├── confidence-scorer.ts │ │ ├── config-validator.ts │ │ ├── enhanced-config-validator.ts │ │ ├── example-generator.ts │ │ ├── execution-processor.ts │ │ ├── expression-format-validator.ts │ │ ├── expression-validator.ts │ │ ├── n8n-api-client.ts │ │ ├── n8n-validation.ts │ │ ├── node-documentation-service.ts │ │ ├── node-similarity-service.ts │ │ ├── node-specific-validators.ts │ │ ├── operation-similarity-service.ts │ │ ├── property-dependencies.ts │ │ ├── property-filter.ts │ │ ├── resource-similarity-service.ts │ │ ├── sqlite-storage-service.ts │ │ ├── task-templates.ts │ │ ├── universal-expression-validator.ts │ │ ├── workflow-auto-fixer.ts │ │ ├── workflow-diff-engine.ts │ │ └── workflow-validator.ts │ ├── telemetry │ │ ├── batch-processor.ts │ │ ├── config-manager.ts │ │ ├── early-error-logger.ts │ │ ├── error-sanitization-utils.ts │ │ ├── error-sanitizer.ts │ │ ├── event-tracker.ts │ │ ├── event-validator.ts │ │ ├── index.ts │ │ ├── performance-monitor.ts │ │ ├── rate-limiter.ts │ │ ├── startup-checkpoints.ts │ │ ├── telemetry-error.ts │ │ ├── telemetry-manager.ts │ │ ├── telemetry-types.ts │ │ └── workflow-sanitizer.ts │ ├── templates │ │ ├── batch-processor.ts │ │ ├── metadata-generator.ts │ │ ├── README.md │ │ ├── template-fetcher.ts │ │ ├── template-repository.ts │ │ └── template-service.ts │ ├── types │ │ ├── index.ts │ │ ├── instance-context.ts │ │ ├── n8n-api.ts │ │ ├── node-types.ts │ │ └── workflow-diff.ts │ └── utils │ ├── auth.ts │ ├── bridge.ts │ ├── cache-utils.ts │ ├── console-manager.ts │ ├── documentation-fetcher.ts │ ├── enhanced-documentation-fetcher.ts │ ├── error-handler.ts │ ├── example-generator.ts │ ├── fixed-collection-validator.ts │ ├── logger.ts │ ├── mcp-client.ts │ ├── n8n-errors.ts │ ├── node-source-extractor.ts │ ├── node-type-normalizer.ts │ ├── node-type-utils.ts │ ├── node-utils.ts │ ├── npm-version-checker.ts │ ├── protocol-version.ts │ ├── simple-cache.ts │ ├── ssrf-protection.ts │ ├── template-node-resolver.ts │ ├── template-sanitizer.ts │ ├── url-detector.ts │ ├── validation-schemas.ts │ └── version.ts ├── test-output.txt ├── test-reinit-fix.sh ├── tests │ ├── __snapshots__ │ │ └── .gitkeep │ ├── auth.test.ts │ ├── benchmarks │ │ ├── database-queries.bench.ts │ │ ├── index.ts │ │ ├── mcp-tools.bench.ts │ │ ├── mcp-tools.bench.ts.disabled │ │ ├── mcp-tools.bench.ts.skip │ │ ├── node-loading.bench.ts.disabled │ │ ├── README.md │ │ ├── search-operations.bench.ts.disabled │ │ └── validation-performance.bench.ts.disabled │ ├── bridge.test.ts │ ├── comprehensive-extraction-test.js │ ├── data │ │ └── .gitkeep │ ├── debug-slack-doc.js │ ├── demo-enhanced-documentation.js │ ├── docker-tests-README.md │ ├── error-handler.test.ts │ ├── examples │ │ └── using-database-utils.test.ts │ ├── extracted-nodes-db │ │ ├── database-import.json │ │ ├── extraction-report.json │ │ ├── insert-nodes.sql │ │ ├── n8n-nodes-base__Airtable.json │ │ ├── n8n-nodes-base__Discord.json │ │ ├── n8n-nodes-base__Function.json │ │ ├── n8n-nodes-base__HttpRequest.json │ │ ├── n8n-nodes-base__If.json │ │ ├── n8n-nodes-base__Slack.json │ │ ├── n8n-nodes-base__SplitInBatches.json │ │ └── n8n-nodes-base__Webhook.json │ ├── factories │ │ ├── node-factory.ts │ │ └── property-definition-factory.ts │ ├── fixtures │ │ ├── .gitkeep │ │ ├── database │ │ │ └── test-nodes.json │ │ ├── factories │ │ │ ├── node.factory.ts │ │ │ └── parser-node.factory.ts │ │ └── template-configs.ts │ ├── helpers │ │ └── env-helpers.ts │ ├── http-server-auth.test.ts │ ├── integration │ │ ├── ai-validation │ │ │ ├── ai-agent-validation.test.ts │ │ │ ├── ai-tool-validation.test.ts │ │ │ ├── chat-trigger-validation.test.ts │ │ │ ├── e2e-validation.test.ts │ │ │ ├── helpers.ts │ │ │ ├── llm-chain-validation.test.ts │ │ │ ├── README.md │ │ │ └── TEST_REPORT.md │ │ ├── ci │ │ │ └── database-population.test.ts │ │ ├── database │ │ │ ├── connection-management.test.ts │ │ │ ├── empty-database.test.ts │ │ │ ├── fts5-search.test.ts │ │ │ ├── node-fts5-search.test.ts │ │ │ ├── node-repository.test.ts │ │ │ ├── performance.test.ts │ │ │ ├── sqljs-memory-leak.test.ts │ │ │ ├── template-node-configs.test.ts │ │ │ ├── template-repository.test.ts │ │ │ ├── test-utils.ts │ │ │ └── transactions.test.ts │ │ ├── database-integration.test.ts │ │ ├── docker │ │ │ ├── docker-config.test.ts │ │ │ ├── docker-entrypoint.test.ts │ │ │ └── test-helpers.ts │ │ ├── flexible-instance-config.test.ts │ │ ├── mcp │ │ │ └── template-examples-e2e.test.ts │ │ ├── mcp-protocol │ │ │ ├── basic-connection.test.ts │ │ │ ├── error-handling.test.ts │ │ │ ├── performance.test.ts │ │ │ ├── protocol-compliance.test.ts │ │ │ ├── README.md │ │ │ ├── session-management.test.ts │ │ │ ├── test-helpers.ts │ │ │ ├── tool-invocation.test.ts │ │ │ └── workflow-error-validation.test.ts │ │ ├── msw-setup.test.ts │ │ ├── n8n-api │ │ │ ├── executions │ │ │ │ ├── delete-execution.test.ts │ │ │ │ ├── get-execution.test.ts │ │ │ │ ├── list-executions.test.ts │ │ │ │ └── trigger-webhook.test.ts │ │ │ ├── scripts │ │ │ │ └── cleanup-orphans.ts │ │ │ ├── system │ │ │ │ ├── diagnostic.test.ts │ │ │ │ ├── health-check.test.ts │ │ │ │ └── list-tools.test.ts │ │ │ ├── test-connection.ts │ │ │ ├── types │ │ │ │ └── mcp-responses.ts │ │ │ ├── utils │ │ │ │ ├── cleanup-helpers.ts │ │ │ │ ├── credentials.ts │ │ │ │ ├── factories.ts │ │ │ │ ├── fixtures.ts │ │ │ │ ├── mcp-context.ts │ │ │ │ ├── n8n-client.ts │ │ │ │ ├── node-repository.ts │ │ │ │ ├── response-types.ts │ │ │ │ ├── test-context.ts │ │ │ │ └── webhook-workflows.ts │ │ │ └── workflows │ │ │ ├── autofix-workflow.test.ts │ │ │ ├── create-workflow.test.ts │ │ │ ├── delete-workflow.test.ts │ │ │ ├── get-workflow-details.test.ts │ │ │ ├── get-workflow-minimal.test.ts │ │ │ ├── get-workflow-structure.test.ts │ │ │ ├── get-workflow.test.ts │ │ │ ├── list-workflows.test.ts │ │ │ ├── smart-parameters.test.ts │ │ │ ├── update-partial-workflow.test.ts │ │ │ ├── update-workflow.test.ts │ │ │ └── validate-workflow.test.ts │ │ ├── security │ │ │ ├── command-injection-prevention.test.ts │ │ │ └── rate-limiting.test.ts │ │ ├── setup │ │ │ ├── integration-setup.ts │ │ │ └── msw-test-server.ts │ │ ├── telemetry │ │ │ ├── docker-user-id-stability.test.ts │ │ │ └── mcp-telemetry.test.ts │ │ ├── templates │ │ │ └── metadata-operations.test.ts │ │ └── workflow-creation-node-type-format.test.ts │ ├── logger.test.ts │ ├── MOCKING_STRATEGY.md │ ├── mocks │ │ ├── n8n-api │ │ │ ├── data │ │ │ │ ├── credentials.ts │ │ │ │ ├── executions.ts │ │ │ │ └── workflows.ts │ │ │ ├── handlers.ts │ │ │ └── index.ts │ │ └── README.md │ ├── node-storage-export.json │ ├── setup │ │ ├── global-setup.ts │ │ ├── msw-setup.ts │ │ ├── TEST_ENV_DOCUMENTATION.md │ │ └── test-env.ts │ ├── test-database-extraction.js │ ├── test-direct-extraction.js │ ├── test-enhanced-documentation.js │ ├── test-enhanced-integration.js │ ├── test-mcp-extraction.js │ ├── test-mcp-server-extraction.js │ ├── test-mcp-tools-integration.js │ ├── test-node-documentation-service.js │ ├── test-node-list.js │ ├── test-package-info.js │ ├── test-parsing-operations.js │ ├── test-slack-node-complete.js │ ├── test-small-rebuild.js │ ├── test-sqlite-search.js │ ├── test-storage-system.js │ ├── unit │ │ ├── __mocks__ │ │ │ ├── n8n-nodes-base.test.ts │ │ │ ├── n8n-nodes-base.ts │ │ │ └── README.md │ │ ├── database │ │ │ ├── __mocks__ │ │ │ │ └── better-sqlite3.ts │ │ │ ├── database-adapter-unit.test.ts │ │ │ ├── node-repository-core.test.ts │ │ │ ├── node-repository-operations.test.ts │ │ │ ├── node-repository-outputs.test.ts │ │ │ ├── README.md │ │ │ └── template-repository-core.test.ts │ │ ├── docker │ │ │ ├── config-security.test.ts │ │ │ ├── edge-cases.test.ts │ │ │ ├── parse-config.test.ts │ │ │ └── serve-command.test.ts │ │ ├── errors │ │ │ └── validation-service-error.test.ts │ │ ├── examples │ │ │ └── using-n8n-nodes-base-mock.test.ts │ │ ├── flexible-instance-security-advanced.test.ts │ │ ├── flexible-instance-security.test.ts │ │ ├── http-server │ │ │ └── multi-tenant-support.test.ts │ │ ├── http-server-n8n-mode.test.ts │ │ ├── http-server-n8n-reinit.test.ts │ │ ├── http-server-session-management.test.ts │ │ ├── loaders │ │ │ └── node-loader.test.ts │ │ ├── mappers │ │ │ └── docs-mapper.test.ts │ │ ├── mcp │ │ │ ├── get-node-essentials-examples.test.ts │ │ │ ├── handlers-n8n-manager-simple.test.ts │ │ │ ├── handlers-n8n-manager.test.ts │ │ │ ├── handlers-workflow-diff.test.ts │ │ │ ├── lru-cache-behavior.test.ts │ │ │ ├── multi-tenant-tool-listing.test.ts.disabled │ │ │ ├── parameter-validation.test.ts │ │ │ ├── search-nodes-examples.test.ts │ │ │ ├── tools-documentation.test.ts │ │ │ └── tools.test.ts │ │ ├── monitoring │ │ │ └── cache-metrics.test.ts │ │ ├── MULTI_TENANT_TEST_COVERAGE.md │ │ ├── multi-tenant-integration.test.ts │ │ ├── parsers │ │ │ ├── node-parser-outputs.test.ts │ │ │ ├── node-parser.test.ts │ │ │ ├── property-extractor.test.ts │ │ │ └── simple-parser.test.ts │ │ ├── scripts │ │ │ └── fetch-templates-extraction.test.ts │ │ ├── services │ │ │ ├── ai-node-validator.test.ts │ │ │ ├── ai-tool-validators.test.ts │ │ │ ├── confidence-scorer.test.ts │ │ │ ├── config-validator-basic.test.ts │ │ │ ├── config-validator-edge-cases.test.ts │ │ │ ├── config-validator-node-specific.test.ts │ │ │ ├── config-validator-security.test.ts │ │ │ ├── debug-validator.test.ts │ │ │ ├── enhanced-config-validator-integration.test.ts │ │ │ ├── enhanced-config-validator-operations.test.ts │ │ │ ├── enhanced-config-validator.test.ts │ │ │ ├── example-generator.test.ts │ │ │ ├── execution-processor.test.ts │ │ │ ├── expression-format-validator.test.ts │ │ │ ├── expression-validator-edge-cases.test.ts │ │ │ ├── expression-validator.test.ts │ │ │ ├── fixed-collection-validation.test.ts │ │ │ ├── loop-output-edge-cases.test.ts │ │ │ ├── n8n-api-client.test.ts │ │ │ ├── n8n-validation.test.ts │ │ │ ├── node-similarity-service.test.ts │ │ │ ├── node-specific-validators.test.ts │ │ │ ├── operation-similarity-service-comprehensive.test.ts │ │ │ ├── operation-similarity-service.test.ts │ │ │ ├── property-dependencies.test.ts │ │ │ ├── property-filter-edge-cases.test.ts │ │ │ ├── property-filter.test.ts │ │ │ ├── resource-similarity-service-comprehensive.test.ts │ │ │ ├── resource-similarity-service.test.ts │ │ │ ├── task-templates.test.ts │ │ │ ├── template-service.test.ts │ │ │ ├── universal-expression-validator.test.ts │ │ │ ├── validation-fixes.test.ts │ │ │ ├── workflow-auto-fixer.test.ts │ │ │ ├── workflow-diff-engine.test.ts │ │ │ ├── workflow-fixed-collection-validation.test.ts │ │ │ ├── workflow-validator-comprehensive.test.ts │ │ │ ├── workflow-validator-edge-cases.test.ts │ │ │ ├── workflow-validator-error-outputs.test.ts │ │ │ ├── workflow-validator-expression-format.test.ts │ │ │ ├── workflow-validator-loops-simple.test.ts │ │ │ ├── workflow-validator-loops.test.ts │ │ │ ├── workflow-validator-mocks.test.ts │ │ │ ├── workflow-validator-performance.test.ts │ │ │ ├── workflow-validator-with-mocks.test.ts │ │ │ └── workflow-validator.test.ts │ │ ├── telemetry │ │ │ ├── batch-processor.test.ts │ │ │ ├── config-manager.test.ts │ │ │ ├── event-tracker.test.ts │ │ │ ├── event-validator.test.ts │ │ │ ├── rate-limiter.test.ts │ │ │ ├── telemetry-error.test.ts │ │ │ ├── telemetry-manager.test.ts │ │ │ ├── v2.18.3-fixes-verification.test.ts │ │ │ └── workflow-sanitizer.test.ts │ │ ├── templates │ │ │ ├── batch-processor.test.ts │ │ │ ├── metadata-generator.test.ts │ │ │ ├── template-repository-metadata.test.ts │ │ │ └── template-repository-security.test.ts │ │ ├── test-env-example.test.ts │ │ ├── test-infrastructure.test.ts │ │ ├── types │ │ │ ├── instance-context-coverage.test.ts │ │ │ └── instance-context-multi-tenant.test.ts │ │ ├── utils │ │ │ ├── auth-timing-safe.test.ts │ │ │ ├── cache-utils.test.ts │ │ │ ├── console-manager.test.ts │ │ │ ├── database-utils.test.ts │ │ │ ├── fixed-collection-validator.test.ts │ │ │ ├── n8n-errors.test.ts │ │ │ ├── node-type-normalizer.test.ts │ │ │ ├── node-type-utils.test.ts │ │ │ ├── node-utils.test.ts │ │ │ ├── simple-cache-memory-leak-fix.test.ts │ │ │ ├── ssrf-protection.test.ts │ │ │ └── template-node-resolver.test.ts │ │ └── validation-fixes.test.ts │ └── utils │ ├── assertions.ts │ ├── builders │ │ └── workflow.builder.ts │ ├── data-generators.ts │ ├── database-utils.ts │ ├── README.md │ └── test-helpers.ts ├── thumbnail.png ├── tsconfig.build.json ├── tsconfig.json ├── types │ ├── mcp.d.ts │ └── test-env.d.ts ├── verify-telemetry-fix.js ├── versioned-nodes.md ├── vitest.config.benchmark.ts ├── vitest.config.integration.ts └── vitest.config.ts ``` # Files -------------------------------------------------------------------------------- /tests/integration/mcp-protocol/session-management.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeAll, afterAll } from 'vitest'; 2 | import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; 3 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 4 | import { TestableN8NMCPServer } from './test-helpers'; 5 | 6 | describe('MCP Session Management', { timeout: 15000 }, () => { 7 | let originalMswEnabled: string | undefined; 8 | 9 | beforeAll(() => { 10 | // Save original value 11 | originalMswEnabled = process.env.MSW_ENABLED; 12 | // Disable MSW for these integration tests 13 | process.env.MSW_ENABLED = 'false'; 14 | }); 15 | 16 | afterAll(async () => { 17 | // Restore original value 18 | if (originalMswEnabled !== undefined) { 19 | process.env.MSW_ENABLED = originalMswEnabled; 20 | } else { 21 | delete process.env.MSW_ENABLED; 22 | } 23 | // Clean up any shared resources 24 | await TestableN8NMCPServer.shutdownShared(); 25 | }); 26 | 27 | describe('Session Lifecycle', () => { 28 | it('should establish a new session', async () => { 29 | const mcpServer = new TestableN8NMCPServer(); 30 | await mcpServer.initialize(); 31 | 32 | const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair(); 33 | await mcpServer.connectToTransport(serverTransport); 34 | 35 | const client = new Client({ 36 | name: 'test-client', 37 | version: '1.0.0' 38 | }, { 39 | capabilities: {} 40 | }); 41 | 42 | await client.connect(clientTransport); 43 | 44 | // Session should be established 45 | const serverInfo = await client.getServerVersion(); 46 | expect(serverInfo).toHaveProperty('name', 'n8n-documentation-mcp'); 47 | 48 | // Clean up - ensure proper order 49 | await client.close(); 50 | await new Promise(resolve => setTimeout(resolve, 50)); // Give time for client to fully close 51 | await mcpServer.close(); 52 | }); 53 | 54 | it('should handle session initialization with capabilities', async () => { 55 | const mcpServer = new TestableN8NMCPServer(); 56 | await mcpServer.initialize(); 57 | 58 | const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair(); 59 | await mcpServer.connectToTransport(serverTransport); 60 | 61 | const client = new Client({ 62 | name: 'test-client', 63 | version: '1.0.0' 64 | }, { 65 | capabilities: { 66 | // Client capabilities 67 | experimental: {} 68 | } 69 | }); 70 | 71 | await client.connect(clientTransport); 72 | 73 | const serverInfo = await client.getServerVersion(); 74 | expect(serverInfo).toBeDefined(); 75 | expect(serverInfo?.name).toBe('n8n-documentation-mcp'); 76 | 77 | // Check capabilities if they exist 78 | if (serverInfo?.capabilities) { 79 | expect(serverInfo.capabilities).toHaveProperty('tools'); 80 | } 81 | 82 | // Clean up - ensure proper order 83 | await client.close(); 84 | await new Promise(resolve => setTimeout(resolve, 50)); // Give time for client to fully close 85 | await mcpServer.close(); 86 | }); 87 | 88 | it('should handle clean session termination', async () => { 89 | const mcpServer = new TestableN8NMCPServer(); 90 | await mcpServer.initialize(); 91 | 92 | const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair(); 93 | await mcpServer.connectToTransport(serverTransport); 94 | 95 | const client = new Client({ 96 | name: 'test-client', 97 | version: '1.0.0' 98 | }, {}); 99 | 100 | await client.connect(clientTransport); 101 | 102 | // Make some requests 103 | await client.callTool({ name: 'get_database_statistics', arguments: {} }); 104 | await client.callTool({ name: 'list_nodes', arguments: { limit: 5 } }); 105 | 106 | // Clean termination 107 | await client.close(); 108 | await new Promise(resolve => setTimeout(resolve, 50)); // Give time for client to fully close 109 | 110 | // Client should be closed 111 | try { 112 | await client.callTool({ name: 'get_database_statistics', arguments: {} }); 113 | expect.fail('Should not be able to make requests after close'); 114 | } catch (error) { 115 | expect(error).toBeDefined(); 116 | } 117 | 118 | await mcpServer.close(); 119 | }); 120 | 121 | it('should handle abrupt disconnection', async () => { 122 | const mcpServer = new TestableN8NMCPServer(); 123 | await mcpServer.initialize(); 124 | 125 | const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair(); 126 | await mcpServer.connectToTransport(serverTransport); 127 | 128 | const client = new Client({ 129 | name: 'test-client', 130 | version: '1.0.0' 131 | }, {}); 132 | 133 | await client.connect(clientTransport); 134 | 135 | // Make a request to ensure connection is active 136 | await client.callTool({ name: 'get_database_statistics', arguments: {} }); 137 | 138 | // Simulate abrupt disconnection by closing transport 139 | await clientTransport.close(); 140 | await new Promise(resolve => setTimeout(resolve, 50)); // Give time for transport to fully close 141 | 142 | // Further operations should fail 143 | try { 144 | await client.callTool({ name: 'list_nodes', arguments: {} }); 145 | expect.fail('Should not be able to make requests after transport close'); 146 | } catch (error) { 147 | expect(error).toBeDefined(); 148 | } 149 | 150 | // Note: client is already disconnected, no need to close it 151 | await mcpServer.close(); 152 | }); 153 | }); 154 | 155 | describe('Multiple Sessions', () => { 156 | it('should handle multiple concurrent sessions', async () => { 157 | // Skip this test for now - it has concurrency issues 158 | // TODO: Fix concurrent session handling in MCP server 159 | console.log('Skipping concurrent sessions test - known timeout issue'); 160 | expect(true).toBe(true); 161 | }, { skip: true }); 162 | 163 | it('should isolate session state', async () => { 164 | // Skip this test for now - it has concurrency issues 165 | // TODO: Fix session isolation in MCP server 166 | console.log('Skipping session isolation test - known timeout issue'); 167 | expect(true).toBe(true); 168 | }, { skip: true }); 169 | 170 | it('should handle sequential sessions without interference', async () => { 171 | // Create first session 172 | const mcpServer1 = new TestableN8NMCPServer(); 173 | await mcpServer1.initialize(); 174 | 175 | const [st1, ct1] = InMemoryTransport.createLinkedPair(); 176 | await mcpServer1.connectToTransport(st1); 177 | 178 | const client1 = new Client({ name: 'seq-client1', version: '1.0.0' }, {}); 179 | await client1.connect(ct1); 180 | 181 | // First session operations 182 | const response1 = await client1.callTool({ name: 'list_nodes', arguments: { limit: 3 } }); 183 | expect(response1).toBeDefined(); 184 | expect((response1 as any).content).toBeDefined(); 185 | expect((response1 as any).content[0]).toHaveProperty('type', 'text'); 186 | const data1 = JSON.parse(((response1 as any).content[0] as any).text); 187 | // Handle both array response and object with nodes property 188 | const nodes1 = Array.isArray(data1) ? data1 : data1.nodes; 189 | expect(nodes1).toHaveLength(3); 190 | 191 | // Close first session completely 192 | await client1.close(); 193 | await mcpServer1.close(); 194 | await new Promise(resolve => setTimeout(resolve, 100)); 195 | 196 | // Create second session 197 | const mcpServer2 = new TestableN8NMCPServer(); 198 | await mcpServer2.initialize(); 199 | 200 | const [st2, ct2] = InMemoryTransport.createLinkedPair(); 201 | await mcpServer2.connectToTransport(st2); 202 | 203 | const client2 = new Client({ name: 'seq-client2', version: '1.0.0' }, {}); 204 | await client2.connect(ct2); 205 | 206 | // Second session operations 207 | const response2 = await client2.callTool({ name: 'list_nodes', arguments: { limit: 5 } }); 208 | expect(response2).toBeDefined(); 209 | expect((response2 as any).content).toBeDefined(); 210 | expect((response2 as any).content[0]).toHaveProperty('type', 'text'); 211 | const data2 = JSON.parse(((response2 as any).content[0] as any).text); 212 | // Handle both array response and object with nodes property 213 | const nodes2 = Array.isArray(data2) ? data2 : data2.nodes; 214 | expect(nodes2).toHaveLength(5); 215 | 216 | // Clean up 217 | await client2.close(); 218 | await mcpServer2.close(); 219 | }); 220 | 221 | it('should handle single server with multiple sequential connections', async () => { 222 | const mcpServer = new TestableN8NMCPServer(); 223 | await mcpServer.initialize(); 224 | 225 | // First connection 226 | const [st1, ct1] = InMemoryTransport.createLinkedPair(); 227 | await mcpServer.connectToTransport(st1); 228 | const client1 = new Client({ name: 'multi-seq-1', version: '1.0.0' }, {}); 229 | await client1.connect(ct1); 230 | 231 | const resp1 = await client1.callTool({ name: 'get_database_statistics', arguments: {} }); 232 | expect(resp1).toBeDefined(); 233 | 234 | await client1.close(); 235 | await new Promise(resolve => setTimeout(resolve, 50)); 236 | 237 | // Second connection to same server 238 | const [st2, ct2] = InMemoryTransport.createLinkedPair(); 239 | await mcpServer.connectToTransport(st2); 240 | const client2 = new Client({ name: 'multi-seq-2', version: '1.0.0' }, {}); 241 | await client2.connect(ct2); 242 | 243 | const resp2 = await client2.callTool({ name: 'get_database_statistics', arguments: {} }); 244 | expect(resp2).toBeDefined(); 245 | 246 | await client2.close(); 247 | await mcpServer.close(); 248 | }); 249 | }); 250 | 251 | describe('Session Recovery', () => { 252 | it('should not persist state between sessions', async () => { 253 | // First session 254 | const mcpServer1 = new TestableN8NMCPServer(); 255 | await mcpServer1.initialize(); 256 | 257 | const [st1, ct1] = InMemoryTransport.createLinkedPair(); 258 | await mcpServer1.connectToTransport(st1); 259 | 260 | const client1 = new Client({ name: 'client1', version: '1.0.0' }, {}); 261 | await client1.connect(ct1); 262 | 263 | // Make some requests 264 | await client1.callTool({ name: 'list_nodes', arguments: { limit: 10 } }); 265 | await client1.close(); 266 | await mcpServer1.close(); 267 | 268 | // Second session - should be fresh 269 | const mcpServer2 = new TestableN8NMCPServer(); 270 | await mcpServer2.initialize(); 271 | 272 | const [st2, ct2] = InMemoryTransport.createLinkedPair(); 273 | await mcpServer2.connectToTransport(st2); 274 | 275 | const client2 = new Client({ name: 'client2', version: '1.0.0' }, {}); 276 | await client2.connect(ct2); 277 | 278 | // Should work normally 279 | const response = await client2.callTool({ name: 'get_database_statistics', arguments: {} }); 280 | expect(response).toBeDefined(); 281 | 282 | await client2.close(); 283 | await mcpServer2.close(); 284 | }); 285 | 286 | it('should handle rapid session cycling', async () => { 287 | for (let i = 0; i < 10; i++) { 288 | const mcpServer = new TestableN8NMCPServer(); 289 | await mcpServer.initialize(); 290 | 291 | const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair(); 292 | await mcpServer.connectToTransport(serverTransport); 293 | 294 | const client = new Client({ 295 | name: `rapid-client-${i}`, 296 | version: '1.0.0' 297 | }, {}); 298 | 299 | await client.connect(clientTransport); 300 | 301 | // Quick operation 302 | const response = await client.callTool({ name: 'get_database_statistics', arguments: {} }); 303 | expect(response).toBeDefined(); 304 | 305 | // Explicit cleanup for each iteration 306 | await client.close(); 307 | await mcpServer.close(); 308 | } 309 | }); 310 | }); 311 | 312 | describe('Session Metadata', () => { 313 | it('should track client information', async () => { 314 | const mcpServer = new TestableN8NMCPServer(); 315 | await mcpServer.initialize(); 316 | 317 | const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair(); 318 | await mcpServer.connectToTransport(serverTransport); 319 | 320 | const client = new Client({ 321 | name: 'test-client-with-metadata', 322 | version: '2.0.0' 323 | }, { 324 | capabilities: { 325 | experimental: {} 326 | } 327 | }); 328 | 329 | await client.connect(clientTransport); 330 | 331 | // Server should be aware of client 332 | const serverInfo = await client.getServerVersion(); 333 | expect(serverInfo).toBeDefined(); 334 | 335 | await client.close(); 336 | await new Promise(resolve => setTimeout(resolve, 50)); // Give time for client to fully close 337 | await mcpServer.close(); 338 | }); 339 | 340 | it('should handle different client versions', async () => { 341 | const mcpServer = new TestableN8NMCPServer(); 342 | await mcpServer.initialize(); 343 | 344 | const clients = []; 345 | 346 | for (const version of ['1.0.0', '1.1.0', '2.0.0']) { 347 | const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair(); 348 | await mcpServer.connectToTransport(serverTransport); 349 | 350 | const client = new Client({ 351 | name: 'version-test-client', 352 | version 353 | }, {}); 354 | 355 | await client.connect(clientTransport); 356 | clients.push(client); 357 | } 358 | 359 | // All versions should work 360 | const responses = await Promise.all( 361 | clients.map(client => client.getServerVersion()) 362 | ); 363 | 364 | responses.forEach(info => { 365 | expect(info!.name).toBe('n8n-documentation-mcp'); 366 | }); 367 | 368 | // Clean up 369 | await Promise.all(clients.map(client => client.close())); 370 | await new Promise(resolve => setTimeout(resolve, 100)); // Give time for all clients to fully close 371 | await mcpServer.close(); 372 | }); 373 | }); 374 | 375 | describe('Session Limits', () => { 376 | it('should handle many sequential sessions', async () => { 377 | const sessionCount = 20; // Reduced for faster tests 378 | 379 | for (let i = 0; i < sessionCount; i++) { 380 | const mcpServer = new TestableN8NMCPServer(); 381 | await mcpServer.initialize(); 382 | 383 | const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair(); 384 | await mcpServer.connectToTransport(serverTransport); 385 | 386 | const client = new Client({ 387 | name: `sequential-client-${i}`, 388 | version: '1.0.0' 389 | }, {}); 390 | 391 | await client.connect(clientTransport); 392 | 393 | // Light operation 394 | if (i % 10 === 0) { 395 | await client.callTool({ name: 'get_database_statistics', arguments: {} }); 396 | } 397 | 398 | // Explicit cleanup 399 | await client.close(); 400 | await mcpServer.close(); 401 | } 402 | }); 403 | 404 | it('should handle session with heavy usage', async () => { 405 | const mcpServer = new TestableN8NMCPServer(); 406 | await mcpServer.initialize(); 407 | 408 | const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair(); 409 | await mcpServer.connectToTransport(serverTransport); 410 | 411 | const client = new Client({ 412 | name: 'heavy-usage-client', 413 | version: '1.0.0' 414 | }, {}); 415 | 416 | await client.connect(clientTransport); 417 | 418 | // Make many requests 419 | const requestCount = 20; // Reduced for faster tests 420 | const promises = []; 421 | 422 | for (let i = 0; i < requestCount; i++) { 423 | const toolName = i % 2 === 0 ? 'list_nodes' : 'get_database_statistics'; 424 | const params = toolName === 'list_nodes' ? { limit: 1 } : {}; 425 | promises.push(client.callTool({ name: toolName as any, arguments: params })); 426 | } 427 | 428 | const responses = await Promise.all(promises); 429 | expect(responses).toHaveLength(requestCount); 430 | 431 | await client.close(); 432 | await new Promise(resolve => setTimeout(resolve, 50)); // Give time for client to fully close 433 | await mcpServer.close(); 434 | }); 435 | }); 436 | 437 | describe('Session Error Recovery', () => { 438 | it('should handle errors without breaking session', async () => { 439 | const mcpServer = new TestableN8NMCPServer(); 440 | await mcpServer.initialize(); 441 | 442 | const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair(); 443 | await mcpServer.connectToTransport(serverTransport); 444 | 445 | const client = new Client({ 446 | name: 'error-recovery-client', 447 | version: '1.0.0' 448 | }, {}); 449 | 450 | await client.connect(clientTransport); 451 | 452 | // Make an error-inducing request 453 | try { 454 | await client.callTool({ name: 'get_node_info', arguments: { 455 | nodeType: 'invalid-node-type' 456 | } }); 457 | expect.fail('Should have thrown an error'); 458 | } catch (error) { 459 | expect(error).toBeDefined(); 460 | } 461 | 462 | // Session should still be active 463 | const response = await client.callTool({ name: 'get_database_statistics', arguments: {} }); 464 | expect(response).toBeDefined(); 465 | 466 | await client.close(); 467 | await new Promise(resolve => setTimeout(resolve, 50)); // Give time for client to fully close 468 | await mcpServer.close(); 469 | }); 470 | 471 | it('should handle multiple errors in sequence', async () => { 472 | const mcpServer = new TestableN8NMCPServer(); 473 | await mcpServer.initialize(); 474 | 475 | const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair(); 476 | await mcpServer.connectToTransport(serverTransport); 477 | 478 | const client = new Client({ 479 | name: 'multi-error-client', 480 | version: '1.0.0' 481 | }, {}); 482 | 483 | await client.connect(clientTransport); 484 | 485 | // Multiple error-inducing requests 486 | // Note: get_node_for_task was removed in v2.15.0 487 | const errorPromises = [ 488 | client.callTool({ name: 'get_node_info', arguments: { nodeType: 'invalid1' } }).catch(e => e), 489 | client.callTool({ name: 'get_node_info', arguments: { nodeType: 'invalid2' } }).catch(e => e), 490 | client.callTool({ name: 'search_nodes', arguments: { query: '' } }).catch(e => e) // Empty query should error 491 | ]; 492 | 493 | const errors = await Promise.all(errorPromises); 494 | errors.forEach(error => { 495 | expect(error).toBeDefined(); 496 | }); 497 | 498 | // Session should still work 499 | const response = await client.callTool({ name: 'list_nodes', arguments: { limit: 1 } }); 500 | expect(response).toBeDefined(); 501 | 502 | await client.close(); 503 | await new Promise(resolve => setTimeout(resolve, 50)); // Give time for client to fully close 504 | await mcpServer.close(); 505 | }); 506 | }); 507 | 508 | describe('Resource Cleanup', () => { 509 | it('should properly close all resources on shutdown', async () => { 510 | const testTimeout = setTimeout(() => { 511 | console.error('Test timeout - possible deadlock in resource cleanup'); 512 | throw new Error('Test timeout after 10 seconds'); 513 | }, 10000); 514 | 515 | const resources = { 516 | servers: [] as TestableN8NMCPServer[], 517 | clients: [] as Client[], 518 | transports: [] as any[] 519 | }; 520 | 521 | try { 522 | // Create multiple servers and clients 523 | for (let i = 0; i < 3; i++) { 524 | const mcpServer = new TestableN8NMCPServer(); 525 | await mcpServer.initialize(); 526 | resources.servers.push(mcpServer); 527 | 528 | const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair(); 529 | resources.transports.push({ serverTransport, clientTransport }); 530 | 531 | await mcpServer.connectToTransport(serverTransport); 532 | 533 | const client = new Client({ 534 | name: `cleanup-test-client-${i}`, 535 | version: '1.0.0' 536 | }, {}); 537 | 538 | await client.connect(clientTransport); 539 | resources.clients.push(client); 540 | 541 | // Make a request to ensure connection is active 542 | await client.callTool({ name: 'get_database_statistics', arguments: {} }); 543 | } 544 | 545 | // Verify all resources are active 546 | expect(resources.servers).toHaveLength(3); 547 | expect(resources.clients).toHaveLength(3); 548 | expect(resources.transports).toHaveLength(3); 549 | 550 | // Clean up all resources in proper order 551 | // 1. Close all clients first 552 | const clientClosePromises = resources.clients.map(async (client, index) => { 553 | const timeout = setTimeout(() => { 554 | console.warn(`Client ${index} close timeout`); 555 | }, 1000); 556 | 557 | try { 558 | await client.close(); 559 | clearTimeout(timeout); 560 | } catch (error) { 561 | clearTimeout(timeout); 562 | console.warn(`Error closing client ${index}:`, error); 563 | } 564 | }); 565 | 566 | await Promise.allSettled(clientClosePromises); 567 | await new Promise(resolve => setTimeout(resolve, 100)); 568 | 569 | // 2. Close all servers 570 | const serverClosePromises = resources.servers.map(async (server, index) => { 571 | const timeout = setTimeout(() => { 572 | console.warn(`Server ${index} close timeout`); 573 | }, 1000); 574 | 575 | try { 576 | await server.close(); 577 | clearTimeout(timeout); 578 | } catch (error) { 579 | clearTimeout(timeout); 580 | console.warn(`Error closing server ${index}:`, error); 581 | } 582 | }); 583 | 584 | await Promise.allSettled(serverClosePromises); 585 | 586 | // 3. Verify cleanup by attempting operations (should fail) 587 | for (let i = 0; i < resources.clients.length; i++) { 588 | try { 589 | await resources.clients[i].callTool({ name: 'get_database_statistics', arguments: {} }); 590 | expect.fail('Client should be closed'); 591 | } catch (error) { 592 | // Expected - client is closed 593 | expect(error).toBeDefined(); 594 | } 595 | } 596 | 597 | // Test passed - all resources cleaned up properly 598 | expect(true).toBe(true); 599 | } finally { 600 | clearTimeout(testTimeout); 601 | 602 | // Final cleanup attempt for any remaining resources 603 | const finalCleanup = setTimeout(() => { 604 | console.warn('Final cleanup timeout'); 605 | }, 2000); 606 | 607 | try { 608 | await Promise.allSettled([ 609 | ...resources.clients.map(c => c.close().catch(() => {})), 610 | ...resources.servers.map(s => s.close().catch(() => {})) 611 | ]); 612 | clearTimeout(finalCleanup); 613 | } catch (error) { 614 | clearTimeout(finalCleanup); 615 | console.warn('Final cleanup error:', error); 616 | } 617 | } 618 | }); 619 | }); 620 | 621 | describe('Session Transport Events', () => { 622 | it('should handle transport reconnection', async () => { 623 | const testTimeout = setTimeout(() => { 624 | console.error('Test timeout - possible deadlock in transport reconnection'); 625 | throw new Error('Test timeout after 10 seconds'); 626 | }, 10000); 627 | 628 | let mcpServer: TestableN8NMCPServer | null = null; 629 | let client: Client | null = null; 630 | let newClient: Client | null = null; 631 | 632 | try { 633 | // Initial connection 634 | mcpServer = new TestableN8NMCPServer(); 635 | await mcpServer.initialize(); 636 | 637 | const [st1, ct1] = InMemoryTransport.createLinkedPair(); 638 | await mcpServer.connectToTransport(st1); 639 | 640 | client = new Client({ 641 | name: 'reconnect-client', 642 | version: '1.0.0' 643 | }, {}); 644 | 645 | await client.connect(ct1); 646 | 647 | // Initial request 648 | const response1 = await client.callTool({ name: 'get_database_statistics', arguments: {} }); 649 | expect(response1).toBeDefined(); 650 | 651 | // Close first client 652 | await client.close(); 653 | await new Promise(resolve => setTimeout(resolve, 100)); // Ensure full cleanup 654 | 655 | // New connection with same server 656 | const [st2, ct2] = InMemoryTransport.createLinkedPair(); 657 | 658 | const connectTimeout = setTimeout(() => { 659 | throw new Error('Second connection timeout'); 660 | }, 3000); 661 | 662 | try { 663 | await mcpServer.connectToTransport(st2); 664 | clearTimeout(connectTimeout); 665 | } catch (error) { 666 | clearTimeout(connectTimeout); 667 | throw error; 668 | } 669 | 670 | newClient = new Client({ 671 | name: 'reconnect-client-2', 672 | version: '1.0.0' 673 | }, {}); 674 | 675 | await newClient.connect(ct2); 676 | 677 | // Should work normally 678 | const callTimeout = setTimeout(() => { 679 | throw new Error('Second call timeout'); 680 | }, 3000); 681 | 682 | try { 683 | const response2 = await newClient.callTool({ name: 'get_database_statistics', arguments: {} }); 684 | clearTimeout(callTimeout); 685 | expect(response2).toBeDefined(); 686 | } catch (error) { 687 | clearTimeout(callTimeout); 688 | throw error; 689 | } 690 | } finally { 691 | clearTimeout(testTimeout); 692 | 693 | // Cleanup with timeout protection 694 | const cleanupTimeout = setTimeout(() => { 695 | console.warn('Cleanup timeout - forcing exit'); 696 | }, 2000); 697 | 698 | try { 699 | if (newClient) { 700 | await newClient.close().catch(e => console.warn('Error closing new client:', e)); 701 | } 702 | await new Promise(resolve => setTimeout(resolve, 100)); 703 | 704 | if (mcpServer) { 705 | await mcpServer.close().catch(e => console.warn('Error closing server:', e)); 706 | } 707 | clearTimeout(cleanupTimeout); 708 | } catch (error) { 709 | clearTimeout(cleanupTimeout); 710 | console.warn('Cleanup error:', error); 711 | } 712 | } 713 | }); 714 | }); 715 | }); ``` -------------------------------------------------------------------------------- /src/utils/enhanced-documentation-fetcher.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { promises as fs } from 'fs'; 2 | import path from 'path'; 3 | import { logger } from './logger'; 4 | import { spawnSync } from 'child_process'; 5 | 6 | // Enhanced documentation structure with rich content 7 | export interface EnhancedNodeDocumentation { 8 | markdown: string; 9 | url: string; 10 | title?: string; 11 | description?: string; 12 | operations?: OperationInfo[]; 13 | apiMethods?: ApiMethodMapping[]; 14 | examples?: CodeExample[]; 15 | templates?: TemplateInfo[]; 16 | relatedResources?: RelatedResource[]; 17 | requiredScopes?: string[]; 18 | metadata?: DocumentationMetadata; 19 | } 20 | 21 | export interface OperationInfo { 22 | resource: string; 23 | operation: string; 24 | description: string; 25 | subOperations?: string[]; 26 | } 27 | 28 | export interface ApiMethodMapping { 29 | resource: string; 30 | operation: string; 31 | apiMethod: string; 32 | apiUrl: string; 33 | } 34 | 35 | export interface CodeExample { 36 | title?: string; 37 | description?: string; 38 | type: 'json' | 'javascript' | 'yaml' | 'text'; 39 | code: string; 40 | language?: string; 41 | } 42 | 43 | export interface TemplateInfo { 44 | name: string; 45 | description?: string; 46 | url?: string; 47 | } 48 | 49 | export interface RelatedResource { 50 | title: string; 51 | url: string; 52 | type: 'documentation' | 'api' | 'tutorial' | 'external'; 53 | } 54 | 55 | export interface DocumentationMetadata { 56 | contentType?: string[]; 57 | priority?: string; 58 | tags?: string[]; 59 | lastUpdated?: Date; 60 | } 61 | 62 | export class EnhancedDocumentationFetcher { 63 | private docsPath: string; 64 | private readonly docsRepoUrl = 'https://github.com/n8n-io/n8n-docs.git'; 65 | private cloned = false; 66 | 67 | constructor(docsPath?: string) { 68 | // SECURITY: Validate and sanitize docsPath to prevent command injection 69 | // See: https://github.com/czlonkowski/n8n-mcp/issues/265 (CRITICAL-01 Part 2) 70 | const defaultPath = path.join(__dirname, '../../temp', 'n8n-docs'); 71 | 72 | if (!docsPath) { 73 | this.docsPath = defaultPath; 74 | } else { 75 | // SECURITY: Block directory traversal and malicious paths 76 | const sanitized = this.sanitizePath(docsPath); 77 | 78 | if (!sanitized) { 79 | logger.error('Invalid docsPath rejected in constructor', { docsPath }); 80 | throw new Error('Invalid docsPath: path contains disallowed characters or patterns'); 81 | } 82 | 83 | // SECURITY: Verify path is absolute and within allowed boundaries 84 | const absolutePath = path.resolve(sanitized); 85 | 86 | // Block paths that could escape to sensitive directories 87 | if (absolutePath.startsWith('/etc') || 88 | absolutePath.startsWith('/sys') || 89 | absolutePath.startsWith('/proc') || 90 | absolutePath.startsWith('/var/log')) { 91 | logger.error('docsPath points to system directory - blocked', { docsPath, absolutePath }); 92 | throw new Error('Invalid docsPath: cannot use system directories'); 93 | } 94 | 95 | this.docsPath = absolutePath; 96 | logger.info('docsPath validated and set', { docsPath: this.docsPath }); 97 | } 98 | 99 | // SECURITY: Validate repository URL is HTTPS 100 | if (!this.docsRepoUrl.startsWith('https://')) { 101 | logger.error('docsRepoUrl must use HTTPS protocol', { url: this.docsRepoUrl }); 102 | throw new Error('Invalid repository URL: must use HTTPS protocol'); 103 | } 104 | } 105 | 106 | /** 107 | * Sanitize path input to prevent command injection and directory traversal 108 | * SECURITY: Part of fix for command injection vulnerability 109 | */ 110 | private sanitizePath(inputPath: string): string | null { 111 | // SECURITY: Reject paths containing any shell metacharacters or control characters 112 | // This prevents command injection even before attempting to sanitize 113 | const dangerousChars = /[;&|`$(){}[\]<>'"\\#\n\r\t]/; 114 | if (dangerousChars.test(inputPath)) { 115 | logger.warn('Path contains shell metacharacters - rejected', { path: inputPath }); 116 | return null; 117 | } 118 | 119 | // Block directory traversal attempts 120 | if (inputPath.includes('..') || inputPath.startsWith('.')) { 121 | logger.warn('Path traversal attempt blocked', { path: inputPath }); 122 | return null; 123 | } 124 | 125 | return inputPath; 126 | } 127 | 128 | /** 129 | * Clone or update the n8n-docs repository 130 | * SECURITY: Uses spawnSync with argument arrays to prevent command injection 131 | * See: https://github.com/czlonkowski/n8n-mcp/issues/265 (CRITICAL-01 Part 2) 132 | */ 133 | async ensureDocsRepository(): Promise<void> { 134 | try { 135 | const exists = await fs.access(this.docsPath).then(() => true).catch(() => false); 136 | 137 | if (!exists) { 138 | logger.info('Cloning n8n-docs repository...', { 139 | url: this.docsRepoUrl, 140 | path: this.docsPath 141 | }); 142 | await fs.mkdir(path.dirname(this.docsPath), { recursive: true }); 143 | 144 | // SECURITY: Use spawnSync with argument array instead of string interpolation 145 | // This prevents command injection even if docsPath or docsRepoUrl are compromised 146 | const cloneResult = spawnSync('git', [ 147 | 'clone', 148 | '--depth', '1', 149 | this.docsRepoUrl, 150 | this.docsPath 151 | ], { 152 | stdio: 'pipe', 153 | encoding: 'utf-8' 154 | }); 155 | 156 | if (cloneResult.status !== 0) { 157 | const error = cloneResult.stderr || cloneResult.error?.message || 'Unknown error'; 158 | logger.error('Git clone failed', { 159 | status: cloneResult.status, 160 | stderr: error, 161 | url: this.docsRepoUrl, 162 | path: this.docsPath 163 | }); 164 | throw new Error(`Git clone failed: ${error}`); 165 | } 166 | 167 | logger.info('n8n-docs repository cloned successfully'); 168 | } else { 169 | logger.info('Updating n8n-docs repository...', { path: this.docsPath }); 170 | 171 | // SECURITY: Use spawnSync with argument array and cwd option 172 | const pullResult = spawnSync('git', [ 173 | 'pull', 174 | '--ff-only' 175 | ], { 176 | cwd: this.docsPath, 177 | stdio: 'pipe', 178 | encoding: 'utf-8' 179 | }); 180 | 181 | if (pullResult.status !== 0) { 182 | const error = pullResult.stderr || pullResult.error?.message || 'Unknown error'; 183 | logger.error('Git pull failed', { 184 | status: pullResult.status, 185 | stderr: error, 186 | cwd: this.docsPath 187 | }); 188 | throw new Error(`Git pull failed: ${error}`); 189 | } 190 | 191 | logger.info('n8n-docs repository updated'); 192 | } 193 | 194 | this.cloned = true; 195 | } catch (error) { 196 | logger.error('Failed to clone/update n8n-docs repository:', error); 197 | throw error; 198 | } 199 | } 200 | 201 | /** 202 | * Get enhanced documentation for a specific node 203 | */ 204 | async getEnhancedNodeDocumentation(nodeType: string): Promise<EnhancedNodeDocumentation | null> { 205 | if (!this.cloned) { 206 | await this.ensureDocsRepository(); 207 | } 208 | 209 | try { 210 | const nodeName = this.extractNodeName(nodeType); 211 | 212 | // Common documentation paths to check 213 | const possiblePaths = [ 214 | path.join(this.docsPath, 'docs', 'integrations', 'builtin', 'app-nodes', `${nodeType}.md`), 215 | path.join(this.docsPath, 'docs', 'integrations', 'builtin', 'core-nodes', `${nodeType}.md`), 216 | path.join(this.docsPath, 'docs', 'integrations', 'builtin', 'trigger-nodes', `${nodeType}.md`), 217 | path.join(this.docsPath, 'docs', 'integrations', 'builtin', 'core-nodes', `${nodeName}.md`), 218 | path.join(this.docsPath, 'docs', 'integrations', 'builtin', 'app-nodes', `${nodeName}.md`), 219 | path.join(this.docsPath, 'docs', 'integrations', 'builtin', 'trigger-nodes', `${nodeName}.md`), 220 | ]; 221 | 222 | for (const docPath of possiblePaths) { 223 | try { 224 | const content = await fs.readFile(docPath, 'utf-8'); 225 | logger.debug(`Checking doc path: ${docPath}`); 226 | 227 | // Skip credential documentation files 228 | if (this.isCredentialDoc(docPath, content)) { 229 | logger.debug(`Skipping credential doc: ${docPath}`); 230 | continue; 231 | } 232 | 233 | logger.info(`Found documentation for ${nodeType} at: ${docPath}`); 234 | return this.parseEnhancedDocumentation(content, docPath); 235 | } catch (error) { 236 | // File doesn't exist, continue 237 | continue; 238 | } 239 | } 240 | 241 | // If no exact match, try to find by searching 242 | logger.debug(`No exact match found, searching for ${nodeType}...`); 243 | const foundPath = await this.searchForNodeDoc(nodeType); 244 | if (foundPath) { 245 | logger.info(`Found documentation via search at: ${foundPath}`); 246 | const content = await fs.readFile(foundPath, 'utf-8'); 247 | 248 | if (!this.isCredentialDoc(foundPath, content)) { 249 | return this.parseEnhancedDocumentation(content, foundPath); 250 | } 251 | } 252 | 253 | logger.warn(`No documentation found for node: ${nodeType}`); 254 | return null; 255 | } catch (error) { 256 | logger.error(`Failed to get documentation for ${nodeType}:`, error); 257 | return null; 258 | } 259 | } 260 | 261 | /** 262 | * Parse markdown content into enhanced documentation structure 263 | */ 264 | private parseEnhancedDocumentation(markdown: string, filePath: string): EnhancedNodeDocumentation { 265 | const doc: EnhancedNodeDocumentation = { 266 | markdown, 267 | url: this.generateDocUrl(filePath), 268 | }; 269 | 270 | // Extract frontmatter metadata 271 | const metadata = this.extractFrontmatter(markdown); 272 | if (metadata) { 273 | doc.metadata = metadata; 274 | doc.title = metadata.title; 275 | doc.description = metadata.description; 276 | } 277 | 278 | // Extract title and description from content if not in frontmatter 279 | if (!doc.title) { 280 | doc.title = this.extractTitle(markdown); 281 | } 282 | if (!doc.description) { 283 | doc.description = this.extractDescription(markdown); 284 | } 285 | 286 | // Extract operations 287 | doc.operations = this.extractOperations(markdown); 288 | 289 | // Extract API method mappings 290 | doc.apiMethods = this.extractApiMethods(markdown); 291 | 292 | // Extract code examples 293 | doc.examples = this.extractCodeExamples(markdown); 294 | 295 | // Extract templates 296 | doc.templates = this.extractTemplates(markdown); 297 | 298 | // Extract related resources 299 | doc.relatedResources = this.extractRelatedResources(markdown); 300 | 301 | // Extract required scopes 302 | doc.requiredScopes = this.extractRequiredScopes(markdown); 303 | 304 | return doc; 305 | } 306 | 307 | /** 308 | * Extract frontmatter metadata 309 | */ 310 | private extractFrontmatter(markdown: string): any { 311 | const frontmatterMatch = markdown.match(/^---\n([\s\S]*?)\n---/); 312 | if (!frontmatterMatch) return null; 313 | 314 | const frontmatter: any = {}; 315 | const lines = frontmatterMatch[1].split('\n'); 316 | 317 | for (const line of lines) { 318 | if (line.includes(':')) { 319 | const [key, ...valueParts] = line.split(':'); 320 | const value = valueParts.join(':').trim(); 321 | 322 | // Parse arrays 323 | if (value.startsWith('[') && value.endsWith(']')) { 324 | frontmatter[key.trim()] = value 325 | .slice(1, -1) 326 | .split(',') 327 | .map(v => v.trim()); 328 | } else { 329 | frontmatter[key.trim()] = value; 330 | } 331 | } 332 | } 333 | 334 | return frontmatter; 335 | } 336 | 337 | /** 338 | * Extract title from markdown 339 | */ 340 | private extractTitle(markdown: string): string | undefined { 341 | const match = markdown.match(/^#\s+(.+)$/m); 342 | return match ? match[1].trim() : undefined; 343 | } 344 | 345 | /** 346 | * Extract description from markdown 347 | */ 348 | private extractDescription(markdown: string): string | undefined { 349 | // Remove frontmatter 350 | const content = markdown.replace(/^---[\s\S]*?---\n/, ''); 351 | 352 | // Find first paragraph after title 353 | const lines = content.split('\n'); 354 | let foundTitle = false; 355 | let description = ''; 356 | 357 | for (const line of lines) { 358 | if (line.startsWith('#')) { 359 | foundTitle = true; 360 | continue; 361 | } 362 | 363 | if (foundTitle && line.trim() && !line.startsWith('#') && !line.startsWith('*') && !line.startsWith('-')) { 364 | description = line.trim(); 365 | break; 366 | } 367 | } 368 | 369 | return description || undefined; 370 | } 371 | 372 | /** 373 | * Extract operations from markdown 374 | */ 375 | private extractOperations(markdown: string): OperationInfo[] { 376 | const operations: OperationInfo[] = []; 377 | 378 | // Find operations section 379 | const operationsMatch = markdown.match(/##\s+Operations\s*\n([\s\S]*?)(?=\n##|\n#|$)/i); 380 | if (!operationsMatch) return operations; 381 | 382 | const operationsText = operationsMatch[1]; 383 | 384 | // Parse operation structure - handle nested bullet points 385 | let currentResource: string | null = null; 386 | const lines = operationsText.split('\n'); 387 | 388 | for (const line of lines) { 389 | const trimmedLine = line.trim(); 390 | 391 | // Skip empty lines 392 | if (!trimmedLine) continue; 393 | 394 | // Resource level - non-indented bullet with bold text (e.g., "* **Channel**") 395 | if (line.match(/^\*\s+\*\*[^*]+\*\*\s*$/) && !line.match(/^\s+/)) { 396 | const match = trimmedLine.match(/^\*\s+\*\*([^*]+)\*\*/); 397 | if (match) { 398 | currentResource = match[1].trim(); 399 | } 400 | continue; 401 | } 402 | 403 | // Skip if we don't have a current resource 404 | if (!currentResource) continue; 405 | 406 | // Operation level - indented bullets (any whitespace + *) 407 | if (line.match(/^\s+\*\s+/) && currentResource) { 408 | // Extract operation name and description 409 | const operationMatch = trimmedLine.match(/^\*\s+\*\*([^*]+)\*\*(.*)$/); 410 | if (operationMatch) { 411 | const operation = operationMatch[1].trim(); 412 | let description = operationMatch[2].trim(); 413 | 414 | // Clean up description 415 | description = description.replace(/^:\s*/, '').replace(/\.$/, '').trim(); 416 | 417 | operations.push({ 418 | resource: currentResource, 419 | operation, 420 | description: description || operation, 421 | }); 422 | } else { 423 | // Handle operations without bold formatting or with different format 424 | const simpleMatch = trimmedLine.match(/^\*\s+(.+)$/); 425 | if (simpleMatch) { 426 | const text = simpleMatch[1].trim(); 427 | // Split by colon to separate operation from description 428 | const colonIndex = text.indexOf(':'); 429 | if (colonIndex > 0) { 430 | operations.push({ 431 | resource: currentResource, 432 | operation: text.substring(0, colonIndex).trim(), 433 | description: text.substring(colonIndex + 1).trim() || text, 434 | }); 435 | } else { 436 | operations.push({ 437 | resource: currentResource, 438 | operation: text, 439 | description: text, 440 | }); 441 | } 442 | } 443 | } 444 | } 445 | } 446 | 447 | return operations; 448 | } 449 | 450 | /** 451 | * Extract API method mappings from markdown tables 452 | */ 453 | private extractApiMethods(markdown: string): ApiMethodMapping[] { 454 | const apiMethods: ApiMethodMapping[] = []; 455 | 456 | // Find API method tables 457 | const tableRegex = /\|.*Resource.*\|.*Operation.*\|.*(?:Slack API method|API method|Method).*\|[\s\S]*?\n(?=\n[^|]|$)/gi; 458 | const tables = markdown.match(tableRegex); 459 | 460 | if (!tables) return apiMethods; 461 | 462 | for (const table of tables) { 463 | const rows = table.split('\n').filter(row => row.trim() && !row.includes('---')); 464 | 465 | // Skip header row 466 | for (let i = 1; i < rows.length; i++) { 467 | const cells = rows[i].split('|').map(cell => cell.trim()).filter(Boolean); 468 | 469 | if (cells.length >= 3) { 470 | const resource = cells[0]; 471 | const operation = cells[1]; 472 | const apiMethodCell = cells[2]; 473 | 474 | // Extract API method and URL from markdown link 475 | const linkMatch = apiMethodCell.match(/\[([^\]]+)\]\(([^)]+)\)/); 476 | 477 | if (linkMatch) { 478 | apiMethods.push({ 479 | resource, 480 | operation, 481 | apiMethod: linkMatch[1], 482 | apiUrl: linkMatch[2], 483 | }); 484 | } else { 485 | apiMethods.push({ 486 | resource, 487 | operation, 488 | apiMethod: apiMethodCell, 489 | apiUrl: '', 490 | }); 491 | } 492 | } 493 | } 494 | } 495 | 496 | return apiMethods; 497 | } 498 | 499 | /** 500 | * Extract code examples from markdown 501 | */ 502 | private extractCodeExamples(markdown: string): CodeExample[] { 503 | const examples: CodeExample[] = []; 504 | 505 | // Extract all code blocks with language 506 | const codeBlockRegex = /```(\w+)?\n([\s\S]*?)```/g; 507 | let match; 508 | 509 | while ((match = codeBlockRegex.exec(markdown)) !== null) { 510 | const language = match[1] || 'text'; 511 | const code = match[2].trim(); 512 | 513 | // Look for title or description before the code block 514 | const beforeCodeIndex = match.index; 515 | const beforeText = markdown.substring(Math.max(0, beforeCodeIndex - 200), beforeCodeIndex); 516 | const titleMatch = beforeText.match(/(?:###|####)\s+(.+)$/m); 517 | 518 | const example: CodeExample = { 519 | type: this.mapLanguageToType(language), 520 | language, 521 | code, 522 | }; 523 | 524 | if (titleMatch) { 525 | example.title = titleMatch[1].trim(); 526 | } 527 | 528 | // Try to parse JSON examples 529 | if (language === 'json') { 530 | try { 531 | JSON.parse(code); 532 | examples.push(example); 533 | } catch (e) { 534 | // Skip invalid JSON 535 | } 536 | } else { 537 | examples.push(example); 538 | } 539 | } 540 | 541 | return examples; 542 | } 543 | 544 | /** 545 | * Extract template information 546 | */ 547 | private extractTemplates(markdown: string): TemplateInfo[] { 548 | const templates: TemplateInfo[] = []; 549 | 550 | // Look for template widget 551 | const templateWidgetMatch = markdown.match(/\[\[\s*templatesWidget\s*\(\s*[^,]+,\s*'([^']+)'\s*\)\s*\]\]/); 552 | if (templateWidgetMatch) { 553 | templates.push({ 554 | name: templateWidgetMatch[1], 555 | description: `Templates for ${templateWidgetMatch[1]}`, 556 | }); 557 | } 558 | 559 | return templates; 560 | } 561 | 562 | /** 563 | * Extract related resources 564 | */ 565 | private extractRelatedResources(markdown: string): RelatedResource[] { 566 | const resources: RelatedResource[] = []; 567 | 568 | // Find related resources section 569 | const relatedMatch = markdown.match(/##\s+(?:Related resources|Related|Resources)\s*\n([\s\S]*?)(?=\n##|\n#|$)/i); 570 | if (!relatedMatch) return resources; 571 | 572 | const relatedText = relatedMatch[1]; 573 | 574 | // Extract links 575 | const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; 576 | let match; 577 | 578 | while ((match = linkRegex.exec(relatedText)) !== null) { 579 | const title = match[1]; 580 | const url = match[2]; 581 | 582 | // Determine resource type 583 | let type: RelatedResource['type'] = 'external'; 584 | if (url.includes('docs.n8n.io') || url.startsWith('/')) { 585 | type = 'documentation'; 586 | } else if (url.includes('api.')) { 587 | type = 'api'; 588 | } 589 | 590 | resources.push({ title, url, type }); 591 | } 592 | 593 | return resources; 594 | } 595 | 596 | /** 597 | * Extract required scopes 598 | */ 599 | private extractRequiredScopes(markdown: string): string[] { 600 | const scopes: string[] = []; 601 | 602 | // Find required scopes section 603 | const scopesMatch = markdown.match(/##\s+(?:Required scopes|Scopes)\s*\n([\s\S]*?)(?=\n##|\n#|$)/i); 604 | if (!scopesMatch) return scopes; 605 | 606 | const scopesText = scopesMatch[1]; 607 | 608 | // Extract scope patterns (common formats) 609 | const scopeRegex = /`([a-z:._-]+)`/gi; 610 | let match; 611 | 612 | while ((match = scopeRegex.exec(scopesText)) !== null) { 613 | const scope = match[1]; 614 | if (scope.includes(':') || scope.includes('.')) { 615 | scopes.push(scope); 616 | } 617 | } 618 | 619 | return [...new Set(scopes)]; // Remove duplicates 620 | } 621 | 622 | /** 623 | * Map language to code example type 624 | */ 625 | private mapLanguageToType(language: string): CodeExample['type'] { 626 | switch (language.toLowerCase()) { 627 | case 'json': 628 | return 'json'; 629 | case 'js': 630 | case 'javascript': 631 | case 'typescript': 632 | case 'ts': 633 | return 'javascript'; 634 | case 'yaml': 635 | case 'yml': 636 | return 'yaml'; 637 | default: 638 | return 'text'; 639 | } 640 | } 641 | 642 | /** 643 | * Check if this is a credential documentation 644 | */ 645 | private isCredentialDoc(filePath: string, content: string): boolean { 646 | return filePath.includes('/credentials/') || 647 | (content.includes('title: ') && 648 | content.includes(' credentials') && 649 | !content.includes(' node documentation')); 650 | } 651 | 652 | /** 653 | * Extract node name from node type 654 | */ 655 | private extractNodeName(nodeType: string): string { 656 | const parts = nodeType.split('.'); 657 | const name = parts[parts.length - 1]; 658 | return name.toLowerCase(); 659 | } 660 | 661 | /** 662 | * Search for node documentation file 663 | * SECURITY: Uses Node.js fs APIs instead of shell commands to prevent command injection 664 | * See: https://github.com/czlonkowski/n8n-mcp/issues/265 (CRITICAL-01) 665 | */ 666 | private async searchForNodeDoc(nodeType: string): Promise<string | null> { 667 | try { 668 | // SECURITY: Sanitize input to prevent command injection and directory traversal 669 | const sanitized = nodeType.replace(/[^a-zA-Z0-9._-]/g, ''); 670 | 671 | if (!sanitized) { 672 | logger.warn('Invalid nodeType after sanitization', { nodeType }); 673 | return null; 674 | } 675 | 676 | // SECURITY: Block directory traversal attacks 677 | if (sanitized.includes('..') || sanitized.startsWith('.') || sanitized.startsWith('/')) { 678 | logger.warn('Path traversal attempt blocked', { nodeType, sanitized }); 679 | return null; 680 | } 681 | 682 | // Log sanitization if it occurred 683 | if (sanitized !== nodeType) { 684 | logger.warn('nodeType was sanitized (potential injection attempt)', { 685 | original: nodeType, 686 | sanitized, 687 | }); 688 | } 689 | 690 | // SECURITY: Use path.basename to strip any path components 691 | const safeName = path.basename(sanitized); 692 | const searchPath = path.join(this.docsPath, 'docs', 'integrations', 'builtin'); 693 | 694 | // SECURITY: Read directory recursively using Node.js fs API (no shell execution!) 695 | const files = await fs.readdir(searchPath, { 696 | recursive: true, 697 | encoding: 'utf-8' 698 | }) as string[]; 699 | 700 | // Try exact match first 701 | let match = files.find(f => 702 | f.endsWith(`${safeName}.md`) && 703 | !f.includes('credentials') && 704 | !f.includes('trigger') 705 | ); 706 | 707 | if (match) { 708 | const fullPath = path.join(searchPath, match); 709 | 710 | // SECURITY: Verify final path is within expected directory 711 | if (!fullPath.startsWith(searchPath)) { 712 | logger.error('Path traversal blocked in final path', { fullPath, searchPath }); 713 | return null; 714 | } 715 | 716 | logger.info('Found documentation (exact match)', { path: fullPath }); 717 | return fullPath; 718 | } 719 | 720 | // Try lowercase match 721 | const lowerSafeName = safeName.toLowerCase(); 722 | match = files.find(f => 723 | f.endsWith(`${lowerSafeName}.md`) && 724 | !f.includes('credentials') && 725 | !f.includes('trigger') 726 | ); 727 | 728 | if (match) { 729 | const fullPath = path.join(searchPath, match); 730 | 731 | // SECURITY: Verify final path is within expected directory 732 | if (!fullPath.startsWith(searchPath)) { 733 | logger.error('Path traversal blocked in final path', { fullPath, searchPath }); 734 | return null; 735 | } 736 | 737 | logger.info('Found documentation (lowercase match)', { path: fullPath }); 738 | return fullPath; 739 | } 740 | 741 | // Try partial match with node name 742 | const nodeName = this.extractNodeName(safeName); 743 | match = files.find(f => 744 | f.toLowerCase().includes(nodeName.toLowerCase()) && 745 | f.endsWith('.md') && 746 | !f.includes('credentials') && 747 | !f.includes('trigger') 748 | ); 749 | 750 | if (match) { 751 | const fullPath = path.join(searchPath, match); 752 | 753 | // SECURITY: Verify final path is within expected directory 754 | if (!fullPath.startsWith(searchPath)) { 755 | logger.error('Path traversal blocked in final path', { fullPath, searchPath }); 756 | return null; 757 | } 758 | 759 | logger.info('Found documentation (partial match)', { path: fullPath }); 760 | return fullPath; 761 | } 762 | 763 | logger.debug('No documentation found', { nodeType: safeName }); 764 | return null; 765 | } catch (error) { 766 | logger.error('Error searching for node documentation:', { 767 | error: error instanceof Error ? error.message : String(error), 768 | nodeType, 769 | }); 770 | return null; 771 | } 772 | } 773 | 774 | /** 775 | * Generate documentation URL from file path 776 | */ 777 | private generateDocUrl(filePath: string): string { 778 | const relativePath = path.relative(this.docsPath, filePath); 779 | const urlPath = relativePath 780 | .replace(/^docs\//, '') 781 | .replace(/\.md$/, '') 782 | .replace(/\\/g, '/'); 783 | 784 | return `https://docs.n8n.io/${urlPath}`; 785 | } 786 | 787 | /** 788 | * Clean up cloned repository 789 | */ 790 | async cleanup(): Promise<void> { 791 | try { 792 | await fs.rm(this.docsPath, { recursive: true, force: true }); 793 | this.cloned = false; 794 | logger.info('Cleaned up documentation repository'); 795 | } catch (error) { 796 | logger.error('Failed to cleanup docs repository:', error); 797 | } 798 | } 799 | } ``` -------------------------------------------------------------------------------- /tests/unit/mcp/parameter-validation.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; 2 | import { N8NDocumentationMCPServer } from '../../../src/mcp/server'; 3 | 4 | // Mock the database and dependencies 5 | vi.mock('../../../src/database/database-adapter'); 6 | vi.mock('../../../src/database/node-repository'); 7 | vi.mock('../../../src/templates/template-service'); 8 | vi.mock('../../../src/utils/logger'); 9 | 10 | class TestableN8NMCPServer extends N8NDocumentationMCPServer { 11 | // Expose the private validateToolParams method for testing 12 | public testValidateToolParams(toolName: string, args: any, requiredParams: string[]): void { 13 | return (this as any).validateToolParams(toolName, args, requiredParams); 14 | } 15 | 16 | // Expose the private executeTool method for testing 17 | public async testExecuteTool(name: string, args: any): Promise<any> { 18 | return (this as any).executeTool(name, args); 19 | } 20 | } 21 | 22 | describe('Parameter Validation', () => { 23 | let server: TestableN8NMCPServer; 24 | 25 | beforeEach(() => { 26 | // Set environment variable to use in-memory database 27 | process.env.NODE_DB_PATH = ':memory:'; 28 | server = new TestableN8NMCPServer(); 29 | }); 30 | 31 | afterEach(() => { 32 | delete process.env.NODE_DB_PATH; 33 | }); 34 | 35 | describe('validateToolParams', () => { 36 | describe('Basic Parameter Validation', () => { 37 | it('should pass validation when all required parameters are provided', () => { 38 | const args = { nodeType: 'nodes-base.httpRequest', config: {} }; 39 | 40 | expect(() => { 41 | server.testValidateToolParams('test_tool', args, ['nodeType', 'config']); 42 | }).not.toThrow(); 43 | }); 44 | 45 | it('should throw error when required parameter is missing', () => { 46 | const args = { config: {} }; 47 | 48 | expect(() => { 49 | server.testValidateToolParams('test_tool', args, ['nodeType', 'config']); 50 | }).toThrow('Missing required parameters for test_tool: nodeType'); 51 | }); 52 | 53 | it('should throw error when multiple required parameters are missing', () => { 54 | const args = {}; 55 | 56 | expect(() => { 57 | server.testValidateToolParams('test_tool', args, ['nodeType', 'config', 'query']); 58 | }).toThrow('Missing required parameters for test_tool: nodeType, config, query'); 59 | }); 60 | 61 | it('should throw error when required parameter is undefined', () => { 62 | const args = { nodeType: undefined, config: {} }; 63 | 64 | expect(() => { 65 | server.testValidateToolParams('test_tool', args, ['nodeType', 'config']); 66 | }).toThrow('Missing required parameters for test_tool: nodeType'); 67 | }); 68 | 69 | it('should throw error when required parameter is null', () => { 70 | const args = { nodeType: null, config: {} }; 71 | 72 | expect(() => { 73 | server.testValidateToolParams('test_tool', args, ['nodeType', 'config']); 74 | }).toThrow('Missing required parameters for test_tool: nodeType'); 75 | }); 76 | 77 | it('should reject when required parameter is empty string (Issue #275 fix)', () => { 78 | const args = { query: '', limit: 10 }; 79 | 80 | expect(() => { 81 | server.testValidateToolParams('test_tool', args, ['query']); 82 | }).toThrow('String parameters cannot be empty'); 83 | }); 84 | 85 | it('should pass when required parameter is zero', () => { 86 | const args = { limit: 0, query: 'test' }; 87 | 88 | expect(() => { 89 | server.testValidateToolParams('test_tool', args, ['limit']); 90 | }).not.toThrow(); 91 | }); 92 | 93 | it('should pass when required parameter is false', () => { 94 | const args = { includeData: false, id: '123' }; 95 | 96 | expect(() => { 97 | server.testValidateToolParams('test_tool', args, ['includeData']); 98 | }).not.toThrow(); 99 | }); 100 | }); 101 | 102 | describe('Edge Cases', () => { 103 | it('should handle empty args object', () => { 104 | expect(() => { 105 | server.testValidateToolParams('test_tool', {}, ['param1']); 106 | }).toThrow('Missing required parameters for test_tool: param1'); 107 | }); 108 | 109 | it('should handle null args', () => { 110 | expect(() => { 111 | server.testValidateToolParams('test_tool', null, ['param1']); 112 | }).toThrow(); 113 | }); 114 | 115 | it('should handle undefined args', () => { 116 | expect(() => { 117 | server.testValidateToolParams('test_tool', undefined, ['param1']); 118 | }).toThrow(); 119 | }); 120 | 121 | it('should pass when no required parameters are specified', () => { 122 | const args = { optionalParam: 'value' }; 123 | 124 | expect(() => { 125 | server.testValidateToolParams('test_tool', args, []); 126 | }).not.toThrow(); 127 | }); 128 | 129 | it('should handle special characters in parameter names', () => { 130 | const args = { 'param-with-dash': 'value', 'param_with_underscore': 'value' }; 131 | 132 | expect(() => { 133 | server.testValidateToolParams('test_tool', args, ['param-with-dash', 'param_with_underscore']); 134 | }).not.toThrow(); 135 | }); 136 | }); 137 | }); 138 | 139 | describe('Tool-Specific Parameter Validation', () => { 140 | // Mock the actual tool methods to avoid database calls 141 | beforeEach(() => { 142 | // Mock all the tool methods that would be called 143 | vi.spyOn(server as any, 'getNodeInfo').mockResolvedValue({ mockResult: true }); 144 | vi.spyOn(server as any, 'searchNodes').mockResolvedValue({ results: [] }); 145 | vi.spyOn(server as any, 'getNodeDocumentation').mockResolvedValue({ docs: 'test' }); 146 | vi.spyOn(server as any, 'getNodeEssentials').mockResolvedValue({ essentials: true }); 147 | vi.spyOn(server as any, 'searchNodeProperties').mockResolvedValue({ properties: [] }); 148 | // Note: getNodeForTask removed in v2.15.0 149 | vi.spyOn(server as any, 'validateNodeConfig').mockResolvedValue({ valid: true }); 150 | vi.spyOn(server as any, 'validateNodeMinimal').mockResolvedValue({ missing: [] }); 151 | vi.spyOn(server as any, 'getPropertyDependencies').mockResolvedValue({ dependencies: {} }); 152 | vi.spyOn(server as any, 'getNodeAsToolInfo').mockResolvedValue({ toolInfo: true }); 153 | vi.spyOn(server as any, 'listNodeTemplates').mockResolvedValue({ templates: [] }); 154 | vi.spyOn(server as any, 'getTemplate').mockResolvedValue({ template: {} }); 155 | vi.spyOn(server as any, 'searchTemplates').mockResolvedValue({ templates: [] }); 156 | vi.spyOn(server as any, 'getTemplatesForTask').mockResolvedValue({ templates: [] }); 157 | vi.spyOn(server as any, 'validateWorkflow').mockResolvedValue({ valid: true }); 158 | vi.spyOn(server as any, 'validateWorkflowConnections').mockResolvedValue({ valid: true }); 159 | vi.spyOn(server as any, 'validateWorkflowExpressions').mockResolvedValue({ valid: true }); 160 | }); 161 | 162 | describe('get_node_info', () => { 163 | it('should require nodeType parameter', async () => { 164 | await expect(server.testExecuteTool('get_node_info', {})) 165 | .rejects.toThrow('Missing required parameters for get_node_info: nodeType'); 166 | }); 167 | 168 | it('should succeed with valid nodeType', async () => { 169 | const result = await server.testExecuteTool('get_node_info', { 170 | nodeType: 'nodes-base.httpRequest' 171 | }); 172 | expect(result).toEqual({ mockResult: true }); 173 | }); 174 | }); 175 | 176 | describe('search_nodes', () => { 177 | it('should require query parameter', async () => { 178 | await expect(server.testExecuteTool('search_nodes', {})) 179 | .rejects.toThrow('search_nodes: Validation failed:\n • query: query is required'); 180 | }); 181 | 182 | it('should succeed with valid query', async () => { 183 | const result = await server.testExecuteTool('search_nodes', { 184 | query: 'http' 185 | }); 186 | expect(result).toEqual({ results: [] }); 187 | }); 188 | 189 | it('should handle optional limit parameter', async () => { 190 | const result = await server.testExecuteTool('search_nodes', { 191 | query: 'http', 192 | limit: 10 193 | }); 194 | expect(result).toEqual({ results: [] }); 195 | }); 196 | 197 | it('should reject invalid limit value', async () => { 198 | await expect(server.testExecuteTool('search_nodes', { 199 | query: 'http', 200 | limit: 'invalid' 201 | })).rejects.toThrow('search_nodes: Validation failed:\n • limit: limit must be a number, got string'); 202 | }); 203 | }); 204 | 205 | describe('validate_node_operation', () => { 206 | it('should require nodeType and config parameters', async () => { 207 | await expect(server.testExecuteTool('validate_node_operation', {})) 208 | .rejects.toThrow('validate_node_operation: Validation failed:\n • nodeType: nodeType is required\n • config: config is required'); 209 | }); 210 | 211 | it('should require nodeType parameter when config is provided', async () => { 212 | await expect(server.testExecuteTool('validate_node_operation', { config: {} })) 213 | .rejects.toThrow('validate_node_operation: Validation failed:\n • nodeType: nodeType is required'); 214 | }); 215 | 216 | it('should require config parameter when nodeType is provided', async () => { 217 | await expect(server.testExecuteTool('validate_node_operation', { nodeType: 'nodes-base.httpRequest' })) 218 | .rejects.toThrow('validate_node_operation: Validation failed:\n • config: config is required'); 219 | }); 220 | 221 | it('should succeed with valid parameters', async () => { 222 | const result = await server.testExecuteTool('validate_node_operation', { 223 | nodeType: 'nodes-base.httpRequest', 224 | config: { method: 'GET', url: 'https://api.example.com' } 225 | }); 226 | expect(result).toEqual({ valid: true }); 227 | }); 228 | }); 229 | 230 | describe('search_node_properties', () => { 231 | it('should require nodeType and query parameters', async () => { 232 | await expect(server.testExecuteTool('search_node_properties', {})) 233 | .rejects.toThrow('Missing required parameters for search_node_properties: nodeType, query'); 234 | }); 235 | 236 | it('should succeed with valid parameters', async () => { 237 | const result = await server.testExecuteTool('search_node_properties', { 238 | nodeType: 'nodes-base.httpRequest', 239 | query: 'auth' 240 | }); 241 | expect(result).toEqual({ properties: [] }); 242 | }); 243 | 244 | it('should handle optional maxResults parameter', async () => { 245 | const result = await server.testExecuteTool('search_node_properties', { 246 | nodeType: 'nodes-base.httpRequest', 247 | query: 'auth', 248 | maxResults: 5 249 | }); 250 | expect(result).toEqual({ properties: [] }); 251 | }); 252 | }); 253 | 254 | describe('list_node_templates', () => { 255 | it('should require nodeTypes parameter', async () => { 256 | await expect(server.testExecuteTool('list_node_templates', {})) 257 | .rejects.toThrow('list_node_templates: Validation failed:\n • nodeTypes: nodeTypes is required'); 258 | }); 259 | 260 | it('should succeed with valid nodeTypes array', async () => { 261 | const result = await server.testExecuteTool('list_node_templates', { 262 | nodeTypes: ['nodes-base.httpRequest', 'nodes-base.slack'] 263 | }); 264 | expect(result).toEqual({ templates: [] }); 265 | }); 266 | }); 267 | 268 | describe('get_template', () => { 269 | it('should require templateId parameter', async () => { 270 | await expect(server.testExecuteTool('get_template', {})) 271 | .rejects.toThrow('Missing required parameters for get_template: templateId'); 272 | }); 273 | 274 | it('should succeed with valid templateId', async () => { 275 | const result = await server.testExecuteTool('get_template', { 276 | templateId: 123 277 | }); 278 | expect(result).toEqual({ template: {} }); 279 | }); 280 | }); 281 | }); 282 | 283 | describe('Numeric Parameter Conversion', () => { 284 | beforeEach(() => { 285 | vi.spyOn(server as any, 'searchNodes').mockResolvedValue({ results: [] }); 286 | vi.spyOn(server as any, 'searchNodeProperties').mockResolvedValue({ properties: [] }); 287 | vi.spyOn(server as any, 'listNodeTemplates').mockResolvedValue({ templates: [] }); 288 | vi.spyOn(server as any, 'getTemplate').mockResolvedValue({ template: {} }); 289 | }); 290 | 291 | describe('limit parameter conversion', () => { 292 | it('should reject string limit values', async () => { 293 | await expect(server.testExecuteTool('search_nodes', { 294 | query: 'test', 295 | limit: '15' 296 | })).rejects.toThrow('search_nodes: Validation failed:\n • limit: limit must be a number, got string'); 297 | }); 298 | 299 | it('should reject invalid string limit values', async () => { 300 | await expect(server.testExecuteTool('search_nodes', { 301 | query: 'test', 302 | limit: 'invalid' 303 | })).rejects.toThrow('search_nodes: Validation failed:\n • limit: limit must be a number, got string'); 304 | }); 305 | 306 | it('should use default when limit is undefined', async () => { 307 | const mockSearchNodes = vi.spyOn(server as any, 'searchNodes'); 308 | 309 | await server.testExecuteTool('search_nodes', { 310 | query: 'test' 311 | }); 312 | 313 | expect(mockSearchNodes).toHaveBeenCalledWith('test', 20, { mode: undefined }); 314 | }); 315 | 316 | it('should reject zero as limit due to minimum constraint', async () => { 317 | await expect(server.testExecuteTool('search_nodes', { 318 | query: 'test', 319 | limit: 0 320 | })).rejects.toThrow('search_nodes: Validation failed:\n • limit: limit must be at least 1, got 0'); 321 | }); 322 | }); 323 | 324 | describe('maxResults parameter conversion', () => { 325 | it('should convert string numbers to numbers', async () => { 326 | const mockSearchNodeProperties = vi.spyOn(server as any, 'searchNodeProperties'); 327 | 328 | await server.testExecuteTool('search_node_properties', { 329 | nodeType: 'nodes-base.httpRequest', 330 | query: 'auth', 331 | maxResults: '5' 332 | }); 333 | 334 | expect(mockSearchNodeProperties).toHaveBeenCalledWith('nodes-base.httpRequest', 'auth', 5); 335 | }); 336 | 337 | it('should use default when maxResults is invalid', async () => { 338 | const mockSearchNodeProperties = vi.spyOn(server as any, 'searchNodeProperties'); 339 | 340 | await server.testExecuteTool('search_node_properties', { 341 | nodeType: 'nodes-base.httpRequest', 342 | query: 'auth', 343 | maxResults: 'invalid' 344 | }); 345 | 346 | expect(mockSearchNodeProperties).toHaveBeenCalledWith('nodes-base.httpRequest', 'auth', 20); 347 | }); 348 | }); 349 | 350 | describe('templateLimit parameter conversion', () => { 351 | it('should reject string limit values', async () => { 352 | await expect(server.testExecuteTool('list_node_templates', { 353 | nodeTypes: ['nodes-base.httpRequest'], 354 | limit: '5' 355 | })).rejects.toThrow('list_node_templates: Validation failed:\n • limit: limit must be a number, got string'); 356 | }); 357 | 358 | it('should reject invalid string limit values', async () => { 359 | await expect(server.testExecuteTool('list_node_templates', { 360 | nodeTypes: ['nodes-base.httpRequest'], 361 | limit: 'invalid' 362 | })).rejects.toThrow('list_node_templates: Validation failed:\n • limit: limit must be a number, got string'); 363 | }); 364 | }); 365 | 366 | describe('templateId parameter handling', () => { 367 | it('should pass through numeric templateId', async () => { 368 | const mockGetTemplate = vi.spyOn(server as any, 'getTemplate'); 369 | 370 | await server.testExecuteTool('get_template', { 371 | templateId: 123 372 | }); 373 | 374 | expect(mockGetTemplate).toHaveBeenCalledWith(123, 'full'); 375 | }); 376 | 377 | it('should convert string templateId to number', async () => { 378 | const mockGetTemplate = vi.spyOn(server as any, 'getTemplate'); 379 | 380 | await server.testExecuteTool('get_template', { 381 | templateId: '123' 382 | }); 383 | 384 | expect(mockGetTemplate).toHaveBeenCalledWith(123, 'full'); 385 | }); 386 | }); 387 | }); 388 | 389 | describe('Tools with No Required Parameters', () => { 390 | beforeEach(() => { 391 | vi.spyOn(server as any, 'getToolsDocumentation').mockResolvedValue({ docs: 'test' }); 392 | vi.spyOn(server as any, 'listNodes').mockResolvedValue({ nodes: [] }); 393 | vi.spyOn(server as any, 'listAITools').mockResolvedValue({ tools: [] }); 394 | vi.spyOn(server as any, 'getDatabaseStatistics').mockResolvedValue({ stats: {} }); 395 | vi.spyOn(server as any, 'listTasks').mockResolvedValue({ tasks: [] }); 396 | }); 397 | 398 | it('should allow tools_documentation with no parameters', async () => { 399 | const result = await server.testExecuteTool('tools_documentation', {}); 400 | expect(result).toEqual({ docs: 'test' }); 401 | }); 402 | 403 | it('should allow list_nodes with no parameters', async () => { 404 | const result = await server.testExecuteTool('list_nodes', {}); 405 | expect(result).toEqual({ nodes: [] }); 406 | }); 407 | 408 | it('should allow list_ai_tools with no parameters', async () => { 409 | const result = await server.testExecuteTool('list_ai_tools', {}); 410 | expect(result).toEqual({ tools: [] }); 411 | }); 412 | 413 | it('should allow get_database_statistics with no parameters', async () => { 414 | const result = await server.testExecuteTool('get_database_statistics', {}); 415 | expect(result).toEqual({ stats: {} }); 416 | }); 417 | 418 | it('should allow list_tasks with no parameters', async () => { 419 | const result = await server.testExecuteTool('list_tasks', {}); 420 | expect(result).toEqual({ tasks: [] }); 421 | }); 422 | }); 423 | 424 | describe('Error Message Quality', () => { 425 | it('should provide clear error messages with tool name', () => { 426 | expect(() => { 427 | server.testValidateToolParams('get_node_info', {}, ['nodeType']); 428 | }).toThrow('Missing required parameters for get_node_info: nodeType. Please provide the required parameters to use this tool.'); 429 | }); 430 | 431 | it('should list all missing parameters', () => { 432 | expect(() => { 433 | server.testValidateToolParams('validate_node_operation', { profile: 'strict' }, ['nodeType', 'config']); 434 | }).toThrow('validate_node_operation: Validation failed:\n • nodeType: nodeType is required\n • config: config is required'); 435 | }); 436 | 437 | it('should include helpful guidance', () => { 438 | try { 439 | server.testValidateToolParams('test_tool', {}, ['param1', 'param2']); 440 | } catch (error: any) { 441 | expect(error.message).toContain('Please provide the required parameters to use this tool'); 442 | } 443 | }); 444 | }); 445 | 446 | describe('MCP Error Response Handling', () => { 447 | it('should convert validation errors to MCP error responses rather than throwing exceptions', async () => { 448 | // This test simulates what happens at the MCP level when a tool validation fails 449 | // The server should catch the validation error and return it as an MCP error response 450 | 451 | // Directly test the executeTool method to ensure it throws appropriately 452 | // The MCP server's request handler should catch these and convert to error responses 453 | await expect(server.testExecuteTool('get_node_info', {})) 454 | .rejects.toThrow('Missing required parameters for get_node_info: nodeType'); 455 | 456 | await expect(server.testExecuteTool('search_nodes', {})) 457 | .rejects.toThrow('search_nodes: Validation failed:\n • query: query is required'); 458 | 459 | await expect(server.testExecuteTool('validate_node_operation', { nodeType: 'test' })) 460 | .rejects.toThrow('validate_node_operation: Validation failed:\n • config: config is required'); 461 | }); 462 | 463 | it('should handle edge cases in parameter validation gracefully', async () => { 464 | // Test with null args (should be handled by args = args || {}) 465 | await expect(server.testExecuteTool('get_node_info', null)) 466 | .rejects.toThrow('Missing required parameters'); 467 | 468 | // Test with undefined args 469 | await expect(server.testExecuteTool('get_node_info', undefined)) 470 | .rejects.toThrow('Missing required parameters'); 471 | }); 472 | 473 | it('should provide consistent error format across all tools', async () => { 474 | // Tools using legacy validation 475 | const legacyValidationTools = [ 476 | { name: 'get_node_info', args: {}, expected: 'Missing required parameters for get_node_info: nodeType' }, 477 | { name: 'get_node_documentation', args: {}, expected: 'Missing required parameters for get_node_documentation: nodeType' }, 478 | { name: 'get_node_essentials', args: {}, expected: 'Missing required parameters for get_node_essentials: nodeType' }, 479 | { name: 'search_node_properties', args: {}, expected: 'Missing required parameters for search_node_properties: nodeType, query' }, 480 | // Note: get_node_for_task removed in v2.15.0 481 | { name: 'get_property_dependencies', args: {}, expected: 'Missing required parameters for get_property_dependencies: nodeType' }, 482 | { name: 'get_node_as_tool_info', args: {}, expected: 'Missing required parameters for get_node_as_tool_info: nodeType' }, 483 | { name: 'get_template', args: {}, expected: 'Missing required parameters for get_template: templateId' }, 484 | ]; 485 | 486 | for (const tool of legacyValidationTools) { 487 | await expect(server.testExecuteTool(tool.name, tool.args)) 488 | .rejects.toThrow(tool.expected); 489 | } 490 | 491 | // Tools using new schema validation 492 | const schemaValidationTools = [ 493 | { name: 'search_nodes', args: {}, expected: 'search_nodes: Validation failed:\n • query: query is required' }, 494 | { name: 'validate_node_operation', args: {}, expected: 'validate_node_operation: Validation failed:\n • nodeType: nodeType is required\n • config: config is required' }, 495 | { name: 'validate_node_minimal', args: {}, expected: 'validate_node_minimal: Validation failed:\n • nodeType: nodeType is required\n • config: config is required' }, 496 | { name: 'list_node_templates', args: {}, expected: 'list_node_templates: Validation failed:\n • nodeTypes: nodeTypes is required' }, 497 | ]; 498 | 499 | for (const tool of schemaValidationTools) { 500 | await expect(server.testExecuteTool(tool.name, tool.args)) 501 | .rejects.toThrow(tool.expected); 502 | } 503 | }); 504 | 505 | it('should validate n8n management tools parameters', async () => { 506 | // Mock the n8n handlers to avoid actual API calls 507 | const mockHandlers = [ 508 | 'handleCreateWorkflow', 509 | 'handleGetWorkflow', 510 | 'handleGetWorkflowDetails', 511 | 'handleGetWorkflowStructure', 512 | 'handleGetWorkflowMinimal', 513 | 'handleUpdateWorkflow', 514 | 'handleDeleteWorkflow', 515 | 'handleValidateWorkflow', 516 | 'handleTriggerWebhookWorkflow', 517 | 'handleGetExecution', 518 | 'handleDeleteExecution' 519 | ]; 520 | 521 | for (const handler of mockHandlers) { 522 | vi.doMock('../../../src/mcp/handlers-n8n-manager', () => ({ 523 | [handler]: vi.fn().mockResolvedValue({ success: true }) 524 | })); 525 | } 526 | 527 | vi.doMock('../../../src/mcp/handlers-workflow-diff', () => ({ 528 | handleUpdatePartialWorkflow: vi.fn().mockResolvedValue({ success: true }) 529 | })); 530 | 531 | const n8nToolsWithRequiredParams = [ 532 | { name: 'n8n_create_workflow', args: {}, expected: 'n8n_create_workflow: Validation failed:\n • name: name is required\n • nodes: nodes is required\n • connections: connections is required' }, 533 | { name: 'n8n_get_workflow', args: {}, expected: 'n8n_get_workflow: Validation failed:\n • id: id is required' }, 534 | { name: 'n8n_get_workflow_details', args: {}, expected: 'n8n_get_workflow_details: Validation failed:\n • id: id is required' }, 535 | { name: 'n8n_get_workflow_structure', args: {}, expected: 'n8n_get_workflow_structure: Validation failed:\n • id: id is required' }, 536 | { name: 'n8n_get_workflow_minimal', args: {}, expected: 'n8n_get_workflow_minimal: Validation failed:\n • id: id is required' }, 537 | { name: 'n8n_update_full_workflow', args: {}, expected: 'n8n_update_full_workflow: Validation failed:\n • id: id is required' }, 538 | { name: 'n8n_delete_workflow', args: {}, expected: 'n8n_delete_workflow: Validation failed:\n • id: id is required' }, 539 | { name: 'n8n_validate_workflow', args: {}, expected: 'n8n_validate_workflow: Validation failed:\n • id: id is required' }, 540 | { name: 'n8n_get_execution', args: {}, expected: 'n8n_get_execution: Validation failed:\n • id: id is required' }, 541 | { name: 'n8n_delete_execution', args: {}, expected: 'n8n_delete_execution: Validation failed:\n • id: id is required' }, 542 | ]; 543 | 544 | // n8n_update_partial_workflow and n8n_trigger_webhook_workflow use legacy validation 545 | await expect(server.testExecuteTool('n8n_update_partial_workflow', {})) 546 | .rejects.toThrow('Missing required parameters for n8n_update_partial_workflow: id, operations'); 547 | 548 | await expect(server.testExecuteTool('n8n_trigger_webhook_workflow', {})) 549 | .rejects.toThrow('Missing required parameters for n8n_trigger_webhook_workflow: webhookUrl'); 550 | 551 | for (const tool of n8nToolsWithRequiredParams) { 552 | await expect(server.testExecuteTool(tool.name, tool.args)) 553 | .rejects.toThrow(tool.expected); 554 | } 555 | }); 556 | }); 557 | }); ``` -------------------------------------------------------------------------------- /tests/unit/http-server/multi-tenant-support.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Comprehensive unit tests for multi-tenant support in http-server-single-session.ts 3 | * 4 | * Tests the new functions and logic: 5 | * - extractMultiTenantHeaders function 6 | * - Instance context creation and validation from headers 7 | * - Session ID generation with configuration hash 8 | * - Context switching with locking mechanism 9 | * - Security logging with sanitization 10 | */ 11 | 12 | import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 13 | import express from 'express'; 14 | import { InstanceContext } from '../../../src/types/instance-context'; 15 | 16 | // Mock dependencies 17 | vi.mock('../../../src/utils/logger', () => ({ 18 | Logger: vi.fn().mockImplementation(() => ({ 19 | debug: vi.fn(), 20 | info: vi.fn(), 21 | warn: vi.fn(), 22 | error: vi.fn() 23 | })), 24 | logger: { 25 | debug: vi.fn(), 26 | info: vi.fn(), 27 | warn: vi.fn(), 28 | error: vi.fn() 29 | } 30 | })); 31 | 32 | vi.mock('../../../src/utils/console-manager', () => ({ 33 | ConsoleManager: { 34 | getInstance: vi.fn().mockReturnValue({ 35 | isolate: vi.fn((fn) => fn()) 36 | }) 37 | } 38 | })); 39 | 40 | vi.mock('../../../src/mcp/server', () => ({ 41 | N8NDocumentationMCPServer: vi.fn().mockImplementation(() => ({ 42 | setInstanceContext: vi.fn(), 43 | handleMessage: vi.fn(), 44 | close: vi.fn() 45 | })) 46 | })); 47 | 48 | vi.mock('uuid', () => ({ 49 | v4: vi.fn(() => 'test-uuid-1234-5678-9012') 50 | })); 51 | 52 | vi.mock('crypto', () => ({ 53 | createHash: vi.fn(() => ({ 54 | update: vi.fn().mockReturnThis(), 55 | digest: vi.fn(() => 'test-hash-abc123') 56 | })) 57 | })); 58 | 59 | // Since the functions are not exported, we'll test them through the HTTP server behavior 60 | describe('HTTP Server Multi-Tenant Support', () => { 61 | let mockRequest: Partial<express.Request>; 62 | let mockResponse: Partial<express.Response>; 63 | let originalEnv: NodeJS.ProcessEnv; 64 | 65 | beforeEach(() => { 66 | originalEnv = { ...process.env }; 67 | 68 | mockRequest = { 69 | headers: {}, 70 | method: 'POST', 71 | url: '/mcp', 72 | body: {} 73 | }; 74 | 75 | mockResponse = { 76 | status: vi.fn().mockReturnThis(), 77 | json: vi.fn().mockReturnThis(), 78 | send: vi.fn().mockReturnThis(), 79 | setHeader: vi.fn().mockReturnThis(), 80 | writeHead: vi.fn(), 81 | write: vi.fn(), 82 | end: vi.fn() 83 | }; 84 | 85 | vi.clearAllMocks(); 86 | }); 87 | 88 | afterEach(() => { 89 | process.env = originalEnv; 90 | }); 91 | 92 | describe('extractMultiTenantHeaders Function', () => { 93 | // Since extractMultiTenantHeaders is not exported, we'll test its behavior indirectly 94 | // by examining how the HTTP server processes headers 95 | 96 | it('should extract all multi-tenant headers when present', () => { 97 | // Arrange 98 | const headers: any = { 99 | 'x-n8n-url': 'https://tenant1.n8n.cloud', 100 | 'x-n8n-key': 'tenant1-api-key', 101 | 'x-instance-id': 'tenant1-instance', 102 | 'x-session-id': 'tenant1-session-123' 103 | }; 104 | 105 | mockRequest.headers = headers; 106 | 107 | // The function would extract these headers in a type-safe manner 108 | // We can verify this behavior by checking if the server processes them correctly 109 | 110 | // Assert that headers are properly typed and extracted 111 | expect(headers['x-n8n-url']).toBe('https://tenant1.n8n.cloud'); 112 | expect(headers['x-n8n-key']).toBe('tenant1-api-key'); 113 | expect(headers['x-instance-id']).toBe('tenant1-instance'); 114 | expect(headers['x-session-id']).toBe('tenant1-session-123'); 115 | }); 116 | 117 | it('should handle missing headers gracefully', () => { 118 | // Arrange 119 | const headers: any = { 120 | 'x-n8n-url': 'https://tenant1.n8n.cloud' 121 | // Other headers missing 122 | }; 123 | 124 | mockRequest.headers = headers; 125 | 126 | // Extract function should handle undefined values 127 | expect(headers['x-n8n-url']).toBe('https://tenant1.n8n.cloud'); 128 | expect(headers['x-n8n-key']).toBeUndefined(); 129 | expect(headers['x-instance-id']).toBeUndefined(); 130 | expect(headers['x-session-id']).toBeUndefined(); 131 | }); 132 | 133 | it('should handle case-insensitive headers', () => { 134 | // Arrange 135 | const headers: any = { 136 | 'X-N8N-URL': 'https://tenant1.n8n.cloud', 137 | 'X-N8N-KEY': 'tenant1-api-key', 138 | 'X-INSTANCE-ID': 'tenant1-instance', 139 | 'X-SESSION-ID': 'tenant1-session-123' 140 | }; 141 | 142 | mockRequest.headers = headers; 143 | 144 | // Express normalizes headers to lowercase 145 | expect(headers['X-N8N-URL']).toBe('https://tenant1.n8n.cloud'); 146 | }); 147 | 148 | it('should handle array header values', () => { 149 | // Arrange - Express can provide headers as arrays 150 | const headers: any = { 151 | 'x-n8n-url': ['https://tenant1.n8n.cloud'], 152 | 'x-n8n-key': ['tenant1-api-key', 'duplicate-key'] // Multiple values 153 | }; 154 | 155 | mockRequest.headers = headers as any; 156 | 157 | // Function should handle array values appropriately 158 | expect(Array.isArray(headers['x-n8n-url'])).toBe(true); 159 | expect(Array.isArray(headers['x-n8n-key'])).toBe(true); 160 | }); 161 | 162 | it('should handle non-string header values', () => { 163 | // Arrange 164 | const headers: any = { 165 | 'x-n8n-url': undefined, 166 | 'x-n8n-key': null, 167 | 'x-instance-id': 123, // Should be string 168 | 'x-session-id': ['value1', 'value2'] 169 | }; 170 | 171 | mockRequest.headers = headers as any; 172 | 173 | // Function should handle type safety 174 | expect(typeof headers['x-instance-id']).toBe('number'); 175 | expect(Array.isArray(headers['x-session-id'])).toBe(true); 176 | }); 177 | }); 178 | 179 | describe('Instance Context Creation and Validation', () => { 180 | it('should create valid instance context from complete headers', () => { 181 | // Arrange 182 | const headers: any = { 183 | 'x-n8n-url': 'https://tenant1.n8n.cloud', 184 | 'x-n8n-key': 'valid-api-key-123', 185 | 'x-instance-id': 'tenant1-instance', 186 | 'x-session-id': 'tenant1-session-123' 187 | }; 188 | 189 | // Simulate instance context creation 190 | const instanceContext: InstanceContext = { 191 | n8nApiUrl: headers['x-n8n-url'], 192 | n8nApiKey: headers['x-n8n-key'], 193 | instanceId: headers['x-instance-id'], 194 | sessionId: headers['x-session-id'] 195 | }; 196 | 197 | // Assert valid context 198 | expect(instanceContext.n8nApiUrl).toBe('https://tenant1.n8n.cloud'); 199 | expect(instanceContext.n8nApiKey).toBe('valid-api-key-123'); 200 | expect(instanceContext.instanceId).toBe('tenant1-instance'); 201 | expect(instanceContext.sessionId).toBe('tenant1-session-123'); 202 | }); 203 | 204 | it('should create partial instance context when some headers missing', () => { 205 | // Arrange 206 | const headers: any = { 207 | 'x-n8n-url': 'https://tenant1.n8n.cloud' 208 | // Other headers missing 209 | }; 210 | 211 | // Simulate partial context creation 212 | const instanceContext: InstanceContext = { 213 | n8nApiUrl: headers['x-n8n-url'], 214 | n8nApiKey: headers['x-n8n-key'], // undefined 215 | instanceId: headers['x-instance-id'], // undefined 216 | sessionId: headers['x-session-id'] // undefined 217 | }; 218 | 219 | // Assert partial context 220 | expect(instanceContext.n8nApiUrl).toBe('https://tenant1.n8n.cloud'); 221 | expect(instanceContext.n8nApiKey).toBeUndefined(); 222 | expect(instanceContext.instanceId).toBeUndefined(); 223 | expect(instanceContext.sessionId).toBeUndefined(); 224 | }); 225 | 226 | it('should return undefined context when no relevant headers present', () => { 227 | // Arrange 228 | const headers: any = { 229 | 'authorization': 'Bearer token', 230 | 'content-type': 'application/json' 231 | // No x-n8n-* headers 232 | }; 233 | 234 | // Simulate context creation logic 235 | const hasUrl = headers['x-n8n-url']; 236 | const hasKey = headers['x-n8n-key']; 237 | const instanceContext = (!hasUrl && !hasKey) ? undefined : {}; 238 | 239 | // Assert no context created 240 | expect(instanceContext).toBeUndefined(); 241 | }); 242 | 243 | it.skip('should validate instance context before use', () => { 244 | // TODO: Fix import issue with validateInstanceContext 245 | // Arrange 246 | const invalidContext: InstanceContext = { 247 | n8nApiUrl: 'invalid-url', 248 | n8nApiKey: 'placeholder' 249 | }; 250 | 251 | // Import validation function to test 252 | const { validateInstanceContext } = require('../../../src/types/instance-context'); 253 | 254 | // Act 255 | const result = validateInstanceContext(invalidContext); 256 | 257 | // Assert 258 | expect(result.valid).toBe(false); 259 | expect(result.errors).toBeDefined(); 260 | expect(result.errors?.length).toBeGreaterThan(0); 261 | }); 262 | 263 | it('should handle malformed URLs in headers', () => { 264 | // Arrange 265 | const headers: any = { 266 | 'x-n8n-url': 'not-a-valid-url', 267 | 'x-n8n-key': 'valid-key' 268 | }; 269 | 270 | const instanceContext: InstanceContext = { 271 | n8nApiUrl: headers['x-n8n-url'], 272 | n8nApiKey: headers['x-n8n-key'] 273 | }; 274 | 275 | // Should not throw during creation 276 | expect(() => instanceContext).not.toThrow(); 277 | expect(instanceContext.n8nApiUrl).toBe('not-a-valid-url'); 278 | }); 279 | 280 | it('should handle special characters in headers', () => { 281 | // Arrange 282 | const headers: any = { 283 | 'x-n8n-url': 'https://[email protected]', 284 | 'x-n8n-key': 'key-with-special-chars!@#$%', 285 | 'x-instance-id': 'instance_with_underscores', 286 | 'x-session-id': 'session-with-hyphens-123' 287 | }; 288 | 289 | const instanceContext: InstanceContext = { 290 | n8nApiUrl: headers['x-n8n-url'], 291 | n8nApiKey: headers['x-n8n-key'], 292 | instanceId: headers['x-instance-id'], 293 | sessionId: headers['x-session-id'] 294 | }; 295 | 296 | // Should handle special characters 297 | expect(instanceContext.n8nApiUrl).toContain('@'); 298 | expect(instanceContext.n8nApiKey).toContain('!@#$%'); 299 | expect(instanceContext.instanceId).toContain('_'); 300 | expect(instanceContext.sessionId).toContain('-'); 301 | }); 302 | }); 303 | 304 | describe('Session ID Generation with Configuration Hash', () => { 305 | it.skip('should generate consistent session ID for same configuration', () => { 306 | // TODO: Fix vi.mocked() issue 307 | // Arrange 308 | const crypto = require('crypto'); 309 | const uuid = require('uuid'); 310 | 311 | const config1 = { 312 | n8nApiUrl: 'https://tenant1.n8n.cloud', 313 | n8nApiKey: 'api-key-123' 314 | }; 315 | 316 | const config2 = { 317 | n8nApiUrl: 'https://tenant1.n8n.cloud', 318 | n8nApiKey: 'api-key-123' 319 | }; 320 | 321 | // Mock hash generation to be deterministic 322 | const mockHash = vi.mocked(crypto.createHash).mockReturnValue({ 323 | update: vi.fn().mockReturnThis(), 324 | digest: vi.fn(() => 'same-hash-for-same-config') 325 | }); 326 | 327 | // Generate session IDs 328 | const sessionId1 = `test-uuid-1234-5678-9012-same-hash-for-same-config`; 329 | const sessionId2 = `test-uuid-1234-5678-9012-same-hash-for-same-config`; 330 | 331 | // Assert same session IDs for same config 332 | expect(sessionId1).toBe(sessionId2); 333 | expect(mockHash).toHaveBeenCalled(); 334 | }); 335 | 336 | it.skip('should generate different session ID for different configuration', () => { 337 | // TODO: Fix vi.mocked() issue 338 | // Arrange 339 | const crypto = require('crypto'); 340 | 341 | const config1 = { 342 | n8nApiUrl: 'https://tenant1.n8n.cloud', 343 | n8nApiKey: 'api-key-123' 344 | }; 345 | 346 | const config2 = { 347 | n8nApiUrl: 'https://tenant2.n8n.cloud', 348 | n8nApiKey: 'different-api-key' 349 | }; 350 | 351 | // Mock different hashes for different configs 352 | let callCount = 0; 353 | const mockHash = vi.mocked(crypto.createHash).mockReturnValue({ 354 | update: vi.fn().mockReturnThis(), 355 | digest: vi.fn(() => callCount++ === 0 ? 'hash-config-1' : 'hash-config-2') 356 | }); 357 | 358 | // Generate session IDs 359 | const sessionId1 = `test-uuid-1234-5678-9012-hash-config-1`; 360 | const sessionId2 = `test-uuid-1234-5678-9012-hash-config-2`; 361 | 362 | // Assert different session IDs for different configs 363 | expect(sessionId1).not.toBe(sessionId2); 364 | expect(sessionId1).toContain('hash-config-1'); 365 | expect(sessionId2).toContain('hash-config-2'); 366 | }); 367 | 368 | it.skip('should include UUID in session ID for uniqueness', () => { 369 | // TODO: Fix vi.mocked() issue 370 | // Arrange 371 | const uuid = require('uuid'); 372 | const crypto = require('crypto'); 373 | 374 | vi.mocked(uuid.v4).mockReturnValue('unique-uuid-abcd-efgh'); 375 | vi.mocked(crypto.createHash).mockReturnValue({ 376 | update: vi.fn().mockReturnThis(), 377 | digest: vi.fn(() => 'config-hash') 378 | }); 379 | 380 | // Generate session ID 381 | const sessionId = `unique-uuid-abcd-efgh-config-hash`; 382 | 383 | // Assert UUID is included 384 | expect(sessionId).toContain('unique-uuid-abcd-efgh'); 385 | expect(sessionId).toContain('config-hash'); 386 | }); 387 | 388 | it.skip('should handle undefined configuration in hash generation', () => { 389 | // TODO: Fix vi.mocked() issue 390 | // Arrange 391 | const crypto = require('crypto'); 392 | 393 | const config = { 394 | n8nApiUrl: undefined, 395 | n8nApiKey: undefined 396 | }; 397 | 398 | // Mock hash for undefined config 399 | const mockHashInstance = { 400 | update: vi.fn().mockReturnThis(), 401 | digest: vi.fn(() => 'undefined-config-hash') 402 | }; 403 | 404 | vi.mocked(crypto.createHash).mockReturnValue(mockHashInstance); 405 | 406 | // Should handle undefined values gracefully 407 | expect(() => { 408 | const configString = JSON.stringify(config); 409 | mockHashInstance.update(configString); 410 | const hash = mockHashInstance.digest(); 411 | }).not.toThrow(); 412 | 413 | expect(mockHashInstance.update).toHaveBeenCalled(); 414 | expect(mockHashInstance.digest).toHaveBeenCalledWith('hex'); 415 | }); 416 | }); 417 | 418 | describe('Security Logging with Sanitization', () => { 419 | it.skip('should sanitize sensitive information in logs', () => { 420 | // TODO: Fix import issue with logger 421 | // Arrange 422 | const { logger } = require('../../../src/utils/logger'); 423 | 424 | const context = { 425 | n8nApiUrl: 'https://tenant1.n8n.cloud', 426 | n8nApiKey: 'super-secret-api-key-123', 427 | instanceId: 'tenant1-instance' 428 | }; 429 | 430 | // Simulate security logging 431 | const sanitizedContext = { 432 | n8nApiUrl: context.n8nApiUrl, 433 | n8nApiKey: '***REDACTED***', 434 | instanceId: context.instanceId 435 | }; 436 | 437 | logger.info('Multi-tenant context created', sanitizedContext); 438 | 439 | // Assert 440 | expect(logger.info).toHaveBeenCalledWith( 441 | 'Multi-tenant context created', 442 | expect.objectContaining({ 443 | n8nApiKey: '***REDACTED***' 444 | }) 445 | ); 446 | }); 447 | 448 | it.skip('should log session creation events', () => { 449 | // TODO: Fix logger import issues 450 | // Arrange 451 | const { logger } = require('../../../src/utils/logger'); 452 | 453 | const sessionData = { 454 | sessionId: 'session-123-abc', 455 | instanceId: 'tenant1-instance', 456 | hasValidConfig: true 457 | }; 458 | 459 | logger.debug('Session created for multi-tenant instance', sessionData); 460 | 461 | // Assert 462 | expect(logger.debug).toHaveBeenCalledWith( 463 | 'Session created for multi-tenant instance', 464 | sessionData 465 | ); 466 | }); 467 | 468 | it.skip('should log context switching events', () => { 469 | // TODO: Fix logger import issues 470 | // Arrange 471 | const { logger } = require('../../../src/utils/logger'); 472 | 473 | const switchingData = { 474 | fromSession: 'session-old-123', 475 | toSession: 'session-new-456', 476 | instanceId: 'tenant2-instance' 477 | }; 478 | 479 | logger.debug('Context switching between instances', switchingData); 480 | 481 | // Assert 482 | expect(logger.debug).toHaveBeenCalledWith( 483 | 'Context switching between instances', 484 | switchingData 485 | ); 486 | }); 487 | 488 | it.skip('should log validation failures securely', () => { 489 | // TODO: Fix logger import issues 490 | // Arrange 491 | const { logger } = require('../../../src/utils/logger'); 492 | 493 | const validationError = { 494 | field: 'n8nApiUrl', 495 | error: 'Invalid URL format', 496 | value: '***REDACTED***' // Sensitive value should be redacted 497 | }; 498 | 499 | logger.warn('Instance context validation failed', validationError); 500 | 501 | // Assert 502 | expect(logger.warn).toHaveBeenCalledWith( 503 | 'Instance context validation failed', 504 | expect.objectContaining({ 505 | value: '***REDACTED***' 506 | }) 507 | ); 508 | }); 509 | 510 | it.skip('should not log API keys or sensitive data in plain text', () => { 511 | // TODO: Fix logger import issues 512 | // Arrange 513 | const { logger } = require('../../../src/utils/logger'); 514 | 515 | // Simulate various log calls that might contain sensitive data 516 | logger.debug('Processing request', { 517 | headers: { 518 | 'x-n8n-key': '***REDACTED***' 519 | } 520 | }); 521 | 522 | logger.info('Context validation', { 523 | n8nApiKey: '***REDACTED***' 524 | }); 525 | 526 | // Assert no sensitive data is logged 527 | const allCalls = [ 528 | ...vi.mocked(logger.debug).mock.calls, 529 | ...vi.mocked(logger.info).mock.calls 530 | ]; 531 | 532 | allCalls.forEach(call => { 533 | const callString = JSON.stringify(call); 534 | expect(callString).not.toMatch(/api[_-]?key['":]?\s*['"][^*]/i); 535 | expect(callString).not.toMatch(/secret/i); 536 | expect(callString).not.toMatch(/password/i); 537 | }); 538 | }); 539 | }); 540 | 541 | describe('Context Switching and Session Management', () => { 542 | it('should handle session creation for new instance context', () => { 543 | // Arrange 544 | const context1: InstanceContext = { 545 | n8nApiUrl: 'https://tenant1.n8n.cloud', 546 | n8nApiKey: 'tenant1-key', 547 | instanceId: 'tenant1' 548 | }; 549 | 550 | // Simulate session creation 551 | const sessionId = 'session-tenant1-123'; 552 | const sessions = new Map(); 553 | 554 | sessions.set(sessionId, { 555 | context: context1, 556 | lastAccess: new Date(), 557 | initialized: true 558 | }); 559 | 560 | // Assert 561 | expect(sessions.has(sessionId)).toBe(true); 562 | expect(sessions.get(sessionId).context).toEqual(context1); 563 | }); 564 | 565 | it('should handle session switching between different contexts', () => { 566 | // Arrange 567 | const context1: InstanceContext = { 568 | n8nApiUrl: 'https://tenant1.n8n.cloud', 569 | n8nApiKey: 'tenant1-key', 570 | instanceId: 'tenant1' 571 | }; 572 | 573 | const context2: InstanceContext = { 574 | n8nApiUrl: 'https://tenant2.n8n.cloud', 575 | n8nApiKey: 'tenant2-key', 576 | instanceId: 'tenant2' 577 | }; 578 | 579 | const sessions = new Map(); 580 | const session1Id = 'session-tenant1-123'; 581 | const session2Id = 'session-tenant2-456'; 582 | 583 | // Create sessions 584 | sessions.set(session1Id, { context: context1, lastAccess: new Date() }); 585 | sessions.set(session2Id, { context: context2, lastAccess: new Date() }); 586 | 587 | // Simulate context switching 588 | let currentSession = session1Id; 589 | expect(sessions.get(currentSession).context.instanceId).toBe('tenant1'); 590 | 591 | currentSession = session2Id; 592 | expect(sessions.get(currentSession).context.instanceId).toBe('tenant2'); 593 | 594 | // Assert successful switching 595 | expect(sessions.size).toBe(2); 596 | expect(sessions.has(session1Id)).toBe(true); 597 | expect(sessions.has(session2Id)).toBe(true); 598 | }); 599 | 600 | it('should prevent race conditions in session management', async () => { 601 | // Arrange 602 | const sessions = new Map(); 603 | const locks = new Map(); 604 | const sessionId = 'session-123'; 605 | 606 | // Simulate locking mechanism 607 | const acquireLock = (id: string) => { 608 | if (locks.has(id)) { 609 | return false; // Lock already acquired 610 | } 611 | locks.set(id, true); 612 | return true; 613 | }; 614 | 615 | const releaseLock = (id: string) => { 616 | locks.delete(id); 617 | }; 618 | 619 | // Test concurrent access 620 | const lock1 = acquireLock(sessionId); 621 | const lock2 = acquireLock(sessionId); 622 | 623 | // Assert only one lock can be acquired 624 | expect(lock1).toBe(true); 625 | expect(lock2).toBe(false); 626 | 627 | // Release and reacquire 628 | releaseLock(sessionId); 629 | const lock3 = acquireLock(sessionId); 630 | expect(lock3).toBe(true); 631 | }); 632 | 633 | it('should handle session cleanup for inactive sessions', () => { 634 | // Arrange 635 | const sessions = new Map(); 636 | const now = new Date(); 637 | const oldTime = new Date(now.getTime() - 10 * 60 * 1000); // 10 minutes ago 638 | 639 | sessions.set('active-session', { 640 | lastAccess: now, 641 | context: { instanceId: 'active' } 642 | }); 643 | 644 | sessions.set('inactive-session', { 645 | lastAccess: oldTime, 646 | context: { instanceId: 'inactive' } 647 | }); 648 | 649 | // Simulate cleanup (5 minute threshold) 650 | const threshold = 5 * 60 * 1000; 651 | const cutoff = new Date(now.getTime() - threshold); 652 | 653 | for (const [sessionId, session] of sessions.entries()) { 654 | if (session.lastAccess < cutoff) { 655 | sessions.delete(sessionId); 656 | } 657 | } 658 | 659 | // Assert cleanup 660 | expect(sessions.has('active-session')).toBe(true); 661 | expect(sessions.has('inactive-session')).toBe(false); 662 | expect(sessions.size).toBe(1); 663 | }); 664 | 665 | it('should handle maximum session limit', () => { 666 | // Arrange 667 | const sessions = new Map(); 668 | const MAX_SESSIONS = 3; 669 | 670 | // Fill to capacity 671 | for (let i = 0; i < MAX_SESSIONS; i++) { 672 | sessions.set(`session-${i}`, { 673 | lastAccess: new Date(), 674 | context: { instanceId: `tenant-${i}` } 675 | }); 676 | } 677 | 678 | // Try to add one more 679 | const oldestSession = 'session-0'; 680 | const newSession = 'session-new'; 681 | 682 | if (sessions.size >= MAX_SESSIONS) { 683 | // Remove oldest session 684 | sessions.delete(oldestSession); 685 | } 686 | 687 | sessions.set(newSession, { 688 | lastAccess: new Date(), 689 | context: { instanceId: 'new-tenant' } 690 | }); 691 | 692 | // Assert limit maintained 693 | expect(sessions.size).toBe(MAX_SESSIONS); 694 | expect(sessions.has(oldestSession)).toBe(false); 695 | expect(sessions.has(newSession)).toBe(true); 696 | }); 697 | }); 698 | 699 | describe('Error Handling and Edge Cases', () => { 700 | it.skip('should handle invalid header types gracefully', () => { 701 | // TODO: Fix require() import issues 702 | // Arrange 703 | const headers: any = { 704 | 'x-n8n-url': ['array', 'of', 'values'], 705 | 'x-n8n-key': 12345, // number instead of string 706 | 'x-instance-id': null, 707 | 'x-session-id': undefined 708 | }; 709 | 710 | // Should not throw when processing invalid types 711 | expect(() => { 712 | const extractedUrl = Array.isArray(headers['x-n8n-url']) 713 | ? headers['x-n8n-url'][0] 714 | : headers['x-n8n-url']; 715 | const extractedKey = typeof headers['x-n8n-key'] === 'string' 716 | ? headers['x-n8n-key'] 717 | : String(headers['x-n8n-key']); 718 | }).not.toThrow(); 719 | }); 720 | 721 | it('should handle missing or corrupt session data', () => { 722 | // Arrange 723 | const sessions = new Map(); 724 | sessions.set('corrupt-session', null); 725 | sessions.set('incomplete-session', { lastAccess: new Date() }); // missing context 726 | 727 | // Should handle corrupt data gracefully 728 | expect(() => { 729 | for (const [sessionId, session] of sessions.entries()) { 730 | if (!session || !session.context) { 731 | sessions.delete(sessionId); 732 | } 733 | } 734 | }).not.toThrow(); 735 | 736 | // Assert cleanup of corrupt data 737 | expect(sessions.has('corrupt-session')).toBe(false); 738 | expect(sessions.has('incomplete-session')).toBe(false); 739 | }); 740 | 741 | it.skip('should handle context validation errors gracefully', () => { 742 | // TODO: Fix require() import issues 743 | // Arrange 744 | const invalidContext: InstanceContext = { 745 | n8nApiUrl: 'not-a-url', 746 | n8nApiKey: '', 747 | n8nApiTimeout: -1, 748 | n8nApiMaxRetries: -5 749 | }; 750 | 751 | const { validateInstanceContext } = require('../../../src/types/instance-context'); 752 | 753 | // Should not throw even with invalid context 754 | expect(() => { 755 | const result = validateInstanceContext(invalidContext); 756 | if (!result.valid) { 757 | // Handle validation errors gracefully 758 | const errors = result.errors || []; 759 | errors.forEach((error: any) => { 760 | // Log error without throwing 761 | console.warn('Validation error:', error); 762 | }); 763 | } 764 | }).not.toThrow(); 765 | }); 766 | 767 | it('should handle memory pressure during session management', () => { 768 | // Arrange 769 | const sessions = new Map(); 770 | const MAX_MEMORY_SESSIONS = 50; 771 | 772 | // Simulate memory pressure 773 | for (let i = 0; i < MAX_MEMORY_SESSIONS * 2; i++) { 774 | sessions.set(`session-${i}`, { 775 | lastAccess: new Date(), 776 | context: { instanceId: `tenant-${i}` }, 777 | data: new Array(1000).fill('memory-pressure-test') // Simulate memory usage 778 | }); 779 | 780 | // Implement emergency cleanup when approaching limits 781 | if (sessions.size > MAX_MEMORY_SESSIONS) { 782 | const oldestEntries = Array.from(sessions.entries()) 783 | .sort(([,a], [,b]) => a.lastAccess.getTime() - b.lastAccess.getTime()) 784 | .slice(0, 10); // Remove 10 oldest 785 | 786 | oldestEntries.forEach(([sessionId]) => { 787 | sessions.delete(sessionId); 788 | }); 789 | } 790 | } 791 | 792 | // Assert memory management 793 | expect(sessions.size).toBeLessThanOrEqual(MAX_MEMORY_SESSIONS + 10); 794 | }); 795 | }); 796 | }); ``` -------------------------------------------------------------------------------- /tests/unit/services/loop-output-edge-cases.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach, vi } from 'vitest'; 2 | import { WorkflowValidator } from '@/services/workflow-validator'; 3 | import { NodeRepository } from '@/database/node-repository'; 4 | import { EnhancedConfigValidator } from '@/services/enhanced-config-validator'; 5 | 6 | // Mock dependencies 7 | vi.mock('@/database/node-repository'); 8 | vi.mock('@/services/enhanced-config-validator'); 9 | 10 | describe('Loop Output Fix - Edge Cases', () => { 11 | let validator: WorkflowValidator; 12 | let mockNodeRepository: any; 13 | let mockNodeValidator: any; 14 | 15 | beforeEach(() => { 16 | vi.clearAllMocks(); 17 | 18 | mockNodeRepository = { 19 | getNode: vi.fn((nodeType: string) => { 20 | // Default return 21 | if (nodeType === 'nodes-base.splitInBatches') { 22 | return { 23 | nodeType: 'nodes-base.splitInBatches', 24 | outputs: [ 25 | { displayName: 'Done', name: 'done' }, 26 | { displayName: 'Loop', name: 'loop' } 27 | ], 28 | outputNames: ['done', 'loop'], 29 | properties: [] 30 | }; 31 | } 32 | return { 33 | nodeType, 34 | properties: [] 35 | }; 36 | }) 37 | }; 38 | 39 | mockNodeValidator = { 40 | validateWithMode: vi.fn().mockReturnValue({ 41 | errors: [], 42 | warnings: [] 43 | }) 44 | }; 45 | 46 | validator = new WorkflowValidator(mockNodeRepository, mockNodeValidator); 47 | }); 48 | 49 | describe('Nodes without outputs', () => { 50 | it('should handle nodes with null outputs gracefully', async () => { 51 | mockNodeRepository.getNode.mockReturnValue({ 52 | nodeType: 'nodes-base.httpRequest', 53 | outputs: null, 54 | outputNames: null, 55 | properties: [] 56 | }); 57 | 58 | const workflow = { 59 | name: 'No Outputs Workflow', 60 | nodes: [ 61 | { 62 | id: '1', 63 | name: 'HTTP Request', 64 | type: 'n8n-nodes-base.httpRequest', 65 | position: [100, 100], 66 | parameters: { url: 'https://example.com' } 67 | }, 68 | { 69 | id: '2', 70 | name: 'Set', 71 | type: 'n8n-nodes-base.set', 72 | position: [300, 100], 73 | parameters: {} 74 | } 75 | ], 76 | connections: { 77 | 'HTTP Request': { 78 | main: [ 79 | [{ node: 'Set', type: 'main', index: 0 }] 80 | ] 81 | } 82 | } 83 | }; 84 | 85 | const result = await validator.validateWorkflow(workflow as any); 86 | 87 | // Should not crash or produce output-related errors 88 | expect(result).toBeDefined(); 89 | const outputErrors = result.errors.filter(e => 90 | e.message?.includes('output') && !e.message?.includes('Connection') 91 | ); 92 | expect(outputErrors).toHaveLength(0); 93 | }); 94 | 95 | it('should handle nodes with undefined outputs gracefully', async () => { 96 | mockNodeRepository.getNode.mockReturnValue({ 97 | nodeType: 'nodes-base.webhook', 98 | // outputs and outputNames are undefined 99 | properties: [] 100 | }); 101 | 102 | const workflow = { 103 | name: 'Undefined Outputs Workflow', 104 | nodes: [ 105 | { 106 | id: '1', 107 | name: 'Webhook', 108 | type: 'n8n-nodes-base.webhook', 109 | position: [100, 100], 110 | parameters: {} 111 | } 112 | ], 113 | connections: {} 114 | }; 115 | 116 | const result = await validator.validateWorkflow(workflow as any); 117 | 118 | expect(result).toBeDefined(); 119 | expect(result.valid).toBeTruthy(); // Empty workflow with webhook should be valid 120 | }); 121 | 122 | it('should handle nodes with empty outputs array', async () => { 123 | mockNodeRepository.getNode.mockReturnValue({ 124 | nodeType: 'nodes-base.customNode', 125 | outputs: [], 126 | outputNames: [], 127 | properties: [] 128 | }); 129 | 130 | const workflow = { 131 | name: 'Empty Outputs Workflow', 132 | nodes: [ 133 | { 134 | id: '1', 135 | name: 'Custom Node', 136 | type: 'n8n-nodes-base.customNode', 137 | position: [100, 100], 138 | parameters: {} 139 | } 140 | ], 141 | connections: { 142 | 'Custom Node': { 143 | main: [ 144 | [{ node: 'Custom Node', type: 'main', index: 0 }] // Self-reference 145 | ] 146 | } 147 | } 148 | }; 149 | 150 | const result = await validator.validateWorkflow(workflow as any); 151 | 152 | // Should warn about self-reference but not crash 153 | const selfRefWarnings = result.warnings.filter(w => 154 | w.message?.includes('self-referencing') 155 | ); 156 | expect(selfRefWarnings).toHaveLength(1); 157 | }); 158 | }); 159 | 160 | describe('Invalid connection indices', () => { 161 | it('should handle negative connection indices', async () => { 162 | // Use default mock that includes outputs for SplitInBatches 163 | 164 | const workflow = { 165 | name: 'Negative Index Workflow', 166 | nodes: [ 167 | { 168 | id: '1', 169 | name: 'Split In Batches', 170 | type: 'n8n-nodes-base.splitInBatches', 171 | position: [100, 100], 172 | parameters: {} 173 | }, 174 | { 175 | id: '2', 176 | name: 'Set', 177 | type: 'n8n-nodes-base.set', 178 | position: [300, 100], 179 | parameters: {} 180 | } 181 | ], 182 | connections: { 183 | 'Split In Batches': { 184 | main: [ 185 | [{ node: 'Set', type: 'main', index: -1 }] // Invalid negative index 186 | ] 187 | } 188 | } 189 | }; 190 | 191 | const result = await validator.validateWorkflow(workflow as any); 192 | 193 | const negativeIndexErrors = result.errors.filter(e => 194 | e.message?.includes('Invalid connection index -1') 195 | ); 196 | expect(negativeIndexErrors).toHaveLength(1); 197 | expect(negativeIndexErrors[0].message).toContain('must be non-negative'); 198 | }); 199 | 200 | it('should handle very large connection indices', async () => { 201 | mockNodeRepository.getNode.mockReturnValue({ 202 | nodeType: 'nodes-base.switch', 203 | outputs: [ 204 | { displayName: 'Output 1' }, 205 | { displayName: 'Output 2' } 206 | ], 207 | properties: [] 208 | }); 209 | 210 | const workflow = { 211 | name: 'Large Index Workflow', 212 | nodes: [ 213 | { 214 | id: '1', 215 | name: 'Switch', 216 | type: 'n8n-nodes-base.switch', 217 | position: [100, 100], 218 | parameters: {} 219 | }, 220 | { 221 | id: '2', 222 | name: 'Set', 223 | type: 'n8n-nodes-base.set', 224 | position: [300, 100], 225 | parameters: {} 226 | } 227 | ], 228 | connections: { 229 | 'Switch': { 230 | main: [ 231 | [{ node: 'Set', type: 'main', index: 999 }] // Very large index 232 | ] 233 | } 234 | } 235 | }; 236 | 237 | const result = await validator.validateWorkflow(workflow as any); 238 | 239 | // Should validate without crashing (n8n allows large indices) 240 | expect(result).toBeDefined(); 241 | }); 242 | }); 243 | 244 | describe('Malformed connection structures', () => { 245 | it('should handle null connection objects', async () => { 246 | // Use default mock that includes outputs for SplitInBatches 247 | 248 | const workflow = { 249 | name: 'Null Connections Workflow', 250 | nodes: [ 251 | { 252 | id: '1', 253 | name: 'Split In Batches', 254 | type: 'n8n-nodes-base.splitInBatches', 255 | position: [100, 100], 256 | parameters: {} 257 | } 258 | ], 259 | connections: { 260 | 'Split In Batches': { 261 | main: [ 262 | null, // Null output 263 | [{ node: 'NonExistent', type: 'main', index: 0 }] 264 | ] as any 265 | } 266 | } 267 | }; 268 | 269 | const result = await validator.validateWorkflow(workflow as any); 270 | 271 | // Should handle gracefully without crashing 272 | expect(result).toBeDefined(); 273 | }); 274 | 275 | it('should handle missing connection properties', async () => { 276 | // Use default mock that includes outputs for SplitInBatches 277 | 278 | const workflow = { 279 | name: 'Malformed Connections Workflow', 280 | nodes: [ 281 | { 282 | id: '1', 283 | name: 'Split In Batches', 284 | type: 'n8n-nodes-base.splitInBatches', 285 | position: [100, 100], 286 | parameters: {} 287 | }, 288 | { 289 | id: '2', 290 | name: 'Set', 291 | type: 'n8n-nodes-base.set', 292 | position: [300, 100], 293 | parameters: {} 294 | } 295 | ], 296 | connections: { 297 | 'Split In Batches': { 298 | main: [ 299 | [ 300 | { node: 'Set' } as any, // Missing type and index 301 | { type: 'main', index: 0 } as any, // Missing node 302 | {} as any // Empty object 303 | ] 304 | ] 305 | } 306 | } 307 | }; 308 | 309 | const result = await validator.validateWorkflow(workflow as any); 310 | 311 | // Should handle malformed connections but report errors 312 | expect(result).toBeDefined(); 313 | expect(result.errors.length).toBeGreaterThan(0); 314 | }); 315 | }); 316 | 317 | describe('Deep loop back detection limits', () => { 318 | it('should respect maxDepth limit in checkForLoopBack', async () => { 319 | // Use default mock that includes outputs for SplitInBatches 320 | 321 | // Create a very deep chain that exceeds maxDepth (50) 322 | const nodes = [ 323 | { 324 | id: '1', 325 | name: 'Split In Batches', 326 | type: 'n8n-nodes-base.splitInBatches', 327 | position: [100, 100], 328 | parameters: {} 329 | } 330 | ]; 331 | 332 | const connections: any = { 333 | 'Split In Batches': { 334 | main: [ 335 | [], // Done output 336 | [{ node: 'Node1', type: 'main', index: 0 }] // Loop output 337 | ] 338 | } 339 | }; 340 | 341 | // Create chain of 60 nodes (exceeds maxDepth of 50) 342 | for (let i = 1; i <= 60; i++) { 343 | nodes.push({ 344 | id: (i + 1).toString(), 345 | name: `Node${i}`, 346 | type: 'n8n-nodes-base.set', 347 | position: [100 + i * 50, 100], 348 | parameters: {} 349 | }); 350 | 351 | if (i < 60) { 352 | connections[`Node${i}`] = { 353 | main: [[{ node: `Node${i + 1}`, type: 'main', index: 0 }]] 354 | }; 355 | } else { 356 | // Last node connects back to Split In Batches 357 | connections[`Node${i}`] = { 358 | main: [[{ node: 'Split In Batches', type: 'main', index: 0 }]] 359 | }; 360 | } 361 | } 362 | 363 | const workflow = { 364 | name: 'Deep Chain Workflow', 365 | nodes, 366 | connections 367 | }; 368 | 369 | const result = await validator.validateWorkflow(workflow as any); 370 | 371 | // Should warn about missing loop back because depth limit prevents detection 372 | const loopBackWarnings = result.warnings.filter(w => 373 | w.message?.includes('doesn\'t connect back') 374 | ); 375 | expect(loopBackWarnings).toHaveLength(1); 376 | }); 377 | 378 | it('should handle circular references without infinite loops', async () => { 379 | // Use default mock that includes outputs for SplitInBatches 380 | 381 | const workflow = { 382 | name: 'Circular Reference Workflow', 383 | nodes: [ 384 | { 385 | id: '1', 386 | name: 'Split In Batches', 387 | type: 'n8n-nodes-base.splitInBatches', 388 | position: [100, 100], 389 | parameters: {} 390 | }, 391 | { 392 | id: '2', 393 | name: 'NodeA', 394 | type: 'n8n-nodes-base.set', 395 | position: [300, 100], 396 | parameters: {} 397 | }, 398 | { 399 | id: '3', 400 | name: 'NodeB', 401 | type: 'n8n-nodes-base.function', 402 | position: [500, 100], 403 | parameters: {} 404 | } 405 | ], 406 | connections: { 407 | 'Split In Batches': { 408 | main: [ 409 | [], 410 | [{ node: 'NodeA', type: 'main', index: 0 }] 411 | ] 412 | }, 413 | 'NodeA': { 414 | main: [ 415 | [{ node: 'NodeB', type: 'main', index: 0 }] 416 | ] 417 | }, 418 | 'NodeB': { 419 | main: [ 420 | [{ node: 'NodeA', type: 'main', index: 0 }] // Circular: B -> A -> B -> A ... 421 | ] 422 | } 423 | } 424 | }; 425 | 426 | const result = await validator.validateWorkflow(workflow as any); 427 | 428 | // Should complete without hanging and warn about missing loop back 429 | expect(result).toBeDefined(); 430 | const loopBackWarnings = result.warnings.filter(w => 431 | w.message?.includes('doesn\'t connect back') 432 | ); 433 | expect(loopBackWarnings).toHaveLength(1); 434 | }); 435 | 436 | it('should handle self-referencing nodes in loop back detection', async () => { 437 | // Use default mock that includes outputs for SplitInBatches 438 | 439 | const workflow = { 440 | name: 'Self Reference Workflow', 441 | nodes: [ 442 | { 443 | id: '1', 444 | name: 'Split In Batches', 445 | type: 'n8n-nodes-base.splitInBatches', 446 | position: [100, 100], 447 | parameters: {} 448 | }, 449 | { 450 | id: '2', 451 | name: 'SelfRef', 452 | type: 'n8n-nodes-base.set', 453 | position: [300, 100], 454 | parameters: {} 455 | } 456 | ], 457 | connections: { 458 | 'Split In Batches': { 459 | main: [ 460 | [], 461 | [{ node: 'SelfRef', type: 'main', index: 0 }] 462 | ] 463 | }, 464 | 'SelfRef': { 465 | main: [ 466 | [{ node: 'SelfRef', type: 'main', index: 0 }] // Self-reference instead of loop back 467 | ] 468 | } 469 | } 470 | }; 471 | 472 | const result = await validator.validateWorkflow(workflow as any); 473 | 474 | // Should warn about missing loop back and self-reference 475 | const loopBackWarnings = result.warnings.filter(w => 476 | w.message?.includes('doesn\'t connect back') 477 | ); 478 | const selfRefWarnings = result.warnings.filter(w => 479 | w.message?.includes('self-referencing') 480 | ); 481 | 482 | expect(loopBackWarnings).toHaveLength(1); 483 | expect(selfRefWarnings).toHaveLength(1); 484 | }); 485 | }); 486 | 487 | describe('Complex output structures', () => { 488 | it('should handle nodes with many outputs', async () => { 489 | const manyOutputs = Array.from({ length: 20 }, (_, i) => ({ 490 | displayName: `Output ${i + 1}`, 491 | name: `output${i + 1}`, 492 | description: `Output number ${i + 1}` 493 | })); 494 | 495 | mockNodeRepository.getNode.mockReturnValue({ 496 | nodeType: 'nodes-base.complexSwitch', 497 | outputs: manyOutputs, 498 | outputNames: manyOutputs.map(o => o.name), 499 | properties: [] 500 | }); 501 | 502 | const workflow = { 503 | name: 'Many Outputs Workflow', 504 | nodes: [ 505 | { 506 | id: '1', 507 | name: 'Complex Switch', 508 | type: 'n8n-nodes-base.complexSwitch', 509 | position: [100, 100], 510 | parameters: {} 511 | }, 512 | { 513 | id: '2', 514 | name: 'Set', 515 | type: 'n8n-nodes-base.set', 516 | position: [300, 100], 517 | parameters: {} 518 | } 519 | ], 520 | connections: { 521 | 'Complex Switch': { 522 | main: Array.from({ length: 20 }, () => [ 523 | { node: 'Set', type: 'main', index: 0 } 524 | ]) 525 | } 526 | } 527 | }; 528 | 529 | const result = await validator.validateWorkflow(workflow as any); 530 | 531 | // Should handle without performance issues 532 | expect(result).toBeDefined(); 533 | }); 534 | 535 | it('should handle mixed output types (main, error, ai_tool)', async () => { 536 | mockNodeRepository.getNode.mockReturnValue({ 537 | nodeType: 'nodes-base.complexNode', 538 | outputs: [ 539 | { displayName: 'Main', type: 'main' }, 540 | { displayName: 'Error', type: 'error' } 541 | ], 542 | properties: [] 543 | }); 544 | 545 | const workflow = { 546 | name: 'Mixed Output Types Workflow', 547 | nodes: [ 548 | { 549 | id: '1', 550 | name: 'Complex Node', 551 | type: 'n8n-nodes-base.complexNode', 552 | position: [100, 100], 553 | parameters: {} 554 | }, 555 | { 556 | id: '2', 557 | name: 'Main Handler', 558 | type: 'n8n-nodes-base.set', 559 | position: [300, 50], 560 | parameters: {} 561 | }, 562 | { 563 | id: '3', 564 | name: 'Error Handler', 565 | type: 'n8n-nodes-base.set', 566 | position: [300, 150], 567 | parameters: {} 568 | }, 569 | { 570 | id: '4', 571 | name: 'Tool', 572 | type: 'n8n-nodes-base.httpRequest', 573 | position: [500, 100], 574 | parameters: {} 575 | } 576 | ], 577 | connections: { 578 | 'Complex Node': { 579 | main: [ 580 | [{ node: 'Main Handler', type: 'main', index: 0 }] 581 | ], 582 | error: [ 583 | [{ node: 'Error Handler', type: 'main', index: 0 }] 584 | ], 585 | ai_tool: [ 586 | [{ node: 'Tool', type: 'main', index: 0 }] 587 | ] 588 | } 589 | } 590 | }; 591 | 592 | const result = await validator.validateWorkflow(workflow as any); 593 | 594 | // Should validate all connection types 595 | expect(result).toBeDefined(); 596 | expect(result.statistics.validConnections).toBe(3); 597 | }); 598 | }); 599 | 600 | describe('SplitInBatches specific edge cases', () => { 601 | it('should handle SplitInBatches with no connections', async () => { 602 | // Use default mock that includes outputs for SplitInBatches 603 | 604 | const workflow = { 605 | name: 'Isolated SplitInBatches', 606 | nodes: [ 607 | { 608 | id: '1', 609 | name: 'Split In Batches', 610 | type: 'n8n-nodes-base.splitInBatches', 611 | position: [100, 100], 612 | parameters: {} 613 | } 614 | ], 615 | connections: {} 616 | }; 617 | 618 | const result = await validator.validateWorkflow(workflow as any); 619 | 620 | // Should not produce SplitInBatches-specific warnings for isolated node 621 | const splitWarnings = result.warnings.filter(w => 622 | w.message?.includes('SplitInBatches') || 623 | w.message?.includes('loop') || 624 | w.message?.includes('done') 625 | ); 626 | expect(splitWarnings).toHaveLength(0); 627 | }); 628 | 629 | it('should handle SplitInBatches with only one output connected', async () => { 630 | // Use default mock that includes outputs for SplitInBatches 631 | 632 | const workflow = { 633 | name: 'Single Output SplitInBatches', 634 | nodes: [ 635 | { 636 | id: '1', 637 | name: 'Split In Batches', 638 | type: 'n8n-nodes-base.splitInBatches', 639 | position: [100, 100], 640 | parameters: {} 641 | }, 642 | { 643 | id: '2', 644 | name: 'Final Action', 645 | type: 'n8n-nodes-base.emailSend', 646 | position: [300, 100], 647 | parameters: {} 648 | } 649 | ], 650 | connections: { 651 | 'Split In Batches': { 652 | main: [ 653 | [{ node: 'Final Action', type: 'main', index: 0 }], // Only done output connected 654 | [] // Loop output empty 655 | ] 656 | } 657 | } 658 | }; 659 | 660 | const result = await validator.validateWorkflow(workflow as any); 661 | 662 | // Should NOT warn about empty loop output (it's only a problem if loop connects to something but doesn't loop back) 663 | // An empty loop output is valid - it just means no looping occurs 664 | const loopWarnings = result.warnings.filter(w => 665 | w.message?.includes('loop') && w.message?.includes('connect back') 666 | ); 667 | expect(loopWarnings).toHaveLength(0); 668 | }); 669 | 670 | it('should handle SplitInBatches with both outputs to same node', async () => { 671 | // Use default mock that includes outputs for SplitInBatches 672 | 673 | const workflow = { 674 | name: 'Same Target SplitInBatches', 675 | nodes: [ 676 | { 677 | id: '1', 678 | name: 'Split In Batches', 679 | type: 'n8n-nodes-base.splitInBatches', 680 | position: [100, 100], 681 | parameters: {} 682 | }, 683 | { 684 | id: '2', 685 | name: 'Multi Purpose', 686 | type: 'n8n-nodes-base.set', 687 | position: [300, 100], 688 | parameters: {} 689 | } 690 | ], 691 | connections: { 692 | 'Split In Batches': { 693 | main: [ 694 | [{ node: 'Multi Purpose', type: 'main', index: 0 }], // Done -> Multi Purpose 695 | [{ node: 'Multi Purpose', type: 'main', index: 0 }] // Loop -> Multi Purpose 696 | ] 697 | }, 698 | 'Multi Purpose': { 699 | main: [ 700 | [{ node: 'Split In Batches', type: 'main', index: 0 }] // Loop back 701 | ] 702 | } 703 | } 704 | }; 705 | 706 | const result = await validator.validateWorkflow(workflow as any); 707 | 708 | // Both outputs go to same node which loops back - should be valid 709 | // No warnings about loop back since it does connect back 710 | const loopWarnings = result.warnings.filter(w => 711 | w.message?.includes('loop') && w.message?.includes('connect back') 712 | ); 713 | expect(loopWarnings).toHaveLength(0); 714 | }); 715 | 716 | it('should detect reversed outputs with processing node on done output', async () => { 717 | // Use default mock that includes outputs for SplitInBatches 718 | 719 | const workflow = { 720 | name: 'Reversed SplitInBatches with Function Node', 721 | nodes: [ 722 | { 723 | id: '1', 724 | name: 'Split In Batches', 725 | type: 'n8n-nodes-base.splitInBatches', 726 | position: [100, 100], 727 | parameters: {} 728 | }, 729 | { 730 | id: '2', 731 | name: 'Process Function', 732 | type: 'n8n-nodes-base.function', 733 | position: [300, 100], 734 | parameters: {} 735 | } 736 | ], 737 | connections: { 738 | 'Split In Batches': { 739 | main: [ 740 | [{ node: 'Process Function', type: 'main', index: 0 }], // Done -> Function (this is wrong) 741 | [] // Loop output empty 742 | ] 743 | }, 744 | 'Process Function': { 745 | main: [ 746 | [{ node: 'Split In Batches', type: 'main', index: 0 }] // Function connects back (indicates it should be on loop) 747 | ] 748 | } 749 | } 750 | }; 751 | 752 | const result = await validator.validateWorkflow(workflow as any); 753 | 754 | // Should error about reversed outputs since function node on done output connects back 755 | const reversedErrors = result.errors.filter(e => 756 | e.message?.includes('SplitInBatches outputs appear reversed') 757 | ); 758 | expect(reversedErrors).toHaveLength(1); 759 | }); 760 | 761 | it('should handle non-existent node type gracefully', async () => { 762 | // Node doesn't exist in repository 763 | mockNodeRepository.getNode.mockReturnValue(null); 764 | 765 | const workflow = { 766 | name: 'Unknown Node Type', 767 | nodes: [ 768 | { 769 | id: '1', 770 | name: 'Unknown Node', 771 | type: 'n8n-nodes-base.unknownNode', 772 | position: [100, 100], 773 | parameters: {} 774 | } 775 | ], 776 | connections: {} 777 | }; 778 | 779 | const result = await validator.validateWorkflow(workflow as any); 780 | 781 | // Should report unknown node type error 782 | const unknownNodeErrors = result.errors.filter(e => 783 | e.message?.includes('Unknown node type') 784 | ); 785 | expect(unknownNodeErrors).toHaveLength(1); 786 | }); 787 | }); 788 | 789 | describe('Performance edge cases', () => { 790 | it('should handle very large workflows efficiently', async () => { 791 | mockNodeRepository.getNode.mockReturnValue({ 792 | nodeType: 'nodes-base.set', 793 | properties: [] 794 | }); 795 | 796 | // Create workflow with 1000 nodes 797 | const nodes = Array.from({ length: 1000 }, (_, i) => ({ 798 | id: `node${i}`, 799 | name: `Node ${i}`, 800 | type: 'n8n-nodes-base.set', 801 | position: [100 + (i % 50) * 50, 100 + Math.floor(i / 50) * 50], 802 | parameters: {} 803 | })); 804 | 805 | // Create simple linear connections 806 | const connections: any = {}; 807 | for (let i = 0; i < 999; i++) { 808 | connections[`Node ${i}`] = { 809 | main: [[{ node: `Node ${i + 1}`, type: 'main', index: 0 }]] 810 | }; 811 | } 812 | 813 | const workflow = { 814 | name: 'Large Workflow', 815 | nodes, 816 | connections 817 | }; 818 | 819 | const startTime = Date.now(); 820 | const result = await validator.validateWorkflow(workflow as any); 821 | const duration = Date.now() - startTime; 822 | 823 | // Should complete within reasonable time (< 5 seconds) 824 | expect(duration).toBeLessThan(5000); 825 | expect(result).toBeDefined(); 826 | expect(result.statistics.totalNodes).toBe(1000); 827 | }); 828 | 829 | it('should handle workflows with many SplitInBatches nodes', async () => { 830 | // Use default mock that includes outputs for SplitInBatches 831 | 832 | // Create 100 SplitInBatches nodes 833 | const nodes = Array.from({ length: 100 }, (_, i) => ({ 834 | id: `split${i}`, 835 | name: `Split ${i}`, 836 | type: 'n8n-nodes-base.splitInBatches', 837 | position: [100 + (i % 10) * 100, 100 + Math.floor(i / 10) * 100], 838 | parameters: {} 839 | })); 840 | 841 | const connections: any = {}; 842 | // Each split connects to the next one 843 | for (let i = 0; i < 99; i++) { 844 | connections[`Split ${i}`] = { 845 | main: [ 846 | [{ node: `Split ${i + 1}`, type: 'main', index: 0 }], // Done -> next split 847 | [] // Empty loop 848 | ] 849 | }; 850 | } 851 | 852 | const workflow = { 853 | name: 'Many SplitInBatches Workflow', 854 | nodes, 855 | connections 856 | }; 857 | 858 | const result = await validator.validateWorkflow(workflow as any); 859 | 860 | // Should validate all nodes without performance issues 861 | expect(result).toBeDefined(); 862 | expect(result.statistics.totalNodes).toBe(100); 863 | }); 864 | }); 865 | }); ```