This is page 16 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 -------------------------------------------------------------------------------- /tests/unit/services/property-filter.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest'; import { PropertyFilter } from '@/services/property-filter'; import type { SimplifiedProperty, FilteredProperties } from '@/services/property-filter'; // Mock the database vi.mock('better-sqlite3'); describe('PropertyFilter', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('deduplicateProperties', () => { it('should remove duplicate properties with same name and conditions', () => { const properties = [ { name: 'url', type: 'string', displayOptions: { show: { method: ['GET'] } } }, { name: 'url', type: 'string', displayOptions: { show: { method: ['GET'] } } }, // Duplicate { name: 'url', type: 'string', displayOptions: { show: { method: ['POST'] } } }, // Different condition ]; const result = PropertyFilter.deduplicateProperties(properties); expect(result).toHaveLength(2); expect(result[0].name).toBe('url'); expect(result[1].name).toBe('url'); expect(result[0].displayOptions).not.toEqual(result[1].displayOptions); }); it('should handle properties without displayOptions', () => { const properties = [ { name: 'timeout', type: 'number' }, { name: 'timeout', type: 'number' }, // Duplicate { name: 'retries', type: 'number' }, ]; const result = PropertyFilter.deduplicateProperties(properties); expect(result).toHaveLength(2); expect(result.map(p => p.name)).toEqual(['timeout', 'retries']); }); }); describe('getEssentials', () => { it('should return configured essentials for HTTP Request node', () => { const properties = [ { name: 'url', type: 'string', required: true }, { name: 'method', type: 'options', options: ['GET', 'POST'] }, { name: 'authentication', type: 'options' }, { name: 'sendBody', type: 'boolean' }, { name: 'contentType', type: 'options' }, { name: 'sendHeaders', type: 'boolean' }, { name: 'someRareOption', type: 'string' }, ]; const result = PropertyFilter.getEssentials(properties, 'nodes-base.httpRequest'); expect(result.required).toHaveLength(1); expect(result.required[0].name).toBe('url'); expect(result.required[0].required).toBe(true); expect(result.common).toHaveLength(5); expect(result.common.map(p => p.name)).toEqual([ 'method', 'authentication', 'sendBody', 'contentType', 'sendHeaders' ]); }); it('should handle nested properties in collections', () => { const properties = [ { name: 'assignments', type: 'collection', options: [ { name: 'field', type: 'string' }, { name: 'value', type: 'string' } ] } ]; const result = PropertyFilter.getEssentials(properties, 'nodes-base.set'); expect(result.common.some(p => p.name === 'assignments')).toBe(true); }); it('should infer essentials for unconfigured nodes', () => { const properties = [ { name: 'requiredField', type: 'string', required: true }, { name: 'simpleField', type: 'string' }, { name: 'conditionalField', type: 'string', displayOptions: { show: { mode: ['advanced'] } } }, { name: 'complexField', type: 'collection' }, ]; const result = PropertyFilter.getEssentials(properties, 'nodes-base.unknownNode'); expect(result.required).toHaveLength(1); expect(result.required[0].name).toBe('requiredField'); // May include both simpleField and complexField (collection type) expect(result.common.length).toBeGreaterThanOrEqual(1); expect(result.common.some(p => p.name === 'simpleField')).toBe(true); }); it('should include conditional properties when needed to reach minimum count', () => { const properties = [ { name: 'field1', type: 'string' }, { name: 'field2', type: 'string', displayOptions: { show: { mode: ['basic'] } } }, { name: 'field3', type: 'string', displayOptions: { show: { mode: ['advanced'], type: ['custom'] } } }, ]; const result = PropertyFilter.getEssentials(properties, 'nodes-base.unknownNode'); expect(result.common).toHaveLength(2); expect(result.common[0].name).toBe('field1'); expect(result.common[1].name).toBe('field2'); // Single condition included }); }); describe('property simplification', () => { it('should simplify options properly', () => { const properties = [ { name: 'method', type: 'options', displayName: 'HTTP Method', options: [ { name: 'GET', value: 'GET' }, { name: 'POST', value: 'POST' }, { name: 'PUT', value: 'PUT' } ] } ]; const result = PropertyFilter.getEssentials(properties, 'nodes-base.httpRequest'); const methodProp = result.common.find(p => p.name === 'method'); expect(methodProp?.options).toHaveLength(3); expect(methodProp?.options?.[0]).toEqual({ value: 'GET', label: 'GET' }); }); it('should handle string array options', () => { const properties = [ { name: 'resource', type: 'options', options: ['user', 'post', 'comment'] } ]; const result = PropertyFilter.getEssentials(properties, 'nodes-base.unknownNode'); const resourceProp = result.common.find(p => p.name === 'resource'); expect(resourceProp?.options).toEqual([ { value: 'user', label: 'user' }, { value: 'post', label: 'post' }, { value: 'comment', label: 'comment' } ]); }); it('should include simple display conditions', () => { const properties = [ { name: 'channel', type: 'string', displayOptions: { show: { resource: ['message'], operation: ['post'] } } } ]; const result = PropertyFilter.getEssentials(properties, 'nodes-base.slack'); const channelProp = result.common.find(p => p.name === 'channel'); expect(channelProp?.showWhen).toEqual({ resource: ['message'], operation: ['post'] }); }); it('should exclude complex display conditions', () => { const properties = [ { name: 'complexField', type: 'string', displayOptions: { show: { mode: ['advanced'], type: ['custom'], enabled: [true], resource: ['special'] } } } ]; const result = PropertyFilter.getEssentials(properties, 'nodes-base.unknownNode'); const complexProp = result.common.find(p => p.name === 'complexField'); expect(complexProp?.showWhen).toBeUndefined(); }); it('should generate usage hints for common property types', () => { const properties = [ { name: 'url', type: 'string' }, { name: 'endpoint', type: 'string' }, { name: 'authentication', type: 'options' }, { name: 'jsonData', type: 'json' }, { name: 'jsCode', type: 'code' }, { name: 'enableFeature', type: 'boolean', displayOptions: { show: { mode: ['advanced'] } } } ]; const result = PropertyFilter.getEssentials(properties, 'nodes-base.unknownNode'); const urlProp = result.common.find(p => p.name === 'url'); expect(urlProp?.usageHint).toBe('Enter the full URL including https://'); const authProp = result.common.find(p => p.name === 'authentication'); expect(authProp?.usageHint).toBe('Select authentication method or credentials'); const jsonProp = result.common.find(p => p.name === 'jsonData'); expect(jsonProp?.usageHint).toBe('Enter valid JSON data'); }); it('should extract descriptions from various fields', () => { const properties = [ { name: 'field1', description: 'Primary description' }, { name: 'field2', hint: 'Hint description' }, { name: 'field3', placeholder: 'Placeholder description' }, { name: 'field4', displayName: 'Display Name Only' }, { name: 'url' } // Should generate description ]; const result = PropertyFilter.getEssentials(properties, 'nodes-base.unknownNode'); expect(result.common[0].description).toBe('Primary description'); expect(result.common[1].description).toBe('Hint description'); expect(result.common[2].description).toBe('Placeholder description'); expect(result.common[3].description).toBe('Display Name Only'); expect(result.common[4].description).toBe('The URL to make the request to'); }); }); describe('searchProperties', () => { const testProperties = [ { name: 'url', displayName: 'URL', type: 'string', description: 'The endpoint URL for the request' }, { name: 'urlParams', displayName: 'URL Parameters', type: 'collection' }, { name: 'authentication', displayName: 'Authentication', type: 'options', description: 'Select the authentication method' }, { name: 'headers', type: 'collection', options: [ { name: 'Authorization', type: 'string' }, { name: 'Content-Type', type: 'string' } ] } ]; it('should find exact name matches with highest score', () => { const results = PropertyFilter.searchProperties(testProperties, 'url'); expect(results).toHaveLength(2); expect(results[0].name).toBe('url'); // Exact match expect(results[1].name).toBe('urlParams'); // Prefix match }); it('should find properties by partial name match', () => { const results = PropertyFilter.searchProperties(testProperties, 'auth'); // May match both 'authentication' and 'Authorization' in headers expect(results.length).toBeGreaterThanOrEqual(1); expect(results.some(r => r.name === 'authentication')).toBe(true); }); it('should find properties by description match', () => { const results = PropertyFilter.searchProperties(testProperties, 'endpoint'); expect(results).toHaveLength(1); expect(results[0].name).toBe('url'); }); it('should search nested properties in collections', () => { const results = PropertyFilter.searchProperties(testProperties, 'authorization'); expect(results).toHaveLength(1); expect(results[0].name).toBe('Authorization'); expect((results[0] as any).path).toBe('headers.Authorization'); }); it('should limit results to maxResults', () => { const manyProperties = Array.from({ length: 30 }, (_, i) => ({ name: `authField${i}`, type: 'string' })); const results = PropertyFilter.searchProperties(manyProperties, 'auth', 5); expect(results).toHaveLength(5); }); it('should handle empty query gracefully', () => { const results = PropertyFilter.searchProperties(testProperties, ''); expect(results).toHaveLength(0); }); it('should search in fixedCollection properties', () => { const properties = [ { name: 'options', type: 'fixedCollection', options: [ { name: 'advanced', values: [ { name: 'timeout', type: 'number' }, { name: 'retries', type: 'number' } ] } ] } ]; const results = PropertyFilter.searchProperties(properties, 'timeout'); expect(results).toHaveLength(1); expect(results[0].name).toBe('timeout'); expect((results[0] as any).path).toBe('options.advanced.timeout'); }); }); describe('edge cases', () => { it('should handle empty properties array', () => { const result = PropertyFilter.getEssentials([], 'nodes-base.httpRequest'); expect(result.required).toHaveLength(0); expect(result.common).toHaveLength(0); }); it('should handle properties with missing fields gracefully', () => { const properties = [ { name: 'field1' }, // No type { type: 'string' }, // No name { name: 'field2', type: 'string' } // Valid ]; const result = PropertyFilter.getEssentials(properties, 'nodes-base.unknownNode'); expect(result.common.length).toBeGreaterThan(0); expect(result.common.every(p => p.name && p.type)).toBe(true); }); it('should handle circular references in nested properties', () => { const circularProp: any = { name: 'circular', type: 'collection', options: [] }; circularProp.options.push(circularProp); // Create circular reference const properties = [circularProp, { name: 'normal', type: 'string' }]; // Should not throw or hang expect(() => { PropertyFilter.getEssentials(properties, 'nodes-base.unknownNode'); }).not.toThrow(); }); it('should preserve default values for simple types', () => { const properties = [ { name: 'method', type: 'options', default: 'GET' }, { name: 'timeout', type: 'number', default: 30000 }, { name: 'enabled', type: 'boolean', default: true }, { name: 'complex', type: 'collection', default: { key: 'value' } } // Should not include ]; const result = PropertyFilter.getEssentials(properties, 'nodes-base.unknownNode'); const method = result.common.find(p => p.name === 'method'); expect(method?.default).toBe('GET'); const timeout = result.common.find(p => p.name === 'timeout'); expect(timeout?.default).toBe(30000); const enabled = result.common.find(p => p.name === 'enabled'); expect(enabled?.default).toBe(true); const complex = result.common.find(p => p.name === 'complex'); expect(complex?.default).toBeUndefined(); }); }); }); ``` -------------------------------------------------------------------------------- /tests/unit/utils/cache-utils.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Unit tests for cache utilities */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { createCacheKey, getCacheConfig, createInstanceCache, CacheMutex, calculateBackoffDelay, withRetry, getCacheStatistics, cacheMetrics, DEFAULT_RETRY_CONFIG } from '../../../src/utils/cache-utils'; describe('cache-utils', () => { beforeEach(() => { // Reset environment variables delete process.env.INSTANCE_CACHE_MAX; delete process.env.INSTANCE_CACHE_TTL_MINUTES; // Reset cache metrics cacheMetrics.reset(); }); describe('createCacheKey', () => { it('should create consistent SHA-256 hash for same input', () => { const input = 'https://api.n8n.cloud:valid-key:instance1'; const hash1 = createCacheKey(input); const hash2 = createCacheKey(input); expect(hash1).toBe(hash2); expect(hash1).toHaveLength(64); // SHA-256 produces 64 hex chars expect(hash1).toMatch(/^[a-f0-9]+$/); // Only hex characters }); it('should produce different hashes for different inputs', () => { const hash1 = createCacheKey('input1'); const hash2 = createCacheKey('input2'); expect(hash1).not.toBe(hash2); }); it('should use memoization for repeated inputs', () => { const input = 'memoized-input'; // First call creates hash const hash1 = createCacheKey(input); // Second call should return memoized result const hash2 = createCacheKey(input); expect(hash1).toBe(hash2); }); it('should limit memoization cache size', () => { // Create more than MAX_MEMO_SIZE (1000) unique hashes const hashes = new Set<string>(); for (let i = 0; i < 1100; i++) { const hash = createCacheKey(`input-${i}`); hashes.add(hash); } // All hashes should be unique expect(hashes.size).toBe(1100); // Early entries should have been evicted from memo cache // but should still produce consistent results const earlyHash = createCacheKey('input-0'); expect(earlyHash).toBe(hashes.values().next().value); }); }); describe('getCacheConfig', () => { it('should return default configuration when no env vars set', () => { const config = getCacheConfig(); expect(config.max).toBe(100); expect(config.ttlMinutes).toBe(30); }); it('should use environment variables when set', () => { process.env.INSTANCE_CACHE_MAX = '500'; process.env.INSTANCE_CACHE_TTL_MINUTES = '60'; const config = getCacheConfig(); expect(config.max).toBe(500); expect(config.ttlMinutes).toBe(60); }); it('should enforce minimum bounds', () => { process.env.INSTANCE_CACHE_MAX = '0'; process.env.INSTANCE_CACHE_TTL_MINUTES = '0'; const config = getCacheConfig(); expect(config.max).toBe(1); // Min is 1 expect(config.ttlMinutes).toBe(1); // Min is 1 }); it('should enforce maximum bounds', () => { process.env.INSTANCE_CACHE_MAX = '20000'; process.env.INSTANCE_CACHE_TTL_MINUTES = '2000'; const config = getCacheConfig(); expect(config.max).toBe(10000); // Max is 10000 expect(config.ttlMinutes).toBe(1440); // Max is 1440 (24 hours) }); it('should handle invalid values gracefully', () => { process.env.INSTANCE_CACHE_MAX = 'invalid'; process.env.INSTANCE_CACHE_TTL_MINUTES = 'not-a-number'; const config = getCacheConfig(); expect(config.max).toBe(100); // Falls back to default expect(config.ttlMinutes).toBe(30); // Falls back to default }); }); describe('createInstanceCache', () => { it('should create LRU cache with correct configuration', () => { process.env.INSTANCE_CACHE_MAX = '50'; process.env.INSTANCE_CACHE_TTL_MINUTES = '15'; const cache = createInstanceCache<{ data: string }>(); // Add items to cache cache.set('key1', { data: 'value1' }); cache.set('key2', { data: 'value2' }); expect(cache.get('key1')).toEqual({ data: 'value1' }); expect(cache.get('key2')).toEqual({ data: 'value2' }); expect(cache.size).toBe(2); }); it('should call dispose callback on eviction', () => { const disposeFn = vi.fn(); const cache = createInstanceCache<{ data: string }>(disposeFn); // Set max to 2 for testing process.env.INSTANCE_CACHE_MAX = '2'; const smallCache = createInstanceCache<{ data: string }>(disposeFn); smallCache.set('key1', { data: 'value1' }); smallCache.set('key2', { data: 'value2' }); smallCache.set('key3', { data: 'value3' }); // Should evict key1 expect(disposeFn).toHaveBeenCalledWith({ data: 'value1' }, 'key1'); }); it('should update age on get', () => { const cache = createInstanceCache<{ data: string }>(); cache.set('key1', { data: 'value1' }); // Access should update age const value = cache.get('key1'); expect(value).toEqual({ data: 'value1' }); // Item should still be in cache expect(cache.has('key1')).toBe(true); }); }); describe('CacheMutex', () => { it('should prevent concurrent access to same key', async () => { const mutex = new CacheMutex(); const key = 'test-key'; const results: number[] = []; // First operation acquires lock const release1 = await mutex.acquire(key); // Second operation should wait const promise2 = mutex.acquire(key).then(release => { results.push(2); release(); }); // First operation completes results.push(1); release1(); // Wait for second operation await promise2; expect(results).toEqual([1, 2]); // Operations executed in order }); it('should allow concurrent access to different keys', async () => { const mutex = new CacheMutex(); const results: string[] = []; const [release1, release2] = await Promise.all([ mutex.acquire('key1'), mutex.acquire('key2') ]); results.push('both-acquired'); release1(); release2(); expect(results).toEqual(['both-acquired']); }); it('should check if key is locked', async () => { const mutex = new CacheMutex(); const key = 'test-key'; expect(mutex.isLocked(key)).toBe(false); const release = await mutex.acquire(key); expect(mutex.isLocked(key)).toBe(true); release(); expect(mutex.isLocked(key)).toBe(false); }); it('should clear all locks', async () => { const mutex = new CacheMutex(); const release1 = await mutex.acquire('key1'); const release2 = await mutex.acquire('key2'); expect(mutex.isLocked('key1')).toBe(true); expect(mutex.isLocked('key2')).toBe(true); mutex.clearAll(); expect(mutex.isLocked('key1')).toBe(false); expect(mutex.isLocked('key2')).toBe(false); // Should not throw when calling release after clear release1(); release2(); }); it('should handle timeout for stuck locks', async () => { const mutex = new CacheMutex(); const key = 'stuck-key'; // Acquire lock but don't release await mutex.acquire(key); // Wait for timeout (mock the timeout) vi.useFakeTimers(); // Try to acquire same lock const acquirePromise = mutex.acquire(key); // Fast-forward past timeout vi.advanceTimersByTime(6000); // Timeout is 5 seconds // Should be able to acquire after timeout const release = await acquirePromise; release(); vi.useRealTimers(); }); }); describe('calculateBackoffDelay', () => { it('should calculate exponential backoff correctly', () => { const config = { ...DEFAULT_RETRY_CONFIG, jitterFactor: 0 }; // No jitter for predictable tests expect(calculateBackoffDelay(0, config)).toBe(1000); // 1 * 1000 expect(calculateBackoffDelay(1, config)).toBe(2000); // 2 * 1000 expect(calculateBackoffDelay(2, config)).toBe(4000); // 4 * 1000 expect(calculateBackoffDelay(3, config)).toBe(8000); // 8 * 1000 }); it('should respect max delay', () => { const config = { ...DEFAULT_RETRY_CONFIG, maxDelayMs: 5000, jitterFactor: 0 }; expect(calculateBackoffDelay(10, config)).toBe(5000); // Capped at max }); it('should add jitter', () => { const config = { ...DEFAULT_RETRY_CONFIG, baseDelayMs: 1000, jitterFactor: 0.5 }; const delay = calculateBackoffDelay(0, config); // With 50% jitter, delay should be between 1000 and 1500 expect(delay).toBeGreaterThanOrEqual(1000); expect(delay).toBeLessThanOrEqual(1500); }); }); describe('withRetry', () => { it('should succeed on first attempt', async () => { const fn = vi.fn().mockResolvedValue('success'); const result = await withRetry(fn); expect(result).toBe('success'); expect(fn).toHaveBeenCalledTimes(1); }); it('should retry on failure and eventually succeed', async () => { // Create retryable errors (503 Service Unavailable) const retryableError1 = new Error('Service temporarily unavailable'); (retryableError1 as any).response = { status: 503 }; const retryableError2 = new Error('Another temporary failure'); (retryableError2 as any).response = { status: 503 }; const fn = vi.fn() .mockRejectedValueOnce(retryableError1) .mockRejectedValueOnce(retryableError2) .mockResolvedValue('success'); const result = await withRetry(fn, { maxAttempts: 3, baseDelayMs: 10, maxDelayMs: 100, jitterFactor: 0 }); expect(result).toBe('success'); expect(fn).toHaveBeenCalledTimes(3); }); it('should throw after max attempts', async () => { // Create retryable error (503 Service Unavailable) const retryableError = new Error('Persistent failure'); (retryableError as any).response = { status: 503 }; const fn = vi.fn().mockRejectedValue(retryableError); await expect(withRetry(fn, { maxAttempts: 3, baseDelayMs: 10, maxDelayMs: 100, jitterFactor: 0 })).rejects.toThrow('Persistent failure'); expect(fn).toHaveBeenCalledTimes(3); }); it('should not retry non-retryable errors', async () => { const error = new Error('Not retryable'); (error as any).response = { status: 400 }; // Client error const fn = vi.fn().mockRejectedValue(error); await expect(withRetry(fn)).rejects.toThrow('Not retryable'); expect(fn).toHaveBeenCalledTimes(1); // No retry }); it('should retry network errors', async () => { const networkError = new Error('Network error'); (networkError as any).code = 'ECONNREFUSED'; const fn = vi.fn() .mockRejectedValueOnce(networkError) .mockResolvedValue('success'); const result = await withRetry(fn, { maxAttempts: 2, baseDelayMs: 10, maxDelayMs: 100, jitterFactor: 0 }); expect(result).toBe('success'); expect(fn).toHaveBeenCalledTimes(2); }); it('should retry 429 Too Many Requests', async () => { const error = new Error('Rate limited'); (error as any).response = { status: 429 }; const fn = vi.fn() .mockRejectedValueOnce(error) .mockResolvedValue('success'); const result = await withRetry(fn, { maxAttempts: 2, baseDelayMs: 10, maxDelayMs: 100, jitterFactor: 0 }); expect(result).toBe('success'); expect(fn).toHaveBeenCalledTimes(2); }); }); describe('cacheMetrics', () => { it('should track cache operations', () => { cacheMetrics.recordHit(); cacheMetrics.recordHit(); cacheMetrics.recordMiss(); cacheMetrics.recordSet(); cacheMetrics.recordDelete(); cacheMetrics.recordEviction(); const metrics = cacheMetrics.getMetrics(); expect(metrics.hits).toBe(2); expect(metrics.misses).toBe(1); expect(metrics.sets).toBe(1); expect(metrics.deletes).toBe(1); expect(metrics.evictions).toBe(1); expect(metrics.avgHitRate).toBeCloseTo(0.667, 2); // 2/3 }); it('should update cache size', () => { cacheMetrics.updateSize(50, 100); const metrics = cacheMetrics.getMetrics(); expect(metrics.size).toBe(50); expect(metrics.maxSize).toBe(100); }); it('should reset metrics', () => { cacheMetrics.recordHit(); cacheMetrics.recordMiss(); cacheMetrics.reset(); const metrics = cacheMetrics.getMetrics(); expect(metrics.hits).toBe(0); expect(metrics.misses).toBe(0); expect(metrics.avgHitRate).toBe(0); }); it('should format metrics for logging', () => { cacheMetrics.recordHit(); cacheMetrics.recordHit(); cacheMetrics.recordMiss(); cacheMetrics.updateSize(25, 100); cacheMetrics.recordEviction(); const formatted = cacheMetrics.getFormattedMetrics(); expect(formatted).toContain('Hits=2'); expect(formatted).toContain('Misses=1'); expect(formatted).toContain('HitRate=66.67%'); expect(formatted).toContain('Size=25/100'); expect(formatted).toContain('Evictions=1'); }); }); describe('getCacheStatistics', () => { it('should return formatted statistics', () => { cacheMetrics.recordHit(); cacheMetrics.recordHit(); cacheMetrics.recordMiss(); cacheMetrics.updateSize(30, 100); const stats = getCacheStatistics(); expect(stats).toContain('Cache Statistics:'); expect(stats).toContain('Total Operations: 3'); expect(stats).toContain('Hit Rate: 66.67%'); expect(stats).toContain('Current Size: 30/100'); }); it('should calculate runtime', () => { const stats = getCacheStatistics(); expect(stats).toContain('Runtime:'); expect(stats).toMatch(/Runtime: \d+ minutes/); }); }); }); ``` -------------------------------------------------------------------------------- /scripts/test-n8n-integration.sh: -------------------------------------------------------------------------------- ```bash #!/bin/bash # Script to test n8n integration with n8n-mcp server set -e # Check for command line arguments if [ "$1" == "--clear-api-key" ] || [ "$1" == "-c" ]; then echo "🗑️ Clearing saved n8n API key..." rm -f "$HOME/.n8n-mcp-test/.n8n-api-key" echo "✅ API key cleared. You'll be prompted for a new key on next run." exit 0 fi if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then echo "Usage: $0 [options]" echo "" echo "Options:" echo " -h, --help Show this help message" echo " -c, --clear-api-key Clear the saved n8n API key" echo "" echo "The script will save your n8n API key on first use and reuse it on" echo "subsequent runs. You can override the saved key at runtime or clear" echo "it with the --clear-api-key option." exit 0 fi echo "🚀 Starting n8n integration test environment..." # Colors for output GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' BLUE='\033[0;34m' NC='\033[0m' # No Color # Configuration N8N_PORT=5678 MCP_PORT=3001 AUTH_TOKEN="test-token-for-n8n-testing-minimum-32-chars" # n8n data directory for persistence N8N_DATA_DIR="$HOME/.n8n-mcp-test" # API key storage file API_KEY_FILE="$N8N_DATA_DIR/.n8n-api-key" # Function to detect OS detect_os() { if [[ "$OSTYPE" == "linux-gnu"* ]]; then if [ -f /etc/os-release ]; then . /etc/os-release echo "$ID" else echo "linux" fi elif [[ "$OSTYPE" == "darwin"* ]]; then echo "macos" elif [[ "$OSTYPE" == "cygwin" ]] || [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "win32" ]]; then echo "windows" else echo "unknown" fi } # Function to check if Docker is installed check_docker() { if command -v docker &> /dev/null; then echo -e "${GREEN}✅ Docker is installed${NC}" # Check if Docker daemon is running if ! docker info &> /dev/null; then echo -e "${YELLOW}⚠️ Docker is installed but not running${NC}" echo -e "${YELLOW}Please start Docker and run this script again${NC}" exit 1 fi return 0 else return 1 fi } # Function to install Docker based on OS install_docker() { local os=$(detect_os) echo -e "${YELLOW}📦 Docker is not installed. Attempting to install...${NC}" case $os in "ubuntu"|"debian") echo -e "${BLUE}Installing Docker on Ubuntu/Debian...${NC}" echo "This requires sudo privileges." sudo apt-get update sudo apt-get install -y ca-certificates curl gnupg sudo install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg sudo chmod a+r /etc/apt/keyrings/docker.gpg echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null sudo apt-get update sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin sudo usermod -aG docker $USER echo -e "${GREEN}✅ Docker installed successfully${NC}" echo -e "${YELLOW}⚠️ Please log out and back in for group changes to take effect${NC}" ;; "fedora"|"rhel"|"centos") echo -e "${BLUE}Installing Docker on Fedora/RHEL/CentOS...${NC}" echo "This requires sudo privileges." sudo dnf -y install dnf-plugins-core sudo dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin sudo systemctl start docker sudo systemctl enable docker sudo usermod -aG docker $USER echo -e "${GREEN}✅ Docker installed successfully${NC}" echo -e "${YELLOW}⚠️ Please log out and back in for group changes to take effect${NC}" ;; "macos") echo -e "${BLUE}Installing Docker on macOS...${NC}" if command -v brew &> /dev/null; then echo "Installing Docker Desktop via Homebrew..." brew install --cask docker echo -e "${GREEN}✅ Docker Desktop installed${NC}" echo -e "${YELLOW}⚠️ Please start Docker Desktop from Applications${NC}" else echo -e "${RED}❌ Homebrew not found${NC}" echo "Please install Docker Desktop manually from:" echo "https://www.docker.com/products/docker-desktop/" fi ;; "windows") echo -e "${RED}❌ Windows detected${NC}" echo "Please install Docker Desktop manually from:" echo "https://www.docker.com/products/docker-desktop/" ;; *) echo -e "${RED}❌ Unknown operating system: $os${NC}" echo "Please install Docker manually from https://docs.docker.com/get-docker/" ;; esac # If we installed Docker on Linux, we need to restart for group changes if [[ "$os" == "ubuntu" ]] || [[ "$os" == "debian" ]] || [[ "$os" == "fedora" ]] || [[ "$os" == "rhel" ]] || [[ "$os" == "centos" ]]; then echo -e "${YELLOW}Please run 'newgrp docker' or log out and back in, then run this script again${NC}" exit 0 fi exit 1 } # Check for Docker if ! check_docker; then install_docker fi # Check for jq (optional but recommended) if ! command -v jq &> /dev/null; then echo -e "${YELLOW}⚠️ jq is not installed (optional)${NC}" echo -e "${YELLOW} Install it for pretty JSON output in tests${NC}" fi # Function to cleanup on exit cleanup() { echo -e "\n${YELLOW}🧹 Cleaning up...${NC}" # Stop n8n container if docker ps -q -f name=n8n-test > /dev/null 2>&1; then echo "Stopping n8n container..." docker stop n8n-test >/dev/null 2>&1 || true docker rm n8n-test >/dev/null 2>&1 || true fi # Kill MCP server if running if [ -n "$MCP_PID" ] && kill -0 $MCP_PID 2>/dev/null; then echo "Stopping MCP server..." kill $MCP_PID 2>/dev/null || true fi echo -e "${GREEN}✅ Cleanup complete${NC}" } # Set trap to cleanup on exit trap cleanup EXIT INT TERM # Check if we're in the right directory if [ ! -f "package.json" ] || [ ! -d "dist" ]; then echo -e "${RED}❌ Error: Must run from n8n-mcp directory${NC}" echo "Please cd to /Users/romualdczlonkowski/Pliki/n8n-mcp/n8n-mcp" exit 1 fi # Always build the project to ensure latest changes echo -e "${YELLOW}📦 Building project...${NC}" npm run build # Create n8n data directory if it doesn't exist if [ ! -d "$N8N_DATA_DIR" ]; then echo -e "${YELLOW}📁 Creating n8n data directory: $N8N_DATA_DIR${NC}" mkdir -p "$N8N_DATA_DIR" fi # Start n8n in Docker with persistent volume echo -e "\n${GREEN}🐳 Starting n8n container with persistent data...${NC}" docker run -d \ --name n8n-test \ -p ${N8N_PORT}:5678 \ -v "${N8N_DATA_DIR}:/home/node/.n8n" \ -e N8N_BASIC_AUTH_ACTIVE=false \ -e N8N_HOST=localhost \ -e N8N_PORT=5678 \ -e N8N_PROTOCOL=http \ -e NODE_ENV=development \ -e N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true \ n8nio/n8n:latest # Wait for n8n to be ready echo -e "${YELLOW}⏳ Waiting for n8n to start...${NC}" for i in {1..30}; do if curl -s http://localhost:${N8N_PORT}/ >/dev/null 2>&1; then echo -e "${GREEN}✅ n8n is ready!${NC}" break fi if [ $i -eq 30 ]; then echo -e "${RED}❌ n8n failed to start${NC}" exit 1 fi sleep 1 done # Check for saved API key if [ -f "$API_KEY_FILE" ]; then # Read saved API key N8N_API_KEY=$(cat "$API_KEY_FILE" 2>/dev/null || echo "") if [ -n "$N8N_API_KEY" ]; then echo -e "\n${GREEN}✅ Using saved n8n API key${NC}" echo -e "${YELLOW} To use a different key, delete: ${API_KEY_FILE}${NC}" # Give user a chance to override echo -e "\n${YELLOW}Press Enter to continue with saved key, or paste a new API key:${NC}" read -r NEW_API_KEY if [ -n "$NEW_API_KEY" ]; then N8N_API_KEY="$NEW_API_KEY" # Save the new key echo "$N8N_API_KEY" > "$API_KEY_FILE" chmod 600 "$API_KEY_FILE" echo -e "${GREEN}✅ New API key saved${NC}" fi else # File exists but is empty, remove it rm -f "$API_KEY_FILE" fi fi # If no saved key, prompt for one if [ -z "$N8N_API_KEY" ]; then # Guide user to get API key echo -e "\n${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo -e "${YELLOW}🔑 n8n API Key Setup${NC}" echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo -e "\nTo enable n8n management tools, you need to create an API key:" echo -e "\n${GREEN}Steps:${NC}" echo -e " 1. Open n8n in your browser: ${BLUE}http://localhost:${N8N_PORT}${NC}" echo -e " 2. Click on your user menu (top right)" echo -e " 3. Go to 'Settings'" echo -e " 4. Navigate to 'API'" echo -e " 5. Click 'Create API Key'" echo -e " 6. Give it a name (e.g., 'n8n-mcp')" echo -e " 7. Copy the generated API key" echo -e "\n${YELLOW}Note: If this is your first time, you'll need to create an account first.${NC}" echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" # Wait for API key input echo -e "\n${YELLOW}Please paste your n8n API key here (or press Enter to skip):${NC}" read -r N8N_API_KEY # Save the API key if provided if [ -n "$N8N_API_KEY" ]; then echo "$N8N_API_KEY" > "$API_KEY_FILE" chmod 600 "$API_KEY_FILE" echo -e "${GREEN}✅ API key saved for future use${NC}" fi fi # Check if API key was provided if [ -z "$N8N_API_KEY" ]; then echo -e "${YELLOW}⚠️ No API key provided. n8n management tools will not be available.${NC}" echo -e "${YELLOW} You can still use documentation and search tools.${NC}" N8N_API_KEY="" N8N_API_URL="" else echo -e "${GREEN}✅ API key received${NC}" # Set the API URL for localhost access (MCP server runs on host, not in Docker) N8N_API_URL="http://localhost:${N8N_PORT}/api/v1" fi # Start MCP server echo -e "\n${GREEN}🚀 Starting MCP server in n8n mode...${NC}" if [ -n "$N8N_API_KEY" ]; then echo -e "${YELLOW} With n8n management tools enabled${NC}" fi N8N_MODE=true \ MCP_MODE=http \ AUTH_TOKEN="${AUTH_TOKEN}" \ PORT=${MCP_PORT} \ N8N_API_KEY="${N8N_API_KEY}" \ N8N_API_URL="${N8N_API_URL}" \ node dist/mcp/index.js > /tmp/mcp-server.log 2>&1 & MCP_PID=$! # Show log file location echo -e "${YELLOW}📄 MCP server logs: /tmp/mcp-server.log${NC}" # Wait for MCP server to be ready echo -e "${YELLOW}⏳ Waiting for MCP server to start...${NC}" for i in {1..10}; do if curl -s http://localhost:${MCP_PORT}/health >/dev/null 2>&1; then echo -e "${GREEN}✅ MCP server is ready!${NC}" break fi if [ $i -eq 10 ]; then echo -e "${RED}❌ MCP server failed to start${NC}" exit 1 fi sleep 1 done # Show status and test endpoints echo -e "\n${GREEN}🎉 Both services are running!${NC}" echo -e "\n📍 Service URLs:" echo -e " • n8n: http://localhost:${N8N_PORT}" echo -e " • MCP server: http://localhost:${MCP_PORT}" echo -e "\n🔑 Auth token: ${AUTH_TOKEN}" echo -e "\n💾 n8n data stored in: ${N8N_DATA_DIR}" echo -e " (Your workflows, credentials, and settings are preserved between runs)" # Test MCP protocol endpoint echo -e "\n${YELLOW}🧪 Testing MCP protocol endpoint...${NC}" echo "Response from GET /mcp:" curl -s http://localhost:${MCP_PORT}/mcp | jq '.' || curl -s http://localhost:${MCP_PORT}/mcp # Test MCP initialization echo -e "\n${YELLOW}🧪 Testing MCP initialization...${NC}" echo "Response from POST /mcp (initialize):" curl -s -X POST http://localhost:${MCP_PORT}/mcp \ -H "Authorization: Bearer ${AUTH_TOKEN}" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{}},"id":1}' \ | jq '.' || echo "(Install jq for pretty JSON output)" # Test available tools echo -e "\n${YELLOW}🧪 Checking available MCP tools...${NC}" if [ -n "$N8N_API_KEY" ]; then echo -e "${GREEN}✅ n8n Management Tools Available:${NC}" echo " • n8n_list_workflows - List all workflows" echo " • n8n_get_workflow - Get workflow details" echo " • n8n_create_workflow - Create new workflows" echo " • n8n_update_workflow - Update existing workflows" echo " • n8n_delete_workflow - Delete workflows" echo " • n8n_trigger_webhook_workflow - Trigger webhook workflows" echo " • n8n_list_executions - List workflow executions" echo " • And more..." else echo -e "${YELLOW}⚠️ n8n Management Tools NOT Available${NC}" echo " To enable, restart with an n8n API key" fi echo -e "\n${GREEN}✅ Documentation Tools Always Available:${NC}" echo " • list_nodes - List available n8n nodes" echo " • search_nodes - Search for specific nodes" echo " • get_node_info - Get detailed node information" echo " • validate_node_operation - Validate node configurations" echo " • And many more..." echo -e "\n${GREEN}✅ Setup complete!${NC}" echo -e "\n📝 Next steps:" echo -e " 1. Open n8n at http://localhost:${N8N_PORT}" echo -e " 2. Create a workflow with the AI Agent node" echo -e " 3. Add MCP Client Tool node" echo -e " 4. Configure it with:" echo -e " • Transport: HTTP" echo -e " • URL: http://host.docker.internal:${MCP_PORT}/mcp" echo -e " • Auth Token: ${BLUE}${AUTH_TOKEN}${NC}" echo -e "\n${YELLOW}Press Ctrl+C to stop both services${NC}" echo -e "\n${YELLOW}📋 To monitor MCP logs: tail -f /tmp/mcp-server.log${NC}" echo -e "${YELLOW}📋 To monitor n8n logs: docker logs -f n8n-test${NC}" # Wait for interrupt wait $MCP_PID ``` -------------------------------------------------------------------------------- /src/database/node-repository.ts: -------------------------------------------------------------------------------- ```typescript import { DatabaseAdapter } from './database-adapter'; import { ParsedNode } from '../parsers/node-parser'; import { SQLiteStorageService } from '../services/sqlite-storage-service'; import { NodeTypeNormalizer } from '../utils/node-type-normalizer'; export class NodeRepository { private db: DatabaseAdapter; constructor(dbOrService: DatabaseAdapter | SQLiteStorageService) { if (dbOrService instanceof SQLiteStorageService) { this.db = dbOrService.db; return; } this.db = dbOrService; } /** * Save node with proper JSON serialization */ saveNode(node: ParsedNode): void { const stmt = this.db.prepare(` INSERT OR REPLACE INTO nodes ( node_type, package_name, display_name, description, category, development_style, is_ai_tool, is_trigger, is_webhook, is_versioned, version, documentation, properties_schema, operations, credentials_required, outputs, output_names ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); stmt.run( node.nodeType, node.packageName, node.displayName, node.description, node.category, node.style, node.isAITool ? 1 : 0, node.isTrigger ? 1 : 0, node.isWebhook ? 1 : 0, node.isVersioned ? 1 : 0, node.version, node.documentation || null, JSON.stringify(node.properties, null, 2), JSON.stringify(node.operations, null, 2), JSON.stringify(node.credentials, null, 2), node.outputs ? JSON.stringify(node.outputs, null, 2) : null, node.outputNames ? JSON.stringify(node.outputNames, null, 2) : null ); } /** * Get node with proper JSON deserialization * Automatically normalizes node type to full form for consistent lookups */ getNode(nodeType: string): any { // Normalize to full form first for consistent lookups const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType); const row = this.db.prepare(` SELECT * FROM nodes WHERE node_type = ? `).get(normalizedType) as any; // Fallback: try original type if normalization didn't help (e.g., community nodes) if (!row && normalizedType !== nodeType) { const originalRow = this.db.prepare(` SELECT * FROM nodes WHERE node_type = ? `).get(nodeType) as any; if (originalRow) { return this.parseNodeRow(originalRow); } } if (!row) return null; return this.parseNodeRow(row); } /** * Get AI tools with proper filtering */ getAITools(): any[] { const rows = this.db.prepare(` SELECT node_type, display_name, description, package_name FROM nodes WHERE is_ai_tool = 1 ORDER BY display_name `).all() as any[]; return rows.map(row => ({ nodeType: row.node_type, displayName: row.display_name, description: row.description, package: row.package_name })); } private safeJsonParse(json: string, defaultValue: any): any { try { return JSON.parse(json); } catch { return defaultValue; } } // Additional methods for benchmarks upsertNode(node: ParsedNode): void { this.saveNode(node); } getNodeByType(nodeType: string): any { return this.getNode(nodeType); } getNodesByCategory(category: string): any[] { const rows = this.db.prepare(` SELECT * FROM nodes WHERE category = ? ORDER BY display_name `).all(category) as any[]; return rows.map(row => this.parseNodeRow(row)); } /** * Legacy LIKE-based search method for direct repository usage. * * NOTE: MCP tools do NOT use this method. They use MCPServer.searchNodes() * which automatically detects and uses FTS5 full-text search when available. * See src/mcp/server.ts:1135-1148 for FTS5 implementation. * * This method remains for: * - Direct repository access in scripts/benchmarks * - Fallback when FTS5 table doesn't exist * - Legacy compatibility */ searchNodes(query: string, mode: 'OR' | 'AND' | 'FUZZY' = 'OR', limit: number = 20): any[] { let sql = ''; const params: any[] = []; if (mode === 'FUZZY') { // Simple fuzzy search sql = ` SELECT * FROM nodes WHERE node_type LIKE ? OR display_name LIKE ? OR description LIKE ? ORDER BY display_name LIMIT ? `; const fuzzyQuery = `%${query}%`; params.push(fuzzyQuery, fuzzyQuery, fuzzyQuery, limit); } else { // OR/AND mode const words = query.split(/\s+/).filter(w => w.length > 0); const conditions = words.map(() => '(node_type LIKE ? OR display_name LIKE ? OR description LIKE ?)' ); const operator = mode === 'AND' ? ' AND ' : ' OR '; sql = ` SELECT * FROM nodes WHERE ${conditions.join(operator)} ORDER BY display_name LIMIT ? `; for (const word of words) { const searchTerm = `%${word}%`; params.push(searchTerm, searchTerm, searchTerm); } params.push(limit); } const rows = this.db.prepare(sql).all(...params) as any[]; return rows.map(row => this.parseNodeRow(row)); } getAllNodes(limit?: number): any[] { let sql = 'SELECT * FROM nodes ORDER BY display_name'; if (limit) { sql += ` LIMIT ${limit}`; } const rows = this.db.prepare(sql).all() as any[]; return rows.map(row => this.parseNodeRow(row)); } getNodeCount(): number { const result = this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any; return result.count; } getAIToolNodes(): any[] { return this.getAITools(); } getNodesByPackage(packageName: string): any[] { const rows = this.db.prepare(` SELECT * FROM nodes WHERE package_name = ? ORDER BY display_name `).all(packageName) as any[]; return rows.map(row => this.parseNodeRow(row)); } searchNodeProperties(nodeType: string, query: string, maxResults: number = 20): any[] { const node = this.getNode(nodeType); if (!node || !node.properties) return []; const results: any[] = []; const searchLower = query.toLowerCase(); function searchProperties(properties: any[], path: string[] = []) { for (const prop of properties) { if (results.length >= maxResults) break; const currentPath = [...path, prop.name || prop.displayName]; const pathString = currentPath.join('.'); if (prop.name?.toLowerCase().includes(searchLower) || prop.displayName?.toLowerCase().includes(searchLower) || prop.description?.toLowerCase().includes(searchLower)) { results.push({ path: pathString, property: prop, description: prop.description }); } // Search nested properties if (prop.options) { searchProperties(prop.options, currentPath); } } } searchProperties(node.properties); return results; } private parseNodeRow(row: any): any { return { nodeType: row.node_type, displayName: row.display_name, description: row.description, category: row.category, developmentStyle: row.development_style, package: row.package_name, isAITool: Number(row.is_ai_tool) === 1, isTrigger: Number(row.is_trigger) === 1, isWebhook: Number(row.is_webhook) === 1, isVersioned: Number(row.is_versioned) === 1, version: row.version, properties: this.safeJsonParse(row.properties_schema, []), operations: this.safeJsonParse(row.operations, []), credentials: this.safeJsonParse(row.credentials_required, []), hasDocumentation: !!row.documentation, outputs: row.outputs ? this.safeJsonParse(row.outputs, null) : null, outputNames: row.output_names ? this.safeJsonParse(row.output_names, null) : null }; } /** * Get operations for a specific node, optionally filtered by resource */ getNodeOperations(nodeType: string, resource?: string): any[] { const node = this.getNode(nodeType); if (!node) return []; const operations: any[] = []; // Parse operations field if (node.operations) { if (Array.isArray(node.operations)) { operations.push(...node.operations); } else if (typeof node.operations === 'object') { // Operations might be grouped by resource if (resource && node.operations[resource]) { return node.operations[resource]; } else { // Return all operations Object.values(node.operations).forEach(ops => { if (Array.isArray(ops)) { operations.push(...ops); } }); } } } // Also check properties for operation fields if (node.properties && Array.isArray(node.properties)) { for (const prop of node.properties) { if (prop.name === 'operation' && prop.options) { // If resource is specified, filter by displayOptions if (resource && prop.displayOptions?.show?.resource) { const allowedResources = Array.isArray(prop.displayOptions.show.resource) ? prop.displayOptions.show.resource : [prop.displayOptions.show.resource]; if (!allowedResources.includes(resource)) { continue; } } // Add operations from this property operations.push(...prop.options); } } } return operations; } /** * Get all resources defined for a node */ getNodeResources(nodeType: string): any[] { const node = this.getNode(nodeType); if (!node || !node.properties) return []; const resources: any[] = []; // Look for resource property for (const prop of node.properties) { if (prop.name === 'resource' && prop.options) { resources.push(...prop.options); } } return resources; } /** * Get operations that are valid for a specific resource */ getOperationsForResource(nodeType: string, resource: string): any[] { const node = this.getNode(nodeType); if (!node || !node.properties) return []; const operations: any[] = []; // Find operation properties that are visible for this resource for (const prop of node.properties) { if (prop.name === 'operation' && prop.displayOptions?.show?.resource) { const allowedResources = Array.isArray(prop.displayOptions.show.resource) ? prop.displayOptions.show.resource : [prop.displayOptions.show.resource]; if (allowedResources.includes(resource) && prop.options) { operations.push(...prop.options); } } } return operations; } /** * Get all operations across all nodes (for analysis) */ getAllOperations(): Map<string, any[]> { const allOperations = new Map<string, any[]>(); const nodes = this.getAllNodes(); for (const node of nodes) { const operations = this.getNodeOperations(node.nodeType); if (operations.length > 0) { allOperations.set(node.nodeType, operations); } } return allOperations; } /** * Get all resources across all nodes (for analysis) */ getAllResources(): Map<string, any[]> { const allResources = new Map<string, any[]>(); const nodes = this.getAllNodes(); for (const node of nodes) { const resources = this.getNodeResources(node.nodeType); if (resources.length > 0) { allResources.set(node.nodeType, resources); } } return allResources; } /** * Get default values for node properties */ getNodePropertyDefaults(nodeType: string): Record<string, any> { try { const node = this.getNode(nodeType); if (!node || !node.properties) return {}; const defaults: Record<string, any> = {}; for (const prop of node.properties) { if (prop.name && prop.default !== undefined) { defaults[prop.name] = prop.default; } } return defaults; } catch (error) { // Log error and return empty defaults rather than throwing console.error(`Error getting property defaults for ${nodeType}:`, error); return {}; } } /** * Get the default operation for a specific resource */ getDefaultOperationForResource(nodeType: string, resource?: string): string | undefined { try { const node = this.getNode(nodeType); if (!node || !node.properties) return undefined; // Find operation property that's visible for this resource for (const prop of node.properties) { if (prop.name === 'operation') { // If there's a resource dependency, check if it matches if (resource && prop.displayOptions?.show?.resource) { // Validate displayOptions structure const resourceDep = prop.displayOptions.show.resource; if (!Array.isArray(resourceDep) && typeof resourceDep !== 'string') { continue; // Skip malformed displayOptions } const allowedResources = Array.isArray(resourceDep) ? resourceDep : [resourceDep]; if (!allowedResources.includes(resource)) { continue; // This operation property doesn't apply to our resource } } // Return the default value if it exists if (prop.default !== undefined) { return prop.default; } // If no default but has options, return the first option's value if (prop.options && Array.isArray(prop.options) && prop.options.length > 0) { const firstOption = prop.options[0]; return typeof firstOption === 'string' ? firstOption : firstOption.value; } } } } catch (error) { // Log error and return undefined rather than throwing // This ensures validation continues even with malformed node data console.error(`Error getting default operation for ${nodeType}:`, error); return undefined; } return undefined; } } ``` -------------------------------------------------------------------------------- /src/telemetry/event-tracker.ts: -------------------------------------------------------------------------------- ```typescript /** * Event Tracker for Telemetry (v2.18.3) * Handles all event tracking logic extracted from TelemetryManager * Now uses shared sanitization utilities to avoid code duplication */ import { TelemetryEvent, WorkflowTelemetry } from './telemetry-types'; import { WorkflowSanitizer } from './workflow-sanitizer'; import { TelemetryRateLimiter } from './rate-limiter'; import { TelemetryEventValidator } from './event-validator'; import { TelemetryError, TelemetryErrorType } from './telemetry-error'; import { logger } from '../utils/logger'; import { existsSync, readFileSync } from 'fs'; import { resolve } from 'path'; import { sanitizeErrorMessageCore } from './error-sanitization-utils'; export class TelemetryEventTracker { private rateLimiter: TelemetryRateLimiter; private validator: TelemetryEventValidator; private eventQueue: TelemetryEvent[] = []; private workflowQueue: WorkflowTelemetry[] = []; private previousTool?: string; private previousToolTimestamp: number = 0; private performanceMetrics: Map<string, number[]> = new Map(); constructor( private getUserId: () => string, private isEnabled: () => boolean ) { this.rateLimiter = new TelemetryRateLimiter(); this.validator = new TelemetryEventValidator(); } /** * Track a tool usage event */ trackToolUsage(toolName: string, success: boolean, duration?: number): void { if (!this.isEnabled()) return; // Check rate limit if (!this.rateLimiter.allow()) { logger.debug(`Rate limited: tool_used event for ${toolName}`); return; } // Track performance metrics if (duration !== undefined) { this.recordPerformanceMetric(toolName, duration); } const event: TelemetryEvent = { user_id: this.getUserId(), event: 'tool_used', properties: { tool: toolName.replace(/[^a-zA-Z0-9_-]/g, '_'), success, duration: duration || 0, } }; // Validate and queue const validated = this.validator.validateEvent(event); if (validated) { this.eventQueue.push(validated); } } /** * Track workflow creation */ async trackWorkflowCreation(workflow: any, validationPassed: boolean): Promise<void> { if (!this.isEnabled()) return; // Check rate limit if (!this.rateLimiter.allow()) { logger.debug('Rate limited: workflow creation event'); return; } // Only store workflows that pass validation if (!validationPassed) { this.trackEvent('workflow_validation_failed', { nodeCount: workflow.nodes?.length || 0, }); return; } try { const sanitized = WorkflowSanitizer.sanitizeWorkflow(workflow); const telemetryData: WorkflowTelemetry = { user_id: this.getUserId(), workflow_hash: sanitized.workflowHash, node_count: sanitized.nodeCount, node_types: sanitized.nodeTypes, has_trigger: sanitized.hasTrigger, has_webhook: sanitized.hasWebhook, complexity: sanitized.complexity, sanitized_workflow: { nodes: sanitized.nodes, connections: sanitized.connections, }, }; // Validate workflow telemetry const validated = this.validator.validateWorkflow(telemetryData); if (validated) { this.workflowQueue.push(validated); // Also track as event this.trackEvent('workflow_created', { nodeCount: sanitized.nodeCount, nodeTypes: sanitized.nodeTypes.length, complexity: sanitized.complexity, hasTrigger: sanitized.hasTrigger, hasWebhook: sanitized.hasWebhook, }); } } catch (error) { logger.debug('Failed to track workflow creation:', error); throw new TelemetryError( TelemetryErrorType.VALIDATION_ERROR, 'Failed to sanitize workflow', { error: error instanceof Error ? error.message : String(error) } ); } } /** * Track an error event */ trackError(errorType: string, context: string, toolName?: string, errorMessage?: string): void { if (!this.isEnabled()) return; // Don't rate limit error tracking - we want to see all errors this.trackEvent('error_occurred', { errorType: this.sanitizeErrorType(errorType), context: this.sanitizeContext(context), tool: toolName ? toolName.replace(/[^a-zA-Z0-9_-]/g, '_') : undefined, error: errorMessage ? this.sanitizeErrorMessage(errorMessage) : undefined, // Add environment context for better error analysis mcpMode: process.env.MCP_MODE || 'stdio', platform: process.platform }, false); // Skip rate limiting for errors } /** * Track a generic event */ trackEvent(eventName: string, properties: Record<string, any>, checkRateLimit: boolean = true): void { if (!this.isEnabled()) return; // Check rate limit unless explicitly skipped if (checkRateLimit && !this.rateLimiter.allow()) { logger.debug(`Rate limited: ${eventName} event`); return; } const event: TelemetryEvent = { user_id: this.getUserId(), event: eventName, properties, }; // Validate and queue const validated = this.validator.validateEvent(event); if (validated) { this.eventQueue.push(validated); } } /** * Track session start with optional startup tracking data (v2.18.2) */ trackSessionStart(startupData?: { durationMs?: number; checkpoints?: string[]; errorCount?: number; }): void { if (!this.isEnabled()) return; this.trackEvent('session_start', { version: this.getPackageVersion(), platform: process.platform, arch: process.arch, nodeVersion: process.version, isDocker: process.env.IS_DOCKER === 'true', cloudPlatform: this.detectCloudPlatform(), mcpMode: process.env.MCP_MODE || 'stdio', // NEW: Startup tracking fields (v2.18.2) startupDurationMs: startupData?.durationMs, checkpointsPassed: startupData?.checkpoints, startupErrorCount: startupData?.errorCount || 0, }); } /** * Track startup completion (v2.18.2) * Called after first successful tool call to confirm server is functional */ trackStartupComplete(): void { if (!this.isEnabled()) return; this.trackEvent('startup_completed', { version: this.getPackageVersion(), }); } /** * Detect cloud platform from environment variables * Returns platform name or null if not in cloud */ private detectCloudPlatform(): string | null { if (process.env.RAILWAY_ENVIRONMENT) return 'railway'; if (process.env.RENDER) return 'render'; if (process.env.FLY_APP_NAME) return 'fly'; if (process.env.HEROKU_APP_NAME) return 'heroku'; if (process.env.AWS_EXECUTION_ENV) return 'aws'; if (process.env.KUBERNETES_SERVICE_HOST) return 'kubernetes'; if (process.env.GOOGLE_CLOUD_PROJECT) return 'gcp'; if (process.env.AZURE_FUNCTIONS_ENVIRONMENT) return 'azure'; return null; } /** * Track search queries */ trackSearchQuery(query: string, resultsFound: number, searchType: string): void { if (!this.isEnabled()) return; this.trackEvent('search_query', { query: query.substring(0, 100), resultsFound, searchType, hasResults: resultsFound > 0, isZeroResults: resultsFound === 0 }); } /** * Track validation details */ trackValidationDetails(nodeType: string, errorType: string, details: Record<string, any>): void { if (!this.isEnabled()) return; this.trackEvent('validation_details', { nodeType: nodeType.replace(/[^a-zA-Z0-9_.-]/g, '_'), errorType: this.sanitizeErrorType(errorType), errorCategory: this.categorizeError(errorType), details }); } /** * Track tool usage sequences */ trackToolSequence(previousTool: string, currentTool: string, timeDelta: number): void { if (!this.isEnabled()) return; this.trackEvent('tool_sequence', { previousTool: previousTool.replace(/[^a-zA-Z0-9_-]/g, '_'), currentTool: currentTool.replace(/[^a-zA-Z0-9_-]/g, '_'), timeDelta: Math.min(timeDelta, 300000), // Cap at 5 minutes isSlowTransition: timeDelta > 10000, sequence: `${previousTool}->${currentTool}` }); } /** * Track node configuration patterns */ trackNodeConfiguration(nodeType: string, propertiesSet: number, usedDefaults: boolean): void { if (!this.isEnabled()) return; this.trackEvent('node_configuration', { nodeType: nodeType.replace(/[^a-zA-Z0-9_.-]/g, '_'), propertiesSet, usedDefaults, complexity: this.categorizeConfigComplexity(propertiesSet) }); } /** * Track performance metrics */ trackPerformanceMetric(operation: string, duration: number, metadata?: Record<string, any>): void { if (!this.isEnabled()) return; // Record for internal metrics this.recordPerformanceMetric(operation, duration); this.trackEvent('performance_metric', { operation: operation.replace(/[^a-zA-Z0-9_-]/g, '_'), duration, isSlow: duration > 1000, isVerySlow: duration > 5000, metadata }); } /** * Update tool sequence tracking */ updateToolSequence(toolName: string): void { if (this.previousTool) { const timeDelta = Date.now() - this.previousToolTimestamp; this.trackToolSequence(this.previousTool, toolName, timeDelta); } this.previousTool = toolName; this.previousToolTimestamp = Date.now(); } /** * Get queued events */ getEventQueue(): TelemetryEvent[] { return [...this.eventQueue]; } /** * Get queued workflows */ getWorkflowQueue(): WorkflowTelemetry[] { return [...this.workflowQueue]; } /** * Clear event queue */ clearEventQueue(): void { this.eventQueue = []; } /** * Clear workflow queue */ clearWorkflowQueue(): void { this.workflowQueue = []; } /** * Get tracking statistics */ getStats() { return { rateLimiter: this.rateLimiter.getStats(), validator: this.validator.getStats(), eventQueueSize: this.eventQueue.length, workflowQueueSize: this.workflowQueue.length, performanceMetrics: this.getPerformanceStats() }; } /** * Record performance metric internally */ private recordPerformanceMetric(operation: string, duration: number): void { if (!this.performanceMetrics.has(operation)) { this.performanceMetrics.set(operation, []); } const metrics = this.performanceMetrics.get(operation)!; metrics.push(duration); // Keep only last 100 measurements if (metrics.length > 100) { metrics.shift(); } } /** * Get performance statistics */ private getPerformanceStats() { const stats: Record<string, any> = {}; for (const [operation, durations] of this.performanceMetrics.entries()) { if (durations.length === 0) continue; const sorted = [...durations].sort((a, b) => a - b); const sum = sorted.reduce((a, b) => a + b, 0); stats[operation] = { count: sorted.length, min: sorted[0], max: sorted[sorted.length - 1], avg: Math.round(sum / sorted.length), p50: sorted[Math.floor(sorted.length * 0.5)], p95: sorted[Math.floor(sorted.length * 0.95)], p99: sorted[Math.floor(sorted.length * 0.99)] }; } return stats; } /** * Categorize error types */ private categorizeError(errorType: string): string { const lowerError = errorType.toLowerCase(); if (lowerError.includes('type')) return 'type_error'; if (lowerError.includes('validation')) return 'validation_error'; if (lowerError.includes('required')) return 'required_field_error'; if (lowerError.includes('connection')) return 'connection_error'; if (lowerError.includes('expression')) return 'expression_error'; return 'other_error'; } /** * Categorize configuration complexity */ private categorizeConfigComplexity(propertiesSet: number): string { if (propertiesSet === 0) return 'defaults_only'; if (propertiesSet <= 3) return 'simple'; if (propertiesSet <= 10) return 'moderate'; return 'complex'; } /** * Get package version */ private getPackageVersion(): string { try { const possiblePaths = [ resolve(__dirname, '..', '..', 'package.json'), resolve(process.cwd(), 'package.json'), resolve(__dirname, '..', '..', '..', 'package.json') ]; for (const packagePath of possiblePaths) { if (existsSync(packagePath)) { const packageJson = JSON.parse(readFileSync(packagePath, 'utf-8')); if (packageJson.version) { return packageJson.version; } } } return 'unknown'; } catch (error) { logger.debug('Failed to get package version:', error); return 'unknown'; } } /** * Sanitize error type */ private sanitizeErrorType(errorType: string): string { return errorType.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 50); } /** * Sanitize context */ private sanitizeContext(context: string): string { // Sanitize in a specific order to preserve some structure let sanitized = context // First replace emails (before URLs eat them) .replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, '[EMAIL]') // Then replace long keys (32+ chars to match validator) .replace(/\b[a-zA-Z0-9_-]{32,}/g, '[KEY]') // Finally replace URLs but keep the path structure .replace(/(https?:\/\/)([^\s\/]+)(\/[^\s]*)?/gi, (match, protocol, domain, path) => { return '[URL]' + (path || ''); }); // Then truncate if needed if (sanitized.length > 100) { sanitized = sanitized.substring(0, 100); } return sanitized; } /** * Sanitize error message * Now uses shared sanitization core from error-sanitization-utils.ts (v2.18.3) * This eliminates code duplication and the ReDoS vulnerability */ private sanitizeErrorMessage(errorMessage: string): string { return sanitizeErrorMessageCore(errorMessage); } } ``` -------------------------------------------------------------------------------- /tests/unit/utils/ssrf-protection.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; // Mock dns module before importing SSRFProtection vi.mock('dns/promises', () => ({ lookup: vi.fn(), })); import { SSRFProtection } from '../../../src/utils/ssrf-protection'; import * as dns from 'dns/promises'; /** * Unit tests for SSRFProtection with configurable security modes * * SECURITY: These tests verify SSRF protection blocks malicious URLs in all modes * See: https://github.com/czlonkowski/n8n-mcp/issues/265 (HIGH-03) */ describe('SSRFProtection', () => { const originalEnv = process.env.WEBHOOK_SECURITY_MODE; beforeEach(() => { // Clear all mocks before each test vi.clearAllMocks(); // Default mock: simulate real DNS behavior - return the hostname as IP if it looks like an IP vi.mocked(dns.lookup).mockImplementation(async (hostname: any) => { // Handle special hostname "localhost" if (hostname === 'localhost') { return { address: '127.0.0.1', family: 4 } as any; } // If hostname is an IP address, return it as-is (simulating real DNS behavior) const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; const ipv6Regex = /^([0-9a-fA-F]{0,4}:)+[0-9a-fA-F]{0,4}$/; if (ipv4Regex.test(hostname)) { return { address: hostname, family: 4 } as any; } if (ipv6Regex.test(hostname) || hostname === '::1') { return { address: hostname, family: 6 } as any; } // For actual hostnames, return a public IP by default return { address: '8.8.8.8', family: 4 } as any; }); }); afterEach(() => { // Restore original environment if (originalEnv) { process.env.WEBHOOK_SECURITY_MODE = originalEnv; } else { delete process.env.WEBHOOK_SECURITY_MODE; } vi.restoreAllMocks(); }); describe('Strict Mode (default)', () => { beforeEach(() => { delete process.env.WEBHOOK_SECURITY_MODE; // Use default strict }); it('should block localhost', async () => { const localhostURLs = [ 'http://localhost:3000/webhook', 'http://127.0.0.1/webhook', 'http://[::1]/webhook', ]; for (const url of localhostURLs) { const result = await SSRFProtection.validateWebhookUrl(url); expect(result.valid, `URL ${url} should be blocked but was valid`).toBe(false); expect(result.reason, `URL ${url} should have a reason`).toBeDefined(); } }); it('should block AWS metadata endpoint', async () => { const result = await SSRFProtection.validateWebhookUrl('http://169.254.169.254/latest/meta-data'); expect(result.valid).toBe(false); expect(result.reason).toContain('Cloud metadata'); }); it('should block GCP metadata endpoint', async () => { const result = await SSRFProtection.validateWebhookUrl('http://metadata.google.internal/computeMetadata/v1/'); expect(result.valid).toBe(false); expect(result.reason).toContain('Cloud metadata'); }); it('should block Alibaba Cloud metadata endpoint', async () => { const result = await SSRFProtection.validateWebhookUrl('http://100.100.100.200/latest/meta-data'); expect(result.valid).toBe(false); expect(result.reason).toContain('Cloud metadata'); }); it('should block Oracle Cloud metadata endpoint', async () => { const result = await SSRFProtection.validateWebhookUrl('http://192.0.0.192/opc/v2/instance/'); expect(result.valid).toBe(false); expect(result.reason).toContain('Cloud metadata'); }); it('should block private IP ranges', async () => { const privateIPs = [ 'http://10.0.0.1/webhook', 'http://192.168.1.1/webhook', 'http://172.16.0.1/webhook', 'http://172.31.255.255/webhook', ]; for (const url of privateIPs) { const result = await SSRFProtection.validateWebhookUrl(url); expect(result.valid).toBe(false); expect(result.reason).toContain('Private IP'); } }); it('should allow public URLs', async () => { const publicURLs = [ 'https://hooks.example.com/webhook', 'https://api.external.com/callback', 'http://public-service.com:8080/hook', ]; for (const url of publicURLs) { const result = await SSRFProtection.validateWebhookUrl(url); expect(result.valid).toBe(true); expect(result.reason).toBeUndefined(); } }); it('should block non-HTTP protocols', async () => { const invalidProtocols = [ 'file:///etc/passwd', 'ftp://internal-server/file', 'gopher://old-service', ]; for (const url of invalidProtocols) { const result = await SSRFProtection.validateWebhookUrl(url); expect(result.valid).toBe(false); expect(result.reason).toContain('protocol'); } }); }); describe('Moderate Mode', () => { beforeEach(() => { process.env.WEBHOOK_SECURITY_MODE = 'moderate'; }); it('should allow localhost', async () => { const localhostURLs = [ 'http://localhost:5678/webhook', 'http://127.0.0.1:5678/webhook', 'http://[::1]:5678/webhook', ]; for (const url of localhostURLs) { const result = await SSRFProtection.validateWebhookUrl(url); expect(result.valid).toBe(true); } }); it('should still block private IPs', async () => { const privateIPs = [ 'http://10.0.0.1/webhook', 'http://192.168.1.1/webhook', 'http://172.16.0.1/webhook', ]; for (const url of privateIPs) { const result = await SSRFProtection.validateWebhookUrl(url); expect(result.valid).toBe(false); expect(result.reason).toContain('Private IP'); } }); it('should still block cloud metadata', async () => { const metadataURLs = [ 'http://169.254.169.254/latest/meta-data', 'http://metadata.google.internal/computeMetadata/v1/', ]; for (const url of metadataURLs) { const result = await SSRFProtection.validateWebhookUrl(url); expect(result.valid).toBe(false); expect(result.reason).toContain('metadata'); } }); it('should allow public URLs', async () => { const result = await SSRFProtection.validateWebhookUrl('https://api.example.com/webhook'); expect(result.valid).toBe(true); }); }); describe('Permissive Mode', () => { beforeEach(() => { process.env.WEBHOOK_SECURITY_MODE = 'permissive'; }); it('should allow localhost', async () => { const result = await SSRFProtection.validateWebhookUrl('http://localhost:5678/webhook'); expect(result.valid).toBe(true); }); it('should allow private IPs', async () => { const privateIPs = [ 'http://10.0.0.1/webhook', 'http://192.168.1.1/webhook', 'http://172.16.0.1/webhook', ]; for (const url of privateIPs) { const result = await SSRFProtection.validateWebhookUrl(url); expect(result.valid).toBe(true); } }); it('should still block cloud metadata', async () => { const metadataURLs = [ 'http://169.254.169.254/latest/meta-data', 'http://metadata.google.internal/computeMetadata/v1/', 'http://169.254.170.2/v2/metadata', ]; for (const url of metadataURLs) { const result = await SSRFProtection.validateWebhookUrl(url); expect(result.valid).toBe(false); expect(result.reason).toContain('metadata'); } }); it('should allow public URLs', async () => { const result = await SSRFProtection.validateWebhookUrl('https://api.example.com/webhook'); expect(result.valid).toBe(true); }); }); describe('DNS Rebinding Prevention', () => { it('should block hostname resolving to private IP (strict mode)', async () => { delete process.env.WEBHOOK_SECURITY_MODE; // strict // Mock DNS lookup to return private IP vi.mocked(dns.lookup).mockResolvedValue({ address: '10.0.0.1', family: 4 } as any); const result = await SSRFProtection.validateWebhookUrl('http://evil.example.com/webhook'); expect(result.valid).toBe(false); expect(result.reason).toContain('Private IP'); }); it('should block hostname resolving to private IP (moderate mode)', async () => { process.env.WEBHOOK_SECURITY_MODE = 'moderate'; // Mock DNS lookup to return private IP vi.mocked(dns.lookup).mockResolvedValue({ address: '192.168.1.100', family: 4 } as any); const result = await SSRFProtection.validateWebhookUrl('http://internal.company.com/webhook'); expect(result.valid).toBe(false); expect(result.reason).toContain('Private IP'); }); it('should allow hostname resolving to private IP (permissive mode)', async () => { process.env.WEBHOOK_SECURITY_MODE = 'permissive'; // Mock DNS lookup to return private IP vi.mocked(dns.lookup).mockResolvedValue({ address: '192.168.1.100', family: 4 } as any); const result = await SSRFProtection.validateWebhookUrl('http://internal.company.com/webhook'); expect(result.valid).toBe(true); }); it('should block hostname resolving to cloud metadata (all modes)', async () => { const modes = ['strict', 'moderate', 'permissive']; for (const mode of modes) { process.env.WEBHOOK_SECURITY_MODE = mode; // Mock DNS lookup to return cloud metadata IP vi.mocked(dns.lookup).mockResolvedValue({ address: '169.254.169.254', family: 4 } as any); const result = await SSRFProtection.validateWebhookUrl('http://evil-domain.com/webhook'); expect(result.valid).toBe(false); expect(result.reason).toContain('metadata'); } }); it('should block hostname resolving to localhost IP (strict mode)', async () => { delete process.env.WEBHOOK_SECURITY_MODE; // strict // Mock DNS lookup to return localhost IP vi.mocked(dns.lookup).mockResolvedValue({ address: '127.0.0.1', family: 4 } as any); const result = await SSRFProtection.validateWebhookUrl('http://suspicious-domain.com/webhook'); expect(result.valid).toBe(false); expect(result.reason).toBeDefined(); }); }); describe('IPv6 Protection', () => { it('should block IPv6 localhost (strict mode)', async () => { delete process.env.WEBHOOK_SECURITY_MODE; // strict // Mock DNS to return IPv6 localhost vi.mocked(dns.lookup).mockResolvedValue({ address: '::1', family: 6 } as any); const result = await SSRFProtection.validateWebhookUrl('http://ipv6-test.com/webhook'); expect(result.valid).toBe(false); // Updated: IPv6 localhost is now caught by the localhost check, not IPv6 check expect(result.reason).toContain('Localhost'); }); it('should block IPv6 link-local (strict mode)', async () => { delete process.env.WEBHOOK_SECURITY_MODE; // strict // Mock DNS to return IPv6 link-local vi.mocked(dns.lookup).mockResolvedValue({ address: 'fe80::1', family: 6 } as any); const result = await SSRFProtection.validateWebhookUrl('http://ipv6-local.com/webhook'); expect(result.valid).toBe(false); expect(result.reason).toContain('IPv6 private'); }); it('should block IPv6 unique local (strict mode)', async () => { delete process.env.WEBHOOK_SECURITY_MODE; // strict // Mock DNS to return IPv6 unique local vi.mocked(dns.lookup).mockResolvedValue({ address: 'fc00::1', family: 6 } as any); const result = await SSRFProtection.validateWebhookUrl('http://ipv6-internal.com/webhook'); expect(result.valid).toBe(false); expect(result.reason).toContain('IPv6 private'); }); it('should block IPv6 unique local fd00::/8 (strict mode)', async () => { delete process.env.WEBHOOK_SECURITY_MODE; // strict // Mock DNS to return IPv6 unique local fd00::/8 vi.mocked(dns.lookup).mockResolvedValue({ address: 'fd00::1', family: 6 } as any); const result = await SSRFProtection.validateWebhookUrl('http://ipv6-fd00.com/webhook'); expect(result.valid).toBe(false); expect(result.reason).toContain('IPv6 private'); }); it('should block IPv6 unspecified address (strict mode)', async () => { delete process.env.WEBHOOK_SECURITY_MODE; // strict // Mock DNS to return IPv6 unspecified address vi.mocked(dns.lookup).mockResolvedValue({ address: '::', family: 6 } as any); const result = await SSRFProtection.validateWebhookUrl('http://ipv6-unspecified.com/webhook'); expect(result.valid).toBe(false); expect(result.reason).toContain('IPv6 private'); }); it('should block IPv4-mapped IPv6 addresses (strict mode)', async () => { delete process.env.WEBHOOK_SECURITY_MODE; // strict // Mock DNS to return IPv4-mapped IPv6 address vi.mocked(dns.lookup).mockResolvedValue({ address: '::ffff:127.0.0.1', family: 6 } as any); const result = await SSRFProtection.validateWebhookUrl('http://ipv4-mapped.com/webhook'); expect(result.valid).toBe(false); expect(result.reason).toContain('IPv6 private'); }); }); describe('DNS Resolution Failures', () => { it('should handle DNS resolution failure gracefully', async () => { // Mock DNS lookup to fail vi.mocked(dns.lookup).mockRejectedValue(new Error('ENOTFOUND')); const result = await SSRFProtection.validateWebhookUrl('http://non-existent-domain.invalid/webhook'); expect(result.valid).toBe(false); expect(result.reason).toBe('DNS resolution failed'); }); }); describe('Edge Cases', () => { it('should handle malformed URLs', async () => { const malformedURLs = [ 'not-a-url', 'http://', '://missing-protocol.com', ]; for (const url of malformedURLs) { const result = await SSRFProtection.validateWebhookUrl(url); expect(result.valid).toBe(false); expect(result.reason).toBe('Invalid URL format'); } }); it('should handle URL with special characters safely', async () => { const result = await SSRFProtection.validateWebhookUrl('https://example.com/webhook?param=value&other=123'); expect(result.valid).toBe(true); }); }); }); ``` -------------------------------------------------------------------------------- /tests/unit/services/example-generator.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ExampleGenerator } from '@/services/example-generator'; import type { NodeExamples } from '@/services/example-generator'; // Mock the database vi.mock('better-sqlite3'); describe('ExampleGenerator', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('getExamples', () => { it('should return curated examples for HTTP Request node', () => { const examples = ExampleGenerator.getExamples('nodes-base.httpRequest'); expect(examples).toHaveProperty('minimal'); expect(examples).toHaveProperty('common'); expect(examples).toHaveProperty('advanced'); // Check minimal example expect(examples.minimal).toEqual({ url: 'https://api.example.com/data' }); // Check common example has required fields expect(examples.common).toMatchObject({ method: 'POST', url: 'https://api.example.com/users', sendBody: true, contentType: 'json' }); // Check advanced example has error handling expect(examples.advanced).toMatchObject({ method: 'POST', onError: 'continueRegularOutput', retryOnFail: true, maxTries: 3 }); }); it('should return curated examples for Webhook node', () => { const examples = ExampleGenerator.getExamples('nodes-base.webhook'); expect(examples.minimal).toMatchObject({ path: 'my-webhook', httpMethod: 'POST' }); expect(examples.common).toMatchObject({ responseMode: 'lastNode', responseData: 'allEntries', responseCode: 200 }); }); it('should return curated examples for Code node', () => { const examples = ExampleGenerator.getExamples('nodes-base.code'); expect(examples.minimal).toMatchObject({ language: 'javaScript', jsCode: 'return [{json: {result: "success"}}];' }); expect(examples.common?.jsCode).toContain('items.map'); expect(examples.common?.jsCode).toContain('DateTime.now()'); expect(examples.advanced?.jsCode).toContain('try'); expect(examples.advanced?.jsCode).toContain('catch'); }); it('should generate basic examples for unconfigured nodes', () => { const essentials = { required: [ { name: 'url', type: 'string' }, { name: 'method', type: 'options', options: [{ value: 'GET' }, { value: 'POST' }] } ], common: [ { name: 'timeout', type: 'number' } ] }; const examples = ExampleGenerator.getExamples('nodes-base.unknownNode', essentials); expect(examples.minimal).toEqual({ url: 'https://api.example.com', method: 'GET' }); expect(examples.common).toBeUndefined(); expect(examples.advanced).toBeUndefined(); }); it('should use common property if no required fields exist', () => { const essentials = { required: [], common: [ { name: 'name', type: 'string' } ] }; const examples = ExampleGenerator.getExamples('nodes-base.unknownNode', essentials); expect(examples.minimal).toEqual({ name: 'John Doe' }); }); it('should return empty minimal object if no essentials provided', () => { const examples = ExampleGenerator.getExamples('nodes-base.unknownNode'); expect(examples.minimal).toEqual({}); }); }); describe('special example nodes', () => { it('should provide webhook processing example', () => { const examples = ExampleGenerator.getExamples('nodes-base.code.webhookProcessing'); expect(examples.minimal?.jsCode).toContain('const webhookData = items[0].json.body'); expect(examples.minimal?.jsCode).toContain('// ❌ WRONG'); expect(examples.minimal?.jsCode).toContain('// ✅ CORRECT'); }); it('should provide data transformation examples', () => { const examples = ExampleGenerator.getExamples('nodes-base.code.dataTransform'); expect(examples.minimal?.jsCode).toContain('CSV-like data to JSON'); expect(examples.minimal?.jsCode).toContain('split'); }); it('should provide aggregation example', () => { const examples = ExampleGenerator.getExamples('nodes-base.code.aggregation'); expect(examples.minimal?.jsCode).toContain('items.reduce'); expect(examples.minimal?.jsCode).toContain('totalAmount'); }); it('should provide JMESPath filtering example', () => { const examples = ExampleGenerator.getExamples('nodes-base.code.jmespathFiltering'); expect(examples.minimal?.jsCode).toContain('$jmespath'); expect(examples.minimal?.jsCode).toContain('`100`'); // Backticks for numeric literals expect(examples.minimal?.jsCode).toContain('✅ CORRECT'); }); it('should provide Python example', () => { const examples = ExampleGenerator.getExamples('nodes-base.code.pythonExample'); expect(examples.minimal?.pythonCode).toContain('_input.all()'); expect(examples.minimal?.pythonCode).toContain('to_py()'); expect(examples.minimal?.pythonCode).toContain('import json'); }); it('should provide AI tool example', () => { const examples = ExampleGenerator.getExamples('nodes-base.code.aiTool'); expect(examples.minimal?.mode).toBe('runOnceForEachItem'); expect(examples.minimal?.jsCode).toContain('calculate discount'); expect(examples.minimal?.jsCode).toContain('$json.quantity'); }); it('should provide crypto usage example', () => { const examples = ExampleGenerator.getExamples('nodes-base.code.crypto'); expect(examples.minimal?.jsCode).toContain("require('crypto')"); expect(examples.minimal?.jsCode).toContain('randomBytes'); expect(examples.minimal?.jsCode).toContain('createHash'); }); it('should provide static data example', () => { const examples = ExampleGenerator.getExamples('nodes-base.code.staticData'); expect(examples.minimal?.jsCode).toContain('$getWorkflowStaticData'); expect(examples.minimal?.jsCode).toContain('processCount'); }); }); describe('database node examples', () => { it('should provide PostgreSQL examples', () => { const examples = ExampleGenerator.getExamples('nodes-base.postgres'); expect(examples.minimal).toMatchObject({ operation: 'executeQuery', query: 'SELECT * FROM users LIMIT 10' }); expect(examples.advanced?.query).toContain('ON CONFLICT'); expect(examples.advanced?.retryOnFail).toBe(true); }); it('should provide MongoDB examples', () => { const examples = ExampleGenerator.getExamples('nodes-base.mongoDb'); expect(examples.minimal).toMatchObject({ operation: 'find', collection: 'users' }); expect(examples.common).toMatchObject({ operation: 'findOneAndUpdate', options: { upsert: true, returnNewDocument: true } }); }); it('should provide MySQL examples', () => { const examples = ExampleGenerator.getExamples('nodes-base.mySql'); expect(examples.minimal?.query).toContain('SELECT * FROM products'); expect(examples.common?.operation).toBe('insert'); }); }); describe('communication node examples', () => { it('should provide Slack examples', () => { const examples = ExampleGenerator.getExamples('nodes-base.slack'); expect(examples.minimal).toMatchObject({ resource: 'message', operation: 'post', channel: '#general', text: 'Hello from n8n!' }); expect(examples.common?.attachments).toBeDefined(); expect(examples.common?.retryOnFail).toBe(true); }); it('should provide Email examples', () => { const examples = ExampleGenerator.getExamples('nodes-base.emailSend'); expect(examples.minimal).toMatchObject({ fromEmail: '[email protected]', toEmail: '[email protected]', subject: 'Test Email' }); expect(examples.common?.html).toContain('<h1>Welcome!</h1>'); }); }); describe('error handling patterns', () => { it('should provide modern error handling patterns', () => { const examples = ExampleGenerator.getExamples('error-handling.modern-patterns'); expect(examples.minimal).toMatchObject({ onError: 'continueRegularOutput' }); expect(examples.advanced).toMatchObject({ onError: 'stopWorkflow', retryOnFail: true, maxTries: 3 }); }); it('should provide API retry patterns', () => { const examples = ExampleGenerator.getExamples('error-handling.api-with-retry'); expect(examples.common?.retryOnFail).toBe(true); expect(examples.common?.maxTries).toBe(5); expect(examples.common?.alwaysOutputData).toBe(true); }); it('should provide database error patterns', () => { const examples = ExampleGenerator.getExamples('error-handling.database-patterns'); expect(examples.common).toMatchObject({ retryOnFail: true, maxTries: 3, onError: 'stopWorkflow' }); }); it('should provide webhook error patterns', () => { const examples = ExampleGenerator.getExamples('error-handling.webhook-patterns'); expect(examples.minimal?.alwaysOutputData).toBe(true); expect(examples.common?.responseCode).toBe(200); }); }); describe('getTaskExample', () => { it('should return minimal example for basic task', () => { const example = ExampleGenerator.getTaskExample('nodes-base.httpRequest', 'basic'); expect(example).toEqual({ url: 'https://api.example.com/data' }); }); it('should return common example for typical task', () => { const example = ExampleGenerator.getTaskExample('nodes-base.httpRequest', 'typical'); expect(example).toMatchObject({ method: 'POST', sendBody: true }); }); it('should return advanced example for complex task', () => { const example = ExampleGenerator.getTaskExample('nodes-base.httpRequest', 'complex'); expect(example).toMatchObject({ retryOnFail: true, maxTries: 3 }); }); it('should default to common example for unknown task', () => { const example = ExampleGenerator.getTaskExample('nodes-base.httpRequest', 'unknown'); expect(example).toMatchObject({ method: 'POST' // This is from common example }); }); it('should return undefined for unknown node type', () => { const example = ExampleGenerator.getTaskExample('nodes-base.unknownNode', 'basic'); expect(example).toBeUndefined(); }); }); describe('default value generation', () => { it('should generate appropriate defaults for different property types', () => { const essentials = { required: [ { name: 'url', type: 'string' }, { name: 'port', type: 'number' }, { name: 'enabled', type: 'boolean' }, { name: 'method', type: 'options', options: [{ value: 'GET' }, { value: 'POST' }] }, { name: 'data', type: 'json' } ], common: [] }; const examples = ExampleGenerator.getExamples('nodes-base.unknownNode', essentials); expect(examples.minimal).toEqual({ url: 'https://api.example.com', port: 80, enabled: false, method: 'GET', data: '{\n "key": "value"\n}' }); }); it('should use property defaults when available', () => { const essentials = { required: [ { name: 'timeout', type: 'number', default: 5000 }, { name: 'retries', type: 'number', default: 3 } ], common: [] }; const examples = ExampleGenerator.getExamples('nodes-base.unknownNode', essentials); expect(examples.minimal).toEqual({ timeout: 5000, retries: 3 }); }); it('should generate context-aware string defaults', () => { const essentials = { required: [ { name: 'fromEmail', type: 'string' }, { name: 'toEmail', type: 'string' }, { name: 'webhookPath', type: 'string' }, { name: 'username', type: 'string' }, { name: 'apiKey', type: 'string' }, { name: 'query', type: 'string' }, { name: 'collection', type: 'string' } ], common: [] }; const examples = ExampleGenerator.getExamples('nodes-base.unknownNode', essentials); expect(examples.minimal).toEqual({ fromEmail: '[email protected]', toEmail: '[email protected]', webhookPath: 'my-webhook', username: 'John Doe', apiKey: 'myKey', query: 'SELECT * FROM table_name LIMIT 10', collection: 'users' }); }); it('should use placeholder as fallback for string defaults', () => { const essentials = { required: [ { name: 'customField', type: 'string', placeholder: 'Enter custom value' } ], common: [] }; const examples = ExampleGenerator.getExamples('nodes-base.unknownNode', essentials); expect(examples.minimal).toEqual({ customField: 'Enter custom value' }); }); }); describe('edge cases', () => { it('should handle empty essentials object', () => { const essentials = { required: [], common: [] }; const examples = ExampleGenerator.getExamples('nodes-base.unknownNode', essentials); expect(examples.minimal).toEqual({}); }); it('should handle properties with missing options', () => { const essentials = { required: [ { name: 'choice', type: 'options' } // No options array ], common: [] }; const examples = ExampleGenerator.getExamples('nodes-base.unknownNode', essentials); expect(examples.minimal).toEqual({ choice: '' }); }); it('should handle collection and fixedCollection types', () => { const essentials = { required: [ { name: 'headers', type: 'collection' }, { name: 'options', type: 'fixedCollection' } ], common: [] }; const examples = ExampleGenerator.getExamples('nodes-base.unknownNode', essentials); expect(examples.minimal).toEqual({ headers: {}, options: {} }); }); }); }); ``` -------------------------------------------------------------------------------- /tests/unit/services/property-filter-edge-cases.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest'; import { PropertyFilter } from '@/services/property-filter'; import type { SimplifiedProperty } from '@/services/property-filter'; // Mock the database vi.mock('better-sqlite3'); describe('PropertyFilter - Edge Cases', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('Null and Undefined Handling', () => { it('should handle null properties gracefully', () => { const result = PropertyFilter.getEssentials(null as any, 'nodes-base.http'); expect(result).toEqual({ required: [], common: [] }); }); it('should handle undefined properties gracefully', () => { const result = PropertyFilter.getEssentials(undefined as any, 'nodes-base.http'); expect(result).toEqual({ required: [], common: [] }); }); it('should handle null nodeType gracefully', () => { const properties = [{ name: 'test', type: 'string' }]; const result = PropertyFilter.getEssentials(properties, null as any); // Should fallback to inferEssentials expect(result.required).toBeDefined(); expect(result.common).toBeDefined(); }); it('should handle properties with null values', () => { const properties = [ { name: 'prop1', type: 'string', displayName: null, description: null }, null, undefined, { name: null, type: 'string' }, { name: 'prop2', type: null } ]; const result = PropertyFilter.getEssentials(properties as any, 'nodes-base.test'); expect(() => result).not.toThrow(); expect(result.required).toBeDefined(); expect(result.common).toBeDefined(); }); }); describe('Boundary Value Testing', () => { it('should handle empty properties array', () => { const result = PropertyFilter.getEssentials([], 'nodes-base.http'); expect(result).toEqual({ required: [], common: [] }); }); it('should handle very large properties array', () => { const largeProperties = Array(10000).fill(null).map((_, i) => ({ name: `prop${i}`, type: 'string', displayName: `Property ${i}`, description: `Description for property ${i}`, required: i % 100 === 0 })); const start = Date.now(); const result = PropertyFilter.getEssentials(largeProperties, 'nodes-base.test'); const duration = Date.now() - start; expect(result).toBeDefined(); expect(duration).toBeLessThan(1000); // Should filter within 1 second // For unconfigured nodes, it uses inferEssentials which limits results expect(result.required.length + result.common.length).toBeLessThanOrEqual(30); }); it('should handle properties with extremely long strings', () => { const properties = [ { name: 'longProp', type: 'string', displayName: 'A'.repeat(1000), description: 'B'.repeat(10000), placeholder: 'C'.repeat(5000), required: true } ]; const result = PropertyFilter.getEssentials(properties, 'nodes-base.test'); // For unconfigured nodes, this might be included as required const allProps = [...result.required, ...result.common]; const longProp = allProps.find(p => p.name === 'longProp'); if (longProp) { expect(longProp.displayName).toBeDefined(); } }); it('should limit options array size', () => { const manyOptions = Array(1000).fill(null).map((_, i) => ({ value: `option${i}`, name: `Option ${i}` })); const properties = [{ name: 'selectProp', type: 'options', displayName: 'Select Property', options: manyOptions, required: true }]; const result = PropertyFilter.getEssentials(properties, 'nodes-base.test'); const allProps = [...result.required, ...result.common]; const selectProp = allProps.find(p => p.name === 'selectProp'); if (selectProp && selectProp.options) { // Should limit options to reasonable number expect(selectProp.options.length).toBeLessThanOrEqual(20); } }); }); describe('Property Type Handling', () => { it('should handle all n8n property types', () => { const propertyTypes = [ 'string', 'number', 'boolean', 'options', 'multiOptions', 'collection', 'fixedCollection', 'json', 'notice', 'assignmentCollection', 'resourceLocator', 'resourceMapper', 'filter', 'credentials' ]; const properties = propertyTypes.map(type => ({ name: `${type}Prop`, type, displayName: `${type} Property`, description: `A ${type} property` })); const result = PropertyFilter.getEssentials(properties, 'nodes-base.test'); expect(result).toBeDefined(); const allProps = [...result.required, ...result.common]; // Should handle various types without crashing expect(allProps.length).toBeGreaterThan(0); }); it('should handle nested collection properties', () => { const properties = [{ name: 'collection', type: 'collection', displayName: 'Collection', options: [ { name: 'nested1', type: 'string', displayName: 'Nested 1' }, { name: 'nested2', type: 'number', displayName: 'Nested 2' } ] }]; const result = PropertyFilter.getEssentials(properties, 'nodes-base.test'); const allProps = [...result.required, ...result.common]; // Should include the collection expect(allProps.some(p => p.name === 'collection')).toBe(true); }); it('should handle fixedCollection properties', () => { const properties = [{ name: 'headers', type: 'fixedCollection', displayName: 'Headers', typeOptions: { multipleValues: true }, options: [{ name: 'parameter', displayName: 'Parameter', values: [ { name: 'name', type: 'string', displayName: 'Name' }, { name: 'value', type: 'string', displayName: 'Value' } ] }] }]; const result = PropertyFilter.getEssentials(properties, 'nodes-base.test'); const allProps = [...result.required, ...result.common]; // Should include the fixed collection expect(allProps.some(p => p.name === 'headers')).toBe(true); }); }); describe('Special Cases', () => { it('should handle circular references in properties', () => { const properties: any = [{ name: 'circular', type: 'string', displayName: 'Circular' }]; properties[0].self = properties[0]; expect(() => { PropertyFilter.getEssentials(properties, 'nodes-base.test'); }).not.toThrow(); }); it('should handle properties with special characters', () => { const properties = [ { name: 'prop-with-dash', type: 'string', displayName: 'Prop With Dash' }, { name: 'prop_with_underscore', type: 'string', displayName: 'Prop With Underscore' }, { name: 'prop.with.dot', type: 'string', displayName: 'Prop With Dot' }, { name: 'prop@special', type: 'string', displayName: 'Prop Special' } ]; const result = PropertyFilter.getEssentials(properties, 'nodes-base.test'); expect(result).toBeDefined(); }); it('should handle duplicate property names', () => { const properties = [ { name: 'duplicate', type: 'string', displayName: 'First Duplicate' }, { name: 'duplicate', type: 'number', displayName: 'Second Duplicate' }, { name: 'duplicate', type: 'boolean', displayName: 'Third Duplicate' } ]; const result = PropertyFilter.getEssentials(properties, 'nodes-base.test'); const allProps = [...result.required, ...result.common]; // Should deduplicate const duplicates = allProps.filter(p => p.name === 'duplicate'); expect(duplicates.length).toBe(1); }); }); describe('Node-Specific Configurations', () => { it('should apply HTTP Request specific filtering', () => { const properties = [ { name: 'url', type: 'string', required: true }, { name: 'method', type: 'options', options: [{ value: 'GET' }, { value: 'POST' }] }, { name: 'authentication', type: 'options' }, { name: 'sendBody', type: 'boolean' }, { name: 'contentType', type: 'options' }, { name: 'sendHeaders', type: 'fixedCollection' }, { name: 'someObscureOption', type: 'string' } ]; const result = PropertyFilter.getEssentials(properties, 'nodes-base.httpRequest'); expect(result.required.some(p => p.name === 'url')).toBe(true); expect(result.common.some(p => p.name === 'method')).toBe(true); expect(result.common.some(p => p.name === 'authentication')).toBe(true); // Should not include obscure option const allProps = [...result.required, ...result.common]; expect(allProps.some(p => p.name === 'someObscureOption')).toBe(false); }); it('should apply Slack specific filtering', () => { const properties = [ { name: 'resource', type: 'options', required: true }, { name: 'operation', type: 'options', required: true }, { name: 'channel', type: 'string' }, { name: 'text', type: 'string' }, { name: 'attachments', type: 'collection' }, { name: 'ts', type: 'string' }, { name: 'advancedOption1', type: 'string' }, { name: 'advancedOption2', type: 'boolean' } ]; const result = PropertyFilter.getEssentials(properties, 'nodes-base.slack'); // In the actual config, resource and operation are in common, not required expect(result.common.some(p => p.name === 'resource')).toBe(true); expect(result.common.some(p => p.name === 'operation')).toBe(true); expect(result.common.some(p => p.name === 'channel')).toBe(true); expect(result.common.some(p => p.name === 'text')).toBe(true); }); }); describe('Fallback Behavior', () => { it('should infer essentials for unconfigured nodes', () => { const properties = [ { name: 'requiredProp', type: 'string', required: true }, { name: 'commonProp', type: 'string', displayName: 'Common Property' }, { name: 'advancedProp', type: 'json', displayName: 'Advanced Property' }, { name: 'debugProp', type: 'boolean', displayName: 'Debug Mode' }, { name: 'internalProp', type: 'hidden' } ]; const result = PropertyFilter.getEssentials(properties, 'nodes-base.unknownNode'); // Should include required properties expect(result.required.some(p => p.name === 'requiredProp')).toBe(true); // Should include some common properties expect(result.common.length).toBeGreaterThan(0); // Should not include internal/hidden properties const allProps = [...result.required, ...result.common]; expect(allProps.some(p => p.name === 'internalProp')).toBe(false); }); it('should handle nodes with only advanced properties', () => { const properties = [ { name: 'advanced1', type: 'json', displayName: 'Advanced Option 1' }, { name: 'advanced2', type: 'collection', displayName: 'Advanced Collection' }, { name: 'advanced3', type: 'assignmentCollection', displayName: 'Advanced Assignment' } ]; const result = PropertyFilter.getEssentials(properties, 'nodes-base.advancedNode'); // Should still return some properties const allProps = [...result.required, ...result.common]; expect(allProps.length).toBeGreaterThan(0); }); }); describe('Property Simplification', () => { it('should simplify complex property structures', () => { const properties = [{ name: 'complexProp', type: 'options', displayName: 'Complex Property', description: 'A'.repeat(500), // Long description default: 'option1', placeholder: 'Select an option', hint: 'This is a hint', displayOptions: { show: { mode: ['advanced'] } }, options: Array(50).fill(null).map((_, i) => ({ value: `option${i}`, name: `Option ${i}`, description: `Description for option ${i}` })) }]; const result = PropertyFilter.getEssentials(properties, 'nodes-base.test'); const allProps = [...result.required, ...result.common]; const simplified = allProps.find(p => p.name === 'complexProp'); if (simplified) { // Should include essential fields expect(simplified.name).toBe('complexProp'); expect(simplified.displayName).toBe('Complex Property'); expect(simplified.type).toBe('options'); // Should limit options if (simplified.options) { expect(simplified.options.length).toBeLessThanOrEqual(20); } } }); it('should handle properties without display names', () => { const properties = [ { name: 'prop_without_display', type: 'string', description: 'Property description' }, { name: 'anotherProp', displayName: '', type: 'number' } ]; const result = PropertyFilter.getEssentials(properties, 'nodes-base.test'); const allProps = [...result.required, ...result.common]; allProps.forEach(prop => { // Should have a displayName (fallback to name if needed) expect(prop.displayName).toBeTruthy(); expect(prop.displayName.length).toBeGreaterThan(0); }); }); }); describe('Performance', () => { it('should handle property filtering efficiently', () => { const nodeTypes = [ 'nodes-base.httpRequest', 'nodes-base.webhook', 'nodes-base.slack', 'nodes-base.googleSheets', 'nodes-base.postgres' ]; const properties = Array(100).fill(null).map((_, i) => ({ name: `prop${i}`, type: i % 2 === 0 ? 'string' : 'options', displayName: `Property ${i}`, required: i < 5 })); const start = Date.now(); nodeTypes.forEach(nodeType => { PropertyFilter.getEssentials(properties, nodeType); }); const duration = Date.now() - start; // Should process multiple nodes quickly expect(duration).toBeLessThan(50); }); }); }); ``` -------------------------------------------------------------------------------- /tests/integration/n8n-api/system/diagnostic.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Integration Tests: handleDiagnostic * * Tests system diagnostic functionality. * Covers environment checks, API status, and verbose mode. */ import { describe, it, expect, beforeEach } from 'vitest'; import { createMcpContext } from '../utils/mcp-context'; import { InstanceContext } from '../../../../src/types/instance-context'; import { handleDiagnostic } from '../../../../src/mcp/handlers-n8n-manager'; import { DiagnosticResponse } from '../utils/response-types'; describe('Integration: handleDiagnostic', () => { let mcpContext: InstanceContext; beforeEach(() => { mcpContext = createMcpContext(); }); // ====================================================================== // Basic Diagnostic // ====================================================================== describe('Basic Diagnostic', () => { it('should run basic diagnostic check', async () => { const response = await handleDiagnostic( { params: { arguments: {} } }, mcpContext ); expect(response.success).toBe(true); expect(response.data).toBeDefined(); const data = response.data as DiagnosticResponse; // Verify core diagnostic fields expect(data).toHaveProperty('timestamp'); expect(data).toHaveProperty('environment'); expect(data).toHaveProperty('apiConfiguration'); expect(data).toHaveProperty('toolsAvailability'); expect(data).toHaveProperty('versionInfo'); expect(data).toHaveProperty('performance'); // Verify timestamp format expect(typeof data.timestamp).toBe('string'); const timestamp = new Date(data.timestamp); expect(timestamp.toString()).not.toBe('Invalid Date'); // Verify version info expect(data.versionInfo).toBeDefined(); if (data.versionInfo) { expect(data.versionInfo).toHaveProperty('current'); expect(data.versionInfo).toHaveProperty('upToDate'); expect(typeof data.versionInfo.upToDate).toBe('boolean'); } // Verify performance metrics expect(data.performance).toBeDefined(); if (data.performance) { expect(data.performance).toHaveProperty('diagnosticResponseTimeMs'); expect(typeof data.performance.diagnosticResponseTimeMs).toBe('number'); } }); it('should include environment variables', async () => { const response = await handleDiagnostic( { params: { arguments: {} } }, mcpContext ); const data = response.data as DiagnosticResponse; expect(data.environment).toBeDefined(); expect(data.environment).toHaveProperty('N8N_API_URL'); expect(data.environment).toHaveProperty('N8N_API_KEY'); expect(data.environment).toHaveProperty('NODE_ENV'); expect(data.environment).toHaveProperty('MCP_MODE'); expect(data.environment).toHaveProperty('isDocker'); expect(data.environment).toHaveProperty('cloudPlatform'); expect(data.environment).toHaveProperty('nodeVersion'); expect(data.environment).toHaveProperty('platform'); // API key should be masked if (data.environment.N8N_API_KEY) { expect(data.environment.N8N_API_KEY).toBe('***configured***'); } // Environment detection types expect(typeof data.environment.isDocker).toBe('boolean'); expect(typeof data.environment.nodeVersion).toBe('string'); expect(typeof data.environment.platform).toBe('string'); }); it('should check API configuration and connectivity', async () => { const response = await handleDiagnostic( { params: { arguments: {} } }, mcpContext ); const data = response.data as DiagnosticResponse; expect(data.apiConfiguration).toBeDefined(); expect(data.apiConfiguration).toHaveProperty('configured'); expect(data.apiConfiguration).toHaveProperty('status'); // In test environment, API should be configured expect(data.apiConfiguration.configured).toBe(true); // Verify API status const status = data.apiConfiguration.status; expect(status).toHaveProperty('configured'); expect(status).toHaveProperty('connected'); // Should successfully connect to n8n API expect(status.connected).toBe(true); // If connected, should have version info if (status.connected) { expect(status).toHaveProperty('version'); } // Config details should be present when configured if (data.apiConfiguration.configured) { expect(data.apiConfiguration).toHaveProperty('config'); expect(data.apiConfiguration.config).toHaveProperty('baseUrl'); expect(data.apiConfiguration.config).toHaveProperty('timeout'); expect(data.apiConfiguration.config).toHaveProperty('maxRetries'); } }); it('should report tools availability', async () => { const response = await handleDiagnostic( { params: { arguments: {} } }, mcpContext ); const data = response.data as DiagnosticResponse; expect(data.toolsAvailability).toBeDefined(); expect(data.toolsAvailability).toHaveProperty('documentationTools'); expect(data.toolsAvailability).toHaveProperty('managementTools'); expect(data.toolsAvailability).toHaveProperty('totalAvailable'); // Documentation tools should always be available const docTools = data.toolsAvailability.documentationTools; expect(docTools.count).toBeGreaterThan(0); expect(docTools.enabled).toBe(true); expect(docTools.description).toBeDefined(); // Management tools should be available when API configured const mgmtTools = data.toolsAvailability.managementTools; expect(mgmtTools).toHaveProperty('count'); expect(mgmtTools).toHaveProperty('enabled'); expect(mgmtTools).toHaveProperty('description'); // In test environment, management tools should be enabled expect(mgmtTools.enabled).toBe(true); expect(mgmtTools.count).toBeGreaterThan(0); // Total should be sum of both expect(data.toolsAvailability.totalAvailable).toBe( docTools.count + mgmtTools.count ); }); it('should include troubleshooting information', async () => { const response = await handleDiagnostic( { params: { arguments: {} } }, mcpContext ); const data = response.data as DiagnosticResponse; // Should have either nextSteps (if API connected) or setupGuide (if not configured) const hasGuidance = data.nextSteps || data.setupGuide || data.troubleshooting; expect(hasGuidance).toBeDefined(); if (data.nextSteps) { expect(data.nextSteps).toHaveProperty('message'); expect(data.nextSteps).toHaveProperty('recommended'); expect(Array.isArray(data.nextSteps.recommended)).toBe(true); } if (data.setupGuide) { expect(data.setupGuide).toHaveProperty('message'); expect(data.setupGuide).toHaveProperty('whatYouCanDoNow'); expect(data.setupGuide).toHaveProperty('whatYouCannotDo'); expect(data.setupGuide).toHaveProperty('howToEnable'); } if (data.troubleshooting) { expect(data.troubleshooting).toHaveProperty('issue'); expect(data.troubleshooting).toHaveProperty('steps'); expect(Array.isArray(data.troubleshooting.steps)).toBe(true); } }); }); // ====================================================================== // Environment Detection // ====================================================================== describe('Environment Detection', () => { it('should provide mode-specific debugging suggestions', async () => { const response = await handleDiagnostic( { params: { arguments: {} } }, mcpContext ); const data = response.data as DiagnosticResponse; // Mode-specific debug should always be present expect(data).toHaveProperty('modeSpecificDebug'); expect(data.modeSpecificDebug).toBeDefined(); expect(data.modeSpecificDebug).toHaveProperty('mode'); expect(data.modeSpecificDebug).toHaveProperty('troubleshooting'); expect(data.modeSpecificDebug).toHaveProperty('commonIssues'); // Verify troubleshooting is an array with content expect(Array.isArray(data.modeSpecificDebug.troubleshooting)).toBe(true); expect(data.modeSpecificDebug.troubleshooting.length).toBeGreaterThan(0); // Verify common issues is an array with content expect(Array.isArray(data.modeSpecificDebug.commonIssues)).toBe(true); expect(data.modeSpecificDebug.commonIssues.length).toBeGreaterThan(0); // Mode should be either 'HTTP Server' or 'Standard I/O (Claude Desktop)' expect(['HTTP Server', 'Standard I/O (Claude Desktop)']).toContain(data.modeSpecificDebug.mode); }); it('should include Docker debugging if IS_DOCKER is true', async () => { // Save original value const originalIsDocker = process.env.IS_DOCKER; try { // Set IS_DOCKER for this test process.env.IS_DOCKER = 'true'; const response = await handleDiagnostic( { params: { arguments: {} } }, mcpContext ); const data = response.data as DiagnosticResponse; // Should have Docker debug section expect(data).toHaveProperty('dockerDebug'); expect(data.dockerDebug).toBeDefined(); expect(data.dockerDebug?.containerDetected).toBe(true); expect(data.dockerDebug?.troubleshooting).toBeDefined(); expect(Array.isArray(data.dockerDebug?.troubleshooting)).toBe(true); expect(data.dockerDebug?.commonIssues).toBeDefined(); } finally { // Restore original value if (originalIsDocker) { process.env.IS_DOCKER = originalIsDocker; } else { delete process.env.IS_DOCKER; } } }); it('should not include Docker debugging if IS_DOCKER is false', async () => { // Save original value const originalIsDocker = process.env.IS_DOCKER; try { // Unset IS_DOCKER for this test delete process.env.IS_DOCKER; const response = await handleDiagnostic( { params: { arguments: {} } }, mcpContext ); const data = response.data as DiagnosticResponse; // Should not have Docker debug section expect(data.dockerDebug).toBeUndefined(); } finally { // Restore original value if (originalIsDocker) { process.env.IS_DOCKER = originalIsDocker; } } }); }); // ====================================================================== // Verbose Mode // ====================================================================== describe('Verbose Mode', () => { it('should include additional debug info in verbose mode', async () => { const response = await handleDiagnostic( { params: { arguments: { verbose: true } } }, mcpContext ); expect(response.success).toBe(true); const data = response.data as DiagnosticResponse; // Verbose mode should add debug section expect(data).toHaveProperty('debug'); expect(data.debug).toBeDefined(); // Verify debug information expect(data.debug).toBeDefined(); expect(data.debug).toHaveProperty('processEnv'); expect(data.debug).toHaveProperty('nodeVersion'); expect(data.debug).toHaveProperty('platform'); expect(data.debug).toHaveProperty('workingDirectory'); // Process env should list relevant environment variables expect(Array.isArray(data.debug?.processEnv)).toBe(true); // Node version should be a string expect(typeof data.debug?.nodeVersion).toBe('string'); expect(data.debug?.nodeVersion).toMatch(/^v\d+\.\d+\.\d+/); // Platform should be a string (linux, darwin, win32, etc.) expect(typeof data.debug?.platform).toBe('string'); expect(data.debug && data.debug.platform.length).toBeGreaterThan(0); // Working directory should be a path expect(typeof data.debug?.workingDirectory).toBe('string'); expect(data.debug && data.debug.workingDirectory.length).toBeGreaterThan(0); }); it('should not include debug info when verbose is false', async () => { const response = await handleDiagnostic( { params: { arguments: { verbose: false } } }, mcpContext ); expect(response.success).toBe(true); const data = response.data as DiagnosticResponse; // Debug section should not be present expect(data.debug).toBeUndefined(); }); it('should not include debug info by default', async () => { const response = await handleDiagnostic( { params: { arguments: {} } }, mcpContext ); expect(response.success).toBe(true); const data = response.data as DiagnosticResponse; // Debug section should not be present when verbose not specified expect(data.debug).toBeUndefined(); }); }); // ====================================================================== // Response Format Verification // ====================================================================== describe('Response Format', () => { it('should return complete diagnostic response structure', async () => { const response = await handleDiagnostic( { params: { arguments: {} } }, mcpContext ); expect(response.success).toBe(true); expect(response.data).toBeDefined(); const data = response.data as DiagnosticResponse; // Verify all required fields (always present) const requiredFields = [ 'timestamp', 'environment', 'apiConfiguration', 'toolsAvailability', 'versionInfo', 'performance' ]; requiredFields.forEach(field => { expect(data).toHaveProperty(field); expect(data[field]).toBeDefined(); }); // Context-specific fields (at least one should be present) const hasContextualGuidance = data.nextSteps || data.setupGuide || data.troubleshooting; expect(hasContextualGuidance).toBeDefined(); // Verify data types expect(typeof data.timestamp).toBe('string'); expect(typeof data.environment).toBe('object'); expect(typeof data.apiConfiguration).toBe('object'); expect(typeof data.toolsAvailability).toBe('object'); expect(typeof data.versionInfo).toBe('object'); expect(typeof data.performance).toBe('object'); }); }); }); ``` -------------------------------------------------------------------------------- /tests/integration/mcp/template-examples-e2e.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { createDatabaseAdapter, DatabaseAdapter } from '../../../src/database/database-adapter'; import fs from 'fs'; import path from 'path'; import { sampleConfigs, compressWorkflow, sampleWorkflows } from '../../fixtures/template-configs'; /** * End-to-end integration tests for template-based examples feature * Tests the complete flow: database -> MCP server -> examples in response */ describe('Template Examples E2E Integration', () => { let db: DatabaseAdapter; beforeEach(async () => { // Create in-memory database db = await createDatabaseAdapter(':memory:'); // Apply schema const schemaPath = path.join(__dirname, '../../../src/database/schema.sql'); const schema = fs.readFileSync(schemaPath, 'utf-8'); db.exec(schema); // Apply migration const migrationPath = path.join(__dirname, '../../../src/database/migrations/add-template-node-configs.sql'); const migration = fs.readFileSync(migrationPath, 'utf-8'); db.exec(migration); // Seed test data seedTemplateConfigs(); }); afterEach(() => { if ('close' in db && typeof db.close === 'function') { db.close(); } }); function seedTemplateConfigs() { // Insert sample templates first to satisfy foreign key constraints // The sampleConfigs use template_id 1-4, edge cases use 998-999 const templateIds = [1, 2, 3, 4, 998, 999]; for (const id of templateIds) { db.prepare(` INSERT INTO templates ( id, workflow_id, name, description, views, nodes_used, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) `).run( id, id, `Test Template ${id}`, 'Test Description', 1000, JSON.stringify(['n8n-nodes-base.webhook', 'n8n-nodes-base.httpRequest']) ); } // Insert webhook configs db.prepare(` INSERT INTO template_node_configs ( node_type, template_id, template_name, template_views, node_name, parameters_json, credentials_json, has_credentials, has_expressions, complexity, use_cases, rank ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( ...Object.values(sampleConfigs.simpleWebhook) ); db.prepare(` INSERT INTO template_node_configs ( node_type, template_id, template_name, template_views, node_name, parameters_json, credentials_json, has_credentials, has_expressions, complexity, use_cases, rank ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( ...Object.values(sampleConfigs.webhookWithAuth) ); // Insert HTTP request configs db.prepare(` INSERT INTO template_node_configs ( node_type, template_id, template_name, template_views, node_name, parameters_json, credentials_json, has_credentials, has_expressions, complexity, use_cases, rank ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( ...Object.values(sampleConfigs.httpRequestBasic) ); db.prepare(` INSERT INTO template_node_configs ( node_type, template_id, template_name, template_views, node_name, parameters_json, credentials_json, has_credentials, has_expressions, complexity, use_cases, rank ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( ...Object.values(sampleConfigs.httpRequestWithExpressions) ); } describe('Querying Examples Directly', () => { it('should fetch top 2 examples for webhook node', () => { const examples = db.prepare(` SELECT parameters_json, template_name, template_views FROM template_node_configs WHERE node_type = ? ORDER BY rank LIMIT 2 `).all('n8n-nodes-base.webhook') as any[]; expect(examples).toHaveLength(2); expect(examples[0].template_name).toBe('Simple Webhook Trigger'); expect(examples[1].template_name).toBe('Authenticated Webhook'); }); it('should fetch top 3 examples with metadata for HTTP request node', () => { const examples = db.prepare(` SELECT parameters_json, template_name, template_views, complexity, use_cases, has_credentials, has_expressions FROM template_node_configs WHERE node_type = ? ORDER BY rank LIMIT 3 `).all('n8n-nodes-base.httpRequest') as any[]; expect(examples).toHaveLength(2); // Only 2 inserted expect(examples[0].template_name).toBe('Basic HTTP GET Request'); expect(examples[0].complexity).toBe('simple'); expect(examples[0].has_expressions).toBe(0); expect(examples[1].template_name).toBe('Dynamic HTTP Request'); expect(examples[1].complexity).toBe('complex'); expect(examples[1].has_expressions).toBe(1); }); }); describe('Example Data Structure Validation', () => { it('should have valid JSON in parameters_json', () => { const examples = db.prepare(` SELECT parameters_json FROM template_node_configs WHERE node_type = ? LIMIT 1 `).all('n8n-nodes-base.webhook') as any[]; expect(() => { const params = JSON.parse(examples[0].parameters_json); expect(params).toHaveProperty('httpMethod'); expect(params).toHaveProperty('path'); }).not.toThrow(); }); it('should have valid JSON in use_cases', () => { const examples = db.prepare(` SELECT use_cases FROM template_node_configs WHERE node_type = ? LIMIT 1 `).all('n8n-nodes-base.webhook') as any[]; expect(() => { const useCases = JSON.parse(examples[0].use_cases); expect(Array.isArray(useCases)).toBe(true); }).not.toThrow(); }); it('should have credentials_json when has_credentials is 1', () => { const examples = db.prepare(` SELECT credentials_json, has_credentials FROM template_node_configs WHERE has_credentials = 1 LIMIT 1 `).all() as any[]; if (examples.length > 0) { expect(examples[0].credentials_json).not.toBeNull(); expect(() => { JSON.parse(examples[0].credentials_json); }).not.toThrow(); } }); }); describe('Ranked View Functionality', () => { it('should return only top 5 ranked configs per node type from view', () => { // Insert templates first to satisfy foreign key constraints // Note: seedTemplateConfigs already created templates 1-4, so start from 5 for (let i = 5; i <= 14; i++) { db.prepare(` INSERT INTO templates ( id, workflow_id, name, description, views, nodes_used, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) `).run(i, i, `Template ${i}`, 'Test', 1000 - (i * 50), '[]'); } // Insert 10 configs for same node type for (let i = 5; i <= 14; i++) { db.prepare(` INSERT INTO template_node_configs ( node_type, template_id, template_name, template_views, node_name, parameters_json, rank ) VALUES (?, ?, ?, ?, ?, ?, ?) `).run( 'n8n-nodes-base.webhook', i, `Template ${i}`, 1000 - (i * 50), 'Webhook', '{}', i ); } const rankedConfigs = db.prepare(` SELECT * FROM ranked_node_configs WHERE node_type = ? `).all('n8n-nodes-base.webhook') as any[]; expect(rankedConfigs.length).toBeLessThanOrEqual(5); }); }); describe('Performance with Real-World Data Volume', () => { beforeEach(() => { // Insert templates first to satisfy foreign key constraints for (let i = 1; i <= 100; i++) { db.prepare(` INSERT INTO templates ( id, workflow_id, name, description, views, nodes_used, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) `).run(i + 100, i + 100, `Template ${i}`, 'Test', Math.floor(Math.random() * 10000), '[]'); } // Insert 100 configs across 10 different node types const nodeTypes = [ 'n8n-nodes-base.slack', 'n8n-nodes-base.googleSheets', 'n8n-nodes-base.code', 'n8n-nodes-base.if', 'n8n-nodes-base.switch', 'n8n-nodes-base.set', 'n8n-nodes-base.merge', 'n8n-nodes-base.splitInBatches', 'n8n-nodes-base.postgres', 'n8n-nodes-base.gmail' ]; for (let i = 1; i <= 100; i++) { const nodeType = nodeTypes[i % nodeTypes.length]; db.prepare(` INSERT INTO template_node_configs ( node_type, template_id, template_name, template_views, node_name, parameters_json, rank ) VALUES (?, ?, ?, ?, ?, ?, ?) `).run( nodeType, i + 100, // Offset template_id `Template ${i}`, Math.floor(Math.random() * 10000), 'Node', '{}', (i % 10) + 1 ); } }); it('should query specific node type examples quickly', () => { const start = Date.now(); const examples = db.prepare(` SELECT * FROM template_node_configs WHERE node_type = ? ORDER BY rank LIMIT 3 `).all('n8n-nodes-base.slack') as any[]; const duration = Date.now() - start; expect(examples.length).toBeGreaterThan(0); expect(duration).toBeLessThan(5); // Should be very fast with index }); it('should filter by complexity efficiently', () => { // Set complexity on configs db.exec(`UPDATE template_node_configs SET complexity = 'simple' WHERE id % 3 = 0`); db.exec(`UPDATE template_node_configs SET complexity = 'medium' WHERE id % 3 = 1`); const start = Date.now(); const examples = db.prepare(` SELECT * FROM template_node_configs WHERE node_type = ? AND complexity = ? ORDER BY rank LIMIT 3 `).all('n8n-nodes-base.code', 'simple') as any[]; const duration = Date.now() - start; expect(duration).toBeLessThan(5); }); }); describe('Edge Cases', () => { it('should handle node types with no configs', () => { const examples = db.prepare(` SELECT * FROM template_node_configs WHERE node_type = ? LIMIT 2 `).all('n8n-nodes-base.nonexistent') as any[]; expect(examples).toHaveLength(0); }); it('should handle very long parameters_json', () => { const longParams = JSON.stringify({ options: { queryParameters: Array.from({ length: 100 }, (_, i) => ({ name: `param${i}`, value: `value${i}`.repeat(10) })) } }); db.prepare(` INSERT INTO template_node_configs ( node_type, template_id, template_name, template_views, node_name, parameters_json, rank ) VALUES (?, ?, ?, ?, ?, ?, ?) `).run( 'n8n-nodes-base.test', 999, 'Long Params Template', 100, 'Test', longParams, 1 ); const example = db.prepare(` SELECT parameters_json FROM template_node_configs WHERE template_id = ? `).get(999) as any; expect(() => { const parsed = JSON.parse(example.parameters_json); expect(parsed.options.queryParameters).toHaveLength(100); }).not.toThrow(); }); it('should handle special characters in parameters', () => { const specialParams = JSON.stringify({ message: "Test with 'quotes' and \"double quotes\"", unicode: "特殊文字 🎉 émojis", symbols: "!@#$%^&*()_+-={}[]|\\:;<>?,./" }); db.prepare(` INSERT INTO template_node_configs ( node_type, template_id, template_name, template_views, node_name, parameters_json, rank ) VALUES (?, ?, ?, ?, ?, ?, ?) `).run( 'n8n-nodes-base.test', 998, 'Special Chars Template', 100, 'Test', specialParams, 1 ); const example = db.prepare(` SELECT parameters_json FROM template_node_configs WHERE template_id = ? `).get(998) as any; expect(() => { const parsed = JSON.parse(example.parameters_json); expect(parsed.message).toContain("'quotes'"); expect(parsed.unicode).toContain("🎉"); }).not.toThrow(); }); }); describe('Data Integrity', () => { it('should maintain referential integrity with templates table', () => { // Try to insert config with non-existent template_id (with FK enabled) db.exec('PRAGMA foreign_keys = ON'); expect(() => { db.prepare(` INSERT INTO template_node_configs ( node_type, template_id, template_name, template_views, node_name, parameters_json, rank ) VALUES (?, ?, ?, ?, ?, ?, ?) `).run( 'n8n-nodes-base.test', 999999, // Non-existent template_id 'Test', 100, 'Node', '{}', 1 ); }).toThrow(); // Should fail due to FK constraint }); it('should cascade delete configs when template is deleted', () => { db.exec('PRAGMA foreign_keys = ON'); // Insert a new template (use id 1000 to avoid conflicts with seedTemplateConfigs) db.prepare(` INSERT INTO templates ( id, workflow_id, name, description, views, nodes_used, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) `).run(1000, 1000, 'Test Template 1000', 'Desc', 100, '[]'); db.prepare(` INSERT INTO template_node_configs ( node_type, template_id, template_name, template_views, node_name, parameters_json, rank ) VALUES (?, ?, ?, ?, ?, ?, ?) `).run( 'n8n-nodes-base.test', 1000, 'Test', 100, 'Node', '{}', 1 ); // Verify config exists let config = db.prepare('SELECT * FROM template_node_configs WHERE template_id = ?').get(1000); expect(config).toBeDefined(); // Delete template db.prepare('DELETE FROM templates WHERE id = ?').run(1000); // Verify config is deleted (CASCADE) config = db.prepare('SELECT * FROM template_node_configs WHERE template_id = ?').get(1000); expect(config).toBeUndefined(); }); }); }); ``` -------------------------------------------------------------------------------- /src/templates/batch-processor.ts: -------------------------------------------------------------------------------- ```typescript import * as fs from 'fs'; import * as path from 'path'; import OpenAI from 'openai'; import { logger } from '../utils/logger'; import { MetadataGenerator, MetadataRequest, MetadataResult } from './metadata-generator'; export interface BatchProcessorOptions { apiKey: string; model?: string; batchSize?: number; outputDir?: string; } export interface BatchJob { id: string; status: 'validating' | 'in_progress' | 'finalizing' | 'completed' | 'failed' | 'expired' | 'cancelled'; created_at: number; completed_at?: number; input_file_id: string; output_file_id?: string; error?: any; } export class BatchProcessor { private client: OpenAI; private generator: MetadataGenerator; private batchSize: number; private outputDir: string; constructor(options: BatchProcessorOptions) { this.client = new OpenAI({ apiKey: options.apiKey }); this.generator = new MetadataGenerator(options.apiKey, options.model); this.batchSize = options.batchSize || 100; this.outputDir = options.outputDir || './temp'; // Ensure output directory exists if (!fs.existsSync(this.outputDir)) { fs.mkdirSync(this.outputDir, { recursive: true }); } } /** * Process templates in batches (parallel submission) */ async processTemplates( templates: MetadataRequest[], progressCallback?: (message: string, current: number, total: number) => void ): Promise<Map<number, MetadataResult>> { const results = new Map<number, MetadataResult>(); const batches = this.createBatches(templates); logger.info(`Processing ${templates.length} templates in ${batches.length} batches`); // Submit all batches in parallel console.log(`\n📤 Submitting ${batches.length} batch${batches.length > 1 ? 'es' : ''} to OpenAI...`); const batchJobs: Array<{ batchNum: number; jobPromise: Promise<any>; templates: MetadataRequest[] }> = []; for (let i = 0; i < batches.length; i++) { const batch = batches[i]; const batchNum = i + 1; try { progressCallback?.(`Submitting batch ${batchNum}/${batches.length}`, i * this.batchSize, templates.length); // Submit batch (don't wait for completion) const jobPromise = this.submitBatch(batch, `batch_${batchNum}`); batchJobs.push({ batchNum, jobPromise, templates: batch }); console.log(` 📨 Submitted batch ${batchNum}/${batches.length} (${batch.length} templates)`); } catch (error) { logger.error(`Error submitting batch ${batchNum}:`, error); console.error(` ❌ Failed to submit batch ${batchNum}`); } } console.log(`\n⏳ All batches submitted. Waiting for completion...`); console.log(` (Batches process in parallel - this is much faster than sequential processing)`); // Process all batches in parallel and collect results as they complete const batchPromises = batchJobs.map(async ({ batchNum, jobPromise, templates: batchTemplates }) => { try { const completedJob = await jobPromise; console.log(`\n📦 Retrieving results for batch ${batchNum}/${batches.length}...`); // Retrieve and parse results const batchResults = await this.retrieveResults(completedJob); logger.info(`Retrieved ${batchResults.length} results from batch ${batchNum}`); progressCallback?.(`Retrieved batch ${batchNum}/${batches.length}`, Math.min(batchNum * this.batchSize, templates.length), templates.length); return { batchNum, results: batchResults }; } catch (error) { logger.error(`Error processing batch ${batchNum}:`, error); console.error(` ❌ Batch ${batchNum} failed:`, error); return { batchNum, results: [] }; } }); // Wait for all batches to complete const allBatchResults = await Promise.all(batchPromises); // Merge all results for (const { batchNum, results: batchResults } of allBatchResults) { for (const result of batchResults) { results.set(result.templateId, result); } if (batchResults.length > 0) { console.log(` ✅ Merged ${batchResults.length} results from batch ${batchNum}`); } } logger.info(`Batch processing complete: ${results.size} results`); return results; } /** * Submit a batch without waiting for completion */ private async submitBatch(templates: MetadataRequest[], batchName: string): Promise<any> { // Create JSONL file const inputFile = await this.createBatchFile(templates, batchName); try { // Upload file to OpenAI const uploadedFile = await this.uploadFile(inputFile); // Create batch job const batchJob = await this.createBatchJob(uploadedFile.id); // Start monitoring (returns promise that resolves when complete) const monitoringPromise = this.monitorBatchJob(batchJob.id); // Clean up input file immediately try { fs.unlinkSync(inputFile); } catch {} // Store file IDs for cleanup later monitoringPromise.then(async (completedJob) => { // Cleanup uploaded files after completion try { await this.client.files.del(uploadedFile.id); if (completedJob.output_file_id) { // Note: We'll delete output file after retrieving results } } catch (error) { logger.warn(`Failed to cleanup files for batch ${batchName}`, error); } }); return monitoringPromise; } catch (error) { // Cleanup on error try { fs.unlinkSync(inputFile); } catch {} throw error; } } /** * Process a single batch */ private async processBatch(templates: MetadataRequest[], batchName: string): Promise<MetadataResult[]> { // Create JSONL file const inputFile = await this.createBatchFile(templates, batchName); try { // Upload file to OpenAI const uploadedFile = await this.uploadFile(inputFile); // Create batch job const batchJob = await this.createBatchJob(uploadedFile.id); // Monitor job until completion const completedJob = await this.monitorBatchJob(batchJob.id); // Retrieve and parse results const results = await this.retrieveResults(completedJob); // Cleanup await this.cleanup(inputFile, uploadedFile.id, completedJob.output_file_id); return results; } catch (error) { // Cleanup on error try { fs.unlinkSync(inputFile); } catch {} throw error; } } /** * Create batches from templates */ private createBatches(templates: MetadataRequest[]): MetadataRequest[][] { const batches: MetadataRequest[][] = []; for (let i = 0; i < templates.length; i += this.batchSize) { batches.push(templates.slice(i, i + this.batchSize)); } return batches; } /** * Create JSONL batch file */ private async createBatchFile(templates: MetadataRequest[], batchName: string): Promise<string> { const filename = path.join(this.outputDir, `${batchName}_${Date.now()}.jsonl`); const stream = fs.createWriteStream(filename); for (const template of templates) { const request = this.generator.createBatchRequest(template); stream.write(JSON.stringify(request) + '\n'); } stream.end(); // Wait for stream to finish await new Promise<void>((resolve, reject) => { stream.on('finish', () => resolve()); stream.on('error', reject); }); logger.debug(`Created batch file: ${filename} with ${templates.length} requests`); return filename; } /** * Upload file to OpenAI */ private async uploadFile(filepath: string): Promise<any> { const file = fs.createReadStream(filepath); const uploadedFile = await this.client.files.create({ file, purpose: 'batch' }); logger.debug(`Uploaded file: ${uploadedFile.id}`); return uploadedFile; } /** * Create batch job */ private async createBatchJob(fileId: string): Promise<any> { const batchJob = await this.client.batches.create({ input_file_id: fileId, endpoint: '/v1/chat/completions', completion_window: '24h' }); logger.info(`Created batch job: ${batchJob.id}`); return batchJob; } /** * Monitor batch job with fixed 1-minute polling interval */ private async monitorBatchJob(batchId: string): Promise<any> { const pollInterval = 60; // Check every 60 seconds (1 minute) let attempts = 0; const maxAttempts = 120; // 120 minutes max (2 hours) const startTime = Date.now(); let lastStatus = ''; while (attempts < maxAttempts) { const batchJob = await this.client.batches.retrieve(batchId); const elapsedMinutes = Math.floor((Date.now() - startTime) / 60000); // Log status on every check (not just on change) const statusSymbol = batchJob.status === 'in_progress' ? '⚙️' : batchJob.status === 'finalizing' ? '📦' : batchJob.status === 'validating' ? '🔍' : batchJob.status === 'completed' ? '✅' : batchJob.status === 'failed' ? '❌' : '⏳'; console.log(` ${statusSymbol} Batch ${batchId.slice(-8)}: ${batchJob.status} (${elapsedMinutes} min, check ${attempts + 1})`); if (batchJob.status !== lastStatus) { logger.info(`Batch ${batchId} status changed: ${lastStatus} -> ${batchJob.status}`); lastStatus = batchJob.status; } if (batchJob.status === 'completed') { console.log(` ✅ Batch ${batchId.slice(-8)} completed successfully in ${elapsedMinutes} minutes`); logger.info(`Batch job ${batchId} completed successfully`); return batchJob; } if (['failed', 'expired', 'cancelled'].includes(batchJob.status)) { logger.error(`Batch job ${batchId} failed with status: ${batchJob.status}`); throw new Error(`Batch job failed with status: ${batchJob.status}`); } // Wait before next check (always 1 minute) logger.debug(`Waiting ${pollInterval} seconds before next check...`); await this.sleep(pollInterval * 1000); attempts++; } throw new Error(`Batch job monitoring timed out after ${maxAttempts} minutes`); } /** * Retrieve and parse results */ private async retrieveResults(batchJob: any): Promise<MetadataResult[]> { const results: MetadataResult[] = []; // Check if we have an output file (successful results) if (batchJob.output_file_id) { const fileResponse = await this.client.files.content(batchJob.output_file_id); const fileContent = await fileResponse.text(); const lines = fileContent.trim().split('\n'); for (const line of lines) { if (!line) continue; try { const result = JSON.parse(line); const parsed = this.generator.parseResult(result); results.push(parsed); } catch (error) { logger.error('Error parsing result line:', error); } } logger.info(`Retrieved ${results.length} successful results from batch job`); } // Check if we have an error file (failed results) if (batchJob.error_file_id) { logger.warn(`Batch job has error file: ${batchJob.error_file_id}`); try { const errorResponse = await this.client.files.content(batchJob.error_file_id); const errorContent = await errorResponse.text(); // Save error file locally for debugging const errorFilePath = path.join(this.outputDir, `batch_${batchJob.id}_error.jsonl`); fs.writeFileSync(errorFilePath, errorContent); logger.warn(`Error file saved to: ${errorFilePath}`); // Parse errors and create default metadata for failed templates const errorLines = errorContent.trim().split('\n'); logger.warn(`Found ${errorLines.length} failed requests in error file`); for (const line of errorLines) { if (!line) continue; try { const errorResult = JSON.parse(line); const templateId = parseInt(errorResult.custom_id?.replace('template-', '') || '0'); if (templateId > 0) { const errorMessage = errorResult.response?.body?.error?.message || errorResult.error?.message || 'Unknown error'; logger.debug(`Template ${templateId} failed: ${errorMessage}`); // Use getDefaultMetadata() from generator (it's private but accessible via bracket notation) const defaultMeta = (this.generator as any).getDefaultMetadata(); results.push({ templateId, metadata: defaultMeta, error: errorMessage }); } } catch (parseError) { logger.error('Error parsing error line:', parseError); } } } catch (error) { logger.error('Failed to process error file:', error); } } // If we have no results at all, something is very wrong if (results.length === 0 && !batchJob.output_file_id && !batchJob.error_file_id) { throw new Error('No output file or error file available for batch job'); } logger.info(`Total results (successful + failed): ${results.length}`); return results; } /** * Cleanup temporary files */ private async cleanup(localFile: string, inputFileId: string, outputFileId?: string): Promise<void> { // Delete local file try { fs.unlinkSync(localFile); logger.debug(`Deleted local file: ${localFile}`); } catch (error) { logger.warn(`Failed to delete local file: ${localFile}`, error); } // Delete uploaded files from OpenAI try { await this.client.files.del(inputFileId); logger.debug(`Deleted input file from OpenAI: ${inputFileId}`); } catch (error) { logger.warn(`Failed to delete input file from OpenAI: ${inputFileId}`, error); } if (outputFileId) { try { await this.client.files.del(outputFileId); logger.debug(`Deleted output file from OpenAI: ${outputFileId}`); } catch (error) { logger.warn(`Failed to delete output file from OpenAI: ${outputFileId}`, error); } } } /** * Sleep helper */ private sleep(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)); } } ``` -------------------------------------------------------------------------------- /tests/unit/mcp/tools-documentation.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest'; import { getToolDocumentation, getToolsOverview, searchToolDocumentation, getToolsByCategory, getAllCategories } from '@/mcp/tools-documentation'; // Mock the tool-docs import vi.mock('@/mcp/tool-docs', () => ({ toolsDocumentation: { search_nodes: { name: 'search_nodes', category: 'discovery', essentials: { description: 'Search nodes by keywords', keyParameters: ['query', 'mode', 'limit'], example: 'search_nodes({query: "slack"})', performance: 'Instant (<10ms)', tips: ['Use single words for precision', 'Try FUZZY mode for typos'] }, full: { description: 'Full-text search across all n8n nodes with multiple matching modes', parameters: { query: { type: 'string', description: 'Search terms', required: true }, mode: { type: 'string', description: 'Search mode', enum: ['OR', 'AND', 'FUZZY'], default: 'OR' }, limit: { type: 'number', description: 'Max results', default: 20 } }, returns: 'Array of matching nodes with metadata', examples: [ 'search_nodes({query: "webhook"})', 'search_nodes({query: "http request", mode: "AND"})' ], useCases: ['Finding integration nodes', 'Discovering available triggers'], performance: 'Instant - uses in-memory index', bestPractices: ['Start with single words', 'Use FUZZY for uncertain names'], pitfalls: ['Overly specific queries may return no results'], relatedTools: ['list_nodes', 'get_node_info'] } }, validate_workflow: { name: 'validate_workflow', category: 'validation', essentials: { description: 'Validate complete workflow structure', keyParameters: ['workflow', 'options'], example: 'validate_workflow(workflow)', performance: 'Moderate (100-500ms)', tips: ['Run before deployment', 'Check all validation types'] }, full: { description: 'Comprehensive workflow validation', parameters: { workflow: { type: 'object', description: 'Workflow JSON', required: true }, options: { type: 'object', description: 'Validation options' } }, returns: 'Validation results with errors and warnings', examples: ['validate_workflow(workflow)'], useCases: ['Pre-deployment checks', 'CI/CD validation'], performance: 'Depends on workflow complexity', bestPractices: ['Validate before saving', 'Fix errors first'], pitfalls: ['Large workflows may take time'], relatedTools: ['validate_node_operation'] } }, get_node_essentials: { name: 'get_node_essentials', category: 'configuration', essentials: { description: 'Get essential node properties only', keyParameters: ['nodeType'], example: 'get_node_essentials("nodes-base.slack")', performance: 'Fast (<100ms)', tips: ['Use this before get_node_info', 'Returns 95% smaller payload'] }, full: { description: 'Returns 10-20 most important properties', parameters: { nodeType: { type: 'string', description: 'Full node type with prefix', required: true } }, returns: 'Essential properties with examples', examples: ['get_node_essentials("nodes-base.httpRequest")'], useCases: ['Quick configuration', 'Property discovery'], performance: 'Fast - pre-filtered data', bestPractices: ['Always try essentials first'], pitfalls: ['May not include all advanced options'], relatedTools: ['get_node_info'] } } } })); // No need to mock package.json - let the actual module read it describe('tools-documentation', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('getToolDocumentation', () => { describe('essentials mode', () => { it('should return essential documentation for existing tool', () => { const doc = getToolDocumentation('search_nodes', 'essentials'); expect(doc).toContain('# search_nodes'); expect(doc).toContain('Search nodes by keywords'); expect(doc).toContain('**Example**: search_nodes({query: "slack"})'); expect(doc).toContain('**Key parameters**: query, mode, limit'); expect(doc).toContain('**Performance**: Instant (<10ms)'); expect(doc).toContain('- Use single words for precision'); expect(doc).toContain('- Try FUZZY mode for typos'); expect(doc).toContain('For full documentation, use: tools_documentation({topic: "search_nodes", depth: "full"})'); }); it('should return error message for unknown tool', () => { const doc = getToolDocumentation('unknown_tool', 'essentials'); expect(doc).toBe("Tool 'unknown_tool' not found. Use tools_documentation() to see available tools."); }); it('should use essentials as default depth', () => { const docDefault = getToolDocumentation('search_nodes'); const docEssentials = getToolDocumentation('search_nodes', 'essentials'); expect(docDefault).toBe(docEssentials); }); }); describe('full mode', () => { it('should return complete documentation for existing tool', () => { const doc = getToolDocumentation('search_nodes', 'full'); expect(doc).toContain('# search_nodes'); expect(doc).toContain('Full-text search across all n8n nodes'); expect(doc).toContain('## Parameters'); expect(doc).toContain('- **query** (string, required): Search terms'); expect(doc).toContain('- **mode** (string): Search mode'); expect(doc).toContain('- **limit** (number): Max results'); expect(doc).toContain('## Returns'); expect(doc).toContain('Array of matching nodes with metadata'); expect(doc).toContain('## Examples'); expect(doc).toContain('search_nodes({query: "webhook"})'); expect(doc).toContain('## Common Use Cases'); expect(doc).toContain('- Finding integration nodes'); expect(doc).toContain('## Performance'); expect(doc).toContain('Instant - uses in-memory index'); expect(doc).toContain('## Best Practices'); expect(doc).toContain('- Start with single words'); expect(doc).toContain('## Common Pitfalls'); expect(doc).toContain('- Overly specific queries'); expect(doc).toContain('## Related Tools'); expect(doc).toContain('- list_nodes'); }); }); describe('special documentation topics', () => { it('should return JavaScript Code node guide for javascript_code_node_guide', () => { const doc = getToolDocumentation('javascript_code_node_guide', 'essentials'); expect(doc).toContain('# JavaScript Code Node Guide'); expect(doc).toContain('$input.all()'); expect(doc).toContain('DateTime'); }); it('should return Python Code node guide for python_code_node_guide', () => { const doc = getToolDocumentation('python_code_node_guide', 'essentials'); expect(doc).toContain('# Python Code Node Guide'); expect(doc).toContain('_input.all()'); expect(doc).toContain('_json'); }); it('should return full JavaScript guide when requested', () => { const doc = getToolDocumentation('javascript_code_node_guide', 'full'); expect(doc).toContain('# JavaScript Code Node Complete Guide'); expect(doc).toContain('## Data Access Patterns'); expect(doc).toContain('## Available Built-in Functions'); expect(doc).toContain('$helpers.httpRequest'); }); it('should return full Python guide when requested', () => { const doc = getToolDocumentation('python_code_node_guide', 'full'); expect(doc).toContain('# Python Code Node Complete Guide'); expect(doc).toContain('## Available Built-in Modules'); expect(doc).toContain('## Limitations & Workarounds'); expect(doc).toContain('import json'); }); }); }); describe('getToolsOverview', () => { describe('essentials mode', () => { it('should return essential overview with categories', () => { const overview = getToolsOverview('essentials'); expect(overview).toContain('# n8n MCP Tools Reference'); expect(overview).toContain('## Important: Compatibility Notice'); // The tools-documentation module dynamically reads version from package.json // so we need to read it the same way to match const packageJson = require('../../../package.json'); const n8nVersion = packageJson.dependencies.n8n.replace(/[^0-9.]/g, ''); expect(overview).toContain(`n8n version ${n8nVersion}`); expect(overview).toContain('## Code Node Configuration'); expect(overview).toContain('## Standard Workflow Pattern'); expect(overview).toContain('**Discovery Tools**'); expect(overview).toContain('**Configuration Tools**'); expect(overview).toContain('**Validation Tools**'); expect(overview).toContain('## Performance Characteristics'); expect(overview).toContain('- Instant (<10ms)'); expect(overview).toContain('tools_documentation({topic: "tool_name", depth: "full"})'); }); it('should use essentials as default', () => { const overviewDefault = getToolsOverview(); const overviewEssentials = getToolsOverview('essentials'); expect(overviewDefault).toBe(overviewEssentials); }); }); describe('full mode', () => { it('should return complete overview with all tools', () => { const overview = getToolsOverview('full'); expect(overview).toContain('# n8n MCP Tools - Complete Reference'); expect(overview).toContain('## All Available Tools by Category'); expect(overview).toContain('### Discovery'); expect(overview).toContain('- **search_nodes**: Search nodes by keywords'); expect(overview).toContain('### Validation'); expect(overview).toContain('- **validate_workflow**: Validate complete workflow structure'); expect(overview).toContain('## Usage Notes'); }); }); }); describe('searchToolDocumentation', () => { it('should find tools matching keyword in name', () => { const results = searchToolDocumentation('search'); expect(results).toContain('search_nodes'); }); it('should find tools matching keyword in description', () => { const results = searchToolDocumentation('workflow'); expect(results).toContain('validate_workflow'); }); it('should be case insensitive', () => { const resultsLower = searchToolDocumentation('search'); const resultsUpper = searchToolDocumentation('SEARCH'); expect(resultsLower).toEqual(resultsUpper); }); it('should return empty array for no matches', () => { const results = searchToolDocumentation('nonexistentxyz123'); expect(results).toEqual([]); }); it('should search in both essentials and full descriptions', () => { const results = searchToolDocumentation('validation'); expect(results.length).toBeGreaterThan(0); }); }); describe('getToolsByCategory', () => { it('should return tools for discovery category', () => { const tools = getToolsByCategory('discovery'); expect(tools).toContain('search_nodes'); }); it('should return tools for validation category', () => { const tools = getToolsByCategory('validation'); expect(tools).toContain('validate_workflow'); }); it('should return tools for configuration category', () => { const tools = getToolsByCategory('configuration'); expect(tools).toContain('get_node_essentials'); }); it('should return empty array for unknown category', () => { const tools = getToolsByCategory('unknown_category'); expect(tools).toEqual([]); }); }); describe('getAllCategories', () => { it('should return all unique categories', () => { const categories = getAllCategories(); expect(categories).toContain('discovery'); expect(categories).toContain('validation'); expect(categories).toContain('configuration'); }); it('should not have duplicates', () => { const categories = getAllCategories(); const uniqueCategories = new Set(categories); expect(categories.length).toBe(uniqueCategories.size); }); it('should return non-empty array', () => { const categories = getAllCategories(); expect(categories.length).toBeGreaterThan(0); }); }); describe('Error Handling', () => { it('should handle missing tool gracefully', () => { const doc = getToolDocumentation('missing_tool'); expect(doc).toContain("Tool 'missing_tool' not found"); expect(doc).toContain('Use tools_documentation()'); }); it('should handle empty search query', () => { const results = searchToolDocumentation(''); // Should match all tools since empty string is in everything expect(results.length).toBeGreaterThan(0); }); }); describe('Documentation Quality', () => { it('should format parameters correctly in full mode', () => { const doc = getToolDocumentation('search_nodes', 'full'); // Check parameter formatting expect(doc).toMatch(/- \*\*query\*\* \(string, required\): Search terms/); expect(doc).toMatch(/- \*\*mode\*\* \(string\): Search mode/); expect(doc).toMatch(/- \*\*limit\*\* \(number\): Max results/); }); it('should include code blocks for examples', () => { const doc = getToolDocumentation('search_nodes', 'full'); expect(doc).toContain('```javascript'); expect(doc).toContain('```'); }); it('should have consistent section headers', () => { const doc = getToolDocumentation('search_nodes', 'full'); const expectedSections = [ '## Parameters', '## Returns', '## Examples', '## Common Use Cases', '## Performance', '## Best Practices', '## Common Pitfalls', '## Related Tools' ]; expectedSections.forEach(section => { expect(doc).toContain(section); }); }); }); }); ```