This is page 17 of 46. Use http://codebase.md/czlonkowski/n8n-mcp?lines=false&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-sanitizer.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-sanitizer.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 -------------------------------------------------------------------------------- /src/mcp/tools-n8n-manager.ts: -------------------------------------------------------------------------------- ```typescript import { ToolDefinition } from '../types'; /** * n8n Management Tools * * These tools enable AI agents to manage n8n workflows through the n8n API. * They require N8N_API_URL and N8N_API_KEY to be configured. */ export const n8nManagementTools: ToolDefinition[] = [ // Workflow Management Tools { name: 'n8n_create_workflow', description: `Create workflow. Requires: name, nodes[], connections{}. Created inactive. Returns workflow with ID.`, inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Workflow name (required)' }, nodes: { type: 'array', description: 'Array of workflow nodes. Each node must have: id, name, type, typeVersion, position, and parameters', items: { type: 'object', required: ['id', 'name', 'type', 'typeVersion', 'position', 'parameters'], properties: { id: { type: 'string' }, name: { type: 'string' }, type: { type: 'string' }, typeVersion: { type: 'number' }, position: { type: 'array', items: { type: 'number' }, minItems: 2, maxItems: 2 }, parameters: { type: 'object' }, credentials: { type: 'object' }, disabled: { type: 'boolean' }, notes: { type: 'string' }, continueOnFail: { type: 'boolean' }, retryOnFail: { type: 'boolean' }, maxTries: { type: 'number' }, waitBetweenTries: { type: 'number' } } } }, connections: { type: 'object', description: 'Workflow connections object. Keys are source node IDs, values define output connections' }, settings: { type: 'object', description: 'Optional workflow settings (execution order, timezone, error handling)', properties: { executionOrder: { type: 'string', enum: ['v0', 'v1'] }, timezone: { type: 'string' }, saveDataErrorExecution: { type: 'string', enum: ['all', 'none'] }, saveDataSuccessExecution: { type: 'string', enum: ['all', 'none'] }, saveManualExecutions: { type: 'boolean' }, saveExecutionProgress: { type: 'boolean' }, executionTimeout: { type: 'number' }, errorWorkflow: { type: 'string' } } } }, required: ['name', 'nodes', 'connections'] } }, { name: 'n8n_get_workflow', description: `Get a workflow by ID. Returns the complete workflow including nodes, connections, and settings.`, inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Workflow ID' } }, required: ['id'] } }, { name: 'n8n_get_workflow_details', description: `Get workflow details with metadata, version, execution stats. More info than get_workflow.`, inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Workflow ID' } }, required: ['id'] } }, { name: 'n8n_get_workflow_structure', description: `Get workflow structure: nodes and connections only. No parameter details.`, inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Workflow ID' } }, required: ['id'] } }, { name: 'n8n_get_workflow_minimal', description: `Get minimal info: ID, name, active status, tags. Fast for listings.`, inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Workflow ID' } }, required: ['id'] } }, { name: 'n8n_update_full_workflow', description: `Full workflow update. Requires complete nodes[] and connections{}. For incremental use n8n_update_partial_workflow.`, inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Workflow ID to update' }, name: { type: 'string', description: 'New workflow name' }, nodes: { type: 'array', description: 'Complete array of workflow nodes (required if modifying workflow structure)', items: { type: 'object', additionalProperties: true } }, connections: { type: 'object', description: 'Complete connections object (required if modifying workflow structure)' }, settings: { type: 'object', description: 'Workflow settings to update' } }, required: ['id'] } }, { name: 'n8n_update_partial_workflow', description: `Update workflow incrementally with diff operations. Types: addNode, removeNode, updateNode, moveNode, enable/disableNode, addConnection, removeConnection, updateSettings, updateName, add/removeTag. See tools_documentation("n8n_update_partial_workflow", "full") for details.`, inputSchema: { type: 'object', additionalProperties: true, // Allow any extra properties Claude Desktop might add properties: { id: { type: 'string', description: 'Workflow ID to update' }, operations: { type: 'array', description: 'Array of diff operations to apply. Each operation must have a "type" field and relevant properties for that operation type.', items: { type: 'object', additionalProperties: true } }, validateOnly: { type: 'boolean', description: 'If true, only validate operations without applying them' }, continueOnError: { type: 'boolean', description: 'If true, apply valid operations even if some fail (best-effort mode). Returns applied and failed operation indices. Default: false (atomic)' } }, required: ['id', 'operations'] } }, { name: 'n8n_delete_workflow', description: `Permanently delete a workflow. This action cannot be undone.`, inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Workflow ID to delete' } }, required: ['id'] } }, { name: 'n8n_list_workflows', description: `List workflows (minimal metadata only). Returns id/name/active/dates/tags. Check hasMore/nextCursor for pagination.`, inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Number of workflows to return (1-100, default: 100)' }, cursor: { type: 'string', description: 'Pagination cursor from previous response' }, active: { type: 'boolean', description: 'Filter by active status' }, tags: { type: 'array', items: { type: 'string' }, description: 'Filter by tags (exact match)' }, projectId: { type: 'string', description: 'Filter by project ID (enterprise feature)' }, excludePinnedData: { type: 'boolean', description: 'Exclude pinned data from response (default: true)' } } } }, { name: 'n8n_validate_workflow', description: `Validate workflow by ID. Checks nodes, connections, expressions. Returns errors/warnings/suggestions.`, inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Workflow ID to validate' }, options: { type: 'object', description: 'Validation options', properties: { validateNodes: { type: 'boolean', description: 'Validate node configurations (default: true)' }, validateConnections: { type: 'boolean', description: 'Validate workflow connections (default: true)' }, validateExpressions: { type: 'boolean', description: 'Validate n8n expressions (default: true)' }, profile: { type: 'string', enum: ['minimal', 'runtime', 'ai-friendly', 'strict'], description: 'Validation profile to use (default: runtime)' } } } }, required: ['id'] } }, { name: 'n8n_autofix_workflow', description: `Automatically fix common workflow validation errors. Preview fixes or apply them. Fixes expression format, typeVersion, error output config, webhook paths.`, inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Workflow ID to fix' }, applyFixes: { type: 'boolean', description: 'Apply fixes to workflow (default: false - preview mode)' }, fixTypes: { type: 'array', description: 'Types of fixes to apply (default: all)', items: { type: 'string', enum: ['expression-format', 'typeversion-correction', 'error-output-config', 'node-type-correction', 'webhook-missing-path'] } }, confidenceThreshold: { type: 'string', enum: ['high', 'medium', 'low'], description: 'Minimum confidence level for fixes (default: medium)' }, maxFixes: { type: 'number', description: 'Maximum number of fixes to apply (default: 50)' } }, required: ['id'] } }, // Execution Management Tools { name: 'n8n_trigger_webhook_workflow', description: `Trigger workflow via webhook. Must be ACTIVE with Webhook node. Method must match config.`, inputSchema: { type: 'object', properties: { webhookUrl: { type: 'string', description: 'Full webhook URL from n8n workflow (e.g., https://n8n.example.com/webhook/abc-def-ghi)' }, httpMethod: { type: 'string', enum: ['GET', 'POST', 'PUT', 'DELETE'], description: 'HTTP method (must match webhook configuration, often GET)' }, data: { type: 'object', description: 'Data to send with the webhook request' }, headers: { type: 'object', description: 'Additional HTTP headers' }, waitForResponse: { type: 'boolean', description: 'Wait for workflow completion (default: true)' } }, required: ['webhookUrl'] } }, { name: 'n8n_get_execution', description: `Get execution details with smart filtering. RECOMMENDED: Use mode='preview' first to assess data size. Examples: - {id, mode:'preview'} - Structure & counts (fast, no data) - {id, mode:'summary'} - 2 samples per node (default) - {id, mode:'filtered', itemsLimit:5} - 5 items per node - {id, nodeNames:['HTTP Request']} - Specific node only - {id, mode:'full'} - Complete data (use with caution)`, inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Execution ID' }, mode: { type: 'string', enum: ['preview', 'summary', 'filtered', 'full'], description: 'Data retrieval mode: preview=structure only, summary=2 items, filtered=custom, full=all data' }, nodeNames: { type: 'array', items: { type: 'string' }, description: 'Filter to specific nodes by name (for filtered mode)' }, itemsLimit: { type: 'number', description: 'Items per node: 0=structure only, 2=default, -1=unlimited (for filtered mode)' }, includeInputData: { type: 'boolean', description: 'Include input data in addition to output (default: false)' }, includeData: { type: 'boolean', description: 'Legacy: Include execution data. Maps to mode=summary if true (deprecated, use mode instead)' } }, required: ['id'] } }, { name: 'n8n_list_executions', description: `List workflow executions (returns up to limit). Check hasMore/nextCursor for pagination.`, inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Number of executions to return (1-100, default: 100)' }, cursor: { type: 'string', description: 'Pagination cursor from previous response' }, workflowId: { type: 'string', description: 'Filter by workflow ID' }, projectId: { type: 'string', description: 'Filter by project ID (enterprise feature)' }, status: { type: 'string', enum: ['success', 'error', 'waiting'], description: 'Filter by execution status' }, includeData: { type: 'boolean', description: 'Include execution data (default: false)' } } } }, { name: 'n8n_delete_execution', description: `Delete an execution record. This only removes the execution history, not any data processed.`, inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Execution ID to delete' } }, required: ['id'] } }, // System Tools { name: 'n8n_health_check', description: `Check n8n instance health and API connectivity. Returns status and available features.`, inputSchema: { type: 'object', properties: {} } }, { name: 'n8n_list_available_tools', description: `List available n8n tools and capabilities.`, inputSchema: { type: 'object', properties: {} } }, { name: 'n8n_diagnostic', description: `Diagnose n8n API config. Shows tool status, API connectivity, env vars. Helps troubleshoot missing tools.`, inputSchema: { type: 'object', properties: { verbose: { type: 'boolean', description: 'Include detailed debug information (default: false)' } } } } ]; ``` -------------------------------------------------------------------------------- /src/utils/fixed-collection-validator.ts: -------------------------------------------------------------------------------- ```typescript /** * Generic utility for validating and fixing fixedCollection structures in n8n nodes * Prevents the "propertyValues[itemName] is not iterable" error */ // Type definitions for node configurations export type NodeConfigValue = string | number | boolean | null | undefined | NodeConfig | NodeConfigValue[]; export interface NodeConfig { [key: string]: NodeConfigValue; } export interface FixedCollectionPattern { nodeType: string; property: string; subProperty?: string; expectedStructure: string; invalidPatterns: string[]; } export interface FixedCollectionValidationResult { isValid: boolean; errors: Array<{ pattern: string; message: string; fix: string; }>; autofix?: NodeConfig | NodeConfigValue[]; } export class FixedCollectionValidator { /** * Type guard to check if value is a NodeConfig */ private static isNodeConfig(value: NodeConfigValue): value is NodeConfig { return typeof value === 'object' && value !== null && !Array.isArray(value); } /** * Safely get nested property value */ private static getNestedValue(obj: NodeConfig, path: string): NodeConfigValue | undefined { const parts = path.split('.'); let current: NodeConfigValue = obj; for (const part of parts) { if (!this.isNodeConfig(current)) { return undefined; } current = current[part]; } return current; } /** * Known problematic patterns for various n8n nodes */ private static readonly KNOWN_PATTERNS: FixedCollectionPattern[] = [ // Conditional nodes (already fixed) { nodeType: 'switch', property: 'rules', expectedStructure: 'rules.values array', invalidPatterns: ['rules.conditions', 'rules.conditions.values'] }, { nodeType: 'if', property: 'conditions', expectedStructure: 'conditions array/object', invalidPatterns: ['conditions.values'] }, { nodeType: 'filter', property: 'conditions', expectedStructure: 'conditions array/object', invalidPatterns: ['conditions.values'] }, // New nodes identified by research { nodeType: 'summarize', property: 'fieldsToSummarize', subProperty: 'values', expectedStructure: 'fieldsToSummarize.values array', invalidPatterns: ['fieldsToSummarize.values.values'] }, { nodeType: 'comparedatasets', property: 'mergeByFields', subProperty: 'values', expectedStructure: 'mergeByFields.values array', invalidPatterns: ['mergeByFields.values.values'] }, { nodeType: 'sort', property: 'sortFieldsUi', subProperty: 'sortField', expectedStructure: 'sortFieldsUi.sortField array', invalidPatterns: ['sortFieldsUi.sortField.values'] }, { nodeType: 'aggregate', property: 'fieldsToAggregate', subProperty: 'fieldToAggregate', expectedStructure: 'fieldsToAggregate.fieldToAggregate array', invalidPatterns: ['fieldsToAggregate.fieldToAggregate.values'] }, { nodeType: 'set', property: 'fields', subProperty: 'values', expectedStructure: 'fields.values array', invalidPatterns: ['fields.values.values'] }, { nodeType: 'html', property: 'extractionValues', subProperty: 'values', expectedStructure: 'extractionValues.values array', invalidPatterns: ['extractionValues.values.values'] }, { nodeType: 'httprequest', property: 'body', subProperty: 'parameters', expectedStructure: 'body.parameters array', invalidPatterns: ['body.parameters.values'] }, { nodeType: 'airtable', property: 'sort', subProperty: 'sortField', expectedStructure: 'sort.sortField array', invalidPatterns: ['sort.sortField.values'] } ]; /** * Validate a node configuration for fixedCollection issues * Includes protection against circular references */ static validate( nodeType: string, config: NodeConfig ): FixedCollectionValidationResult { // Early return for non-object configs if (typeof config !== 'object' || config === null || Array.isArray(config)) { return { isValid: true, errors: [] }; } const normalizedNodeType = this.normalizeNodeType(nodeType); const pattern = this.getPatternForNode(normalizedNodeType); if (!pattern) { return { isValid: true, errors: [] }; } const result: FixedCollectionValidationResult = { isValid: true, errors: [] }; // Check for invalid patterns for (const invalidPattern of pattern.invalidPatterns) { if (this.hasInvalidStructure(config, invalidPattern)) { result.isValid = false; result.errors.push({ pattern: invalidPattern, message: `Invalid structure for nodes-base.${pattern.nodeType} node: found nested "${invalidPattern}" but expected "${pattern.expectedStructure}". This causes "propertyValues[itemName] is not iterable" error in n8n.`, fix: this.generateFixMessage(pattern) }); // Generate autofix if (!result.autofix) { result.autofix = this.generateAutofix(config, pattern); } } } return result; } /** * Apply autofix to a configuration */ static applyAutofix( config: NodeConfig, pattern: FixedCollectionPattern ): NodeConfig | NodeConfigValue[] { const fixedConfig = this.generateAutofix(config, pattern); // For If/Filter nodes, the autofix might return just the values array if (pattern.nodeType === 'if' || pattern.nodeType === 'filter') { const conditions = config.conditions; if (conditions && typeof conditions === 'object' && !Array.isArray(conditions) && 'values' in conditions) { const values = conditions.values; if (values !== undefined && values !== null && (Array.isArray(values) || typeof values === 'object')) { return values as NodeConfig | NodeConfigValue[]; } } } return fixedConfig; } /** * Normalize node type to handle various formats */ private static normalizeNodeType(nodeType: string): string { return nodeType .replace('n8n-nodes-base.', '') .replace('nodes-base.', '') .replace('@n8n/n8n-nodes-langchain.', '') .toLowerCase(); } /** * Get pattern configuration for a specific node type */ private static getPatternForNode(nodeType: string): FixedCollectionPattern | undefined { return this.KNOWN_PATTERNS.find(p => p.nodeType === nodeType); } /** * Check if configuration has an invalid structure * Includes circular reference protection */ private static hasInvalidStructure( config: NodeConfig, pattern: string ): boolean { const parts = pattern.split('.'); let current: NodeConfigValue = config; const visited = new WeakSet<object>(); for (const part of parts) { // Check for null/undefined if (current === null || current === undefined) { return false; } // Check if it's an object (but not an array for property access) if (typeof current !== 'object' || Array.isArray(current)) { return false; } // Check for circular reference if (visited.has(current)) { return false; // Circular reference detected, invalid structure } visited.add(current); // Check if property exists (using hasOwnProperty to avoid prototype pollution) if (!Object.prototype.hasOwnProperty.call(current, part)) { return false; } const nextValue = (current as NodeConfig)[part]; if (typeof nextValue !== 'object' || nextValue === null) { // If we have more parts to traverse but current value is not an object, invalid structure if (parts.indexOf(part) < parts.length - 1) { return false; } } current = nextValue as NodeConfig; } return true; } /** * Generate a fix message for the specific pattern */ private static generateFixMessage(pattern: FixedCollectionPattern): string { switch (pattern.nodeType) { case 'switch': return 'Use: { "rules": { "values": [{ "conditions": {...}, "outputKey": "output1" }] } }'; case 'if': case 'filter': return 'Use: { "conditions": {...} } or { "conditions": [...] } directly, not nested under "values"'; case 'summarize': return 'Use: { "fieldsToSummarize": { "values": [...] } } not nested values.values'; case 'comparedatasets': return 'Use: { "mergeByFields": { "values": [...] } } not nested values.values'; case 'sort': return 'Use: { "sortFieldsUi": { "sortField": [...] } } not sortField.values'; case 'aggregate': return 'Use: { "fieldsToAggregate": { "fieldToAggregate": [...] } } not fieldToAggregate.values'; case 'set': return 'Use: { "fields": { "values": [...] } } not nested values.values'; case 'html': return 'Use: { "extractionValues": { "values": [...] } } not nested values.values'; case 'httprequest': return 'Use: { "body": { "parameters": [...] } } not parameters.values'; case 'airtable': return 'Use: { "sort": { "sortField": [...] } } not sortField.values'; default: return `Use ${pattern.expectedStructure} structure`; } } /** * Generate autofix for invalid structures */ private static generateAutofix( config: NodeConfig, pattern: FixedCollectionPattern ): NodeConfig | NodeConfigValue[] { const fixedConfig = { ...config }; switch (pattern.nodeType) { case 'switch': { const rules = config.rules; if (this.isNodeConfig(rules)) { const conditions = rules.conditions; if (this.isNodeConfig(conditions) && 'values' in conditions) { const values = conditions.values; fixedConfig.rules = { values: Array.isArray(values) ? values.map((condition, index) => ({ conditions: condition, outputKey: `output${index + 1}` })) : [{ conditions: values, outputKey: 'output1' }] }; } else if (conditions) { fixedConfig.rules = { values: [{ conditions: conditions, outputKey: 'output1' }] }; } } break; } case 'if': case 'filter': { const conditions = config.conditions; if (this.isNodeConfig(conditions) && 'values' in conditions) { const values = conditions.values; if (values !== undefined && values !== null && (Array.isArray(values) || typeof values === 'object')) { return values as NodeConfig | NodeConfigValue[]; } } break; } case 'summarize': { const fieldsToSummarize = config.fieldsToSummarize; if (this.isNodeConfig(fieldsToSummarize)) { const values = fieldsToSummarize.values; if (this.isNodeConfig(values) && 'values' in values) { fixedConfig.fieldsToSummarize = { values: values.values }; } } break; } case 'comparedatasets': { const mergeByFields = config.mergeByFields; if (this.isNodeConfig(mergeByFields)) { const values = mergeByFields.values; if (this.isNodeConfig(values) && 'values' in values) { fixedConfig.mergeByFields = { values: values.values }; } } break; } case 'sort': { const sortFieldsUi = config.sortFieldsUi; if (this.isNodeConfig(sortFieldsUi)) { const sortField = sortFieldsUi.sortField; if (this.isNodeConfig(sortField) && 'values' in sortField) { fixedConfig.sortFieldsUi = { sortField: sortField.values }; } } break; } case 'aggregate': { const fieldsToAggregate = config.fieldsToAggregate; if (this.isNodeConfig(fieldsToAggregate)) { const fieldToAggregate = fieldsToAggregate.fieldToAggregate; if (this.isNodeConfig(fieldToAggregate) && 'values' in fieldToAggregate) { fixedConfig.fieldsToAggregate = { fieldToAggregate: fieldToAggregate.values }; } } break; } case 'set': { const fields = config.fields; if (this.isNodeConfig(fields)) { const values = fields.values; if (this.isNodeConfig(values) && 'values' in values) { fixedConfig.fields = { values: values.values }; } } break; } case 'html': { const extractionValues = config.extractionValues; if (this.isNodeConfig(extractionValues)) { const values = extractionValues.values; if (this.isNodeConfig(values) && 'values' in values) { fixedConfig.extractionValues = { values: values.values }; } } break; } case 'httprequest': { const body = config.body; if (this.isNodeConfig(body)) { const parameters = body.parameters; if (this.isNodeConfig(parameters) && 'values' in parameters) { fixedConfig.body = { ...body, parameters: parameters.values }; } } break; } case 'airtable': { const sort = config.sort; if (this.isNodeConfig(sort)) { const sortField = sort.sortField; if (this.isNodeConfig(sortField) && 'values' in sortField) { fixedConfig.sort = { sortField: sortField.values }; } } break; } } return fixedConfig; } /** * Get all known patterns (for testing and documentation) * Returns a deep copy to prevent external modifications */ static getAllPatterns(): FixedCollectionPattern[] { return this.KNOWN_PATTERNS.map(pattern => ({ ...pattern, invalidPatterns: [...pattern.invalidPatterns] })); } /** * Check if a node type is susceptible to fixedCollection issues */ static isNodeSusceptible(nodeType: string): boolean { const normalizedType = this.normalizeNodeType(nodeType); return this.KNOWN_PATTERNS.some(p => p.nodeType === normalizedType); } } ``` -------------------------------------------------------------------------------- /tests/unit/parsers/node-parser-outputs.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest'; import { NodeParser } from '@/parsers/node-parser'; import { PropertyExtractor } from '@/parsers/property-extractor'; // Mock PropertyExtractor vi.mock('@/parsers/property-extractor'); describe('NodeParser - Output Extraction', () => { let parser: NodeParser; let mockPropertyExtractor: any; beforeEach(() => { vi.clearAllMocks(); mockPropertyExtractor = { extractProperties: vi.fn().mockReturnValue([]), extractCredentials: vi.fn().mockReturnValue([]), detectAIToolCapability: vi.fn().mockReturnValue(false), extractOperations: vi.fn().mockReturnValue([]) }; (PropertyExtractor as any).mockImplementation(() => mockPropertyExtractor); parser = new NodeParser(); }); describe('extractOutputs method', () => { it('should extract outputs array from base description', () => { const outputs = [ { displayName: 'Done', description: 'Final results when loop completes' }, { displayName: 'Loop', description: 'Current batch data during iteration' } ]; const nodeDescription = { name: 'splitInBatches', displayName: 'Split In Batches', outputs }; const NodeClass = class { description = nodeDescription; }; const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.outputs).toEqual(outputs); expect(result.outputNames).toBeUndefined(); }); it('should extract outputNames array from base description', () => { const outputNames = ['done', 'loop']; const nodeDescription = { name: 'splitInBatches', displayName: 'Split In Batches', outputNames }; const NodeClass = class { description = nodeDescription; }; const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.outputNames).toEqual(outputNames); expect(result.outputs).toBeUndefined(); }); it('should extract both outputs and outputNames when both are present', () => { const outputs = [ { displayName: 'Done', description: 'Final results when loop completes' }, { displayName: 'Loop', description: 'Current batch data during iteration' } ]; const outputNames = ['done', 'loop']; const nodeDescription = { name: 'splitInBatches', displayName: 'Split In Batches', outputs, outputNames }; const NodeClass = class { description = nodeDescription; }; const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.outputs).toEqual(outputs); expect(result.outputNames).toEqual(outputNames); }); it('should convert single output to array format', () => { const singleOutput = { displayName: 'Output', description: 'Single output' }; const nodeDescription = { name: 'singleOutputNode', displayName: 'Single Output Node', outputs: singleOutput }; const NodeClass = class { description = nodeDescription; }; const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.outputs).toEqual([singleOutput]); }); it('should convert single outputName to array format', () => { const nodeDescription = { name: 'singleOutputNode', displayName: 'Single Output Node', outputNames: 'main' }; const NodeClass = class { description = nodeDescription; }; const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.outputNames).toEqual(['main']); }); it('should extract outputs from versioned node when not in base description', () => { const versionedOutputs = [ { displayName: 'True', description: 'Items that match condition' }, { displayName: 'False', description: 'Items that do not match condition' } ]; const NodeClass = class { description = { name: 'if', displayName: 'IF' // No outputs in base description }; nodeVersions = { 1: { description: { outputs: versionedOutputs } }, 2: { description: { outputs: versionedOutputs, outputNames: ['true', 'false'] } } }; }; const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); // Should get outputs from latest version (2) expect(result.outputs).toEqual(versionedOutputs); expect(result.outputNames).toEqual(['true', 'false']); }); it('should handle node instantiation failure gracefully', () => { const NodeClass = class { // Static description that can be accessed when instantiation fails static description = { name: 'problematic', displayName: 'Problematic Node' }; constructor() { throw new Error('Cannot instantiate'); } }; const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.outputs).toBeUndefined(); expect(result.outputNames).toBeUndefined(); }); it('should return empty result when no outputs found anywhere', () => { const nodeDescription = { name: 'noOutputs', displayName: 'No Outputs Node' // No outputs or outputNames }; const NodeClass = class { description = nodeDescription; }; const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.outputs).toBeUndefined(); expect(result.outputNames).toBeUndefined(); }); it('should handle complex versioned node structure', () => { const NodeClass = class VersionedNodeType { baseDescription = { name: 'complexVersioned', displayName: 'Complex Versioned Node', defaultVersion: 3 }; nodeVersions = { 1: { description: { outputs: [{ displayName: 'V1 Output' }] } }, 2: { description: { outputs: [ { displayName: 'V2 Output 1' }, { displayName: 'V2 Output 2' } ] } }, 3: { description: { outputs: [ { displayName: 'V3 True', description: 'True branch' }, { displayName: 'V3 False', description: 'False branch' } ], outputNames: ['true', 'false'] } } }; }; const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); // Should use latest version (3) expect(result.outputs).toEqual([ { displayName: 'V3 True', description: 'True branch' }, { displayName: 'V3 False', description: 'False branch' } ]); expect(result.outputNames).toEqual(['true', 'false']); }); it('should prefer base description outputs over versioned when both exist', () => { const baseOutputs = [{ displayName: 'Base Output' }]; const versionedOutputs = [{ displayName: 'Versioned Output' }]; const NodeClass = class { description = { name: 'preferBase', displayName: 'Prefer Base', outputs: baseOutputs }; nodeVersions = { 1: { description: { outputs: versionedOutputs } } }; }; const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.outputs).toEqual(baseOutputs); }); it('should handle IF node with typical output structure', () => { const ifOutputs = [ { displayName: 'True', description: 'Items that match the condition' }, { displayName: 'False', description: 'Items that do not match the condition' } ]; const NodeClass = class { description = { name: 'if', displayName: 'IF', outputs: ifOutputs, outputNames: ['true', 'false'] }; }; const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.outputs).toEqual(ifOutputs); expect(result.outputNames).toEqual(['true', 'false']); }); it('should handle SplitInBatches node with counterintuitive output structure', () => { const splitInBatchesOutputs = [ { displayName: 'Done', description: 'Final results when loop completes' }, { displayName: 'Loop', description: 'Current batch data during iteration' } ]; const NodeClass = class { description = { name: 'splitInBatches', displayName: 'Split In Batches', outputs: splitInBatchesOutputs, outputNames: ['done', 'loop'] }; }; const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.outputs).toEqual(splitInBatchesOutputs); expect(result.outputNames).toEqual(['done', 'loop']); // Verify the counterintuitive order: done=0, loop=1 expect(result.outputs).toBeDefined(); expect(result.outputNames).toBeDefined(); expect(result.outputs![0].displayName).toBe('Done'); expect(result.outputs![1].displayName).toBe('Loop'); expect(result.outputNames![0]).toBe('done'); expect(result.outputNames![1]).toBe('loop'); }); it('should handle Switch node with multiple outputs', () => { const switchOutputs = [ { displayName: 'Output 1', description: 'First branch' }, { displayName: 'Output 2', description: 'Second branch' }, { displayName: 'Output 3', description: 'Third branch' }, { displayName: 'Fallback', description: 'Default branch when no conditions match' } ]; const NodeClass = class { description = { name: 'switch', displayName: 'Switch', outputs: switchOutputs, outputNames: ['0', '1', '2', 'fallback'] }; }; const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.outputs).toEqual(switchOutputs); expect(result.outputNames).toEqual(['0', '1', '2', 'fallback']); }); it('should handle empty outputs array', () => { const NodeClass = class { description = { name: 'emptyOutputs', displayName: 'Empty Outputs', outputs: [], outputNames: [] }; }; const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.outputs).toEqual([]); expect(result.outputNames).toEqual([]); }); it('should handle mismatched outputs and outputNames arrays', () => { const outputs = [ { displayName: 'Output 1' }, { displayName: 'Output 2' } ]; const outputNames = ['first', 'second', 'third']; // One extra const NodeClass = class { description = { name: 'mismatched', displayName: 'Mismatched Arrays', outputs, outputNames }; }; const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.outputs).toEqual(outputs); expect(result.outputNames).toEqual(outputNames); }); }); describe('real-world node structures', () => { it('should handle actual n8n SplitInBatches node structure', () => { // This mimics the actual structure from n8n-nodes-base const NodeClass = class { description = { name: 'splitInBatches', displayName: 'Split In Batches', description: 'Split data into batches and iterate over each batch', icon: 'fa:th-large', group: ['transform'], version: 3, outputs: [ { displayName: 'Done', name: 'done', type: 'main', hint: 'Receives the final data after all batches have been processed' }, { displayName: 'Loop', name: 'loop', type: 'main', hint: 'Receives the current batch data during each iteration' } ], outputNames: ['done', 'loop'] }; }; const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.outputs).toHaveLength(2); expect(result.outputs).toBeDefined(); expect(result.outputs![0].displayName).toBe('Done'); expect(result.outputs![1].displayName).toBe('Loop'); expect(result.outputNames).toEqual(['done', 'loop']); }); it('should handle actual n8n IF node structure', () => { // This mimics the actual structure from n8n-nodes-base const NodeClass = class { description = { name: 'if', displayName: 'IF', description: 'Route items to different outputs based on conditions', icon: 'fa:map-signs', group: ['transform'], version: 2, outputs: [ { displayName: 'True', name: 'true', type: 'main', hint: 'Items that match the condition' }, { displayName: 'False', name: 'false', type: 'main', hint: 'Items that do not match the condition' } ], outputNames: ['true', 'false'] }; }; const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.outputs).toHaveLength(2); expect(result.outputs).toBeDefined(); expect(result.outputs![0].displayName).toBe('True'); expect(result.outputs![1].displayName).toBe('False'); expect(result.outputNames).toEqual(['true', 'false']); }); it('should handle single-output nodes like HTTP Request', () => { const NodeClass = class { description = { name: 'httpRequest', displayName: 'HTTP Request', description: 'Make HTTP requests', icon: 'fa:at', group: ['input'], version: 4 // No outputs specified - single main output implied }; }; const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.outputs).toBeUndefined(); expect(result.outputNames).toBeUndefined(); }); }); }); ``` -------------------------------------------------------------------------------- /src/data/canonical-ai-tool-examples.json: -------------------------------------------------------------------------------- ```json { "description": "Canonical configuration examples for critical AI tools based on FINAL_AI_VALIDATION_SPEC.md", "version": "1.0.0", "examples": [ { "node_type": "@n8n/n8n-nodes-langchain.toolHttpRequest", "display_name": "HTTP Request Tool", "examples": [ { "name": "Weather API Tool", "use_case": "Fetch current weather data for AI Agent", "complexity": "simple", "parameters": { "method": "GET", "url": "https://api.weatherapi.com/v1/current.json?key={{$credentials.weatherApiKey}}&q={city}", "toolDescription": "Get current weather conditions for a city. Provide the city name (e.g., 'London', 'New York') and receive temperature, humidity, wind speed, and conditions.", "placeholderDefinitions": { "values": [ { "name": "city", "description": "Name of the city to get weather for", "type": "string" } ] }, "authentication": "predefinedCredentialType", "nodeCredentialType": "weatherApiApi" }, "credentials": { "weatherApiApi": { "id": "1", "name": "Weather API account" } }, "notes": "Example shows proper toolDescription, URL with placeholder, and credential configuration" }, { "name": "GitHub Issues Tool", "use_case": "Create GitHub issues from AI Agent conversations", "complexity": "medium", "parameters": { "method": "POST", "url": "https://api.github.com/repos/{owner}/{repo}/issues", "toolDescription": "Create a new GitHub issue. Requires owner (repo owner username), repo (repository name), title, and body. Returns the created issue URL and number.", "placeholderDefinitions": { "values": [ { "name": "owner", "description": "GitHub repository owner username", "type": "string" }, { "name": "repo", "description": "Repository name", "type": "string" }, { "name": "title", "description": "Issue title", "type": "string" }, { "name": "body", "description": "Issue description and details", "type": "string" } ] }, "sendBody": true, "specifyBody": "json", "jsonBody": "={{ { \"title\": $json.title, \"body\": $json.body } }}", "authentication": "predefinedCredentialType", "nodeCredentialType": "githubApi" }, "credentials": { "githubApi": { "id": "2", "name": "GitHub credentials" } }, "notes": "Example shows POST request with JSON body, multiple placeholders, and expressions" }, { "name": "Slack Message Tool", "use_case": "Send Slack messages from AI Agent", "complexity": "simple", "parameters": { "method": "POST", "url": "https://slack.com/api/chat.postMessage", "toolDescription": "Send a message to a Slack channel. Provide channel ID or name (e.g., '#general', 'C1234567890') and message text.", "placeholderDefinitions": { "values": [ { "name": "channel", "description": "Channel ID or name (e.g., #general)", "type": "string" }, { "name": "text", "description": "Message text to send", "type": "string" } ] }, "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "Content-Type", "value": "application/json; charset=utf-8" }, { "name": "Authorization", "value": "=Bearer {{$credentials.slackApi.accessToken}}" } ] }, "sendBody": true, "specifyBody": "json", "jsonBody": "={{ { \"channel\": $json.channel, \"text\": $json.text } }}", "authentication": "predefinedCredentialType", "nodeCredentialType": "slackApi" }, "credentials": { "slackApi": { "id": "3", "name": "Slack account" } }, "notes": "Example shows headers with credential expressions and JSON body construction" } ] }, { "node_type": "@n8n/n8n-nodes-langchain.toolCode", "display_name": "Code Tool", "examples": [ { "name": "Calculate Shipping Cost", "use_case": "Calculate shipping costs based on weight and distance", "complexity": "simple", "parameters": { "name": "calculate_shipping_cost", "description": "Calculate shipping cost based on package weight (in kg) and distance (in km). Returns the cost in USD.", "language": "javaScript", "code": "const baseRate = 5;\nconst perKgRate = 2;\nconst perKmRate = 0.1;\n\nconst weight = $input.weight || 0;\nconst distance = $input.distance || 0;\n\nconst cost = baseRate + (weight * perKgRate) + (distance * perKmRate);\n\nreturn { cost: parseFloat(cost.toFixed(2)), currency: 'USD' };", "specifyInputSchema": true, "schemaType": "manual", "inputSchema": "{\n \"type\": \"object\",\n \"properties\": {\n \"weight\": {\n \"type\": \"number\",\n \"description\": \"Package weight in kilograms\"\n },\n \"distance\": {\n \"type\": \"number\",\n \"description\": \"Shipping distance in kilometers\"\n }\n },\n \"required\": [\"weight\", \"distance\"]\n}" }, "notes": "Example shows proper function naming, detailed description, input schema, and return value" }, { "name": "Format Customer Data", "use_case": "Transform and validate customer information", "complexity": "medium", "parameters": { "name": "format_customer_data", "description": "Format and validate customer data. Takes raw customer info (name, email, phone) and returns formatted object with validation status.", "language": "javaScript", "code": "const { name, email, phone } = $input;\n\n// Validation\nconst emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\nconst phoneRegex = /^\\+?[1-9]\\d{1,14}$/;\n\nconst errors = [];\nif (!emailRegex.test(email)) errors.push('Invalid email format');\nif (!phoneRegex.test(phone)) errors.push('Invalid phone format');\n\n// Formatting\nconst formatted = {\n name: name.trim(),\n email: email.toLowerCase().trim(),\n phone: phone.replace(/\\s/g, ''),\n valid: errors.length === 0,\n errors: errors\n};\n\nreturn formatted;", "specifyInputSchema": true, "schemaType": "manual", "inputSchema": "{\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\",\n \"description\": \"Customer full name\"\n },\n \"email\": {\n \"type\": \"string\",\n \"description\": \"Customer email address\"\n },\n \"phone\": {\n \"type\": \"string\",\n \"description\": \"Customer phone number\"\n }\n },\n \"required\": [\"name\", \"email\", \"phone\"]\n}" }, "notes": "Example shows data validation, formatting, and structured error handling" }, { "name": "Parse Date Range", "use_case": "Convert natural language date ranges to ISO format", "complexity": "medium", "parameters": { "name": "parse_date_range", "description": "Parse natural language date ranges (e.g., 'last 7 days', 'this month', 'Q1 2024') into start and end dates in ISO format.", "language": "javaScript", "code": "const input = $input.dateRange || '';\nconst now = new Date();\nlet start, end;\n\nif (input.includes('last') && input.includes('days')) {\n const days = parseInt(input.match(/\\d+/)[0]);\n start = new Date(now.getTime() - (days * 24 * 60 * 60 * 1000));\n end = now;\n} else if (input === 'this month') {\n start = new Date(now.getFullYear(), now.getMonth(), 1);\n end = new Date(now.getFullYear(), now.getMonth() + 1, 0);\n} else if (input === 'this year') {\n start = new Date(now.getFullYear(), 0, 1);\n end = new Date(now.getFullYear(), 11, 31);\n} else {\n throw new Error('Unsupported date range format');\n}\n\nreturn {\n startDate: start.toISOString().split('T')[0],\n endDate: end.toISOString().split('T')[0],\n daysCount: Math.ceil((end - start) / (24 * 60 * 60 * 1000))\n};", "specifyInputSchema": true, "schemaType": "manual", "inputSchema": "{\n \"type\": \"object\",\n \"properties\": {\n \"dateRange\": {\n \"type\": \"string\",\n \"description\": \"Natural language date range (e.g., 'last 7 days', 'this month')\"\n }\n },\n \"required\": [\"dateRange\"]\n}" }, "notes": "Example shows complex logic, error handling, and date manipulation" } ] }, { "node_type": "@n8n/n8n-nodes-langchain.agentTool", "display_name": "AI Agent Tool", "examples": [ { "name": "Research Specialist Agent", "use_case": "Specialized sub-agent for in-depth research tasks", "complexity": "medium", "parameters": { "name": "research_specialist", "description": "Expert research agent that can search multiple sources, synthesize information, and provide comprehensive analysis on any topic. Use this when you need detailed, well-researched information.", "promptType": "define", "text": "You are a research specialist. Your role is to:\n1. Search for relevant information from multiple sources\n2. Synthesize findings into a coherent analysis\n3. Cite your sources\n4. Highlight key insights and patterns\n\nProvide thorough, well-structured research that answers the user's question comprehensively.", "systemMessage": "You are a meticulous researcher focused on accuracy and completeness. Always cite sources and acknowledge limitations in available information." }, "connections": { "ai_languageModel": [ { "node": "OpenAI GPT-4", "type": "ai_languageModel", "index": 0 } ], "ai_tool": [ { "node": "SerpApi Tool", "type": "ai_tool", "index": 0 }, { "node": "Wikipedia Tool", "type": "ai_tool", "index": 0 } ] }, "notes": "Example shows specialized sub-agent with custom prompt, specific system message, and multiple search tools" }, { "name": "Data Analysis Agent", "use_case": "Sub-agent for analyzing and visualizing data", "complexity": "complex", "parameters": { "name": "data_analyst", "description": "Data analysis specialist that can process datasets, calculate statistics, identify trends, and generate insights. Use for any data analysis or statistical questions.", "promptType": "auto", "systemMessage": "You are a data analyst with expertise in statistics and data interpretation. Break down complex datasets into understandable insights. Use the Code Tool to perform calculations when needed.", "maxIterations": 10 }, "connections": { "ai_languageModel": [ { "node": "Anthropic Claude", "type": "ai_languageModel", "index": 0 } ], "ai_tool": [ { "node": "Code Tool - Stats", "type": "ai_tool", "index": 0 }, { "node": "HTTP Request Tool - Data API", "type": "ai_tool", "index": 0 } ] }, "notes": "Example shows auto prompt type with specialized system message and analytical tools" } ] }, { "node_type": "@n8n/n8n-nodes-langchain.mcpClientTool", "display_name": "MCP Client Tool", "examples": [ { "name": "Filesystem MCP Tool", "use_case": "Access filesystem operations via MCP protocol", "complexity": "medium", "parameters": { "description": "Access file system operations through MCP. Can read files, list directories, create files, and search for content.", "mcpServer": { "transport": "stdio", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/directory"] }, "tool": "read_file" }, "notes": "Example shows stdio transport MCP server with filesystem access tool" }, { "name": "Puppeteer MCP Tool", "use_case": "Browser automation via MCP for AI Agents", "complexity": "complex", "parameters": { "description": "Control a web browser to navigate pages, take screenshots, and extract content. Useful for web scraping and automated testing.", "mcpServer": { "transport": "stdio", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-puppeteer"] }, "tool": "puppeteer_navigate" }, "notes": "Example shows Puppeteer MCP server for browser automation" }, { "name": "Database MCP Tool", "use_case": "Query databases via MCP protocol", "complexity": "complex", "parameters": { "description": "Execute SQL queries and retrieve data from PostgreSQL databases. Supports SELECT, INSERT, UPDATE operations with proper escaping.", "mcpServer": { "transport": "sse", "url": "https://mcp-server.example.com/database" }, "tool": "execute_query" }, "notes": "Example shows SSE transport MCP server for remote database access" } ] } ] } ``` -------------------------------------------------------------------------------- /tests/unit/services/workflow-validator-with-mocks.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest'; import { WorkflowValidator } from '@/services/workflow-validator'; import { EnhancedConfigValidator } from '@/services/enhanced-config-validator'; // Mock logger to prevent console output vi.mock('@/utils/logger', () => ({ Logger: vi.fn().mockImplementation(() => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn() })) })); describe('WorkflowValidator - Simple Unit Tests', () => { let validator: WorkflowValidator; // Create a simple mock repository const createMockRepository = (nodeData: Record<string, any>) => ({ getNode: vi.fn((type: string) => nodeData[type] || null), findSimilarNodes: vi.fn().mockReturnValue([]) }); // Create a simple mock validator class const createMockValidatorClass = (validationResult: any) => ({ validateWithMode: vi.fn().mockReturnValue(validationResult) }); beforeEach(() => { vi.clearAllMocks(); }); describe('Basic validation scenarios', () => { it('should pass validation for a webhook workflow with single node', async () => { // Arrange const nodeData = { 'n8n-nodes-base.webhook': { type: 'nodes-base.webhook', displayName: 'Webhook', name: 'webhook', version: 1, isVersioned: true, properties: [] }, 'nodes-base.webhook': { type: 'nodes-base.webhook', displayName: 'Webhook', name: 'webhook', version: 1, isVersioned: true, properties: [] } }; const mockRepository = createMockRepository(nodeData); const mockValidatorClass = createMockValidatorClass({ valid: true, errors: [], warnings: [], suggestions: [] }); validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any); const workflow = { name: 'Webhook Workflow', nodes: [ { id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', typeVersion: 1, position: [250, 300] as [number, number], parameters: {} } ], connections: {} }; // Act const result = await validator.validateWorkflow(workflow as any); // Assert expect(result.valid).toBe(true); expect(result.errors).toHaveLength(0); // Single webhook node should just have a warning about no connections expect(result.warnings.some(w => w.message.includes('no connections'))).toBe(true); }); it('should fail validation for unknown node types', async () => { // Arrange const mockRepository = createMockRepository({}); // Empty node data const mockValidatorClass = createMockValidatorClass({ valid: true, errors: [], warnings: [], suggestions: [] }); validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any); const workflow = { name: 'Test Workflow', nodes: [ { id: '1', name: 'Unknown', type: 'n8n-nodes-base.unknownNode', position: [250, 300] as [number, number], parameters: {} } ], connections: {} }; // Act const result = await validator.validateWorkflow(workflow as any); // Assert expect(result.valid).toBe(false); // Check for either the error message or valid being false const hasUnknownNodeError = result.errors.some(e => e.message && (e.message.includes('Unknown node type') || e.message.includes('unknown-node-type')) ); expect(result.errors.length > 0 || hasUnknownNodeError).toBe(true); }); it('should detect duplicate node names', async () => { // Arrange const mockRepository = createMockRepository({}); const mockValidatorClass = createMockValidatorClass({ valid: true, errors: [], warnings: [], suggestions: [] }); validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any); const workflow = { name: 'Duplicate Names', nodes: [ { id: '1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', position: [250, 300] as [number, number], parameters: {} }, { id: '2', name: 'HTTP Request', // Duplicate name type: 'n8n-nodes-base.httpRequest', position: [450, 300] as [number, number], parameters: {} } ], connections: {} }; // Act const result = await validator.validateWorkflow(workflow as any); // Assert expect(result.valid).toBe(false); expect(result.errors.some(e => e.message.includes('Duplicate node name'))).toBe(true); }); it('should validate connections properly', async () => { // Arrange const nodeData = { 'n8n-nodes-base.manualTrigger': { type: 'nodes-base.manualTrigger', displayName: 'Manual Trigger', isVersioned: false, properties: [] }, 'nodes-base.manualTrigger': { type: 'nodes-base.manualTrigger', displayName: 'Manual Trigger', isVersioned: false, properties: [] }, 'n8n-nodes-base.set': { type: 'nodes-base.set', displayName: 'Set', version: 2, isVersioned: true, properties: [] }, 'nodes-base.set': { type: 'nodes-base.set', displayName: 'Set', version: 2, isVersioned: true, properties: [] } }; const mockRepository = createMockRepository(nodeData); const mockValidatorClass = createMockValidatorClass({ valid: true, errors: [], warnings: [], suggestions: [] }); validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any); const workflow = { name: 'Connected Workflow', nodes: [ { id: '1', name: 'Manual Trigger', type: 'n8n-nodes-base.manualTrigger', position: [250, 300] as [number, number], parameters: {} }, { id: '2', name: 'Set', type: 'n8n-nodes-base.set', typeVersion: 2, position: [450, 300] as [number, number], parameters: {} } ], connections: { 'Manual Trigger': { main: [[{ node: 'Set', type: 'main', index: 0 }]] } } }; // Act const result = await validator.validateWorkflow(workflow as any); // Assert expect(result.valid).toBe(true); expect(result.statistics.validConnections).toBe(1); expect(result.statistics.invalidConnections).toBe(0); }); it('should detect workflow cycles', async () => { // Arrange const nodeData = { 'n8n-nodes-base.set': { type: 'nodes-base.set', displayName: 'Set', isVersioned: true, version: 2, properties: [] }, 'nodes-base.set': { type: 'nodes-base.set', displayName: 'Set', isVersioned: true, version: 2, properties: [] } }; const mockRepository = createMockRepository(nodeData); const mockValidatorClass = createMockValidatorClass({ valid: true, errors: [], warnings: [], suggestions: [] }); validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any); const workflow = { name: 'Cyclic Workflow', nodes: [ { id: '1', name: 'Node A', type: 'n8n-nodes-base.set', typeVersion: 2, position: [250, 300] as [number, number], parameters: {} }, { id: '2', name: 'Node B', type: 'n8n-nodes-base.set', typeVersion: 2, position: [450, 300] as [number, number], parameters: {} } ], connections: { 'Node A': { main: [[{ node: 'Node B', type: 'main', index: 0 }]] }, 'Node B': { main: [[{ node: 'Node A', type: 'main', index: 0 }]] // Creates a cycle } } }; // Act const result = await validator.validateWorkflow(workflow as any); // Assert expect(result.valid).toBe(false); expect(result.errors.some(e => e.message.includes('cycle'))).toBe(true); }); it('should handle null workflow gracefully', async () => { // Arrange const mockRepository = createMockRepository({}); const mockValidatorClass = createMockValidatorClass({ valid: true, errors: [], warnings: [], suggestions: [] }); validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any); // Act const result = await validator.validateWorkflow(null as any); // Assert expect(result.valid).toBe(false); expect(result.errors[0].message).toContain('workflow is null or undefined'); }); it('should require connections for multi-node workflows', async () => { // Arrange const nodeData = { 'n8n-nodes-base.manualTrigger': { type: 'nodes-base.manualTrigger', displayName: 'Manual Trigger', properties: [] }, 'nodes-base.manualTrigger': { type: 'nodes-base.manualTrigger', displayName: 'Manual Trigger', properties: [] }, 'n8n-nodes-base.set': { type: 'nodes-base.set', displayName: 'Set', version: 2, isVersioned: true, properties: [] }, 'nodes-base.set': { type: 'nodes-base.set', displayName: 'Set', version: 2, isVersioned: true, properties: [] } }; const mockRepository = createMockRepository(nodeData); const mockValidatorClass = createMockValidatorClass({ valid: true, errors: [], warnings: [], suggestions: [] }); validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any); const workflow = { name: 'No Connections', nodes: [ { id: '1', name: 'Manual Trigger', type: 'n8n-nodes-base.manualTrigger', position: [250, 300] as [number, number], parameters: {} }, { id: '2', name: 'Set', type: 'n8n-nodes-base.set', typeVersion: 2, position: [450, 300] as [number, number], parameters: {} } ], connections: {} // No connections between nodes }; // Act const result = await validator.validateWorkflow(workflow as any); // Assert expect(result.valid).toBe(false); expect(result.errors.some(e => e.message.includes('Multi-node workflow has no connections'))).toBe(true); }); it('should validate typeVersion for versioned nodes', async () => { // Arrange const nodeData = { 'n8n-nodes-base.httpRequest': { type: 'nodes-base.httpRequest', displayName: 'HTTP Request', isVersioned: true, version: 3, // Latest version is 3 properties: [] }, 'nodes-base.httpRequest': { type: 'nodes-base.httpRequest', displayName: 'HTTP Request', isVersioned: true, version: 3, properties: [] } }; const mockRepository = createMockRepository(nodeData); const mockValidatorClass = createMockValidatorClass({ valid: true, errors: [], warnings: [], suggestions: [] }); validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any); const workflow = { name: 'Version Test', nodes: [ { id: '1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', typeVersion: 2, // Outdated version position: [250, 300] as [number, number], parameters: {} } ], connections: {} }; // Act const result = await validator.validateWorkflow(workflow as any); // Assert expect(result.warnings.some(w => w.message.includes('Outdated typeVersion'))).toBe(true); }); it('should normalize and validate nodes-base prefix to find the node', async () => { // Arrange - Test that full-form types are normalized to short form to find the node // The repository only has the node under the SHORT normalized key (database format) const nodeData = { 'nodes-base.webhook': { // Repository has it under SHORT form (database format) type: 'nodes-base.webhook', displayName: 'Webhook', isVersioned: true, version: 2, properties: [] } }; // Mock repository that simulates the normalization behavior // After our changes, getNode is called with the already-normalized type (short form) const mockRepository = { getNode: vi.fn((type: string) => { // The validator now normalizes to short form before calling getNode // So getNode receives 'nodes-base.webhook' if (type === 'nodes-base.webhook') { return nodeData['nodes-base.webhook']; } return null; }), findSimilarNodes: vi.fn().mockReturnValue([]) }; const mockValidatorClass = createMockValidatorClass({ valid: true, errors: [], warnings: [], suggestions: [] }); validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any); const workflow = { name: 'Valid Alternative Prefix', nodes: [ { id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', // Using the full-form prefix (will be normalized to short) position: [250, 300] as [number, number], parameters: {}, typeVersion: 2 } ], connections: {} }; // Act const result = await validator.validateWorkflow(workflow as any); // Assert - The node should be found through normalization expect(result.valid).toBe(true); expect(result.errors).toHaveLength(0); // Verify the repository was called (once with original, once with normalized) expect(mockRepository.getNode).toHaveBeenCalled(); }); }); }); ``` -------------------------------------------------------------------------------- /tests/unit/mcp/lru-cache-behavior.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Comprehensive unit tests for LRU cache behavior in handlers-n8n-manager.ts * * This test file focuses specifically on cache behavior, TTL, eviction, and dispose callbacks */ import { describe, it, expect, beforeEach, afterEach, vi, Mock } from 'vitest'; import { LRUCache } from 'lru-cache'; import { createHash } from 'crypto'; import { getN8nApiClient } from '../../../src/mcp/handlers-n8n-manager'; import { InstanceContext, validateInstanceContext } from '../../../src/types/instance-context'; import { N8nApiClient } from '../../../src/services/n8n-api-client'; import { getN8nApiConfigFromContext } from '../../../src/config/n8n-api'; import { logger } from '../../../src/utils/logger'; // Mock dependencies vi.mock('../../../src/services/n8n-api-client'); vi.mock('../../../src/config/n8n-api'); vi.mock('../../../src/utils/logger'); vi.mock('../../../src/types/instance-context', async () => { const actual = await vi.importActual('../../../src/types/instance-context'); return { ...actual, validateInstanceContext: vi.fn() }; }); describe('LRU Cache Behavior Tests', () => { let mockN8nApiClient: Mock; let mockGetN8nApiConfigFromContext: Mock; let mockLogger: any; // Logger mock has complex type let mockValidateInstanceContext: Mock; beforeEach(() => { vi.resetAllMocks(); vi.resetModules(); vi.clearAllMocks(); mockN8nApiClient = vi.mocked(N8nApiClient); mockGetN8nApiConfigFromContext = vi.mocked(getN8nApiConfigFromContext); mockLogger = vi.mocked(logger); mockValidateInstanceContext = vi.mocked(validateInstanceContext); // Default mock returns valid config mockGetN8nApiConfigFromContext.mockReturnValue({ baseUrl: 'https://api.n8n.cloud', apiKey: 'test-key', timeout: 30000, maxRetries: 3 }); // Default mock returns valid context validation mockValidateInstanceContext.mockReturnValue({ valid: true, errors: undefined }); // Force re-import of the module to get fresh cache state vi.resetModules(); }); afterEach(() => { vi.clearAllMocks(); }); describe('Cache Key Generation and Collision', () => { it('should generate different cache keys for different contexts', () => { const context1: InstanceContext = { n8nApiUrl: 'https://api1.n8n.cloud', n8nApiKey: 'key1', instanceId: 'instance1' }; const context2: InstanceContext = { n8nApiUrl: 'https://api2.n8n.cloud', n8nApiKey: 'key2', instanceId: 'instance2' }; // Generate expected hashes manually const hash1 = createHash('sha256') .update(`${context1.n8nApiUrl}:${context1.n8nApiKey}:${context1.instanceId}`) .digest('hex'); const hash2 = createHash('sha256') .update(`${context2.n8nApiUrl}:${context2.n8nApiKey}:${context2.instanceId}`) .digest('hex'); expect(hash1).not.toBe(hash2); // Create clients to verify different cache entries const client1 = getN8nApiClient(context1); const client2 = getN8nApiClient(context2); expect(mockN8nApiClient).toHaveBeenCalledTimes(2); }); it('should generate same cache key for identical contexts', () => { const context: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'same-key', instanceId: 'same-instance' }; const client1 = getN8nApiClient(context); const client2 = getN8nApiClient(context); // Should only create one client (cache hit) expect(mockN8nApiClient).toHaveBeenCalledTimes(1); expect(client1).toBe(client2); }); it('should handle potential cache key collisions gracefully', () => { // Create contexts that might produce similar hashes but are valid const contexts = [ { n8nApiUrl: 'https://a.com', n8nApiKey: 'keyb', instanceId: 'c' }, { n8nApiUrl: 'https://ab.com', n8nApiKey: 'key', instanceId: 'bc' }, { n8nApiUrl: 'https://abc.com', n8nApiKey: 'differentkey', // Fixed: empty string causes config creation to fail instanceId: 'key' } ]; contexts.forEach((context, index) => { const client = getN8nApiClient(context); expect(client).toBeDefined(); }); // Each should create a separate client due to different hashes expect(mockN8nApiClient).toHaveBeenCalledTimes(3); }); }); describe('LRU Eviction Behavior', () => { it('should evict oldest entries when cache is full', async () => { const loggerDebugSpy = vi.spyOn(logger, 'debug'); // Create 101 different contexts to exceed max cache size of 100 const contexts: InstanceContext[] = []; for (let i = 0; i < 101; i++) { contexts.push({ n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: `key-${i}`, instanceId: `instance-${i}` }); } // Create clients for all contexts contexts.forEach(context => { getN8nApiClient(context); }); // Should have called dispose callback for evicted entries expect(loggerDebugSpy).toHaveBeenCalledWith( 'Evicting API client from cache', expect.objectContaining({ cacheKey: expect.stringMatching(/^[a-f0-9]{8}\.\.\.$/i) }) ); // Verify dispose was called at least once expect(loggerDebugSpy).toHaveBeenCalled(); }); it('should maintain LRU order during access', () => { const contexts: InstanceContext[] = []; for (let i = 0; i < 5; i++) { contexts.push({ n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: `key-${i}`, instanceId: `instance-${i}` }); } // Create initial clients contexts.forEach(context => { getN8nApiClient(context); }); expect(mockN8nApiClient).toHaveBeenCalledTimes(5); // Access first context again (should move to most recent) getN8nApiClient(contexts[0]); // Should not create new client (cache hit) expect(mockN8nApiClient).toHaveBeenCalledTimes(5); }); it('should handle rapid successive access patterns', () => { const context: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'rapid-access-key', instanceId: 'rapid-instance' }; // Rapidly access same context multiple times for (let i = 0; i < 10; i++) { getN8nApiClient(context); } // Should only create one client despite multiple accesses expect(mockN8nApiClient).toHaveBeenCalledTimes(1); }); }); describe('TTL (Time To Live) Behavior', () => { it('should respect TTL settings', async () => { const context: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'ttl-test-key', instanceId: 'ttl-instance' }; // Create initial client const client1 = getN8nApiClient(context); expect(mockN8nApiClient).toHaveBeenCalledTimes(1); // Access again immediately (should hit cache) const client2 = getN8nApiClient(context); expect(mockN8nApiClient).toHaveBeenCalledTimes(1); expect(client1).toBe(client2); // Note: We can't easily test TTL expiration in unit tests // as it requires actual time passage, but we can verify // the updateAgeOnGet behavior }); it('should update age on cache access (updateAgeOnGet)', () => { const context: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'age-update-key', instanceId: 'age-instance' }; // Create and access multiple times getN8nApiClient(context); getN8nApiClient(context); getN8nApiClient(context); // Should only create one client due to cache hits expect(mockN8nApiClient).toHaveBeenCalledTimes(1); }); }); describe('Dispose Callback Security and Logging', () => { it('should sanitize cache keys in dispose callback logs', () => { const loggerDebugSpy = vi.spyOn(logger, 'debug'); // Create enough contexts to trigger eviction const contexts: InstanceContext[] = []; for (let i = 0; i < 102; i++) { contexts.push({ n8nApiUrl: 'https://sensitive-api.n8n.cloud', n8nApiKey: `super-secret-key-${i}`, instanceId: `sensitive-instance-${i}` }); } // Create clients to trigger eviction contexts.forEach(context => { getN8nApiClient(context); }); // Verify dispose callback logs don't contain sensitive data const logCalls = loggerDebugSpy.mock.calls.filter(call => call[0] === 'Evicting API client from cache' ); logCalls.forEach(call => { const logData = call[1] as any; // Should only log partial cache key (first 8 chars + ...) expect(logData.cacheKey).toMatch(/^[a-f0-9]{8}\.\.\.$/i); // Should not contain any sensitive information const logString = JSON.stringify(call); expect(logString).not.toContain('super-secret-key'); expect(logString).not.toContain('sensitive-api'); expect(logString).not.toContain('sensitive-instance'); }); }); it('should handle dispose callback with undefined client', () => { const loggerDebugSpy = vi.spyOn(logger, 'debug'); // Create many contexts to trigger disposal for (let i = 0; i < 105; i++) { const context: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: `disposal-key-${i}`, instanceId: `disposal-${i}` }; getN8nApiClient(context); } // Should handle disposal gracefully expect(() => { // The dispose callback should have been called expect(loggerDebugSpy).toHaveBeenCalled(); }).not.toThrow(); }); }); describe('Cache Memory Management', () => { it('should maintain consistent cache size limits', () => { // Create exactly 100 contexts (max cache size) const contexts: InstanceContext[] = []; for (let i = 0; i < 100; i++) { contexts.push({ n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: `memory-key-${i}`, instanceId: `memory-${i}` }); } // Create all clients contexts.forEach(context => { getN8nApiClient(context); }); // All should be cached expect(mockN8nApiClient).toHaveBeenCalledTimes(100); // Access all again - should hit cache contexts.forEach(context => { getN8nApiClient(context); }); // Should not create additional clients expect(mockN8nApiClient).toHaveBeenCalledTimes(100); }); it('should handle edge case of single cache entry', () => { const context: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'single-key', instanceId: 'single-instance' }; // Create and access multiple times for (let i = 0; i < 5; i++) { getN8nApiClient(context); } expect(mockN8nApiClient).toHaveBeenCalledTimes(1); }); }); describe('Cache Configuration Validation', () => { it('should use reasonable cache limits', () => { // These values should match the actual cache configuration const MAX_CACHE_SIZE = 100; const TTL_MINUTES = 30; const TTL_MS = TTL_MINUTES * 60 * 1000; // Verify limits are reasonable expect(MAX_CACHE_SIZE).toBeGreaterThan(0); expect(MAX_CACHE_SIZE).toBeLessThanOrEqual(1000); expect(TTL_MS).toBeGreaterThan(0); expect(TTL_MS).toBeLessThanOrEqual(60 * 60 * 1000); // Max 1 hour }); }); describe('Cache Interaction with Validation', () => { it('should not cache when context validation fails', () => { // Reset mocks to ensure clean state for this test vi.clearAllMocks(); mockValidateInstanceContext.mockClear(); const invalidContext: InstanceContext = { n8nApiUrl: 'invalid-url', n8nApiKey: 'test-key', instanceId: 'invalid-instance' }; // Mock validation failure mockValidateInstanceContext.mockReturnValue({ valid: false, errors: ['Invalid n8nApiUrl format'] }); const client = getN8nApiClient(invalidContext); // Should not create client or cache anything expect(client).toBeNull(); expect(mockN8nApiClient).not.toHaveBeenCalled(); }); it('should handle cache when config creation fails', () => { const context: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'test-key', instanceId: 'config-fail' }; // Mock config creation failure mockGetN8nApiConfigFromContext.mockReturnValue(null); const client = getN8nApiClient(context); expect(client).toBeNull(); }); }); describe('Complex Cache Scenarios', () => { it('should handle mixed valid and invalid contexts', () => { // Reset mocks to ensure clean state for this test vi.clearAllMocks(); mockValidateInstanceContext.mockClear(); // First, set up default valid behavior mockValidateInstanceContext.mockReturnValue({ valid: true, errors: undefined }); const validContext: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'valid-key', instanceId: 'valid' }; const invalidContext: InstanceContext = { n8nApiUrl: 'invalid-url', n8nApiKey: 'key', instanceId: 'invalid' }; // Valid context should work const validClient = getN8nApiClient(validContext); expect(validClient).toBeDefined(); // Change mock for invalid context mockValidateInstanceContext.mockReturnValueOnce({ valid: false, errors: ['Invalid URL'] }); const invalidClient = getN8nApiClient(invalidContext); expect(invalidClient).toBeNull(); // Reset mock back to valid for subsequent calls mockValidateInstanceContext.mockReturnValue({ valid: true, errors: undefined }); // Valid context should still work (cache hit) const validClient2 = getN8nApiClient(validContext); expect(validClient2).toBe(validClient); }); it('should handle concurrent access to same cache key', () => { const context: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'concurrent-key', instanceId: 'concurrent' }; // Simulate concurrent access const promises = Array(10).fill(null).map(() => Promise.resolve(getN8nApiClient(context)) ); return Promise.all(promises).then(clients => { // All should return the same cached client const firstClient = clients[0]; clients.forEach(client => { expect(client).toBe(firstClient); }); // Should only create one client expect(mockN8nApiClient).toHaveBeenCalledTimes(1); }); }); }); }); ``` -------------------------------------------------------------------------------- /tests/unit/templates/metadata-generator.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest'; import { MetadataGenerator, TemplateMetadataSchema, MetadataRequest } from '../../../src/templates/metadata-generator'; // Mock OpenAI vi.mock('openai', () => { return { default: vi.fn().mockImplementation(() => ({ chat: { completions: { create: vi.fn() } } })) }; }); describe('MetadataGenerator', () => { let generator: MetadataGenerator; beforeEach(() => { generator = new MetadataGenerator('test-api-key', 'gpt-5-mini-2025-08-07'); }); describe('createBatchRequest', () => { it('should create a valid batch request', () => { const template: MetadataRequest = { templateId: 123, name: 'Test Workflow', description: 'A test workflow', nodes: ['n8n-nodes-base.webhook', 'n8n-nodes-base.httpRequest', 'n8n-nodes-base.slack'] }; const request = generator.createBatchRequest(template); expect(request.custom_id).toBe('template-123'); expect(request.method).toBe('POST'); expect(request.url).toBe('/v1/chat/completions'); expect(request.body.model).toBe('gpt-5-mini-2025-08-07'); expect(request.body.response_format.type).toBe('json_schema'); expect(request.body.response_format.json_schema.strict).toBe(true); expect(request.body.messages).toHaveLength(2); }); it('should summarize nodes effectively', () => { const template: MetadataRequest = { templateId: 456, name: 'Complex Workflow', nodes: [ 'n8n-nodes-base.webhook', 'n8n-nodes-base.httpRequest', 'n8n-nodes-base.httpRequest', 'n8n-nodes-base.postgres', 'n8n-nodes-base.slack', '@n8n/n8n-nodes-langchain.agent' ] }; const request = generator.createBatchRequest(template); const userMessage = request.body.messages[1].content; expect(userMessage).toContain('Complex Workflow'); expect(userMessage).toContain('Nodes Used (6)'); expect(userMessage).toContain('HTTP/Webhooks'); }); }); describe('parseResult', () => { it('should parse a successful result', () => { const mockResult = { custom_id: 'template-789', response: { body: { choices: [{ message: { content: JSON.stringify({ categories: ['automation', 'integration'], complexity: 'medium', use_cases: ['API integration', 'Data sync'], estimated_setup_minutes: 30, required_services: ['Slack API'], key_features: ['Webhook triggers', 'API calls'], target_audience: ['developers'] }) }, finish_reason: 'stop' }] } } }; const result = generator.parseResult(mockResult); expect(result.templateId).toBe(789); expect(result.metadata.categories).toEqual(['automation', 'integration']); expect(result.metadata.complexity).toBe('medium'); expect(result.error).toBeUndefined(); }); it('should handle error results', () => { const mockResult = { custom_id: 'template-999', error: { message: 'API error' } }; const result = generator.parseResult(mockResult); expect(result.templateId).toBe(999); expect(result.error).toBe('API error'); expect(result.metadata).toBeDefined(); expect(result.metadata.complexity).toBe('medium'); // Default metadata }); it('should handle malformed responses', () => { const mockResult = { custom_id: 'template-111', response: { body: { choices: [{ message: { content: 'not valid json' }, finish_reason: 'stop' }] } } }; const result = generator.parseResult(mockResult); expect(result.templateId).toBe(111); expect(result.error).toContain('Unexpected token'); expect(result.metadata).toBeDefined(); }); }); describe('TemplateMetadataSchema', () => { it('should validate correct metadata', () => { const validMetadata = { categories: ['automation', 'integration'], complexity: 'simple' as const, use_cases: ['API calls', 'Data processing'], estimated_setup_minutes: 15, required_services: [], key_features: ['Fast processing'], target_audience: ['developers'] }; const result = TemplateMetadataSchema.safeParse(validMetadata); expect(result.success).toBe(true); }); it('should reject invalid complexity', () => { const invalidMetadata = { categories: ['automation'], complexity: 'very-hard', // Invalid use_cases: ['API calls'], estimated_setup_minutes: 15, required_services: [], key_features: ['Fast'], target_audience: ['developers'] }; const result = TemplateMetadataSchema.safeParse(invalidMetadata); expect(result.success).toBe(false); }); it('should enforce array limits', () => { const tooManyCategories = { categories: ['a', 'b', 'c', 'd', 'e', 'f'], // Max 5 complexity: 'simple' as const, use_cases: ['API calls'], estimated_setup_minutes: 15, required_services: [], key_features: ['Fast'], target_audience: ['developers'] }; const result = TemplateMetadataSchema.safeParse(tooManyCategories); expect(result.success).toBe(false); }); it('should enforce time limits', () => { const tooLongSetup = { categories: ['automation'], complexity: 'complex' as const, use_cases: ['API calls'], estimated_setup_minutes: 500, // Max 480 required_services: [], key_features: ['Fast'], target_audience: ['developers'] }; const result = TemplateMetadataSchema.safeParse(tooLongSetup); expect(result.success).toBe(false); }); }); describe('Input Sanitization and Security', () => { it('should handle malicious template names safely', () => { const maliciousTemplate: MetadataRequest = { templateId: 123, name: '<script>alert("xss")</script>', description: 'javascript:alert(1)', nodes: ['n8n-nodes-base.webhook'] }; const request = generator.createBatchRequest(maliciousTemplate); const userMessage = request.body.messages[1].content; // Should contain the malicious content as-is (OpenAI will handle it) // but should not cause any injection in our code expect(userMessage).toContain('<script>alert("xss")</script>'); expect(userMessage).toContain('javascript:alert(1)'); expect(request.body.model).toBe('gpt-5-mini-2025-08-07'); }); it('should handle extremely long template names', () => { const longName = 'A'.repeat(10000); // Very long name const template: MetadataRequest = { templateId: 456, name: longName, nodes: ['n8n-nodes-base.webhook'] }; const request = generator.createBatchRequest(template); expect(request.custom_id).toBe('template-456'); expect(request.body.messages[1].content).toContain(longName); }); it('should handle special characters in node names', () => { const template: MetadataRequest = { templateId: 789, name: 'Test Workflow', nodes: [ 'n8n-nodes-base.webhook', '@n8n/custom-node.with.dots', 'custom-package/node-with-slashes', 'node_with_underscore', 'node-with-unicode-名前' ] }; const request = generator.createBatchRequest(template); const userMessage = request.body.messages[1].content; expect(userMessage).toContain('HTTP/Webhooks'); expect(userMessage).toContain('custom-node.with.dots'); }); it('should handle empty or undefined descriptions safely', () => { const template: MetadataRequest = { templateId: 100, name: 'Test', description: undefined, nodes: ['n8n-nodes-base.webhook'] }; const request = generator.createBatchRequest(template); const userMessage = request.body.messages[1].content; // Should not include undefined or null in the message expect(userMessage).not.toContain('undefined'); expect(userMessage).not.toContain('null'); expect(userMessage).toContain('Test'); }); it('should limit context size for very large workflows', () => { const manyNodes = Array.from({ length: 1000 }, (_, i) => `n8n-nodes-base.node${i}`); const template: MetadataRequest = { templateId: 200, name: 'Huge Workflow', nodes: manyNodes, workflow: { nodes: Array.from({ length: 500 }, (_, i) => ({ id: `node${i}` })), connections: {} } }; const request = generator.createBatchRequest(template); const userMessage = request.body.messages[1].content; // Should handle large amounts of data gracefully expect(userMessage.length).toBeLessThan(50000); // Reasonable limit expect(userMessage).toContain('Huge Workflow'); }); }); describe('Error Handling and Edge Cases', () => { it('should handle malformed OpenAI responses', () => { const malformedResults = [ { custom_id: 'template-111', response: { body: { choices: [{ message: { content: '{"invalid": json syntax}' }, finish_reason: 'stop' }] } } }, { custom_id: 'template-222', response: { body: { choices: [{ message: { content: null }, finish_reason: 'stop' }] } } }, { custom_id: 'template-333', response: { body: { choices: [] } } } ]; malformedResults.forEach(result => { const parsed = generator.parseResult(result); expect(parsed.error).toBeDefined(); expect(parsed.metadata).toBeDefined(); expect(parsed.metadata.complexity).toBe('medium'); // Default metadata }); }); it('should handle Zod validation failures', () => { const invalidResponse = { custom_id: 'template-444', response: { body: { choices: [{ message: { content: JSON.stringify({ categories: ['too', 'many', 'categories', 'here', 'way', 'too', 'many'], complexity: 'invalid-complexity', use_cases: [], estimated_setup_minutes: -5, // Invalid negative time required_services: 'not-an-array', key_features: null, target_audience: ['too', 'many', 'audiences', 'here'] }) }, finish_reason: 'stop' }] } } }; const result = generator.parseResult(invalidResponse); expect(result.templateId).toBe(444); expect(result.error).toBeDefined(); expect(result.metadata).toEqual(generator['getDefaultMetadata']()); }); it('should handle network timeouts gracefully in generateSingle', async () => { // Create a new generator with mocked OpenAI client const mockClient = { chat: { completions: { create: vi.fn().mockRejectedValue(new Error('Request timed out')) } } }; // Override the client property using Object.defineProperty Object.defineProperty(generator, 'client', { value: mockClient, writable: true }); const template: MetadataRequest = { templateId: 555, name: 'Timeout Test', nodes: ['n8n-nodes-base.webhook'] }; const result = await generator.generateSingle(template); // Should return default metadata instead of throwing expect(result).toEqual(generator['getDefaultMetadata']()); }); }); describe('Node Summarization Logic', () => { it('should group similar nodes correctly', () => { const template: MetadataRequest = { templateId: 666, name: 'Complex Workflow', nodes: [ 'n8n-nodes-base.webhook', 'n8n-nodes-base.httpRequest', 'n8n-nodes-base.postgres', 'n8n-nodes-base.mysql', 'n8n-nodes-base.slack', 'n8n-nodes-base.gmail', '@n8n/n8n-nodes-langchain.openAi', '@n8n/n8n-nodes-langchain.agent', 'n8n-nodes-base.googleSheets', 'n8n-nodes-base.excel' ] }; const request = generator.createBatchRequest(template); const userMessage = request.body.messages[1].content; expect(userMessage).toContain('HTTP/Webhooks (2)'); expect(userMessage).toContain('Database (2)'); expect(userMessage).toContain('Communication (2)'); expect(userMessage).toContain('AI/ML (2)'); expect(userMessage).toContain('Spreadsheets (2)'); }); it('should handle unknown node types gracefully', () => { const template: MetadataRequest = { templateId: 777, name: 'Unknown Nodes', nodes: [ 'custom-package.unknownNode', 'another-package.weirdNodeType', 'someNodeTrigger', 'anotherNode' ] }; const request = generator.createBatchRequest(template); const userMessage = request.body.messages[1].content; // Should handle unknown nodes without crashing expect(userMessage).toContain('unknownNode'); expect(userMessage).toContain('weirdNodeType'); expect(userMessage).toContain('someNode'); // Trigger suffix removed }); it('should limit node summary length', () => { const manyNodes = Array.from({ length: 50 }, (_, i) => `n8n-nodes-base.customNode${i}` ); const template: MetadataRequest = { templateId: 888, name: 'Many Nodes', nodes: manyNodes }; const request = generator.createBatchRequest(template); const userMessage = request.body.messages[1].content; // Should limit to top 10 groups const summaryLine = userMessage.split('\n').find((line: string) => line.includes('Nodes Used (50)') ); expect(summaryLine).toBeDefined(); const nodeGroups = summaryLine!.split(': ')[1].split(', '); expect(nodeGroups.length).toBeLessThanOrEqual(10); }); }); }); ``` -------------------------------------------------------------------------------- /tests/integration/docker/docker-config.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; import { execSync, spawn } from 'child_process'; import path from 'path'; import fs from 'fs'; import os from 'os'; import { exec, waitForHealthy, isRunningInHttpMode, getProcessEnv } from './test-helpers'; // Skip tests if not in CI or if Docker is not available const SKIP_DOCKER_TESTS = process.env.CI !== 'true' && !process.env.RUN_DOCKER_TESTS; const describeDocker = SKIP_DOCKER_TESTS ? describe.skip : describe; // Helper to check if Docker is available async function isDockerAvailable(): Promise<boolean> { try { await exec('docker --version'); return true; } catch { return false; } } // Helper to generate unique container names function generateContainerName(suffix: string): string { return `n8n-mcp-test-${Date.now()}-${suffix}`; } // Helper to clean up containers async function cleanupContainer(containerName: string) { try { await exec(`docker stop ${containerName}`); await exec(`docker rm ${containerName}`); } catch { // Ignore errors - container might not exist } } describeDocker('Docker Config File Integration', () => { let tempDir: string; let dockerAvailable: boolean; const imageName = 'n8n-mcp-test:latest'; const containers: string[] = []; beforeAll(async () => { dockerAvailable = await isDockerAvailable(); if (!dockerAvailable) { console.warn('Docker not available, skipping Docker integration tests'); return; } // Check if image exists let imageExists = false; try { await exec(`docker image inspect ${imageName}`); imageExists = true; } catch { imageExists = false; } // Build test image if in CI or if explicitly requested or if image doesn't exist if (!imageExists || process.env.CI === 'true' || process.env.BUILD_DOCKER_TEST_IMAGE === 'true') { const projectRoot = path.resolve(__dirname, '../../../'); console.log('Building Docker image for tests...'); try { execSync(`docker build -t ${imageName} .`, { cwd: projectRoot, stdio: 'inherit' }); console.log('Docker image built successfully'); } catch (error) { console.error('Failed to build Docker image:', error); throw new Error('Docker image build failed - tests cannot continue'); } } else { console.log(`Using existing Docker image: ${imageName}`); } }, 60000); // Increase timeout to 60s for Docker build beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docker-config-test-')); }); afterEach(async () => { // Clean up containers for (const container of containers) { await cleanupContainer(container); } containers.length = 0; // Clean up temp directory if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true }); } }); describe('Config file loading', () => { it('should load config.json and set environment variables', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('config-load'); containers.push(containerName); // Create config file const configPath = path.join(tempDir, 'config.json'); const config = { mcp_mode: 'http', auth_token: 'test-token-from-config', port: 3456, database: { path: '/data/custom.db' } }; fs.writeFileSync(configPath, JSON.stringify(config)); // Run container with config file mounted const { stdout } = await exec( `docker run --name ${containerName} -v "${configPath}:/app/config.json:ro" ${imageName} sh -c "env | grep -E '^(MCP_MODE|AUTH_TOKEN|PORT|DATABASE_PATH)=' | sort"` ); const envVars = stdout.trim().split('\n').reduce((acc, line) => { const [key, value] = line.split('='); acc[key] = value; return acc; }, {} as Record<string, string>); expect(envVars.MCP_MODE).toBe('http'); expect(envVars.AUTH_TOKEN).toBe('test-token-from-config'); expect(envVars.PORT).toBe('3456'); expect(envVars.DATABASE_PATH).toBe('/data/custom.db'); }); it('should give precedence to environment variables over config file', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('env-precedence'); containers.push(containerName); // Create config file const configPath = path.join(tempDir, 'config.json'); const config = { mcp_mode: 'stdio', auth_token: 'config-token', custom_var: 'from-config' }; fs.writeFileSync(configPath, JSON.stringify(config)); // Run container with both env vars and config file const { stdout } = await exec( `docker run --name ${containerName} ` + `-e MCP_MODE=http ` + `-e AUTH_TOKEN=env-token ` + `-v "${configPath}:/app/config.json:ro" ` + `${imageName} sh -c "env | grep -E '^(MCP_MODE|AUTH_TOKEN|CUSTOM_VAR)=' | sort"` ); const envVars = stdout.trim().split('\n').reduce((acc, line) => { const [key, value] = line.split('='); acc[key] = value; return acc; }, {} as Record<string, string>); expect(envVars.MCP_MODE).toBe('http'); // From env var expect(envVars.AUTH_TOKEN).toBe('env-token'); // From env var expect(envVars.CUSTOM_VAR).toBe('from-config'); // From config file }); it('should handle missing config file gracefully', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('no-config'); containers.push(containerName); // Run container without config file const { stdout, stderr } = await exec( `docker run --name ${containerName} ${imageName} echo "Container started successfully"` ); expect(stdout.trim()).toBe('Container started successfully'); expect(stderr).toBe(''); }); it('should handle invalid JSON in config file gracefully', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('invalid-json'); containers.push(containerName); // Create invalid config file const configPath = path.join(tempDir, 'config.json'); fs.writeFileSync(configPath, '{ invalid json }'); // Container should still start despite invalid config const { stdout } = await exec( `docker run --name ${containerName} -v "${configPath}:/app/config.json:ro" ${imageName} echo "Started despite invalid config"` ); expect(stdout.trim()).toBe('Started despite invalid config'); }); }); describe('n8n-mcp serve command', () => { it('should automatically set MCP_MODE=http for "n8n-mcp serve" command', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('serve-command'); containers.push(containerName); // Run container with n8n-mcp serve command // Start the container in detached mode await exec( `docker run -d --name ${containerName} -e AUTH_TOKEN=test-token -p 13001:3000 ${imageName} n8n-mcp serve` ); // Give it time to start await new Promise(resolve => setTimeout(resolve, 3000)); // Verify it's running in HTTP mode by checking the health endpoint const { stdout } = await exec( `docker exec ${containerName} curl -s http://localhost:3000/health || echo 'Server not responding'` ); // If HTTP mode is active, health endpoint should respond expect(stdout).toContain('ok'); }); it('should preserve additional arguments when using "n8n-mcp serve"', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('serve-args'); containers.push(containerName); // Test that additional arguments are passed through // Note: This test is checking the command construction, not actual execution const result = await exec( `docker run --name ${containerName} ${imageName} sh -c "set -x; n8n-mcp serve --port 8080 2>&1 | grep -E 'node.*index.js.*--port.*8080' || echo 'Pattern not found'"` ); // The serve command should transform to node command with arguments preserved expect(result.stdout).toBeTruthy(); }); }); describe('Database initialization', () => { it('should initialize database when not present', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('db-init'); containers.push(containerName); // Run container and check database initialization const { stdout } = await exec( `docker run --name ${containerName} ${imageName} sh -c "ls -la /app/data/nodes.db && echo 'Database initialized'"` ); expect(stdout).toContain('nodes.db'); expect(stdout).toContain('Database initialized'); }); it('should respect NODE_DB_PATH from config file', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('custom-db-path'); containers.push(containerName); // Create config with custom database path const configPath = path.join(tempDir, 'config.json'); const config = { NODE_DB_PATH: '/app/data/custom/custom.db' // Use uppercase and a writable path }; fs.writeFileSync(configPath, JSON.stringify(config)); // Run container in detached mode to check environment after initialization // Set MCP_MODE=http so the server keeps running (stdio mode exits when stdin is closed in detached mode) await exec( `docker run -d --name ${containerName} -e MCP_MODE=http -e AUTH_TOKEN=test -v "${configPath}:/app/config.json:ro" ${imageName}` ); // Give it time to load config and start await new Promise(resolve => setTimeout(resolve, 2000)); // Check the actual process environment const { stdout } = await exec( `docker exec ${containerName} sh -c "cat /proc/1/environ | tr '\\0' '\\n' | grep NODE_DB_PATH || echo 'NODE_DB_PATH not found'"` ); expect(stdout.trim()).toBe('NODE_DB_PATH=/app/data/custom/custom.db'); }); }); describe('Authentication configuration', () => { it('should enforce AUTH_TOKEN requirement in HTTP mode', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('auth-required'); containers.push(containerName); // Try to run in HTTP mode without auth token try { await exec( `docker run --name ${containerName} -e MCP_MODE=http ${imageName} echo "Should not reach here"` ); expect.fail('Container should have exited with error'); } catch (error: any) { expect(error.stderr).toContain('AUTH_TOKEN or AUTH_TOKEN_FILE is required for HTTP mode'); } }); it('should accept AUTH_TOKEN from config file', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('auth-config'); containers.push(containerName); // Create config with auth token const configPath = path.join(tempDir, 'config.json'); const config = { mcp_mode: 'http', auth_token: 'config-auth-token' }; fs.writeFileSync(configPath, JSON.stringify(config)); // Run container with config file const { stdout } = await exec( `docker run --name ${containerName} -v "${configPath}:/app/config.json:ro" ${imageName} sh -c "env | grep AUTH_TOKEN"` ); expect(stdout.trim()).toBe('AUTH_TOKEN=config-auth-token'); }); }); describe('Security and permissions', () => { it('should handle malicious config values safely', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('security-test'); containers.push(containerName); // Create config with potentially malicious values const configPath = path.join(tempDir, 'config.json'); const config = { malicious1: "'; echo 'hacked' > /tmp/hacked.txt; '", malicious2: "$( touch /tmp/command-injection.txt )", malicious3: "`touch /tmp/backtick-injection.txt`" }; fs.writeFileSync(configPath, JSON.stringify(config)); // Run container and check that no files were created const { stdout } = await exec( `docker run --name ${containerName} -v "${configPath}:/app/config.json:ro" ${imageName} sh -c "ls -la /tmp/ | grep -E '(hacked|injection)' || echo 'No malicious files created'"` ); expect(stdout.trim()).toBe('No malicious files created'); }); it('should run as non-root user by default', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('non-root'); containers.push(containerName); // Check user inside container const { stdout } = await exec( `docker run --name ${containerName} ${imageName} whoami` ); expect(stdout.trim()).toBe('nodejs'); }); }); describe('Complex configuration scenarios', () => { it('should handle nested configuration with all supported types', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('complex-config'); containers.push(containerName); // Create complex config const configPath = path.join(tempDir, 'config.json'); const config = { server: { http: { port: 8080, host: '0.0.0.0', ssl: { enabled: true, cert_path: '/certs/server.crt' } } }, features: { debug: false, metrics: true, logging: { level: 'info', format: 'json' } }, limits: { max_connections: 100, timeout_seconds: 30 } }; fs.writeFileSync(configPath, JSON.stringify(config)); // Run container and verify all variables const { stdout } = await exec( `docker run --name ${containerName} -v "${configPath}:/app/config.json:ro" ${imageName} sh -c "env | grep -E '^(SERVER_|FEATURES_|LIMITS_)' | sort"` ); const lines = stdout.trim().split('\n'); const envVars = lines.reduce((acc, line) => { const [key, value] = line.split('='); acc[key] = value; return acc; }, {} as Record<string, string>); // Verify nested values are correctly flattened expect(envVars.SERVER_HTTP_PORT).toBe('8080'); expect(envVars.SERVER_HTTP_HOST).toBe('0.0.0.0'); expect(envVars.SERVER_HTTP_SSL_ENABLED).toBe('true'); expect(envVars.SERVER_HTTP_SSL_CERT_PATH).toBe('/certs/server.crt'); expect(envVars.FEATURES_DEBUG).toBe('false'); expect(envVars.FEATURES_METRICS).toBe('true'); expect(envVars.FEATURES_LOGGING_LEVEL).toBe('info'); expect(envVars.FEATURES_LOGGING_FORMAT).toBe('json'); expect(envVars.LIMITS_MAX_CONNECTIONS).toBe('100'); expect(envVars.LIMITS_TIMEOUT_SECONDS).toBe('30'); }); }); }); ``` -------------------------------------------------------------------------------- /tests/unit/docker/config-security.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { execSync } from 'child_process'; import fs from 'fs'; import path from 'path'; import os from 'os'; describe('Config File Security Tests', () => { let tempDir: string; let configPath: string; const parseConfigPath = path.resolve(__dirname, '../../../docker/parse-config.js'); // Clean environment for tests - only include essential variables const cleanEnv = { PATH: process.env.PATH, HOME: process.env.HOME, NODE_ENV: process.env.NODE_ENV }; beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'config-security-test-')); configPath = path.join(tempDir, 'config.json'); }); afterEach(() => { if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true }); } }); describe('Command injection prevention', () => { it('should prevent basic command injection attempts', () => { const maliciousConfigs = [ { cmd: "'; echo 'hacked' > /tmp/hacked.txt; '" }, { cmd: '"; echo "hacked" > /tmp/hacked.txt; "' }, { cmd: '`echo hacked > /tmp/hacked.txt`' }, { cmd: '$(echo hacked > /tmp/hacked.txt)' }, { cmd: '| echo hacked > /tmp/hacked.txt' }, { cmd: '|| echo hacked > /tmp/hacked.txt' }, { cmd: '& echo hacked > /tmp/hacked.txt' }, { cmd: '&& echo hacked > /tmp/hacked.txt' }, { cmd: '; echo hacked > /tmp/hacked.txt' }, { cmd: '\n echo hacked > /tmp/hacked.txt \n' }, { cmd: '\r\n echo hacked > /tmp/hacked.txt \r\n' } ]; maliciousConfigs.forEach((config, index) => { fs.writeFileSync(configPath, JSON.stringify(config)); const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8', env: cleanEnv }); // The output should safely quote the malicious content expect(output).toContain("export CMD='"); // Verify that the output contains a properly quoted export expect(output).toContain("export CMD='"); // Create a test script to verify safety const testScript = `#!/bin/sh set -e ${output} # If command injection worked, this would fail test -f /tmp/hacked.txt && exit 1 echo "SUCCESS: No injection occurred" `; const tempScript = path.join(tempDir, `test-injection-${index}.sh`); fs.writeFileSync(tempScript, testScript); fs.chmodSync(tempScript, '755'); const result = execSync(tempScript, { encoding: 'utf8', env: cleanEnv }); expect(result.trim()).toBe('SUCCESS: No injection occurred'); // Double-check no files were created expect(fs.existsSync('/tmp/hacked.txt')).toBe(false); }); }); it('should handle complex nested injection attempts', () => { const config = { database: { host: "localhost'; DROP TABLE users; --", port: 5432, credentials: { password: "$( cat /etc/passwd )", backup_cmd: "`rm -rf /`" } }, scripts: { init: "#!/bin/bash\nrm -rf /\nexit 0" } }; fs.writeFileSync(configPath, JSON.stringify(config)); const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8', env: cleanEnv }); // All values should be safely quoted expect(output).toContain("DATABASE_HOST='localhost'\"'\"'; DROP TABLE users; --'"); expect(output).toContain("DATABASE_CREDENTIALS_PASSWORD='$( cat /etc/passwd )'"); expect(output).toContain("DATABASE_CREDENTIALS_BACKUP_CMD='`rm -rf /`'"); expect(output).toContain("SCRIPTS_INIT='#!/bin/bash\nrm -rf /\nexit 0'"); }); it('should handle Unicode and special characters safely', () => { const config = { unicode: "Hello 世界 🌍", emoji: "🚀 Deploy! 🎉", special: "Line1\nLine2\tTab\rCarriage", quotes_mix: `It's a "test" with 'various' quotes`, backslash: "C:\\Users\\test\\path", regex: "^[a-zA-Z0-9]+$", json_string: '{"key": "value"}', xml_string: '<tag attr="value">content</tag>', sql_injection: "1' OR '1'='1", null_byte: "test\x00null", escape_sequences: "test\\n\\r\\t\\b\\f" }; fs.writeFileSync(configPath, JSON.stringify(config)); const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8', env: cleanEnv }); // All special characters should be preserved within quotes expect(output).toContain("UNICODE='Hello 世界 🌍'"); expect(output).toContain("EMOJI='🚀 Deploy! 🎉'"); expect(output).toContain("SPECIAL='Line1\nLine2\tTab\rCarriage'"); expect(output).toContain("BACKSLASH='C:\\Users\\test\\path'"); expect(output).toContain("REGEX='^[a-zA-Z0-9]+$'"); expect(output).toContain("SQL_INJECTION='1'\"'\"' OR '\"'\"'1'\"'\"'='\"'\"'1'"); }); }); describe('Shell metacharacter handling', () => { it('should safely handle all shell metacharacters', () => { const config = { dollar: "$HOME $USER ${PATH}", backtick: "`date` `whoami`", parentheses: "$(date) $(whoami)", semicolon: "cmd1; cmd2; cmd3", ampersand: "cmd1 & cmd2 && cmd3", pipe: "cmd1 | cmd2 || cmd3", redirect: "cmd > file < input >> append", glob: "*.txt ?.log [a-z]*", tilde: "~/home ~/.config", exclamation: "!history !!", question: "file? test?", asterisk: "*.* *", brackets: "[abc] [0-9]", braces: "{a,b,c} ${var}", caret: "^pattern^replacement^", hash: "#comment # another", at: "@variable @{array}" }; fs.writeFileSync(configPath, JSON.stringify(config)); const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8', env: cleanEnv }); // Verify all metacharacters are safely quoted const lines = output.trim().split('\n'); lines.forEach(line => { // Each line should be in the format: export KEY='value' expect(line).toMatch(/^export [A-Z_]+='.*'$/); }); // Test that the values are safe when evaluated const testScript = ` #!/bin/sh set -e ${output} # If any metacharacters were unescaped, these would fail test "\$DOLLAR" = '\$HOME \$USER \${PATH}' test "\$BACKTICK" = '\`date\` \`whoami\`' test "\$PARENTHESES" = '\$(date) \$(whoami)' test "\$SEMICOLON" = 'cmd1; cmd2; cmd3' test "\$PIPE" = 'cmd1 | cmd2 || cmd3' echo "SUCCESS: All metacharacters safely contained" `; const tempScript = path.join(tempDir, 'test-metachar.sh'); fs.writeFileSync(tempScript, testScript); fs.chmodSync(tempScript, '755'); const result = execSync(tempScript, { encoding: 'utf8', env: cleanEnv }); expect(result.trim()).toBe('SUCCESS: All metacharacters safely contained'); }); }); describe('Escaping edge cases', () => { it('should handle consecutive single quotes', () => { const config = { test1: "'''", test2: "It'''s", test3: "start'''middle'''end", test4: "''''''''", }; fs.writeFileSync(configPath, JSON.stringify(config)); const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8', env: cleanEnv }); // Verify the escaping is correct expect(output).toContain(`TEST1=''"'"''"'"''"'"'`); expect(output).toContain(`TEST2='It'"'"''"'"''"'"'s'`); }); it('should handle empty and whitespace-only values', () => { const config = { empty: "", space: " ", spaces: " ", tab: "\t", newline: "\n", mixed_whitespace: " \t\n\r " }; fs.writeFileSync(configPath, JSON.stringify(config)); const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8', env: cleanEnv }); expect(output).toContain("EMPTY=''"); expect(output).toContain("SPACE=' '"); expect(output).toContain("SPACES=' '"); expect(output).toContain("TAB='\t'"); expect(output).toContain("NEWLINE='\n'"); expect(output).toContain("MIXED_WHITESPACE=' \t\n\r '"); }); it('should handle very long values', () => { const longString = 'a'.repeat(10000) + "'; echo 'injection'; '" + 'b'.repeat(10000); const config = { long_value: longString }; fs.writeFileSync(configPath, JSON.stringify(config)); const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8', env: cleanEnv }); expect(output).toContain('LONG_VALUE='); expect(output.length).toBeGreaterThan(20000); // The injection attempt should be safely quoted expect(output).toContain("'\"'\"'; echo '\"'\"'injection'\"'\"'; '\"'\"'"); }); }); describe('Environment variable name security', () => { it('should handle potentially dangerous key names', () => { const config = { "PATH": "should-not-override", "LD_PRELOAD": "dangerous", "valid_key": "safe_value", "123invalid": "should-be-skipped", "key-with-dash": "should-work", "key.with.dots": "should-work", "KEY WITH SPACES": "should-work" }; fs.writeFileSync(configPath, JSON.stringify(config)); const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8', env: cleanEnv }); // Dangerous variables should be blocked expect(output).not.toContain("export PATH="); expect(output).not.toContain("export LD_PRELOAD="); // Valid keys should be converted to safe names expect(output).toContain("export VALID_KEY='safe_value'"); expect(output).toContain("export KEY_WITH_DASH='should-work'"); expect(output).toContain("export KEY_WITH_DOTS='should-work'"); expect(output).toContain("export KEY_WITH_SPACES='should-work'"); // Invalid starting with number should be prefixed with _ expect(output).toContain("export _123INVALID='should-be-skipped'"); }); }); describe('Real-world attack scenarios', () => { it('should prevent path traversal attempts', () => { const config = { file_path: "../../../etc/passwd", backup_location: "../../../../../../tmp/evil", template: "${../../secret.key}", include: "<?php include('/etc/passwd'); ?>" }; fs.writeFileSync(configPath, JSON.stringify(config)); const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8', env: cleanEnv }); // Path traversal attempts should be preserved as strings, not resolved expect(output).toContain("FILE_PATH='../../../etc/passwd'"); expect(output).toContain("BACKUP_LOCATION='../../../../../../tmp/evil'"); expect(output).toContain("TEMPLATE='${../../secret.key}'"); expect(output).toContain("INCLUDE='<?php include('\"'\"'/etc/passwd'\"'\"'); ?>'"); }); it('should handle polyglot payloads safely', () => { const config = { // JavaScript/Shell polyglot polyglot1: "';alert(String.fromCharCode(88,83,83))//';alert(String.fromCharCode(88,83,83))//\";alert(String.fromCharCode(88,83,83))//\";alert(String.fromCharCode(88,83,83))//--></SCRIPT>\">'><SCRIPT>alert(String.fromCharCode(88,83,83))</SCRIPT>", // SQL/Shell polyglot polyglot2: "1' OR '1'='1' /*' or 1=1 # ' or 1=1-- ' or 1=1;--", // XML/Shell polyglot polyglot3: "<?xml version=\"1.0\"?><!DOCTYPE foo [<!ENTITY xxe SYSTEM \"file:///etc/passwd\">]><foo>&xxe;</foo>" }; fs.writeFileSync(configPath, JSON.stringify(config)); const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8', env: cleanEnv }); // All polyglot payloads should be safely quoted const lines = output.trim().split('\n'); lines.forEach(line => { if (line.startsWith('export POLYGLOT')) { // Should be safely wrapped in single quotes with proper escaping expect(line).toMatch(/^export POLYGLOT[0-9]='.*'$/); // The dangerous content is there but safely quoted // What matters is that when evaluated, it's just a string } }); }); }); describe('Stress testing', () => { it('should handle deeply nested malicious structures', () => { const createNestedMalicious = (depth: number): any => { if (depth === 0) { return "'; rm -rf /; '"; } return { [`level${depth}`]: createNestedMalicious(depth - 1), [`inject${depth}`]: "$( echo 'level " + depth + "' )" }; }; const config = createNestedMalicious(10); fs.writeFileSync(configPath, JSON.stringify(config)); const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8', env: cleanEnv }); // Should handle deep nesting without issues expect(output).toContain("LEVEL10_LEVEL9_LEVEL8"); expect(output).toContain("'\"'\"'; rm -rf /; '\"'\"'"); // All injection attempts should be quoted const lines = output.trim().split('\n'); lines.forEach(line => { if (line.includes('INJECT')) { expect(line).toContain("$( echo '\"'\"'level"); } }); }); it('should handle mixed attack vectors in single config', () => { const config = { normal_value: "This is safe", sql_injection: "1' OR '1'='1", cmd_injection: "; cat /etc/passwd", xxe_attempt: '<!ENTITY xxe SYSTEM "file:///etc/passwd">', code_injection: "${constructor.constructor('return process')().exit()}", format_string: "%s%s%s%s%s%s%s%s%s%s", buffer_overflow: "A".repeat(10000), null_injection: "test\x00admin", ldap_injection: "*)(&(1=1", xpath_injection: "' or '1'='1", template_injection: "{{7*7}}", ssti: "${7*7}", crlf_injection: "test\r\nSet-Cookie: admin=true", host_header: "evil.com\r\nX-Forwarded-Host: evil.com", cache_poisoning: "index.html%0d%0aContent-Length:%200%0d%0a%0d%0aHTTP/1.1%20200%20OK" }; fs.writeFileSync(configPath, JSON.stringify(config)); const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8', env: cleanEnv }); // Verify each attack vector is safely handled expect(output).toContain("NORMAL_VALUE='This is safe'"); expect(output).toContain("SQL_INJECTION='1'\"'\"' OR '\"'\"'1'\"'\"'='\"'\"'1'"); expect(output).toContain("CMD_INJECTION='; cat /etc/passwd'"); expect(output).toContain("XXE_ATTEMPT='<!ENTITY xxe SYSTEM \"file:///etc/passwd\">'"); expect(output).toContain("CODE_INJECTION='${constructor.constructor('\"'\"'return process'\"'\"')().exit()}'"); // Verify no actual code execution occurs const evalTest = `${output}\necho "Test completed successfully"`; const result = execSync(evalTest, { shell: '/bin/sh', encoding: 'utf8' }); expect(result).toContain("Test completed successfully"); }); }); }); ``` -------------------------------------------------------------------------------- /tests/unit/mcp/get-node-essentials-examples.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { N8NDocumentationMCPServer } from '../../../src/mcp/server'; /** * Unit tests for get_node_essentials with includeExamples parameter * Testing P0-R3 feature: Template-based configuration examples with metadata */ describe('get_node_essentials with includeExamples', () => { let server: N8NDocumentationMCPServer; beforeEach(async () => { process.env.NODE_DB_PATH = ':memory:'; server = new N8NDocumentationMCPServer(); await (server as any).initialized; // Populate in-memory database with test nodes // NOTE: Database stores nodes in SHORT form (nodes-base.xxx, not n8n-nodes-base.xxx) const testNodes = [ { node_type: 'nodes-base.httpRequest', package_name: 'n8n-nodes-base', display_name: 'HTTP Request', description: 'Makes an HTTP request', category: 'Core Nodes', is_ai_tool: 0, is_trigger: 0, is_webhook: 0, is_versioned: 1, version: '1', properties_schema: JSON.stringify([]), operations: JSON.stringify([]) }, { node_type: 'nodes-base.webhook', package_name: 'n8n-nodes-base', display_name: 'Webhook', description: 'Starts workflow on webhook call', category: 'Core Nodes', is_ai_tool: 0, is_trigger: 1, is_webhook: 1, is_versioned: 1, version: '1', properties_schema: JSON.stringify([]), operations: JSON.stringify([]) }, { node_type: 'nodes-base.test', package_name: 'n8n-nodes-base', display_name: 'Test Node', description: 'Test node for examples', category: 'Core Nodes', is_ai_tool: 0, is_trigger: 0, is_webhook: 0, is_versioned: 1, version: '1', properties_schema: JSON.stringify([]), operations: JSON.stringify([]) } ]; // Insert test nodes into the in-memory database const db = (server as any).db; if (db) { const insertStmt = db.prepare(` INSERT INTO nodes ( node_type, package_name, display_name, description, category, is_ai_tool, is_trigger, is_webhook, is_versioned, version, properties_schema, operations ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); for (const node of testNodes) { insertStmt.run( node.node_type, node.package_name, node.display_name, node.description, node.category, node.is_ai_tool, node.is_trigger, node.is_webhook, node.is_versioned, node.version, node.properties_schema, node.operations ); } } }); afterEach(() => { delete process.env.NODE_DB_PATH; }); describe('includeExamples parameter', () => { it('should not include examples when includeExamples is false', async () => { const result = await (server as any).getNodeEssentials('nodes-base.httpRequest', false); expect(result).toBeDefined(); expect(result.examples).toBeUndefined(); }); it('should not include examples when includeExamples is undefined', async () => { const result = await (server as any).getNodeEssentials('nodes-base.httpRequest', undefined); expect(result).toBeDefined(); expect(result.examples).toBeUndefined(); }); it('should include examples when includeExamples is true', async () => { const result = await (server as any).getNodeEssentials('nodes-base.httpRequest', true); expect(result).toBeDefined(); // Note: In-memory test database may not have template configs // This test validates the parameter is processed correctly }); it('should limit examples to top 3 per node', async () => { const result = await (server as any).getNodeEssentials('nodes-base.webhook', true); expect(result).toBeDefined(); if (result.examples) { expect(result.examples.length).toBeLessThanOrEqual(3); } }); }); describe('example data structure with metadata', () => { it('should return examples with full metadata structure', async () => { // Mock database to return example data with metadata const mockDb = (server as any).db; if (mockDb) { const originalPrepare = mockDb.prepare.bind(mockDb); mockDb.prepare = vi.fn((query: string) => { if (query.includes('template_node_configs')) { return { all: vi.fn(() => [ { parameters_json: JSON.stringify({ httpMethod: 'POST', path: 'webhook-test', responseMode: 'lastNode' }), template_name: 'Webhook Template', template_views: 2000, complexity: 'simple', use_cases: JSON.stringify(['webhook processing', 'API integration']), has_credentials: 0, has_expressions: 1 } ]) }; } return originalPrepare(query); }); const result = await (server as any).getNodeEssentials('nodes-base.webhook', true); if (result.examples && result.examples.length > 0) { const example = result.examples[0]; // Verify structure expect(example).toHaveProperty('configuration'); expect(example).toHaveProperty('source'); expect(example).toHaveProperty('useCases'); expect(example).toHaveProperty('metadata'); // Verify source structure expect(example.source).toHaveProperty('template'); expect(example.source).toHaveProperty('views'); expect(example.source).toHaveProperty('complexity'); // Verify metadata structure expect(example.metadata).toHaveProperty('hasCredentials'); expect(example.metadata).toHaveProperty('hasExpressions'); // Verify types expect(typeof example.configuration).toBe('object'); expect(typeof example.source.template).toBe('string'); expect(typeof example.source.views).toBe('number'); expect(typeof example.source.complexity).toBe('string'); expect(Array.isArray(example.useCases)).toBe(true); expect(typeof example.metadata.hasCredentials).toBe('boolean'); expect(typeof example.metadata.hasExpressions).toBe('boolean'); } } }); it('should include complexity in source metadata', async () => { const mockDb = (server as any).db; if (mockDb) { const originalPrepare = mockDb.prepare.bind(mockDb); mockDb.prepare = vi.fn((query: string) => { if (query.includes('template_node_configs')) { return { all: vi.fn(() => [ { parameters_json: JSON.stringify({ url: 'https://api.example.com' }), template_name: 'Simple HTTP Request', template_views: 500, complexity: 'simple', use_cases: JSON.stringify([]), has_credentials: 0, has_expressions: 0 }, { parameters_json: JSON.stringify({ url: '={{ $json.url }}', options: { timeout: 30000 } }), template_name: 'Complex HTTP Request', template_views: 300, complexity: 'complex', use_cases: JSON.stringify(['advanced API calls']), has_credentials: 1, has_expressions: 1 } ]) }; } return originalPrepare(query); }); const result = await (server as any).getNodeEssentials('nodes-base.httpRequest', true); if (result.examples && result.examples.length >= 2) { expect(result.examples[0].source.complexity).toBe('simple'); expect(result.examples[1].source.complexity).toBe('complex'); } } }); it('should limit use cases to 2 items', async () => { const mockDb = (server as any).db; if (mockDb) { const originalPrepare = mockDb.prepare.bind(mockDb); mockDb.prepare = vi.fn((query: string) => { if (query.includes('template_node_configs')) { return { all: vi.fn(() => [ { parameters_json: JSON.stringify({}), template_name: 'Test Template', template_views: 100, complexity: 'medium', use_cases: JSON.stringify([ 'use case 1', 'use case 2', 'use case 3', 'use case 4' ]), has_credentials: 0, has_expressions: 0 } ]) }; } return originalPrepare(query); }); const result = await (server as any).getNodeEssentials('nodes-base.test', true); if (result.examples && result.examples.length > 0) { expect(result.examples[0].useCases.length).toBeLessThanOrEqual(2); } } }); it('should handle empty use_cases gracefully', async () => { const mockDb = (server as any).db; if (mockDb) { const originalPrepare = mockDb.prepare.bind(mockDb); mockDb.prepare = vi.fn((query: string) => { if (query.includes('template_node_configs')) { return { all: vi.fn(() => [ { parameters_json: JSON.stringify({}), template_name: 'Test Template', template_views: 100, complexity: 'medium', use_cases: null, has_credentials: 0, has_expressions: 0 } ]) }; } return originalPrepare(query); }); const result = await (server as any).getNodeEssentials('nodes-base.test', true); if (result.examples && result.examples.length > 0) { expect(result.examples[0].useCases).toEqual([]); } } }); }); describe('caching behavior with includeExamples', () => { it('should use different cache keys for with/without examples', async () => { const cache = (server as any).cache; const cacheGetSpy = vi.spyOn(cache, 'get'); // First call without examples await (server as any).getNodeEssentials('nodes-base.httpRequest', false); expect(cacheGetSpy).toHaveBeenCalledWith(expect.stringContaining('basic')); // Second call with examples await (server as any).getNodeEssentials('nodes-base.httpRequest', true); expect(cacheGetSpy).toHaveBeenCalledWith(expect.stringContaining('withExamples')); }); it('should cache results separately for different includeExamples values', async () => { // Call with examples const resultWithExamples1 = await (server as any).getNodeEssentials('nodes-base.httpRequest', true); // Call without examples const resultWithoutExamples = await (server as any).getNodeEssentials('nodes-base.httpRequest', false); // Call with examples again (should be cached) const resultWithExamples2 = await (server as any).getNodeEssentials('nodes-base.httpRequest', true); // Results with examples should match expect(resultWithExamples1).toEqual(resultWithExamples2); // Result without examples should not have examples expect(resultWithoutExamples.examples).toBeUndefined(); }); }); describe('backward compatibility', () => { it('should maintain backward compatibility when includeExamples not specified', async () => { const result = await (server as any).getNodeEssentials('nodes-base.httpRequest'); expect(result).toBeDefined(); expect(result.nodeType).toBeDefined(); expect(result.displayName).toBeDefined(); expect(result.examples).toBeUndefined(); }); it('should return same core data regardless of includeExamples value', async () => { const resultWithout = await (server as any).getNodeEssentials('nodes-base.httpRequest', false); const resultWith = await (server as any).getNodeEssentials('nodes-base.httpRequest', true); // Core fields should be identical expect(resultWithout.nodeType).toBe(resultWith.nodeType); expect(resultWithout.displayName).toBe(resultWith.displayName); expect(resultWithout.description).toBe(resultWith.description); }); }); describe('error handling', () => { it('should continue to work even if example fetch fails', async () => { const mockDb = (server as any).db; if (mockDb) { const originalPrepare = mockDb.prepare.bind(mockDb); mockDb.prepare = vi.fn((query: string) => { if (query.includes('template_node_configs')) { throw new Error('Database error'); } return originalPrepare(query); }); // Should not throw const result = await (server as any).getNodeEssentials('nodes-base.webhook', true); expect(result).toBeDefined(); expect(result.nodeType).toBeDefined(); // Examples should be empty array due to error (fallback behavior) expect(result.examples).toEqual([]); expect(result.examplesCount).toBe(0); } }); it('should handle malformed JSON in template configs gracefully', async () => { const mockDb = (server as any).db; if (mockDb) { const originalPrepare = mockDb.prepare.bind(mockDb); mockDb.prepare = vi.fn((query: string) => { if (query.includes('template_node_configs')) { return { all: vi.fn(() => [ { parameters_json: 'invalid json', template_name: 'Test', template_views: 100, complexity: 'medium', use_cases: 'also invalid', has_credentials: 0, has_expressions: 0 } ]) }; } return originalPrepare(query); }); // Should not throw const result = await (server as any).getNodeEssentials('nodes-base.test', true); expect(result).toBeDefined(); } }); }); describe('performance', () => { it('should complete in reasonable time with examples', async () => { const start = Date.now(); await (server as any).getNodeEssentials('nodes-base.httpRequest', true); const duration = Date.now() - start; // Should complete under 100ms expect(duration).toBeLessThan(100); }); it('should not add significant overhead when includeExamples is false', async () => { const startWithout = Date.now(); await (server as any).getNodeEssentials('nodes-base.httpRequest', false); const durationWithout = Date.now() - startWithout; const startWith = Date.now(); await (server as any).getNodeEssentials('nodes-base.httpRequest', true); const durationWith = Date.now() - startWith; // Both should be fast expect(durationWithout).toBeLessThan(50); expect(durationWith).toBeLessThan(100); }); }); }); ``` -------------------------------------------------------------------------------- /tests/unit/multi-tenant-integration.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Integration tests for multi-tenant support across the entire codebase * * This test file provides comprehensive coverage for the multi-tenant implementation * by testing the actual behavior and integration points rather than implementation details. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { InstanceContext, isInstanceContext, validateInstanceContext } from '../../src/types/instance-context'; // Mock logger properly vi.mock('../../src/utils/logger', () => ({ Logger: vi.fn().mockImplementation(() => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() })), logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } })); describe('Multi-Tenant Support Integration', () => { let originalEnv: NodeJS.ProcessEnv; beforeEach(() => { originalEnv = { ...process.env }; vi.clearAllMocks(); }); afterEach(() => { process.env = originalEnv; }); describe('InstanceContext Validation', () => { describe('Real-world URL patterns', () => { const validUrls = [ 'https://app.n8n.cloud', 'https://tenant1.n8n.cloud', 'https://my-company.n8n.cloud', 'https://n8n.example.com', 'https://automation.company.com', 'http://localhost:5678', 'https://localhost:8443', 'http://127.0.0.1:5678', 'https://192.168.1.100:8080', 'https://10.0.0.1:3000', 'http://n8n.internal.company.com', 'https://workflow.enterprise.local' ]; validUrls.forEach(url => { it(`should accept realistic n8n URL: ${url}`, () => { const context: InstanceContext = { n8nApiUrl: url, n8nApiKey: 'valid-api-key-123' }; expect(isInstanceContext(context)).toBe(true); const validation = validateInstanceContext(context); expect(validation.valid).toBe(true); expect(validation.errors).toBeUndefined(); }); }); }); describe('Security validation', () => { const maliciousUrls = [ 'javascript:alert("xss")', 'vbscript:msgbox("xss")', 'data:text/html,<script>alert("xss")</script>', 'file:///etc/passwd', 'ldap://attacker.com/cn=admin', 'ftp://malicious.com' ]; maliciousUrls.forEach(url => { it(`should reject potentially malicious URL: ${url}`, () => { const context: InstanceContext = { n8nApiUrl: url, n8nApiKey: 'valid-key' }; expect(isInstanceContext(context)).toBe(false); const validation = validateInstanceContext(context); expect(validation.valid).toBe(false); expect(validation.errors).toBeDefined(); }); }); }); describe('API key validation', () => { const invalidApiKeys = [ '', 'placeholder', 'YOUR_API_KEY', 'example', 'your_api_key_here' ]; invalidApiKeys.forEach(key => { it(`should reject invalid API key: "${key}"`, () => { const context: InstanceContext = { n8nApiUrl: 'https://valid.n8n.cloud', n8nApiKey: key }; if (key === '') { // Empty string validation const validation = validateInstanceContext(context); expect(validation.valid).toBe(false); expect(validation.errors?.[0]).toContain('empty string'); } else { // Placeholder validation expect(isInstanceContext(context)).toBe(false); } }); }); it('should accept valid API keys', () => { const validKeys = [ 'sk_live_AbCdEf123456789', 'api-key-12345-abcdef', 'n8n_api_key_production_v1_xyz', 'Bearer-token-abc123', 'jwt.eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9' ]; validKeys.forEach(key => { const context: InstanceContext = { n8nApiUrl: 'https://valid.n8n.cloud', n8nApiKey: key }; expect(isInstanceContext(context)).toBe(true); const validation = validateInstanceContext(context); expect(validation.valid).toBe(true); }); }); }); describe('Edge cases and error handling', () => { it('should handle partial instance context', () => { const partialContext: InstanceContext = { n8nApiUrl: 'https://tenant1.n8n.cloud' // n8nApiKey intentionally missing }; expect(isInstanceContext(partialContext)).toBe(true); const validation = validateInstanceContext(partialContext); expect(validation.valid).toBe(true); }); it('should handle completely empty context', () => { const emptyContext: InstanceContext = {}; expect(isInstanceContext(emptyContext)).toBe(true); const validation = validateInstanceContext(emptyContext); expect(validation.valid).toBe(true); }); it('should handle numerical values gracefully', () => { const contextWithNumbers: InstanceContext = { n8nApiUrl: 'https://tenant1.n8n.cloud', n8nApiKey: 'valid-key', n8nApiTimeout: 30000, n8nApiMaxRetries: 3 }; expect(isInstanceContext(contextWithNumbers)).toBe(true); const validation = validateInstanceContext(contextWithNumbers); expect(validation.valid).toBe(true); }); it('should reject invalid numerical values', () => { const invalidTimeout: InstanceContext = { n8nApiUrl: 'https://tenant1.n8n.cloud', n8nApiKey: 'valid-key', n8nApiTimeout: -1 }; expect(isInstanceContext(invalidTimeout)).toBe(false); const validation = validateInstanceContext(invalidTimeout); expect(validation.valid).toBe(false); expect(validation.errors?.[0]).toContain('Must be positive'); }); it('should reject invalid retry values', () => { const invalidRetries: InstanceContext = { n8nApiUrl: 'https://tenant1.n8n.cloud', n8nApiKey: 'valid-key', n8nApiMaxRetries: -5 }; expect(isInstanceContext(invalidRetries)).toBe(false); const validation = validateInstanceContext(invalidRetries); expect(validation.valid).toBe(false); expect(validation.errors?.[0]).toContain('Must be non-negative'); }); }); }); describe('Environment Variable Handling', () => { it('should handle ENABLE_MULTI_TENANT flag correctly', () => { // Test various flag values const flagValues = [ { value: 'true', expected: true }, { value: 'false', expected: false }, { value: 'TRUE', expected: false }, // Case sensitive { value: 'yes', expected: false }, { value: '1', expected: false }, { value: '', expected: false }, { value: undefined, expected: false } ]; flagValues.forEach(({ value, expected }) => { if (value === undefined) { delete process.env.ENABLE_MULTI_TENANT; } else { process.env.ENABLE_MULTI_TENANT = value; } const isEnabled = process.env.ENABLE_MULTI_TENANT === 'true'; expect(isEnabled).toBe(expected); }); }); it('should handle N8N_API_URL and N8N_API_KEY environment variables', () => { // Test backward compatibility process.env.N8N_API_URL = 'https://env.n8n.cloud'; process.env.N8N_API_KEY = 'env-api-key'; const hasEnvConfig = !!(process.env.N8N_API_URL || process.env.N8N_API_KEY); expect(hasEnvConfig).toBe(true); // Test when not set delete process.env.N8N_API_URL; delete process.env.N8N_API_KEY; const hasNoEnvConfig = !!(process.env.N8N_API_URL || process.env.N8N_API_KEY); expect(hasNoEnvConfig).toBe(false); }); }); describe('Header Processing Simulation', () => { it('should process multi-tenant headers correctly', () => { // Simulate Express request headers const mockHeaders = { 'x-n8n-url': 'https://tenant1.n8n.cloud', 'x-n8n-key': 'tenant1-api-key', 'x-instance-id': 'tenant1-instance', 'x-session-id': 'tenant1-session-123' }; // Simulate header extraction const extractedContext: InstanceContext = { n8nApiUrl: mockHeaders['x-n8n-url'], n8nApiKey: mockHeaders['x-n8n-key'], instanceId: mockHeaders['x-instance-id'], sessionId: mockHeaders['x-session-id'] }; expect(isInstanceContext(extractedContext)).toBe(true); const validation = validateInstanceContext(extractedContext); expect(validation.valid).toBe(true); }); it('should handle missing headers gracefully', () => { const mockHeaders: any = { 'authorization': 'Bearer token', 'content-type': 'application/json' // No x-n8n-* headers }; const extractedContext = { n8nApiUrl: mockHeaders['x-n8n-url'], // undefined n8nApiKey: mockHeaders['x-n8n-key'] // undefined }; // When no relevant headers exist, context should be undefined const shouldCreateContext = !!(extractedContext.n8nApiUrl || extractedContext.n8nApiKey); expect(shouldCreateContext).toBe(false); }); it('should handle malformed headers', () => { const mockHeaders = { 'x-n8n-url': 'not-a-url', 'x-n8n-key': 'placeholder' }; const extractedContext: InstanceContext = { n8nApiUrl: mockHeaders['x-n8n-url'], n8nApiKey: mockHeaders['x-n8n-key'] }; expect(isInstanceContext(extractedContext)).toBe(false); const validation = validateInstanceContext(extractedContext); expect(validation.valid).toBe(false); }); }); describe('Configuration Priority Logic', () => { it('should implement correct priority logic for tool inclusion', () => { // Test the shouldIncludeManagementTools logic const scenarios = [ { name: 'env config only', envUrl: 'https://env.example.com', envKey: 'env-key', instanceContext: undefined, multiTenant: false, expected: true }, { name: 'instance config only', envUrl: undefined, envKey: undefined, instanceContext: { n8nApiUrl: 'https://tenant.example.com', n8nApiKey: 'tenant-key' }, multiTenant: false, expected: true }, { name: 'multi-tenant flag only', envUrl: undefined, envKey: undefined, instanceContext: undefined, multiTenant: true, expected: true }, { name: 'no configuration', envUrl: undefined, envKey: undefined, instanceContext: undefined, multiTenant: false, expected: false } ]; scenarios.forEach(({ name, envUrl, envKey, instanceContext, multiTenant, expected }) => { // Setup environment if (envUrl) process.env.N8N_API_URL = envUrl; else delete process.env.N8N_API_URL; if (envKey) process.env.N8N_API_KEY = envKey; else delete process.env.N8N_API_KEY; if (multiTenant) process.env.ENABLE_MULTI_TENANT = 'true'; else delete process.env.ENABLE_MULTI_TENANT; // Test logic const hasEnvConfig = !!(process.env.N8N_API_URL || process.env.N8N_API_KEY); const hasInstanceConfig = !!(instanceContext?.n8nApiUrl || instanceContext?.n8nApiKey); const isMultiTenantEnabled = process.env.ENABLE_MULTI_TENANT === 'true'; const shouldIncludeManagementTools = hasEnvConfig || hasInstanceConfig || isMultiTenantEnabled; expect(shouldIncludeManagementTools).toBe(expected); }); }); }); describe('Session Management Concepts', () => { it('should generate consistent identifiers for same configuration', () => { const config1 = { n8nApiUrl: 'https://tenant1.n8n.cloud', n8nApiKey: 'api-key-123' }; const config2 = { n8nApiUrl: 'https://tenant1.n8n.cloud', n8nApiKey: 'api-key-123' }; // Same configuration should produce same hash const hash1 = JSON.stringify(config1); const hash2 = JSON.stringify(config2); expect(hash1).toBe(hash2); }); it('should generate different identifiers for different configurations', () => { const config1 = { n8nApiUrl: 'https://tenant1.n8n.cloud', n8nApiKey: 'api-key-123' }; const config2 = { n8nApiUrl: 'https://tenant2.n8n.cloud', n8nApiKey: 'different-api-key' }; // Different configuration should produce different hash const hash1 = JSON.stringify(config1); const hash2 = JSON.stringify(config2); expect(hash1).not.toBe(hash2); }); it('should handle session isolation concepts', () => { const sessions = new Map(); // Simulate creating sessions for different tenants const tenant1Context = { n8nApiUrl: 'https://tenant1.n8n.cloud', n8nApiKey: 'tenant1-key', instanceId: 'tenant1' }; const tenant2Context = { n8nApiUrl: 'https://tenant2.n8n.cloud', n8nApiKey: 'tenant2-key', instanceId: 'tenant2' }; sessions.set('session-1', { context: tenant1Context, lastAccess: new Date() }); sessions.set('session-2', { context: tenant2Context, lastAccess: new Date() }); // Verify isolation expect(sessions.get('session-1').context.instanceId).toBe('tenant1'); expect(sessions.get('session-2').context.instanceId).toBe('tenant2'); expect(sessions.size).toBe(2); }); }); describe('Error Scenarios and Recovery', () => { it('should handle validation errors gracefully', () => { const invalidContext: InstanceContext = { n8nApiUrl: '', // Empty URL n8nApiKey: '', // Empty key n8nApiTimeout: -1, // Invalid timeout n8nApiMaxRetries: -1 // Invalid retries }; // Should not throw expect(() => isInstanceContext(invalidContext)).not.toThrow(); expect(() => validateInstanceContext(invalidContext)).not.toThrow(); const validation = validateInstanceContext(invalidContext); expect(validation.valid).toBe(false); expect(validation.errors?.length).toBeGreaterThan(0); // Each error should be descriptive validation.errors?.forEach(error => { expect(error).toContain('Invalid'); expect(typeof error).toBe('string'); expect(error.length).toBeGreaterThan(10); }); }); it('should provide specific error messages', () => { const testCases = [ { context: { n8nApiUrl: '', n8nApiKey: 'valid' }, expectedError: 'empty string' }, { context: { n8nApiUrl: 'https://example.com', n8nApiKey: 'placeholder' }, expectedError: 'placeholder' }, { context: { n8nApiUrl: 'https://example.com', n8nApiKey: 'valid', n8nApiTimeout: -1 }, expectedError: 'Must be positive' }, { context: { n8nApiUrl: 'https://example.com', n8nApiKey: 'valid', n8nApiMaxRetries: -1 }, expectedError: 'Must be non-negative' } ]; testCases.forEach(({ context, expectedError }) => { const validation = validateInstanceContext(context); expect(validation.valid).toBe(false); expect(validation.errors?.some(err => err.includes(expectedError))).toBe(true); }); }); }); }); ```