This is page 19 of 59. Use http://codebase.md/czlonkowski/n8n-mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── _config.yml ├── .claude │ └── agents │ ├── code-reviewer.md │ ├── context-manager.md │ ├── debugger.md │ ├── deployment-engineer.md │ ├── mcp-backend-engineer.md │ ├── n8n-mcp-tester.md │ ├── technical-researcher.md │ └── test-automator.md ├── .dockerignore ├── .env.docker ├── .env.example ├── .env.n8n.example ├── .env.test ├── .env.test.example ├── .github │ ├── ABOUT.md │ ├── BENCHMARK_THRESHOLDS.md │ ├── FUNDING.yml │ ├── gh-pages.yml │ ├── secret_scanning.yml │ └── workflows │ ├── benchmark-pr.yml │ ├── benchmark.yml │ ├── docker-build-fast.yml │ ├── docker-build-n8n.yml │ ├── docker-build.yml │ ├── release.yml │ ├── test.yml │ └── update-n8n-deps.yml ├── .gitignore ├── .npmignore ├── ATTRIBUTION.md ├── CHANGELOG.md ├── CLAUDE.md ├── codecov.yml ├── coverage.json ├── data │ ├── .gitkeep │ ├── nodes.db │ ├── nodes.db-shm │ ├── nodes.db-wal │ └── templates.db ├── deploy │ └── quick-deploy-n8n.sh ├── docker │ ├── docker-entrypoint.sh │ ├── n8n-mcp │ ├── parse-config.js │ └── README.md ├── docker-compose.buildkit.yml ├── docker-compose.extract.yml ├── docker-compose.n8n.yml ├── docker-compose.override.yml.example ├── docker-compose.test-n8n.yml ├── docker-compose.yml ├── Dockerfile ├── Dockerfile.railway ├── Dockerfile.test ├── docs │ ├── AUTOMATED_RELEASES.md │ ├── BENCHMARKS.md │ ├── CHANGELOG.md │ ├── CLAUDE_CODE_SETUP.md │ ├── CLAUDE_INTERVIEW.md │ ├── CODECOV_SETUP.md │ ├── CODEX_SETUP.md │ ├── CURSOR_SETUP.md │ ├── DEPENDENCY_UPDATES.md │ ├── DOCKER_README.md │ ├── DOCKER_TROUBLESHOOTING.md │ ├── FINAL_AI_VALIDATION_SPEC.md │ ├── FLEXIBLE_INSTANCE_CONFIGURATION.md │ ├── HTTP_DEPLOYMENT.md │ ├── img │ │ ├── cc_command.png │ │ ├── cc_connected.png │ │ ├── codex_connected.png │ │ ├── cursor_tut.png │ │ ├── Railway_api.png │ │ ├── Railway_server_address.png │ │ ├── vsc_ghcp_chat_agent_mode.png │ │ ├── vsc_ghcp_chat_instruction_files.png │ │ ├── vsc_ghcp_chat_thinking_tool.png │ │ └── windsurf_tut.png │ ├── INSTALLATION.md │ ├── LIBRARY_USAGE.md │ ├── local │ │ ├── DEEP_DIVE_ANALYSIS_2025-10-02.md │ │ ├── DEEP_DIVE_ANALYSIS_README.md │ │ ├── Deep_dive_p1_p2.md │ │ ├── integration-testing-plan.md │ │ ├── integration-tests-phase1-summary.md │ │ ├── N8N_AI_WORKFLOW_BUILDER_ANALYSIS.md │ │ ├── P0_IMPLEMENTATION_PLAN.md │ │ └── TEMPLATE_MINING_ANALYSIS.md │ ├── MCP_ESSENTIALS_README.md │ ├── MCP_QUICK_START_GUIDE.md │ ├── N8N_DEPLOYMENT.md │ ├── RAILWAY_DEPLOYMENT.md │ ├── README_CLAUDE_SETUP.md │ ├── README.md │ ├── tools-documentation-usage.md │ ├── VS_CODE_PROJECT_SETUP.md │ ├── WINDSURF_SETUP.md │ └── workflow-diff-examples.md ├── examples │ └── enhanced-documentation-demo.js ├── fetch_log.txt ├── LICENSE ├── MEMORY_N8N_UPDATE.md ├── MEMORY_TEMPLATE_UPDATE.md ├── monitor_fetch.sh ├── N8N_HTTP_STREAMABLE_SETUP.md ├── n8n-nodes.db ├── P0-R3-TEST-PLAN.md ├── package-lock.json ├── package.json ├── package.runtime.json ├── PRIVACY.md ├── railway.json ├── README.md ├── renovate.json ├── scripts │ ├── analyze-optimization.sh │ ├── audit-schema-coverage.ts │ ├── build-optimized.sh │ ├── compare-benchmarks.js │ ├── demo-optimization.sh │ ├── deploy-http.sh │ ├── deploy-to-vm.sh │ ├── export-webhook-workflows.ts │ ├── extract-changelog.js │ ├── extract-from-docker.js │ ├── extract-nodes-docker.sh │ ├── extract-nodes-simple.sh │ ├── format-benchmark-results.js │ ├── generate-benchmark-stub.js │ ├── generate-detailed-reports.js │ ├── generate-test-summary.js │ ├── http-bridge.js │ ├── mcp-http-client.js │ ├── migrate-nodes-fts.ts │ ├── migrate-tool-docs.ts │ ├── n8n-docs-mcp.service │ ├── nginx-n8n-mcp.conf │ ├── prebuild-fts5.ts │ ├── prepare-release.js │ ├── publish-npm-quick.sh │ ├── publish-npm.sh │ ├── quick-test.ts │ ├── run-benchmarks-ci.js │ ├── sync-runtime-version.js │ ├── test-ai-validation-debug.ts │ ├── test-code-node-enhancements.ts │ ├── test-code-node-fixes.ts │ ├── test-docker-config.sh │ ├── test-docker-fingerprint.ts │ ├── test-docker-optimization.sh │ ├── test-docker.sh │ ├── test-empty-connection-validation.ts │ ├── test-error-message-tracking.ts │ ├── test-error-output-validation.ts │ ├── test-error-validation.js │ ├── test-essentials.ts │ ├── test-expression-code-validation.ts │ ├── test-expression-format-validation.js │ ├── test-fts5-search.ts │ ├── test-fuzzy-fix.ts │ ├── test-fuzzy-simple.ts │ ├── test-helpers-validation.ts │ ├── test-http-search.ts │ ├── test-http.sh │ ├── test-jmespath-validation.ts │ ├── test-multi-tenant-simple.ts │ ├── test-multi-tenant.ts │ ├── test-n8n-integration.sh │ ├── test-node-info.js │ ├── test-node-type-validation.ts │ ├── test-nodes-base-prefix.ts │ ├── test-operation-validation.ts │ ├── test-optimized-docker.sh │ ├── test-release-automation.js │ ├── test-search-improvements.ts │ ├── test-security.ts │ ├── test-single-session.sh │ ├── test-sqljs-triggers.ts │ ├── test-telemetry-debug.ts │ ├── test-telemetry-direct.ts │ ├── test-telemetry-env.ts │ ├── test-telemetry-integration.ts │ ├── test-telemetry-no-select.ts │ ├── test-telemetry-security.ts │ ├── test-telemetry-simple.ts │ ├── test-typeversion-validation.ts │ ├── test-url-configuration.ts │ ├── test-user-id-persistence.ts │ ├── test-webhook-validation.ts │ ├── test-workflow-insert.ts │ ├── test-workflow-sanitizer.ts │ ├── test-workflow-tracking-debug.ts │ ├── update-and-publish-prep.sh │ ├── update-n8n-deps.js │ ├── update-readme-version.js │ ├── vitest-benchmark-json-reporter.js │ └── vitest-benchmark-reporter.ts ├── SECURITY.md ├── src │ ├── config │ │ └── n8n-api.ts │ ├── data │ │ └── canonical-ai-tool-examples.json │ ├── database │ │ ├── database-adapter.ts │ │ ├── migrations │ │ │ └── add-template-node-configs.sql │ │ ├── node-repository.ts │ │ ├── nodes.db │ │ ├── schema-optimized.sql │ │ └── schema.sql │ ├── errors │ │ └── validation-service-error.ts │ ├── http-server-single-session.ts │ ├── http-server.ts │ ├── index.ts │ ├── loaders │ │ └── node-loader.ts │ ├── mappers │ │ └── docs-mapper.ts │ ├── mcp │ │ ├── handlers-n8n-manager.ts │ │ ├── handlers-workflow-diff.ts │ │ ├── index.ts │ │ ├── server.ts │ │ ├── stdio-wrapper.ts │ │ ├── tool-docs │ │ │ ├── configuration │ │ │ │ ├── get-node-as-tool-info.ts │ │ │ │ ├── get-node-documentation.ts │ │ │ │ ├── get-node-essentials.ts │ │ │ │ ├── get-node-info.ts │ │ │ │ ├── get-property-dependencies.ts │ │ │ │ ├── index.ts │ │ │ │ └── search-node-properties.ts │ │ │ ├── discovery │ │ │ │ ├── get-database-statistics.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-ai-tools.ts │ │ │ │ ├── list-nodes.ts │ │ │ │ └── search-nodes.ts │ │ │ ├── guides │ │ │ │ ├── ai-agents-guide.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── system │ │ │ │ ├── index.ts │ │ │ │ ├── n8n-diagnostic.ts │ │ │ │ ├── n8n-health-check.ts │ │ │ │ ├── n8n-list-available-tools.ts │ │ │ │ └── tools-documentation.ts │ │ │ ├── templates │ │ │ │ ├── get-template.ts │ │ │ │ ├── get-templates-for-task.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-node-templates.ts │ │ │ │ ├── list-tasks.ts │ │ │ │ ├── search-templates-by-metadata.ts │ │ │ │ └── search-templates.ts │ │ │ ├── types.ts │ │ │ ├── validation │ │ │ │ ├── index.ts │ │ │ │ ├── validate-node-minimal.ts │ │ │ │ ├── validate-node-operation.ts │ │ │ │ ├── validate-workflow-connections.ts │ │ │ │ ├── validate-workflow-expressions.ts │ │ │ │ └── validate-workflow.ts │ │ │ └── workflow_management │ │ │ ├── index.ts │ │ │ ├── n8n-autofix-workflow.ts │ │ │ ├── n8n-create-workflow.ts │ │ │ ├── n8n-delete-execution.ts │ │ │ ├── n8n-delete-workflow.ts │ │ │ ├── n8n-get-execution.ts │ │ │ ├── n8n-get-workflow-details.ts │ │ │ ├── n8n-get-workflow-minimal.ts │ │ │ ├── n8n-get-workflow-structure.ts │ │ │ ├── n8n-get-workflow.ts │ │ │ ├── n8n-list-executions.ts │ │ │ ├── n8n-list-workflows.ts │ │ │ ├── n8n-trigger-webhook-workflow.ts │ │ │ ├── n8n-update-full-workflow.ts │ │ │ ├── n8n-update-partial-workflow.ts │ │ │ └── n8n-validate-workflow.ts │ │ ├── tools-documentation.ts │ │ ├── tools-n8n-friendly.ts │ │ ├── tools-n8n-manager.ts │ │ ├── tools.ts │ │ └── workflow-examples.ts │ ├── mcp-engine.ts │ ├── mcp-tools-engine.ts │ ├── n8n │ │ ├── MCPApi.credentials.ts │ │ └── MCPNode.node.ts │ ├── parsers │ │ ├── node-parser.ts │ │ ├── property-extractor.ts │ │ └── simple-parser.ts │ ├── scripts │ │ ├── debug-http-search.ts │ │ ├── extract-from-docker.ts │ │ ├── fetch-templates-robust.ts │ │ ├── fetch-templates.ts │ │ ├── rebuild-database.ts │ │ ├── rebuild-optimized.ts │ │ ├── rebuild.ts │ │ ├── sanitize-templates.ts │ │ ├── seed-canonical-ai-examples.ts │ │ ├── test-autofix-documentation.ts │ │ ├── test-autofix-workflow.ts │ │ ├── test-execution-filtering.ts │ │ ├── test-node-suggestions.ts │ │ ├── test-protocol-negotiation.ts │ │ ├── test-summary.ts │ │ ├── test-webhook-autofix.ts │ │ ├── validate.ts │ │ └── validation-summary.ts │ ├── services │ │ ├── ai-node-validator.ts │ │ ├── ai-tool-validators.ts │ │ ├── confidence-scorer.ts │ │ ├── config-validator.ts │ │ ├── enhanced-config-validator.ts │ │ ├── example-generator.ts │ │ ├── execution-processor.ts │ │ ├── expression-format-validator.ts │ │ ├── expression-validator.ts │ │ ├── n8n-api-client.ts │ │ ├── n8n-validation.ts │ │ ├── node-documentation-service.ts │ │ ├── node-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/expression-format-validator.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect } from 'vitest'; 2 | import { ExpressionFormatValidator } from '../../../src/services/expression-format-validator'; 3 | 4 | describe('ExpressionFormatValidator', () => { 5 | describe('validateAndFix', () => { 6 | const context = { 7 | nodeType: 'n8n-nodes-base.httpRequest', 8 | nodeName: 'HTTP Request', 9 | nodeId: 'test-id-1' 10 | }; 11 | 12 | describe('Simple string expressions', () => { 13 | it('should detect missing = prefix for expression', () => { 14 | const value = '{{ $env.API_KEY }}'; 15 | const issue = ExpressionFormatValidator.validateAndFix(value, 'apiKey', context); 16 | 17 | expect(issue).toBeTruthy(); 18 | expect(issue?.issueType).toBe('missing-prefix'); 19 | expect(issue?.correctedValue).toBe('={{ $env.API_KEY }}'); 20 | expect(issue?.severity).toBe('error'); 21 | }); 22 | 23 | it('should accept expression with = prefix', () => { 24 | const value = '={{ $env.API_KEY }}'; 25 | const issue = ExpressionFormatValidator.validateAndFix(value, 'apiKey', context); 26 | 27 | expect(issue).toBeNull(); 28 | }); 29 | 30 | it('should detect mixed content without prefix', () => { 31 | const value = 'Bearer {{ $env.TOKEN }}'; 32 | const issue = ExpressionFormatValidator.validateAndFix(value, 'authorization', context); 33 | 34 | expect(issue).toBeTruthy(); 35 | expect(issue?.issueType).toBe('missing-prefix'); 36 | expect(issue?.correctedValue).toBe('=Bearer {{ $env.TOKEN }}'); 37 | }); 38 | 39 | it('should accept mixed content with prefix', () => { 40 | const value = '=Bearer {{ $env.TOKEN }}'; 41 | const issue = ExpressionFormatValidator.validateAndFix(value, 'authorization', context); 42 | 43 | expect(issue).toBeNull(); 44 | }); 45 | 46 | it('should ignore plain strings without expressions', () => { 47 | const value = 'https://api.example.com'; 48 | const issue = ExpressionFormatValidator.validateAndFix(value, 'url', context); 49 | 50 | expect(issue).toBeNull(); 51 | }); 52 | }); 53 | 54 | describe('Resource Locator fields', () => { 55 | const githubContext = { 56 | nodeType: 'n8n-nodes-base.github', 57 | nodeName: 'GitHub', 58 | nodeId: 'github-1' 59 | }; 60 | 61 | it('should detect expression in owner field needing resource locator', () => { 62 | const value = '{{ $vars.GITHUB_OWNER }}'; 63 | const issue = ExpressionFormatValidator.validateAndFix(value, 'owner', githubContext); 64 | 65 | expect(issue).toBeTruthy(); 66 | expect(issue?.issueType).toBe('needs-resource-locator'); 67 | expect(issue?.correctedValue).toEqual({ 68 | __rl: true, 69 | value: '={{ $vars.GITHUB_OWNER }}', 70 | mode: 'expression' 71 | }); 72 | expect(issue?.severity).toBe('error'); 73 | }); 74 | 75 | it('should accept resource locator with expression', () => { 76 | const value = { 77 | __rl: true, 78 | value: '={{ $vars.GITHUB_OWNER }}', 79 | mode: 'expression' 80 | }; 81 | const issue = ExpressionFormatValidator.validateAndFix(value, 'owner', githubContext); 82 | 83 | expect(issue).toBeNull(); 84 | }); 85 | 86 | it('should detect missing prefix in resource locator value', () => { 87 | const value = { 88 | __rl: true, 89 | value: '{{ $vars.GITHUB_OWNER }}', 90 | mode: 'expression' 91 | }; 92 | const issue = ExpressionFormatValidator.validateAndFix(value, 'owner', githubContext); 93 | 94 | expect(issue).toBeTruthy(); 95 | expect(issue?.issueType).toBe('missing-prefix'); 96 | expect(issue?.correctedValue.value).toBe('={{ $vars.GITHUB_OWNER }}'); 97 | }); 98 | 99 | it('should warn if expression has prefix but should use RL format', () => { 100 | const value = '={{ $vars.GITHUB_OWNER }}'; 101 | const issue = ExpressionFormatValidator.validateAndFix(value, 'owner', githubContext); 102 | 103 | expect(issue).toBeTruthy(); 104 | expect(issue?.issueType).toBe('needs-resource-locator'); 105 | expect(issue?.severity).toBe('warning'); 106 | }); 107 | }); 108 | 109 | describe('Multiple expressions', () => { 110 | it('should detect multiple expressions without prefix', () => { 111 | const value = '{{ $json.first }} - {{ $json.last }}'; 112 | const issue = ExpressionFormatValidator.validateAndFix(value, 'fullName', context); 113 | 114 | expect(issue).toBeTruthy(); 115 | expect(issue?.issueType).toBe('missing-prefix'); 116 | expect(issue?.correctedValue).toBe('={{ $json.first }} - {{ $json.last }}'); 117 | }); 118 | 119 | it('should accept multiple expressions with prefix', () => { 120 | const value = '={{ $json.first }} - {{ $json.last }}'; 121 | const issue = ExpressionFormatValidator.validateAndFix(value, 'fullName', context); 122 | 123 | expect(issue).toBeNull(); 124 | }); 125 | }); 126 | 127 | describe('Edge cases', () => { 128 | it('should handle null values', () => { 129 | const issue = ExpressionFormatValidator.validateAndFix(null, 'field', context); 130 | expect(issue).toBeNull(); 131 | }); 132 | 133 | it('should handle undefined values', () => { 134 | const issue = ExpressionFormatValidator.validateAndFix(undefined, 'field', context); 135 | expect(issue).toBeNull(); 136 | }); 137 | 138 | it('should handle empty strings', () => { 139 | const issue = ExpressionFormatValidator.validateAndFix('', 'field', context); 140 | expect(issue).toBeNull(); 141 | }); 142 | 143 | it('should handle numbers', () => { 144 | const issue = ExpressionFormatValidator.validateAndFix(42, 'field', context); 145 | expect(issue).toBeNull(); 146 | }); 147 | 148 | it('should handle booleans', () => { 149 | const issue = ExpressionFormatValidator.validateAndFix(true, 'field', context); 150 | expect(issue).toBeNull(); 151 | }); 152 | 153 | it('should handle arrays', () => { 154 | const issue = ExpressionFormatValidator.validateAndFix(['item1', 'item2'], 'field', context); 155 | expect(issue).toBeNull(); 156 | }); 157 | }); 158 | }); 159 | 160 | describe('validateNodeParameters', () => { 161 | const context = { 162 | nodeType: 'n8n-nodes-base.emailSend', 163 | nodeName: 'Send Email', 164 | nodeId: 'email-1' 165 | }; 166 | 167 | it('should validate all parameters recursively', () => { 168 | const parameters = { 169 | fromEmail: '{{ $env.SENDER_EMAIL }}', 170 | toEmail: '[email protected]', 171 | subject: 'Test {{ $json.type }}', 172 | body: { 173 | html: '<p>Hello {{ $json.name }}</p>', 174 | text: 'Hello {{ $json.name }}' 175 | }, 176 | options: { 177 | replyTo: '={{ $env.REPLY_EMAIL }}' 178 | } 179 | }; 180 | 181 | const issues = ExpressionFormatValidator.validateNodeParameters(parameters, context); 182 | 183 | expect(issues).toHaveLength(4); 184 | expect(issues.map(i => i.fieldPath)).toContain('fromEmail'); 185 | expect(issues.map(i => i.fieldPath)).toContain('subject'); 186 | expect(issues.map(i => i.fieldPath)).toContain('body.html'); 187 | expect(issues.map(i => i.fieldPath)).toContain('body.text'); 188 | }); 189 | 190 | it('should handle arrays with expressions', () => { 191 | const parameters = { 192 | recipients: [ 193 | '{{ $json.email1 }}', 194 | '[email protected]', 195 | '={{ $json.email2 }}' 196 | ] 197 | }; 198 | 199 | const issues = ExpressionFormatValidator.validateNodeParameters(parameters, context); 200 | 201 | expect(issues).toHaveLength(1); 202 | expect(issues[0].fieldPath).toBe('recipients[0]'); 203 | expect(issues[0].correctedValue).toBe('={{ $json.email1 }}'); 204 | }); 205 | 206 | it('should handle nested objects', () => { 207 | const parameters = { 208 | config: { 209 | database: { 210 | host: '{{ $env.DB_HOST }}', 211 | port: 5432, 212 | name: 'mydb' 213 | } 214 | } 215 | }; 216 | 217 | const issues = ExpressionFormatValidator.validateNodeParameters(parameters, context); 218 | 219 | expect(issues).toHaveLength(1); 220 | expect(issues[0].fieldPath).toBe('config.database.host'); 221 | }); 222 | 223 | it('should skip circular references', () => { 224 | const circular: any = { a: 1 }; 225 | circular.self = circular; 226 | 227 | const parameters = { 228 | normal: '{{ $json.value }}', 229 | circular 230 | }; 231 | 232 | const issues = ExpressionFormatValidator.validateNodeParameters(parameters, context); 233 | 234 | // Should only find the issue in 'normal', not crash on circular 235 | expect(issues).toHaveLength(1); 236 | expect(issues[0].fieldPath).toBe('normal'); 237 | }); 238 | 239 | it('should handle maximum recursion depth', () => { 240 | // Create a deeply nested object (105 levels deep, exceeding the limit of 100) 241 | let deepObject: any = { value: '{{ $json.data }}' }; 242 | let current = deepObject; 243 | for (let i = 0; i < 105; i++) { 244 | current.nested = { value: `{{ $json.level${i} }}` }; 245 | current = current.nested; 246 | } 247 | 248 | const parameters = { 249 | deep: deepObject 250 | }; 251 | 252 | const issues = ExpressionFormatValidator.validateNodeParameters(parameters, context); 253 | 254 | // Should find expression format issues up to the depth limit 255 | const depthWarning = issues.find(i => i.explanation.includes('Maximum recursion depth')); 256 | expect(depthWarning).toBeTruthy(); 257 | expect(depthWarning?.severity).toBe('warning'); 258 | 259 | // Should still find some expression format errors before hitting the limit 260 | const formatErrors = issues.filter(i => i.issueType === 'missing-prefix'); 261 | expect(formatErrors.length).toBeGreaterThan(0); 262 | expect(formatErrors.length).toBeLessThanOrEqual(100); // Should not exceed the depth limit 263 | }); 264 | }); 265 | 266 | describe('formatErrorMessage', () => { 267 | const context = { 268 | nodeType: 'n8n-nodes-base.github', 269 | nodeName: 'Create Issue', 270 | nodeId: 'github-1' 271 | }; 272 | 273 | it('should format error message for missing prefix', () => { 274 | const issue = { 275 | fieldPath: 'title', 276 | currentValue: '{{ $json.title }}', 277 | correctedValue: '={{ $json.title }}', 278 | issueType: 'missing-prefix' as const, 279 | explanation: "Expression missing required '=' prefix.", 280 | severity: 'error' as const 281 | }; 282 | 283 | const message = ExpressionFormatValidator.formatErrorMessage(issue, context); 284 | 285 | expect(message).toContain("Expression format error in node 'Create Issue'"); 286 | expect(message).toContain('Field \'title\''); 287 | expect(message).toContain('Current (incorrect):'); 288 | expect(message).toContain('"title": "{{ $json.title }}"'); 289 | expect(message).toContain('Fixed (correct):'); 290 | expect(message).toContain('"title": "={{ $json.title }}"'); 291 | }); 292 | 293 | it('should format error message for resource locator', () => { 294 | const issue = { 295 | fieldPath: 'owner', 296 | currentValue: '{{ $vars.OWNER }}', 297 | correctedValue: { 298 | __rl: true, 299 | value: '={{ $vars.OWNER }}', 300 | mode: 'expression' 301 | }, 302 | issueType: 'needs-resource-locator' as const, 303 | explanation: 'Field needs resource locator format.', 304 | severity: 'error' as const 305 | }; 306 | 307 | const message = ExpressionFormatValidator.formatErrorMessage(issue, context); 308 | 309 | expect(message).toContain("Expression format error in node 'Create Issue'"); 310 | expect(message).toContain('Current (incorrect):'); 311 | expect(message).toContain('"owner": "{{ $vars.OWNER }}"'); 312 | expect(message).toContain('Fixed (correct):'); 313 | expect(message).toContain('"__rl": true'); 314 | expect(message).toContain('"value": "={{ $vars.OWNER }}"'); 315 | expect(message).toContain('"mode": "expression"'); 316 | }); 317 | }); 318 | 319 | describe('Real-world examples', () => { 320 | it('should validate Email Send node example', () => { 321 | const context = { 322 | nodeType: 'n8n-nodes-base.emailSend', 323 | nodeName: 'Error Handler', 324 | nodeId: 'b9dd1cfd-ee66-4049-97e7-1af6d976a4e0' 325 | }; 326 | 327 | const parameters = { 328 | fromEmail: '{{ $env.ADMIN_EMAIL }}', 329 | toEmail: '[email protected]', 330 | subject: 'GitHub Issue Workflow Error - HIGH PRIORITY', 331 | options: {} 332 | }; 333 | 334 | const issues = ExpressionFormatValidator.validateNodeParameters(parameters, context); 335 | 336 | expect(issues).toHaveLength(1); 337 | expect(issues[0].fieldPath).toBe('fromEmail'); 338 | expect(issues[0].correctedValue).toBe('={{ $env.ADMIN_EMAIL }}'); 339 | }); 340 | 341 | it('should validate GitHub node example', () => { 342 | const context = { 343 | nodeType: 'n8n-nodes-base.github', 344 | nodeName: 'Send Welcome Comment', 345 | nodeId: '3c742ca1-af8f-4d80-a47e-e68fb1ced491' 346 | }; 347 | 348 | const parameters = { 349 | operation: 'createComment', 350 | owner: '{{ $vars.GITHUB_OWNER }}', 351 | repository: '{{ $vars.GITHUB_REPO }}', 352 | issueNumber: null, 353 | body: '👋 Hi @{{ $(\'Extract Issue Data\').first().json.author }}!\n\nThank you for creating this issue.' 354 | }; 355 | 356 | const issues = ExpressionFormatValidator.validateNodeParameters(parameters, context); 357 | 358 | expect(issues.length).toBeGreaterThan(0); 359 | expect(issues.some(i => i.fieldPath === 'owner')).toBe(true); 360 | expect(issues.some(i => i.fieldPath === 'repository')).toBe(true); 361 | expect(issues.some(i => i.fieldPath === 'body')).toBe(true); 362 | }); 363 | }); 364 | }); ``` -------------------------------------------------------------------------------- /tests/unit/services/enhanced-config-validator-operations.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for EnhancedConfigValidator operation and resource validation 3 | */ 4 | 5 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 6 | import { EnhancedConfigValidator } from '../../../src/services/enhanced-config-validator'; 7 | import { NodeRepository } from '../../../src/database/node-repository'; 8 | import { createTestDatabase } from '../../utils/database-utils'; 9 | 10 | describe('EnhancedConfigValidator - Operation and Resource Validation', () => { 11 | let repository: NodeRepository; 12 | let testDb: any; 13 | 14 | beforeEach(async () => { 15 | testDb = await createTestDatabase(); 16 | repository = testDb.nodeRepository; 17 | 18 | // Initialize similarity services 19 | EnhancedConfigValidator.initializeSimilarityServices(repository); 20 | 21 | // Add Google Drive test node 22 | const googleDriveNode = { 23 | nodeType: 'nodes-base.googleDrive', 24 | packageName: 'n8n-nodes-base', 25 | displayName: 'Google Drive', 26 | description: 'Access Google Drive', 27 | category: 'transform', 28 | style: 'declarative' as const, 29 | isAITool: false, 30 | isTrigger: false, 31 | isWebhook: false, 32 | isVersioned: true, 33 | version: '1', 34 | properties: [ 35 | { 36 | name: 'resource', 37 | type: 'options', 38 | required: true, 39 | options: [ 40 | { value: 'file', name: 'File' }, 41 | { value: 'folder', name: 'Folder' }, 42 | { value: 'fileFolder', name: 'File & Folder' } 43 | ] 44 | }, 45 | { 46 | name: 'operation', 47 | type: 'options', 48 | required: true, 49 | displayOptions: { 50 | show: { 51 | resource: ['file'] 52 | } 53 | }, 54 | options: [ 55 | { value: 'copy', name: 'Copy' }, 56 | { value: 'delete', name: 'Delete' }, 57 | { value: 'download', name: 'Download' }, 58 | { value: 'list', name: 'List' }, 59 | { value: 'share', name: 'Share' }, 60 | { value: 'update', name: 'Update' }, 61 | { value: 'upload', name: 'Upload' } 62 | ] 63 | }, 64 | { 65 | name: 'operation', 66 | type: 'options', 67 | required: true, 68 | displayOptions: { 69 | show: { 70 | resource: ['folder'] 71 | } 72 | }, 73 | options: [ 74 | { value: 'create', name: 'Create' }, 75 | { value: 'delete', name: 'Delete' }, 76 | { value: 'share', name: 'Share' } 77 | ] 78 | }, 79 | { 80 | name: 'operation', 81 | type: 'options', 82 | required: true, 83 | displayOptions: { 84 | show: { 85 | resource: ['fileFolder'] 86 | } 87 | }, 88 | options: [ 89 | { value: 'search', name: 'Search' } 90 | ] 91 | } 92 | ], 93 | operations: [], 94 | credentials: [] 95 | }; 96 | 97 | repository.saveNode(googleDriveNode); 98 | 99 | // Add Slack test node 100 | const slackNode = { 101 | nodeType: 'nodes-base.slack', 102 | packageName: 'n8n-nodes-base', 103 | displayName: 'Slack', 104 | description: 'Send messages to Slack', 105 | category: 'communication', 106 | style: 'declarative' as const, 107 | isAITool: false, 108 | isTrigger: false, 109 | isWebhook: false, 110 | isVersioned: true, 111 | version: '2', 112 | properties: [ 113 | { 114 | name: 'resource', 115 | type: 'options', 116 | required: true, 117 | options: [ 118 | { value: 'channel', name: 'Channel' }, 119 | { value: 'message', name: 'Message' }, 120 | { value: 'user', name: 'User' } 121 | ] 122 | }, 123 | { 124 | name: 'operation', 125 | type: 'options', 126 | required: true, 127 | displayOptions: { 128 | show: { 129 | resource: ['message'] 130 | } 131 | }, 132 | options: [ 133 | { value: 'send', name: 'Send' }, 134 | { value: 'update', name: 'Update' }, 135 | { value: 'delete', name: 'Delete' } 136 | ] 137 | } 138 | ], 139 | operations: [], 140 | credentials: [] 141 | }; 142 | 143 | repository.saveNode(slackNode); 144 | }); 145 | 146 | afterEach(async () => { 147 | // Clean up database 148 | if (testDb) { 149 | await testDb.cleanup(); 150 | } 151 | }); 152 | 153 | describe('Invalid Operations', () => { 154 | it('should detect invalid operation "listFiles" for Google Drive', () => { 155 | const config = { 156 | resource: 'fileFolder', 157 | operation: 'listFiles' 158 | }; 159 | 160 | const node = repository.getNode('nodes-base.googleDrive'); 161 | const result = EnhancedConfigValidator.validateWithMode( 162 | 'nodes-base.googleDrive', 163 | config, 164 | node.properties, 165 | 'operation', 166 | 'ai-friendly' 167 | ); 168 | 169 | expect(result.valid).toBe(false); 170 | 171 | // Should have an error for invalid operation 172 | const operationError = result.errors.find(e => e.property === 'operation'); 173 | expect(operationError).toBeDefined(); 174 | expect(operationError!.message).toContain('Invalid operation "listFiles"'); 175 | expect(operationError!.message).toContain('Did you mean'); 176 | expect(operationError!.fix).toContain('search'); // Should suggest 'search' for fileFolder resource 177 | }); 178 | 179 | it('should provide suggestions for typos in operations', () => { 180 | const config = { 181 | resource: 'file', 182 | operation: 'downlod' // Typo: missing 'a' 183 | }; 184 | 185 | const node = repository.getNode('nodes-base.googleDrive'); 186 | const result = EnhancedConfigValidator.validateWithMode( 187 | 'nodes-base.googleDrive', 188 | config, 189 | node.properties, 190 | 'operation', 191 | 'ai-friendly' 192 | ); 193 | 194 | expect(result.valid).toBe(false); 195 | 196 | const operationError = result.errors.find(e => e.property === 'operation'); 197 | expect(operationError).toBeDefined(); 198 | expect(operationError!.message).toContain('Did you mean "download"'); 199 | }); 200 | 201 | it('should list valid operations for the resource', () => { 202 | const config = { 203 | resource: 'folder', 204 | operation: 'upload' // Invalid for folder resource 205 | }; 206 | 207 | const node = repository.getNode('nodes-base.googleDrive'); 208 | const result = EnhancedConfigValidator.validateWithMode( 209 | 'nodes-base.googleDrive', 210 | config, 211 | node.properties, 212 | 'operation', 213 | 'ai-friendly' 214 | ); 215 | 216 | expect(result.valid).toBe(false); 217 | 218 | const operationError = result.errors.find(e => e.property === 'operation'); 219 | expect(operationError).toBeDefined(); 220 | expect(operationError!.fix).toContain('Valid operations for resource "folder"'); 221 | expect(operationError!.fix).toContain('create'); 222 | expect(operationError!.fix).toContain('delete'); 223 | expect(operationError!.fix).toContain('share'); 224 | }); 225 | }); 226 | 227 | describe('Invalid Resources', () => { 228 | it('should detect plural resource "files" and suggest singular', () => { 229 | const config = { 230 | resource: 'files', // Should be 'file' 231 | operation: 'list' 232 | }; 233 | 234 | const node = repository.getNode('nodes-base.googleDrive'); 235 | const result = EnhancedConfigValidator.validateWithMode( 236 | 'nodes-base.googleDrive', 237 | config, 238 | node.properties, 239 | 'operation', 240 | 'ai-friendly' 241 | ); 242 | 243 | expect(result.valid).toBe(false); 244 | 245 | const resourceError = result.errors.find(e => e.property === 'resource'); 246 | expect(resourceError).toBeDefined(); 247 | expect(resourceError!.message).toContain('Invalid resource "files"'); 248 | expect(resourceError!.message).toContain('Did you mean "file"'); 249 | expect(resourceError!.fix).toContain('Use singular'); 250 | }); 251 | 252 | it('should suggest similar resources for typos', () => { 253 | const config = { 254 | resource: 'flie', // Typo 255 | operation: 'download' 256 | }; 257 | 258 | const node = repository.getNode('nodes-base.googleDrive'); 259 | const result = EnhancedConfigValidator.validateWithMode( 260 | 'nodes-base.googleDrive', 261 | config, 262 | node.properties, 263 | 'operation', 264 | 'ai-friendly' 265 | ); 266 | 267 | expect(result.valid).toBe(false); 268 | 269 | const resourceError = result.errors.find(e => e.property === 'resource'); 270 | expect(resourceError).toBeDefined(); 271 | expect(resourceError!.message).toContain('Did you mean "file"'); 272 | }); 273 | 274 | it('should list valid resources when no match found', () => { 275 | const config = { 276 | resource: 'document', // Not a valid resource 277 | operation: 'create' 278 | }; 279 | 280 | const node = repository.getNode('nodes-base.googleDrive'); 281 | const result = EnhancedConfigValidator.validateWithMode( 282 | 'nodes-base.googleDrive', 283 | config, 284 | node.properties, 285 | 'operation', 286 | 'ai-friendly' 287 | ); 288 | 289 | expect(result.valid).toBe(false); 290 | 291 | const resourceError = result.errors.find(e => e.property === 'resource'); 292 | expect(resourceError).toBeDefined(); 293 | expect(resourceError!.fix).toContain('Valid resources:'); 294 | expect(resourceError!.fix).toContain('file'); 295 | expect(resourceError!.fix).toContain('folder'); 296 | }); 297 | }); 298 | 299 | describe('Combined Resource and Operation Validation', () => { 300 | it('should validate both resource and operation together', () => { 301 | const config = { 302 | resource: 'files', // Invalid: should be singular 303 | operation: 'listFiles' // Invalid: should be 'list' or 'search' 304 | }; 305 | 306 | const node = repository.getNode('nodes-base.googleDrive'); 307 | const result = EnhancedConfigValidator.validateWithMode( 308 | 'nodes-base.googleDrive', 309 | config, 310 | node.properties, 311 | 'operation', 312 | 'ai-friendly' 313 | ); 314 | 315 | expect(result.valid).toBe(false); 316 | expect(result.errors.length).toBeGreaterThanOrEqual(2); 317 | 318 | // Should have error for resource 319 | const resourceError = result.errors.find(e => e.property === 'resource'); 320 | expect(resourceError).toBeDefined(); 321 | expect(resourceError!.message).toContain('files'); 322 | 323 | // Should have error for operation 324 | const operationError = result.errors.find(e => e.property === 'operation'); 325 | expect(operationError).toBeDefined(); 326 | expect(operationError!.message).toContain('listFiles'); 327 | }); 328 | }); 329 | 330 | describe('Slack Node Validation', () => { 331 | it('should suggest "send" instead of "sendMessage"', () => { 332 | const config = { 333 | resource: 'message', 334 | operation: 'sendMessage' // Common mistake 335 | }; 336 | 337 | const node = repository.getNode('nodes-base.slack'); 338 | const result = EnhancedConfigValidator.validateWithMode( 339 | 'nodes-base.slack', 340 | config, 341 | node.properties, 342 | 'operation', 343 | 'ai-friendly' 344 | ); 345 | 346 | expect(result.valid).toBe(false); 347 | 348 | const operationError = result.errors.find(e => e.property === 'operation'); 349 | expect(operationError).toBeDefined(); 350 | expect(operationError!.message).toContain('Did you mean "send"'); 351 | }); 352 | 353 | it('should suggest singular "channel" instead of "channels"', () => { 354 | const config = { 355 | resource: 'channels', // Should be singular 356 | operation: 'create' 357 | }; 358 | 359 | const node = repository.getNode('nodes-base.slack'); 360 | const result = EnhancedConfigValidator.validateWithMode( 361 | 'nodes-base.slack', 362 | config, 363 | node.properties, 364 | 'operation', 365 | 'ai-friendly' 366 | ); 367 | 368 | expect(result.valid).toBe(false); 369 | 370 | const resourceError = result.errors.find(e => e.property === 'resource'); 371 | expect(resourceError).toBeDefined(); 372 | expect(resourceError!.message).toContain('Did you mean "channel"'); 373 | }); 374 | }); 375 | 376 | describe('Valid Configurations', () => { 377 | it('should accept valid Google Drive configuration', () => { 378 | const config = { 379 | resource: 'file', 380 | operation: 'download' 381 | }; 382 | 383 | const node = repository.getNode('nodes-base.googleDrive'); 384 | const result = EnhancedConfigValidator.validateWithMode( 385 | 'nodes-base.googleDrive', 386 | config, 387 | node.properties, 388 | 'operation', 389 | 'ai-friendly' 390 | ); 391 | 392 | // Should not have errors for resource or operation 393 | const resourceError = result.errors.find(e => e.property === 'resource'); 394 | const operationError = result.errors.find(e => e.property === 'operation'); 395 | expect(resourceError).toBeUndefined(); 396 | expect(operationError).toBeUndefined(); 397 | }); 398 | 399 | it('should accept valid Slack configuration', () => { 400 | const config = { 401 | resource: 'message', 402 | operation: 'send' 403 | }; 404 | 405 | const node = repository.getNode('nodes-base.slack'); 406 | const result = EnhancedConfigValidator.validateWithMode( 407 | 'nodes-base.slack', 408 | config, 409 | node.properties, 410 | 'operation', 411 | 'ai-friendly' 412 | ); 413 | 414 | // Should not have errors for resource or operation 415 | const resourceError = result.errors.find(e => e.property === 'resource'); 416 | const operationError = result.errors.find(e => e.property === 'operation'); 417 | expect(resourceError).toBeUndefined(); 418 | expect(operationError).toBeUndefined(); 419 | }); 420 | }); 421 | }); ``` -------------------------------------------------------------------------------- /tests/unit/services/node-sanitizer.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Node Sanitizer Tests 3 | * Tests for auto-adding required metadata to filter-based nodes 4 | */ 5 | 6 | import { describe, it, expect } from 'vitest'; 7 | import { sanitizeNode, validateNodeMetadata } from '../../../src/services/node-sanitizer'; 8 | import { WorkflowNode } from '../../../src/types/n8n-api'; 9 | 10 | describe('Node Sanitizer', () => { 11 | describe('sanitizeNode', () => { 12 | it('should add complete filter options to IF v2.2 node', () => { 13 | const node: WorkflowNode = { 14 | id: 'test-if', 15 | name: 'IF Node', 16 | type: 'n8n-nodes-base.if', 17 | typeVersion: 2.2, 18 | position: [0, 0], 19 | parameters: { 20 | conditions: { 21 | conditions: [ 22 | { 23 | id: 'condition1', 24 | leftValue: '={{ $json.value }}', 25 | rightValue: '', 26 | operator: { 27 | type: 'string', 28 | operation: 'isNotEmpty' 29 | } 30 | } 31 | ] 32 | } 33 | } 34 | }; 35 | 36 | const sanitized = sanitizeNode(node); 37 | 38 | // Check that options were added 39 | expect(sanitized.parameters.conditions).toHaveProperty('options'); 40 | const options = (sanitized.parameters.conditions as any).options; 41 | 42 | expect(options).toEqual({ 43 | version: 2, 44 | leftValue: '', 45 | caseSensitive: true, 46 | typeValidation: 'strict' 47 | }); 48 | }); 49 | 50 | it('should preserve existing options while adding missing fields', () => { 51 | const node: WorkflowNode = { 52 | id: 'test-if-partial', 53 | name: 'IF Node Partial', 54 | type: 'n8n-nodes-base.if', 55 | typeVersion: 2.2, 56 | position: [0, 0], 57 | parameters: { 58 | conditions: { 59 | options: { 60 | caseSensitive: false // User-provided value 61 | }, 62 | conditions: [] 63 | } 64 | } 65 | }; 66 | 67 | const sanitized = sanitizeNode(node); 68 | const options = (sanitized.parameters.conditions as any).options; 69 | 70 | // Should preserve user value 71 | expect(options.caseSensitive).toBe(false); 72 | 73 | // Should add missing fields 74 | expect(options.version).toBe(2); 75 | expect(options.leftValue).toBe(''); 76 | expect(options.typeValidation).toBe('strict'); 77 | }); 78 | 79 | it('should fix invalid operator structure (type field misuse)', () => { 80 | const node: WorkflowNode = { 81 | id: 'test-if-bad-operator', 82 | name: 'IF Bad Operator', 83 | type: 'n8n-nodes-base.if', 84 | typeVersion: 2.2, 85 | position: [0, 0], 86 | parameters: { 87 | conditions: { 88 | conditions: [ 89 | { 90 | id: 'condition1', 91 | leftValue: '={{ $json.value }}', 92 | rightValue: '', 93 | operator: { 94 | type: 'isNotEmpty' // WRONG: type should be data type, not operation 95 | } 96 | } 97 | ] 98 | } 99 | } 100 | }; 101 | 102 | const sanitized = sanitizeNode(node); 103 | const condition = (sanitized.parameters.conditions as any).conditions[0]; 104 | 105 | // Should fix operator structure 106 | expect(condition.operator.type).toBe('boolean'); // Inferred data type (isEmpty/isNotEmpty are boolean ops) 107 | expect(condition.operator.operation).toBe('isNotEmpty'); // Moved to operation field 108 | }); 109 | 110 | it('should add singleValue for unary operators', () => { 111 | const node: WorkflowNode = { 112 | id: 'test-if-unary', 113 | name: 'IF Unary', 114 | type: 'n8n-nodes-base.if', 115 | typeVersion: 2.2, 116 | position: [0, 0], 117 | parameters: { 118 | conditions: { 119 | conditions: [ 120 | { 121 | id: 'condition1', 122 | leftValue: '={{ $json.value }}', 123 | rightValue: '', 124 | operator: { 125 | type: 'string', 126 | operation: 'isNotEmpty' 127 | // Missing singleValue 128 | } 129 | } 130 | ] 131 | } 132 | } 133 | }; 134 | 135 | const sanitized = sanitizeNode(node); 136 | const condition = (sanitized.parameters.conditions as any).conditions[0]; 137 | 138 | expect(condition.operator.singleValue).toBe(true); 139 | }); 140 | 141 | it('should sanitize Switch v3.2 node rules', () => { 142 | const node: WorkflowNode = { 143 | id: 'test-switch', 144 | name: 'Switch Node', 145 | type: 'n8n-nodes-base.switch', 146 | typeVersion: 3.2, 147 | position: [0, 0], 148 | parameters: { 149 | mode: 'rules', 150 | rules: { 151 | rules: [ 152 | { 153 | outputKey: 'audio', 154 | conditions: { 155 | conditions: [ 156 | { 157 | id: 'cond1', 158 | leftValue: '={{ $json.fileType }}', 159 | rightValue: 'audio', 160 | operator: { 161 | type: 'string', 162 | operation: 'equals' 163 | } 164 | } 165 | ] 166 | } 167 | } 168 | ] 169 | } 170 | } 171 | }; 172 | 173 | const sanitized = sanitizeNode(node); 174 | const rule = (sanitized.parameters.rules as any).rules[0]; 175 | 176 | // Check that options were added to rule conditions 177 | expect(rule.conditions).toHaveProperty('options'); 178 | expect(rule.conditions.options).toEqual({ 179 | version: 2, 180 | leftValue: '', 181 | caseSensitive: true, 182 | typeValidation: 'strict' 183 | }); 184 | }); 185 | 186 | it('should not modify non-filter nodes', () => { 187 | const node: WorkflowNode = { 188 | id: 'test-http', 189 | name: 'HTTP Request', 190 | type: 'n8n-nodes-base.httpRequest', 191 | typeVersion: 4.2, 192 | position: [0, 0], 193 | parameters: { 194 | method: 'GET', 195 | url: 'https://example.com' 196 | } 197 | }; 198 | 199 | const sanitized = sanitizeNode(node); 200 | 201 | // Should return unchanged 202 | expect(sanitized).toEqual(node); 203 | }); 204 | 205 | it('should not modify old IF versions', () => { 206 | const node: WorkflowNode = { 207 | id: 'test-if-old', 208 | name: 'Old IF', 209 | type: 'n8n-nodes-base.if', 210 | typeVersion: 2.0, // Pre-filter version 211 | position: [0, 0], 212 | parameters: { 213 | conditions: [] 214 | } 215 | }; 216 | 217 | const sanitized = sanitizeNode(node); 218 | 219 | // Should return unchanged 220 | expect(sanitized).toEqual(node); 221 | }); 222 | 223 | it('should remove singleValue from binary operators like "equals"', () => { 224 | const node: WorkflowNode = { 225 | id: 'test-if-binary', 226 | name: 'IF Binary Operator', 227 | type: 'n8n-nodes-base.if', 228 | typeVersion: 2.2, 229 | position: [0, 0], 230 | parameters: { 231 | conditions: { 232 | conditions: [ 233 | { 234 | id: 'condition1', 235 | leftValue: '={{ $json.value }}', 236 | rightValue: 'test', 237 | operator: { 238 | type: 'string', 239 | operation: 'equals', 240 | singleValue: true // WRONG: equals is binary, not unary 241 | } 242 | } 243 | ] 244 | } 245 | } 246 | }; 247 | 248 | const sanitized = sanitizeNode(node); 249 | const condition = (sanitized.parameters.conditions as any).conditions[0]; 250 | 251 | // Should remove singleValue from binary operator 252 | expect(condition.operator.singleValue).toBeUndefined(); 253 | expect(condition.operator.type).toBe('string'); 254 | expect(condition.operator.operation).toBe('equals'); 255 | }); 256 | }); 257 | 258 | describe('validateNodeMetadata', () => { 259 | it('should detect missing conditions.options', () => { 260 | const node: WorkflowNode = { 261 | id: 'test', 262 | name: 'IF Missing Options', 263 | type: 'n8n-nodes-base.if', 264 | typeVersion: 2.2, 265 | position: [0, 0], 266 | parameters: { 267 | conditions: { 268 | conditions: [] 269 | // Missing options 270 | } 271 | } 272 | }; 273 | 274 | const issues = validateNodeMetadata(node); 275 | 276 | expect(issues.length).toBeGreaterThan(0); 277 | expect(issues[0]).toBe('Missing conditions.options'); 278 | }); 279 | 280 | it('should detect missing operator.type', () => { 281 | const node: WorkflowNode = { 282 | id: 'test', 283 | name: 'IF Bad Operator', 284 | type: 'n8n-nodes-base.if', 285 | typeVersion: 2.2, 286 | position: [0, 0], 287 | parameters: { 288 | conditions: { 289 | options: { 290 | version: 2, 291 | leftValue: '', 292 | caseSensitive: true, 293 | typeValidation: 'strict' 294 | }, 295 | conditions: [ 296 | { 297 | id: 'cond1', 298 | leftValue: '={{ $json.value }}', 299 | rightValue: '', 300 | operator: { 301 | operation: 'equals' 302 | // Missing type 303 | } 304 | } 305 | ] 306 | } 307 | } 308 | }; 309 | 310 | const issues = validateNodeMetadata(node); 311 | 312 | expect(issues.length).toBeGreaterThan(0); 313 | expect(issues.some(issue => issue.includes("missing required field 'type'"))).toBe(true); 314 | }); 315 | 316 | it('should detect invalid operator.type value', () => { 317 | const node: WorkflowNode = { 318 | id: 'test', 319 | name: 'IF Invalid Type', 320 | type: 'n8n-nodes-base.if', 321 | typeVersion: 2.2, 322 | position: [0, 0], 323 | parameters: { 324 | conditions: { 325 | options: { 326 | version: 2, 327 | leftValue: '', 328 | caseSensitive: true, 329 | typeValidation: 'strict' 330 | }, 331 | conditions: [ 332 | { 333 | id: 'cond1', 334 | leftValue: '={{ $json.value }}', 335 | rightValue: '', 336 | operator: { 337 | type: 'isNotEmpty', // WRONG: operation name, not data type 338 | operation: 'isNotEmpty' 339 | } 340 | } 341 | ] 342 | } 343 | } 344 | }; 345 | 346 | const issues = validateNodeMetadata(node); 347 | 348 | expect(issues.some(issue => issue.includes('invalid type "isNotEmpty"'))).toBe(true); 349 | }); 350 | 351 | it('should detect missing singleValue for unary operators', () => { 352 | const node: WorkflowNode = { 353 | id: 'test', 354 | name: 'IF Missing SingleValue', 355 | type: 'n8n-nodes-base.if', 356 | typeVersion: 2.2, 357 | position: [0, 0], 358 | parameters: { 359 | conditions: { 360 | options: { 361 | version: 2, 362 | leftValue: '', 363 | caseSensitive: true, 364 | typeValidation: 'strict' 365 | }, 366 | conditions: [ 367 | { 368 | id: 'cond1', 369 | leftValue: '={{ $json.value }}', 370 | rightValue: '', 371 | operator: { 372 | type: 'string', 373 | operation: 'isNotEmpty' 374 | // Missing singleValue: true 375 | } 376 | } 377 | ] 378 | } 379 | } 380 | }; 381 | 382 | const issues = validateNodeMetadata(node); 383 | 384 | expect(issues.length).toBeGreaterThan(0); 385 | expect(issues.some(issue => issue.includes('requires singleValue: true'))).toBe(true); 386 | }); 387 | 388 | it('should detect singleValue on binary operators', () => { 389 | const node: WorkflowNode = { 390 | id: 'test', 391 | name: 'IF Binary with SingleValue', 392 | type: 'n8n-nodes-base.if', 393 | typeVersion: 2.2, 394 | position: [0, 0], 395 | parameters: { 396 | conditions: { 397 | options: { 398 | version: 2, 399 | leftValue: '', 400 | caseSensitive: true, 401 | typeValidation: 'strict' 402 | }, 403 | conditions: [ 404 | { 405 | id: 'cond1', 406 | leftValue: '={{ $json.value }}', 407 | rightValue: 'test', 408 | operator: { 409 | type: 'string', 410 | operation: 'equals', 411 | singleValue: true // WRONG: equals is binary 412 | } 413 | } 414 | ] 415 | } 416 | } 417 | }; 418 | 419 | const issues = validateNodeMetadata(node); 420 | 421 | expect(issues.length).toBeGreaterThan(0); 422 | expect(issues.some(issue => issue.includes('should not have singleValue: true'))).toBe(true); 423 | }); 424 | 425 | it('should return empty array for valid node', () => { 426 | const node: WorkflowNode = { 427 | id: 'test', 428 | name: 'Valid IF', 429 | type: 'n8n-nodes-base.if', 430 | typeVersion: 2.2, 431 | position: [0, 0], 432 | parameters: { 433 | conditions: { 434 | options: { 435 | version: 2, 436 | leftValue: '', 437 | caseSensitive: true, 438 | typeValidation: 'strict' 439 | }, 440 | conditions: [ 441 | { 442 | id: 'cond1', 443 | leftValue: '={{ $json.value }}', 444 | rightValue: '', 445 | operator: { 446 | type: 'string', 447 | operation: 'isNotEmpty', 448 | singleValue: true 449 | } 450 | } 451 | ] 452 | } 453 | } 454 | }; 455 | 456 | const issues = validateNodeMetadata(node); 457 | 458 | expect(issues).toEqual([]); 459 | }); 460 | }); 461 | }); 462 | ``` -------------------------------------------------------------------------------- /P0-R3-TEST-PLAN.md: -------------------------------------------------------------------------------- ```markdown 1 | # P0-R3 Feature Test Coverage Plan 2 | 3 | ## Executive Summary 4 | 5 | This document outlines comprehensive test coverage for the P0-R3 feature (Template-based Configuration Examples). The feature adds real-world configuration examples from popular templates to node search and essentials tools. 6 | 7 | **Feature Overview:** 8 | - New database table: `template_node_configs` (197 pre-extracted configurations) 9 | - Enhanced tools: `search_nodes({includeExamples: true})` and `get_node_essentials({includeExamples: true})` 10 | - Breaking changes: Removed `get_node_for_task` tool 11 | 12 | ## Test Files Created 13 | 14 | ### Unit Tests 15 | 16 | #### 1. `/tests/unit/scripts/fetch-templates-extraction.test.ts` ✅ 17 | **Purpose:** Test template extraction logic from `fetch-templates.ts` 18 | 19 | **Coverage:** 20 | - `extractNodeConfigs()` - 90%+ coverage 21 | - Valid workflows with multiple nodes 22 | - Empty workflows 23 | - Malformed compressed data 24 | - Invalid JSON 25 | - Nodes without parameters 26 | - Sticky note filtering 27 | - Credential handling 28 | - Expression detection 29 | - Special characters 30 | - Large workflows (100 nodes) 31 | 32 | - `detectExpressions()` - 100% coverage 33 | - `={{...}}` syntax detection 34 | - `$json` references 35 | - `$node` references 36 | - Nested objects 37 | - Arrays 38 | - Null/undefined handling 39 | - Multiple expression types 40 | 41 | **Test Count:** 27 tests 42 | **Expected Coverage:** 92%+ 43 | 44 | --- 45 | 46 | #### 2. `/tests/unit/mcp/search-nodes-examples.test.ts` ✅ 47 | **Purpose:** Test `search_nodes` tool with includeExamples parameter 48 | 49 | **Coverage:** 50 | - includeExamples parameter behavior 51 | - false: no examples returned 52 | - undefined: no examples returned (default) 53 | - true: examples returned 54 | - Example data structure validation 55 | - Top 2 limit enforcement 56 | - Backward compatibility 57 | - Performance (<100ms) 58 | - Error handling (malformed JSON, database errors) 59 | - searchNodesLIKE integration 60 | - searchNodesFTS integration 61 | 62 | **Test Count:** 12 tests 63 | **Expected Coverage:** 85%+ 64 | 65 | --- 66 | 67 | #### 3. `/tests/unit/mcp/get-node-essentials-examples.test.ts` ✅ 68 | **Purpose:** Test `get_node_essentials` tool with includeExamples parameter 69 | 70 | **Coverage:** 71 | - includeExamples parameter behavior 72 | - Full metadata structure 73 | - configuration object 74 | - source (template, views, complexity) 75 | - useCases (limited to 2) 76 | - metadata (hasCredentials, hasExpressions) 77 | - Cache key differentiation 78 | - Backward compatibility 79 | - Performance (<100ms) 80 | - Error handling 81 | - Top 3 limit enforcement 82 | 83 | **Test Count:** 13 tests 84 | **Expected Coverage:** 88%+ 85 | 86 | --- 87 | 88 | ### Integration Tests 89 | 90 | #### 4. `/tests/integration/database/template-node-configs.test.ts` ✅ 91 | **Purpose:** Test database schema, migrations, and operations 92 | 93 | **Coverage:** 94 | - Schema validation 95 | - Table creation 96 | - All columns present 97 | - Correct types and constraints 98 | - CHECK constraint on complexity 99 | - Indexes 100 | - idx_config_node_type_rank 101 | - idx_config_complexity 102 | - idx_config_auth 103 | - View: ranked_node_configs 104 | - Top 5 per node_type 105 | - Correct ordering 106 | - Foreign key constraints 107 | - CASCADE delete 108 | - Referential integrity 109 | - Data operations 110 | - INSERT with all fields 111 | - Nullable fields 112 | - Rank updates 113 | - Delete rank > 10 114 | - Performance 115 | - 1000 records < 10ms queries 116 | - Migration idempotency 117 | 118 | **Test Count:** 19 tests 119 | **Expected Coverage:** 95%+ 120 | 121 | --- 122 | 123 | #### 5. `/tests/integration/mcp/template-examples-e2e.test.ts` ✅ 124 | **Purpose:** End-to-end integration testing 125 | 126 | **Coverage:** 127 | - Direct SQL queries 128 | - Top 2 examples for search_nodes 129 | - Top 3 examples with metadata for get_node_essentials 130 | - Data structure validation 131 | - Valid JSON in all fields 132 | - Credentials when has_credentials=1 133 | - Ranked view functionality 134 | - Performance with 100+ configs 135 | - Query performance < 5ms 136 | - Complexity filtering 137 | - Edge cases 138 | - Non-existent node types 139 | - Long parameters_json (100 params) 140 | - Special characters (Unicode, emojis, symbols) 141 | - Data integrity 142 | - Foreign key constraints 143 | - Cascade deletes 144 | 145 | **Test Count:** 14 tests 146 | **Expected Coverage:** 90%+ 147 | 148 | --- 149 | 150 | ### Test Fixtures 151 | 152 | #### 6. `/tests/fixtures/template-configs.ts` ✅ 153 | **Purpose:** Reusable test data 154 | 155 | **Provides:** 156 | - `sampleConfigs`: 7 realistic node configurations 157 | - simpleWebhook 158 | - webhookWithAuth 159 | - httpRequestBasic 160 | - httpRequestWithExpressions 161 | - slackMessage 162 | - codeNodeTransform 163 | - codeNodeWithExpressions 164 | 165 | - `sampleWorkflows`: 3 complete workflows 166 | - webhookToSlack 167 | - apiWorkflow 168 | - complexWorkflow 169 | 170 | - **Helper Functions:** 171 | - `compressWorkflow()` - Compress to base64 172 | - `createTemplateMetadata()` - Generate metadata 173 | - `createConfigBatch()` - Batch create configs 174 | - `getConfigByComplexity()` - Filter by complexity 175 | - `getConfigsWithExpressions()` - Filter with expressions 176 | - `getConfigsWithCredentials()` - Filter with credentials 177 | - `createInsertStatement()` - SQL insert helper 178 | 179 | --- 180 | 181 | ## Existing Tests Requiring Updates 182 | 183 | ### High Priority 184 | 185 | #### 1. `tests/unit/mcp/parameter-validation.test.ts` 186 | **Line 480:** Remove `get_node_for_task` from legacyValidationTools array 187 | 188 | ```typescript 189 | // REMOVE THIS: 190 | { name: 'get_node_for_task', args: {}, expected: 'Missing required parameters for get_node_for_task: task' }, 191 | ``` 192 | 193 | **Status:** ⚠️ BREAKING CHANGE - Tool removed 194 | 195 | --- 196 | 197 | #### 2. `tests/unit/mcp/tools.test.ts` 198 | **Update:** Remove `get_node_for_task` from templates category 199 | 200 | ```typescript 201 | // BEFORE: 202 | templates: ['list_tasks', 'get_node_for_task', 'search_templates', ...] 203 | 204 | // AFTER: 205 | templates: ['list_tasks', 'search_templates', ...] 206 | ``` 207 | 208 | **Add:** Tests for new includeExamples parameter in tool definitions 209 | 210 | ```typescript 211 | it('should have includeExamples parameter in search_nodes', () => { 212 | const searchNodesTool = tools.find(t => t.name === 'search_nodes'); 213 | expect(searchNodesTool.inputSchema.properties.includeExamples).toBeDefined(); 214 | expect(searchNodesTool.inputSchema.properties.includeExamples.type).toBe('boolean'); 215 | expect(searchNodesTool.inputSchema.properties.includeExamples.default).toBe(false); 216 | }); 217 | 218 | it('should have includeExamples parameter in get_node_essentials', () => { 219 | const essentialsTool = tools.find(t => t.name === 'get_node_essentials'); 220 | expect(essentialsTool.inputSchema.properties.includeExamples).toBeDefined(); 221 | }); 222 | ``` 223 | 224 | **Status:** ⚠️ REQUIRED UPDATE 225 | 226 | --- 227 | 228 | #### 3. `tests/integration/mcp-protocol/session-management.test.ts` 229 | **Remove:** Test case calling `get_node_for_task` with invalid task 230 | 231 | ```typescript 232 | // REMOVE THIS TEST: 233 | client.callTool({ name: 'get_node_for_task', arguments: { task: 'invalid_task' } }).catch(e => e) 234 | ``` 235 | 236 | **Status:** ⚠️ BREAKING CHANGE 237 | 238 | --- 239 | 240 | #### 4. `tests/integration/mcp-protocol/tool-invocation.test.ts` 241 | **Remove:** Entire `get_node_for_task` describe block 242 | 243 | **Add:** Tests for new includeExamples functionality 244 | 245 | ```typescript 246 | describe('search_nodes with includeExamples', () => { 247 | it('should return examples when includeExamples is true', async () => { 248 | const response = await client.callTool({ 249 | name: 'search_nodes', 250 | arguments: { query: 'webhook', includeExamples: true } 251 | }); 252 | 253 | expect(response.results).toBeDefined(); 254 | // Examples may or may not be present depending on database 255 | }); 256 | 257 | it('should not return examples when includeExamples is false', async () => { 258 | const response = await client.callTool({ 259 | name: 'search_nodes', 260 | arguments: { query: 'webhook', includeExamples: false } 261 | }); 262 | 263 | expect(response.results).toBeDefined(); 264 | response.results.forEach(node => { 265 | expect(node.examples).toBeUndefined(); 266 | }); 267 | }); 268 | }); 269 | 270 | describe('get_node_essentials with includeExamples', () => { 271 | it('should return examples with metadata when includeExamples is true', async () => { 272 | const response = await client.callTool({ 273 | name: 'get_node_essentials', 274 | arguments: { nodeType: 'nodes-base.webhook', includeExamples: true } 275 | }); 276 | 277 | expect(response.nodeType).toBeDefined(); 278 | // Examples may or may not be present depending on database 279 | }); 280 | }); 281 | ``` 282 | 283 | **Status:** ⚠️ REQUIRED UPDATE 284 | 285 | --- 286 | 287 | ### Medium Priority 288 | 289 | #### 5. `tests/unit/services/task-templates.test.ts` 290 | **Status:** ✅ No changes needed (TaskTemplates marked as deprecated but not removed) 291 | 292 | **Note:** TaskTemplates remains for backward compatibility. Tests should continue to pass. 293 | 294 | --- 295 | 296 | ## Test Execution Plan 297 | 298 | ### Phase 1: Unit Tests 299 | ```bash 300 | # Run new unit tests 301 | npm test tests/unit/scripts/fetch-templates-extraction.test.ts 302 | npm test tests/unit/mcp/search-nodes-examples.test.ts 303 | npm test tests/unit/mcp/get-node-essentials-examples.test.ts 304 | 305 | # Expected: All pass, 52 tests 306 | ``` 307 | 308 | ### Phase 2: Integration Tests 309 | ```bash 310 | # Run new integration tests 311 | npm test tests/integration/database/template-node-configs.test.ts 312 | npm test tests/integration/mcp/template-examples-e2e.test.ts 313 | 314 | # Expected: All pass, 33 tests 315 | ``` 316 | 317 | ### Phase 3: Update Existing Tests 318 | ```bash 319 | # Update files as outlined above, then run: 320 | npm test tests/unit/mcp/parameter-validation.test.ts 321 | npm test tests/unit/mcp/tools.test.ts 322 | npm test tests/integration/mcp-protocol/session-management.test.ts 323 | npm test tests/integration/mcp-protocol/tool-invocation.test.ts 324 | 325 | # Expected: All pass after updates 326 | ``` 327 | 328 | ### Phase 4: Full Test Suite 329 | ```bash 330 | # Run all tests 331 | npm test 332 | 333 | # Run with coverage 334 | npm run test:coverage 335 | 336 | # Expected coverage improvements: 337 | # - src/scripts/fetch-templates.ts: +20% (60% → 80%) 338 | # - src/mcp/server.ts: +5% (75% → 80%) 339 | # - Overall project: +2% (current → current+2%) 340 | ``` 341 | 342 | --- 343 | 344 | ## Coverage Expectations 345 | 346 | ### New Code Coverage 347 | 348 | | File | Function | Target | Tests | 349 | |------|----------|--------|-------| 350 | | fetch-templates.ts | extractNodeConfigs | 95% | 15 tests | 351 | | fetch-templates.ts | detectExpressions | 100% | 12 tests | 352 | | server.ts | searchNodes (with examples) | 90% | 8 tests | 353 | | server.ts | getNodeEssentials (with examples) | 90% | 10 tests | 354 | | Database migration | template_node_configs | 100% | 19 tests | 355 | 356 | ### Overall Coverage Goals 357 | 358 | - **Unit Tests:** 90%+ coverage for new code 359 | - **Integration Tests:** All happy paths + critical error paths 360 | - **E2E Tests:** Complete feature workflows 361 | - **Performance:** All queries <10ms (database), <100ms (MCP) 362 | 363 | --- 364 | 365 | ## Test Infrastructure 366 | 367 | ### Dependencies Required 368 | All dependencies already present in `package.json`: 369 | - vitest (test runner) 370 | - better-sqlite3 (database) 371 | - @vitest/coverage-v8 (coverage) 372 | 373 | ### Test Utilities Used 374 | - TestDatabase helper (from existing test utils) 375 | - createTestDatabaseAdapter (from existing test utils) 376 | - Standard vitest matchers 377 | 378 | ### No New Dependencies Required ✅ 379 | 380 | --- 381 | 382 | ## Regression Prevention 383 | 384 | ### Critical Paths Protected 385 | 386 | 1. **Backward Compatibility** 387 | - Tools work without includeExamples parameter 388 | - Existing workflows unchanged 389 | - Cache keys differentiated 390 | 391 | 2. **Performance** 392 | - No degradation when includeExamples=false 393 | - Indexed queries <10ms 394 | - Example fetch errors don't break responses 395 | 396 | 3. **Data Integrity** 397 | - Foreign key constraints enforced 398 | - JSON validation in all fields 399 | - Rank calculations correct 400 | 401 | --- 402 | 403 | ## CI/CD Integration 404 | 405 | ### GitHub Actions Updates 406 | No changes required. Existing test commands will run new tests: 407 | 408 | ```yaml 409 | - run: npm test 410 | - run: npm run test:coverage 411 | ``` 412 | 413 | ### Coverage Thresholds 414 | Current thresholds maintained. Expected improvements: 415 | - Lines: +2% 416 | - Functions: +3% 417 | - Branches: +2% 418 | 419 | --- 420 | 421 | ## Manual Testing Checklist 422 | 423 | ### Pre-Deployment Verification 424 | 425 | - [ ] Run `npm run rebuild` - Verify migration applies cleanly 426 | - [ ] Run `npm run fetch:templates --extract-only` - Verify extraction works 427 | - [ ] Check database: `SELECT COUNT(*) FROM template_node_configs` - Should be ~197 428 | - [ ] Test MCP tool: `search_nodes({query: "webhook", includeExamples: true})` 429 | - [ ] Test MCP tool: `get_node_essentials({nodeType: "nodes-base.webhook", includeExamples: true})` 430 | - [ ] Verify backward compatibility: Tools work without includeExamples parameter 431 | - [ ] Performance test: Query 100 nodes with examples < 200ms 432 | 433 | --- 434 | 435 | ## Rollback Plan 436 | 437 | If issues are detected: 438 | 439 | 1. **Database Rollback:** 440 | ```sql 441 | DROP TABLE IF EXISTS template_node_configs; 442 | DROP VIEW IF EXISTS ranked_node_configs; 443 | ``` 444 | 445 | 2. **Code Rollback:** 446 | - Revert server.ts changes 447 | - Revert tools.ts changes 448 | - Restore get_node_for_task tool (if critical) 449 | 450 | 3. **Test Rollback:** 451 | - Revert parameter-validation.test.ts 452 | - Revert tools.test.ts 453 | - Revert tool-invocation.test.ts 454 | 455 | --- 456 | 457 | ## Success Metrics 458 | 459 | ### Test Metrics 460 | - ✅ 85+ new tests added 461 | - ✅ 0 tests failing after updates 462 | - ✅ Coverage increase 2%+ 463 | - ✅ All performance tests pass 464 | 465 | ### Feature Metrics 466 | - ✅ 197 template configs extracted 467 | - ✅ Top 2/3 examples returned correctly 468 | - ✅ Query performance <10ms 469 | - ✅ No backward compatibility breaks 470 | 471 | --- 472 | 473 | ## Conclusion 474 | 475 | This test plan provides **comprehensive coverage** for the P0-R3 feature with: 476 | - **85+ new tests** across unit, integration, and E2E levels 477 | - **Complete coverage** of extraction, storage, and retrieval 478 | - **Backward compatibility** protection 479 | - **Performance validation** (<10ms queries) 480 | - **Clear migration path** for existing tests 481 | 482 | **All test files are ready for execution.** Update the 4 existing test files as outlined, then run the full test suite. 483 | 484 | **Estimated Total Implementation Time:** 2-3 hours for updating existing tests + validation 485 | ``` -------------------------------------------------------------------------------- /tests/unit/services/expression-validator-edge-cases.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { ExpressionValidator } from '@/services/expression-validator'; 3 | 4 | // Mock the database 5 | vi.mock('better-sqlite3'); 6 | 7 | describe('ExpressionValidator - Edge Cases', () => { 8 | beforeEach(() => { 9 | vi.clearAllMocks(); 10 | }); 11 | 12 | describe('Null and Undefined Handling', () => { 13 | it('should handle null expression gracefully', () => { 14 | const context = { availableNodes: ['Node1'] }; 15 | const result = ExpressionValidator.validateExpression(null as any, context); 16 | expect(result.valid).toBe(true); 17 | expect(result.errors).toEqual([]); 18 | }); 19 | 20 | it('should handle undefined expression gracefully', () => { 21 | const context = { availableNodes: ['Node1'] }; 22 | const result = ExpressionValidator.validateExpression(undefined as any, context); 23 | expect(result.valid).toBe(true); 24 | expect(result.errors).toEqual([]); 25 | }); 26 | 27 | it('should handle null context gracefully', () => { 28 | const result = ExpressionValidator.validateExpression('{{ $json.data }}', null as any); 29 | expect(result).toBeDefined(); 30 | // With null context, it will likely have errors about missing context 31 | expect(result.valid).toBe(false); 32 | }); 33 | 34 | it('should handle undefined context gracefully', () => { 35 | const result = ExpressionValidator.validateExpression('{{ $json.data }}', undefined as any); 36 | expect(result).toBeDefined(); 37 | // With undefined context, it will likely have errors about missing context 38 | expect(result.valid).toBe(false); 39 | }); 40 | }); 41 | 42 | describe('Boundary Value Testing', () => { 43 | it('should handle empty string expression', () => { 44 | const context = { availableNodes: [] }; 45 | const result = ExpressionValidator.validateExpression('', context); 46 | expect(result.valid).toBe(true); 47 | expect(result.errors).toEqual([]); 48 | expect(result.usedVariables.size).toBe(0); 49 | }); 50 | 51 | it('should handle extremely long expressions', () => { 52 | const longExpression = '{{ ' + '$json.field'.repeat(1000) + ' }}'; 53 | const context = { availableNodes: ['Node1'] }; 54 | 55 | const start = Date.now(); 56 | const result = ExpressionValidator.validateExpression(longExpression, context); 57 | const duration = Date.now() - start; 58 | 59 | expect(result).toBeDefined(); 60 | expect(duration).toBeLessThan(1000); // Should process within 1 second 61 | }); 62 | 63 | it('should handle deeply nested property access', () => { 64 | const deepExpression = '{{ $json' + '.property'.repeat(50) + ' }}'; 65 | const context = { availableNodes: ['Node1'] }; 66 | 67 | const result = ExpressionValidator.validateExpression(deepExpression, context); 68 | expect(result.valid).toBe(true); 69 | expect(result.usedVariables.has('$json')).toBe(true); 70 | }); 71 | 72 | it('should handle many different variables in one expression', () => { 73 | const complexExpression = `{{ 74 | $json.data + 75 | $node["Node1"].json.value + 76 | $input.item.field + 77 | $items("Node2", 0)[0].data + 78 | $parameter["apiKey"] + 79 | $env.API_URL + 80 | $workflow.name + 81 | $execution.id + 82 | $itemIndex + 83 | $now 84 | }}`; 85 | 86 | const context = { 87 | availableNodes: ['Node1', 'Node2'], 88 | hasInputData: true 89 | }; 90 | 91 | const result = ExpressionValidator.validateExpression(complexExpression, context); 92 | expect(result.usedVariables.size).toBeGreaterThan(5); 93 | expect(result.usedNodes.has('Node1')).toBe(true); 94 | expect(result.usedNodes.has('Node2')).toBe(true); 95 | }); 96 | }); 97 | 98 | describe('Invalid Syntax Handling', () => { 99 | it('should detect unclosed expressions', () => { 100 | const expressions = [ 101 | '{{ $json.field', 102 | '$json.field }}', 103 | '{{ $json.field }', 104 | '{ $json.field }}' 105 | ]; 106 | 107 | const context = { availableNodes: [] }; 108 | 109 | expressions.forEach(expr => { 110 | const result = ExpressionValidator.validateExpression(expr, context); 111 | expect(result.errors.some(e => e.includes('Unmatched'))).toBe(true); 112 | }); 113 | }); 114 | 115 | it('should detect nested expressions', () => { 116 | const nestedExpression = '{{ $json.field + {{ $node["Node1"].json }} }}'; 117 | const context = { availableNodes: ['Node1'] }; 118 | 119 | const result = ExpressionValidator.validateExpression(nestedExpression, context); 120 | expect(result.errors.some(e => e.includes('Nested expressions'))).toBe(true); 121 | }); 122 | 123 | it('should detect empty expressions', () => { 124 | const emptyExpression = 'Value: {{}}'; 125 | const context = { availableNodes: [] }; 126 | 127 | const result = ExpressionValidator.validateExpression(emptyExpression, context); 128 | expect(result.errors.some(e => e.includes('Empty expression'))).toBe(true); 129 | }); 130 | 131 | it('should handle malformed node references', () => { 132 | const expressions = [ 133 | '{{ $node[].json }}', 134 | '{{ $node[""].json }}', 135 | '{{ $node[Node1].json }}', // Missing quotes 136 | '{{ $node["Node1" ].json }}' // Extra space - this might actually be valid 137 | ]; 138 | 139 | const context = { availableNodes: ['Node1'] }; 140 | 141 | expressions.forEach(expr => { 142 | const result = ExpressionValidator.validateExpression(expr, context); 143 | // Some of these might generate warnings or errors 144 | expect(result).toBeDefined(); 145 | }); 146 | }); 147 | }); 148 | 149 | describe('Special Characters and Unicode', () => { 150 | it('should handle special characters in node names', () => { 151 | const specialNodes = ['Node-123', 'Node_Test', 'Node@Special', 'Node 中文', 'Node😊']; 152 | const context = { availableNodes: specialNodes }; 153 | 154 | specialNodes.forEach(nodeName => { 155 | const expression = `{{ $node["${nodeName}"].json.value }}`; 156 | const result = ExpressionValidator.validateExpression(expression, context); 157 | expect(result.usedNodes.has(nodeName)).toBe(true); 158 | expect(result.errors.filter(e => e.includes(nodeName))).toHaveLength(0); 159 | }); 160 | }); 161 | 162 | it('should handle Unicode in property names', () => { 163 | const expression = '{{ $json.名前 + $json.שם + $json.имя }}'; 164 | const context = { availableNodes: [] }; 165 | 166 | const result = ExpressionValidator.validateExpression(expression, context); 167 | expect(result.usedVariables.has('$json')).toBe(true); 168 | }); 169 | }); 170 | 171 | describe('Context Validation', () => { 172 | it('should warn about $input when no input data available', () => { 173 | const expression = '{{ $input.item.data }}'; 174 | const context = { 175 | availableNodes: [], 176 | hasInputData: false 177 | }; 178 | 179 | const result = ExpressionValidator.validateExpression(expression, context); 180 | expect(result.warnings.some(w => w.includes('$input'))).toBe(true); 181 | }); 182 | 183 | it('should handle references to non-existent nodes', () => { 184 | const expression = '{{ $node["NonExistentNode"].json.value }}'; 185 | const context = { availableNodes: ['Node1', 'Node2'] }; 186 | 187 | const result = ExpressionValidator.validateExpression(expression, context); 188 | expect(result.errors.some(e => e.includes('NonExistentNode'))).toBe(true); 189 | }); 190 | 191 | it('should validate $items function references', () => { 192 | const expression = '{{ $items("NonExistentNode", 0)[0].json }}'; 193 | const context = { availableNodes: ['Node1', 'Node2'] }; 194 | 195 | const result = ExpressionValidator.validateExpression(expression, context); 196 | expect(result.errors.some(e => e.includes('NonExistentNode'))).toBe(true); 197 | }); 198 | }); 199 | 200 | describe('Complex Expression Patterns', () => { 201 | it('should handle JavaScript operations in expressions', () => { 202 | const expressions = [ 203 | '{{ $json.count > 10 ? "high" : "low" }}', 204 | '{{ Math.round($json.price * 1.2) }}', 205 | '{{ $json.items.filter(item => item.active).length }}', 206 | '{{ new Date($json.timestamp).toISOString() }}', 207 | '{{ $json.name.toLowerCase().replace(" ", "-") }}' 208 | ]; 209 | 210 | const context = { availableNodes: [] }; 211 | 212 | expressions.forEach(expr => { 213 | const result = ExpressionValidator.validateExpression(expr, context); 214 | expect(result.usedVariables.has('$json')).toBe(true); 215 | }); 216 | }); 217 | 218 | it('should handle array access patterns', () => { 219 | const expressions = [ 220 | '{{ $json[0] }}', 221 | '{{ $json.items[5].name }}', 222 | '{{ $node["Node1"].json[0].data[1] }}', 223 | '{{ $json["items"][0]["name"] }}' 224 | ]; 225 | 226 | const context = { availableNodes: ['Node1'] }; 227 | 228 | expressions.forEach(expr => { 229 | const result = ExpressionValidator.validateExpression(expr, context); 230 | expect(result.usedVariables.size).toBeGreaterThan(0); 231 | }); 232 | }); 233 | }); 234 | 235 | describe('validateNodeExpressions', () => { 236 | it('should validate all expressions in node parameters', () => { 237 | const parameters = { 238 | field1: '{{ $json.data }}', 239 | field2: 'static value', 240 | nested: { 241 | field3: '{{ $node["Node1"].json.value }}', 242 | array: [ 243 | '{{ $json.item1 }}', 244 | 'not an expression', 245 | '{{ $json.item2 }}' 246 | ] 247 | } 248 | }; 249 | 250 | const context = { availableNodes: ['Node1'] }; 251 | const result = ExpressionValidator.validateNodeExpressions(parameters, context); 252 | 253 | expect(result.usedVariables.has('$json')).toBe(true); 254 | expect(result.usedNodes.has('Node1')).toBe(true); 255 | expect(result.valid).toBe(true); 256 | }); 257 | 258 | it('should handle null/undefined in parameters', () => { 259 | const parameters = { 260 | field1: null, 261 | field2: undefined, 262 | field3: '', 263 | field4: '{{ $json.data }}' 264 | }; 265 | 266 | const context = { availableNodes: [] }; 267 | const result = ExpressionValidator.validateNodeExpressions(parameters, context); 268 | 269 | expect(result.usedVariables.has('$json')).toBe(true); 270 | expect(result.errors.length).toBe(0); 271 | }); 272 | 273 | it('should handle circular references in parameters', () => { 274 | const parameters: any = { 275 | field1: '{{ $json.data }}' 276 | }; 277 | parameters.circular = parameters; 278 | 279 | const context = { availableNodes: [] }; 280 | // Should not throw 281 | expect(() => { 282 | ExpressionValidator.validateNodeExpressions(parameters, context); 283 | }).not.toThrow(); 284 | }); 285 | 286 | it('should aggregate errors from multiple expressions', () => { 287 | const parameters = { 288 | field1: '{{ $node["Missing1"].json }}', 289 | field2: '{{ $node["Missing2"].json }}', 290 | field3: '{{ }}', // Empty expression 291 | field4: '{{ $json.valid }}' 292 | }; 293 | 294 | const context = { availableNodes: ['ValidNode'] }; 295 | const result = ExpressionValidator.validateNodeExpressions(parameters, context); 296 | 297 | expect(result.valid).toBe(false); 298 | // Should have at least 3 errors: 2 missing nodes + 1 empty expression 299 | expect(result.errors.length).toBeGreaterThanOrEqual(3); 300 | expect(result.usedVariables.has('$json')).toBe(true); 301 | }); 302 | }); 303 | 304 | describe('Performance Edge Cases', () => { 305 | it('should handle recursive parameter structures efficiently', () => { 306 | const createNestedObject = (depth: number): any => { 307 | if (depth === 0) return '{{ $json.value }}'; 308 | return { 309 | level: depth, 310 | expression: `{{ $json.level${depth} }}`, 311 | nested: createNestedObject(depth - 1) 312 | }; 313 | }; 314 | 315 | const deepParameters = createNestedObject(100); 316 | const context = { availableNodes: [] }; 317 | 318 | const start = Date.now(); 319 | const result = ExpressionValidator.validateNodeExpressions(deepParameters, context); 320 | const duration = Date.now() - start; 321 | 322 | expect(result).toBeDefined(); 323 | expect(duration).toBeLessThan(1000); // Should complete within 1 second 324 | }); 325 | 326 | it('should handle large arrays of expressions', () => { 327 | const parameters = { 328 | items: Array(1000).fill(null).map((_, i) => `{{ $json.item${i} }}`) 329 | }; 330 | 331 | const context = { availableNodes: [] }; 332 | const result = ExpressionValidator.validateNodeExpressions(parameters, context); 333 | 334 | expect(result.usedVariables.has('$json')).toBe(true); 335 | expect(result.valid).toBe(true); 336 | }); 337 | }); 338 | 339 | describe('Error Message Quality', () => { 340 | it('should provide helpful error messages', () => { 341 | const testCases = [ 342 | { 343 | expression: '{{ $node["Node With Spaces"].json }}', 344 | context: { availableNodes: ['NodeWithSpaces'] }, 345 | expectedError: 'Node With Spaces' 346 | }, 347 | { 348 | expression: '{{ $items("WrongNode", -1) }}', 349 | context: { availableNodes: ['RightNode'] }, 350 | expectedError: 'WrongNode' 351 | } 352 | ]; 353 | 354 | testCases.forEach(({ expression, context, expectedError }) => { 355 | const result = ExpressionValidator.validateExpression(expression, context); 356 | const hasRelevantError = result.errors.some(e => e.includes(expectedError)); 357 | expect(hasRelevantError).toBe(true); 358 | }); 359 | }); 360 | }); 361 | }); ``` -------------------------------------------------------------------------------- /tests/unit/validation-fixes.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Test suite for validation system fixes 3 | * Covers issues #58, #68, #70, #73 4 | */ 5 | 6 | import { describe, test, expect, beforeAll, afterAll } from 'vitest'; 7 | import { WorkflowValidator } from '../../src/services/workflow-validator'; 8 | import { EnhancedConfigValidator } from '../../src/services/enhanced-config-validator'; 9 | import { ToolValidation, Validator, ValidationError } from '../../src/utils/validation-schemas'; 10 | 11 | describe('Validation System Fixes', () => { 12 | let workflowValidator: WorkflowValidator; 13 | let mockNodeRepository: any; 14 | 15 | beforeAll(async () => { 16 | // Initialize test environment 17 | process.env.NODE_ENV = 'test'; 18 | 19 | // Mock repository for testing 20 | mockNodeRepository = { 21 | getNode: (nodeType: string) => { 22 | if (nodeType === 'nodes-base.webhook' || nodeType === 'n8n-nodes-base.webhook') { 23 | return { 24 | nodeType: 'nodes-base.webhook', 25 | displayName: 'Webhook', 26 | properties: [ 27 | { name: 'path', required: true, displayName: 'Path' }, 28 | { name: 'httpMethod', required: true, displayName: 'HTTP Method' } 29 | ] 30 | }; 31 | } 32 | if (nodeType === 'nodes-base.set' || nodeType === 'n8n-nodes-base.set') { 33 | return { 34 | nodeType: 'nodes-base.set', 35 | displayName: 'Set', 36 | properties: [ 37 | { name: 'values', required: false, displayName: 'Values' } 38 | ] 39 | }; 40 | } 41 | return null; 42 | } 43 | } as any; 44 | 45 | workflowValidator = new WorkflowValidator(mockNodeRepository, EnhancedConfigValidator); 46 | }); 47 | 48 | afterAll(() => { 49 | // Reset NODE_ENV instead of deleting it 50 | delete (process.env as any).NODE_ENV; 51 | }); 52 | 53 | describe('Issue #73: validate_node_minimal crashes without input validation', () => { 54 | test('should handle empty config in validation schemas', () => { 55 | // Test the validation schema handles empty config 56 | const result = ToolValidation.validateNodeMinimal({ 57 | nodeType: 'nodes-base.webhook', 58 | config: undefined 59 | }); 60 | 61 | expect(result).toBeDefined(); 62 | expect(result.valid).toBe(false); 63 | expect(result.errors.length).toBeGreaterThan(0); 64 | expect(result.errors[0].field).toBe('config'); 65 | }); 66 | 67 | test('should handle null config in validation schemas', () => { 68 | const result = ToolValidation.validateNodeMinimal({ 69 | nodeType: 'nodes-base.webhook', 70 | config: null 71 | }); 72 | 73 | expect(result).toBeDefined(); 74 | expect(result.valid).toBe(false); 75 | expect(result.errors.length).toBeGreaterThan(0); 76 | expect(result.errors[0].field).toBe('config'); 77 | }); 78 | 79 | test('should accept valid config object', () => { 80 | const result = ToolValidation.validateNodeMinimal({ 81 | nodeType: 'nodes-base.webhook', 82 | config: { path: '/webhook', httpMethod: 'POST' } 83 | }); 84 | 85 | expect(result).toBeDefined(); 86 | expect(result.valid).toBe(true); 87 | expect(result.errors).toHaveLength(0); 88 | }); 89 | }); 90 | 91 | describe('Issue #58: validate_node_operation crashes on nested input', () => { 92 | test('should handle invalid nodeType gracefully', () => { 93 | expect(() => { 94 | EnhancedConfigValidator.validateWithMode( 95 | undefined as any, 96 | { resource: 'channel', operation: 'create' }, 97 | [], 98 | 'operation', 99 | 'ai-friendly' 100 | ); 101 | }).toThrow(Error); 102 | }); 103 | 104 | test('should handle null nodeType gracefully', () => { 105 | expect(() => { 106 | EnhancedConfigValidator.validateWithMode( 107 | null as any, 108 | { resource: 'channel', operation: 'create' }, 109 | [], 110 | 'operation', 111 | 'ai-friendly' 112 | ); 113 | }).toThrow(Error); 114 | }); 115 | 116 | test('should handle non-string nodeType gracefully', () => { 117 | expect(() => { 118 | EnhancedConfigValidator.validateWithMode( 119 | { type: 'nodes-base.slack' } as any, 120 | { resource: 'channel', operation: 'create' }, 121 | [], 122 | 'operation', 123 | 'ai-friendly' 124 | ); 125 | }).toThrow(Error); 126 | }); 127 | 128 | test('should handle valid nodeType properly', () => { 129 | const result = EnhancedConfigValidator.validateWithMode( 130 | 'nodes-base.set', 131 | { values: {} }, 132 | [], 133 | 'operation', 134 | 'ai-friendly' 135 | ); 136 | 137 | expect(result).toBeDefined(); 138 | expect(typeof result.valid).toBe('boolean'); 139 | }); 140 | }); 141 | 142 | describe('Issue #70: Profile settings not respected', () => { 143 | test('should pass profile parameter to all validation phases', async () => { 144 | const workflow = { 145 | nodes: [ 146 | { 147 | id: '1', 148 | name: 'Webhook', 149 | type: 'n8n-nodes-base.webhook', 150 | position: [100, 200] as [number, number], 151 | parameters: { path: '/test', httpMethod: 'POST' }, 152 | typeVersion: 1 153 | }, 154 | { 155 | id: '2', 156 | name: 'Set', 157 | type: 'n8n-nodes-base.set', 158 | position: [300, 200] as [number, number], 159 | parameters: { values: {} }, 160 | typeVersion: 1 161 | } 162 | ], 163 | connections: { 164 | 'Webhook': { 165 | main: [[{ node: 'Set', type: 'main', index: 0 }]] 166 | } 167 | } 168 | }; 169 | 170 | const result = await workflowValidator.validateWorkflow(workflow, { 171 | validateNodes: true, 172 | validateConnections: true, 173 | validateExpressions: true, 174 | profile: 'minimal' 175 | }); 176 | 177 | expect(result).toBeDefined(); 178 | expect(result.valid).toBe(true); 179 | // In minimal profile, should have fewer warnings/errors - just check it's reasonable 180 | expect(result.warnings.length).toBeLessThanOrEqual(5); 181 | }); 182 | 183 | test('should filter out sticky notes from validation', async () => { 184 | const workflow = { 185 | nodes: [ 186 | { 187 | id: '1', 188 | name: 'Webhook', 189 | type: 'n8n-nodes-base.webhook', 190 | position: [100, 200] as [number, number], 191 | parameters: { path: '/test', httpMethod: 'POST' }, 192 | typeVersion: 1 193 | }, 194 | { 195 | id: '2', 196 | name: 'Sticky Note', 197 | type: 'n8n-nodes-base.stickyNote', 198 | position: [300, 100] as [number, number], 199 | parameters: { content: 'This is a note' }, 200 | typeVersion: 1 201 | } 202 | ], 203 | connections: {} 204 | }; 205 | 206 | const result = await workflowValidator.validateWorkflow(workflow); 207 | 208 | expect(result).toBeDefined(); 209 | expect(result.statistics.totalNodes).toBe(1); // Only webhook, sticky note excluded 210 | expect(result.statistics.enabledNodes).toBe(1); 211 | }); 212 | 213 | test('should allow legitimate loops in cycle detection', async () => { 214 | const workflow = { 215 | nodes: [ 216 | { 217 | id: '1', 218 | name: 'Manual Trigger', 219 | type: 'n8n-nodes-base.manualTrigger', 220 | position: [100, 200] as [number, number], 221 | parameters: {}, 222 | typeVersion: 1 223 | }, 224 | { 225 | id: '2', 226 | name: 'SplitInBatches', 227 | type: 'n8n-nodes-base.splitInBatches', 228 | position: [300, 200] as [number, number], 229 | parameters: { batchSize: 1 }, 230 | typeVersion: 1 231 | }, 232 | { 233 | id: '3', 234 | name: 'Set', 235 | type: 'n8n-nodes-base.set', 236 | position: [500, 200] as [number, number], 237 | parameters: { values: {} }, 238 | typeVersion: 1 239 | } 240 | ], 241 | connections: { 242 | 'Manual Trigger': { 243 | main: [[{ node: 'SplitInBatches', type: 'main', index: 0 }]] 244 | }, 245 | 'SplitInBatches': { 246 | main: [ 247 | [{ node: 'Set', type: 'main', index: 0 }], // Done output 248 | [{ node: 'Set', type: 'main', index: 0 }] // Loop output 249 | ] 250 | }, 251 | 'Set': { 252 | main: [[{ node: 'SplitInBatches', type: 'main', index: 0 }]] // Loop back 253 | } 254 | } 255 | }; 256 | 257 | const result = await workflowValidator.validateWorkflow(workflow); 258 | 259 | expect(result).toBeDefined(); 260 | // Should not report cycle error for legitimate SplitInBatches loop 261 | const cycleErrors = result.errors.filter(e => e.message.includes('cycle')); 262 | expect(cycleErrors).toHaveLength(0); 263 | }); 264 | }); 265 | 266 | describe('Issue #68: Better error recovery suggestions', () => { 267 | test('should provide recovery suggestions for invalid node types', async () => { 268 | const workflow = { 269 | nodes: [ 270 | { 271 | id: '1', 272 | name: 'Invalid Node', 273 | type: 'invalid-node-type', 274 | position: [100, 200] as [number, number], 275 | parameters: {}, 276 | typeVersion: 1 277 | } 278 | ], 279 | connections: {} 280 | }; 281 | 282 | const result = await workflowValidator.validateWorkflow(workflow); 283 | 284 | expect(result).toBeDefined(); 285 | expect(result.valid).toBe(false); 286 | expect(result.suggestions.length).toBeGreaterThan(0); 287 | 288 | // Should contain recovery suggestions 289 | const recoveryStarted = result.suggestions.some(s => s.includes('🔧 RECOVERY')); 290 | expect(recoveryStarted).toBe(true); 291 | }); 292 | 293 | test('should provide recovery suggestions for connection errors', async () => { 294 | const workflow = { 295 | nodes: [ 296 | { 297 | id: '1', 298 | name: 'Webhook', 299 | type: 'n8n-nodes-base.webhook', 300 | position: [100, 200] as [number, number], 301 | parameters: { path: '/test', httpMethod: 'POST' }, 302 | typeVersion: 1 303 | } 304 | ], 305 | connections: { 306 | 'Webhook': { 307 | main: [[{ node: 'NonExistentNode', type: 'main', index: 0 }]] 308 | } 309 | } 310 | }; 311 | 312 | const result = await workflowValidator.validateWorkflow(workflow); 313 | 314 | expect(result).toBeDefined(); 315 | expect(result.valid).toBe(false); 316 | expect(result.suggestions.length).toBeGreaterThan(0); 317 | 318 | // Should contain connection recovery suggestions 319 | const connectionRecovery = result.suggestions.some(s => 320 | s.includes('Connection errors detected') || s.includes('connection') 321 | ); 322 | expect(connectionRecovery).toBe(true); 323 | }); 324 | 325 | test('should provide workflow for multiple errors', async () => { 326 | const workflow = { 327 | nodes: [ 328 | { 329 | id: '1', 330 | name: 'Invalid Node 1', 331 | type: 'invalid-type-1', 332 | position: [100, 200] as [number, number], 333 | parameters: {} 334 | // Missing typeVersion 335 | }, 336 | { 337 | id: '2', 338 | name: 'Invalid Node 2', 339 | type: 'invalid-type-2', 340 | position: [300, 200] as [number, number], 341 | parameters: {} 342 | // Missing typeVersion 343 | }, 344 | { 345 | id: '3', 346 | name: 'Invalid Node 3', 347 | type: 'invalid-type-3', 348 | position: [500, 200] as [number, number], 349 | parameters: {} 350 | // Missing typeVersion 351 | } 352 | ], 353 | connections: { 354 | 'Invalid Node 1': { 355 | main: [[{ node: 'NonExistent', type: 'main', index: 0 }]] 356 | } 357 | } 358 | }; 359 | 360 | const result = await workflowValidator.validateWorkflow(workflow); 361 | 362 | expect(result).toBeDefined(); 363 | expect(result.valid).toBe(false); 364 | expect(result.errors.length).toBeGreaterThan(3); 365 | 366 | // Should provide step-by-step recovery workflow 367 | const workflowSuggestion = result.suggestions.some(s => 368 | s.includes('SUGGESTED WORKFLOW') && s.includes('Too many errors detected') 369 | ); 370 | expect(workflowSuggestion).toBe(true); 371 | }); 372 | }); 373 | 374 | describe('Enhanced Input Validation', () => { 375 | test('should validate tool parameters with schemas', () => { 376 | // Test validate_node_operation parameters 377 | const validationResult = ToolValidation.validateNodeOperation({ 378 | nodeType: 'nodes-base.webhook', 379 | config: { path: '/test' }, 380 | profile: 'ai-friendly' 381 | }); 382 | 383 | expect(validationResult.valid).toBe(true); 384 | expect(validationResult.errors).toHaveLength(0); 385 | }); 386 | 387 | test('should reject invalid parameters', () => { 388 | const validationResult = ToolValidation.validateNodeOperation({ 389 | nodeType: 123, // Invalid type 390 | config: 'not an object', // Invalid type 391 | profile: 'invalid-profile' // Invalid enum value 392 | }); 393 | 394 | expect(validationResult.valid).toBe(false); 395 | expect(validationResult.errors.length).toBeGreaterThan(0); 396 | }); 397 | 398 | test('should format validation errors properly', () => { 399 | const validationResult = ToolValidation.validateNodeOperation({ 400 | nodeType: null, 401 | config: null 402 | }); 403 | 404 | const errorMessage = Validator.formatErrors(validationResult, 'validate_node_operation'); 405 | 406 | expect(errorMessage).toContain('validate_node_operation: Validation failed:'); 407 | expect(errorMessage).toContain('nodeType'); 408 | expect(errorMessage).toContain('config'); 409 | }); 410 | }); 411 | }); ``` -------------------------------------------------------------------------------- /tests/unit/services/config-validator-edge-cases.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { ConfigValidator } from '@/services/config-validator'; 3 | import type { ValidationResult, ValidationError, ValidationWarning } from '@/services/config-validator'; 4 | 5 | // Mock the database 6 | vi.mock('better-sqlite3'); 7 | 8 | describe('ConfigValidator - Edge Cases', () => { 9 | beforeEach(() => { 10 | vi.clearAllMocks(); 11 | }); 12 | 13 | describe('Null and Undefined Handling', () => { 14 | it('should handle null config gracefully', () => { 15 | const nodeType = 'nodes-base.test'; 16 | const config = null as any; 17 | const properties: any[] = []; 18 | 19 | expect(() => { 20 | ConfigValidator.validate(nodeType, config, properties); 21 | }).toThrow(TypeError); 22 | }); 23 | 24 | it('should handle undefined config gracefully', () => { 25 | const nodeType = 'nodes-base.test'; 26 | const config = undefined as any; 27 | const properties: any[] = []; 28 | 29 | expect(() => { 30 | ConfigValidator.validate(nodeType, config, properties); 31 | }).toThrow(TypeError); 32 | }); 33 | 34 | it('should handle null properties array gracefully', () => { 35 | const nodeType = 'nodes-base.test'; 36 | const config = {}; 37 | const properties = null as any; 38 | 39 | expect(() => { 40 | ConfigValidator.validate(nodeType, config, properties); 41 | }).toThrow(TypeError); 42 | }); 43 | 44 | it('should handle undefined properties array gracefully', () => { 45 | const nodeType = 'nodes-base.test'; 46 | const config = {}; 47 | const properties = undefined as any; 48 | 49 | expect(() => { 50 | ConfigValidator.validate(nodeType, config, properties); 51 | }).toThrow(TypeError); 52 | }); 53 | 54 | it('should handle properties with null values in config', () => { 55 | const nodeType = 'nodes-base.test'; 56 | const config = { 57 | nullField: null, 58 | undefinedField: undefined, 59 | validField: 'value' 60 | }; 61 | const properties = [ 62 | { name: 'nullField', type: 'string', required: true }, 63 | { name: 'undefinedField', type: 'string', required: true }, 64 | { name: 'validField', type: 'string' } 65 | ]; 66 | 67 | const result = ConfigValidator.validate(nodeType, config, properties); 68 | 69 | // Check that we have errors for both null and undefined required fields 70 | expect(result.errors.some(e => e.property === 'nullField')).toBe(true); 71 | expect(result.errors.some(e => e.property === 'undefinedField')).toBe(true); 72 | 73 | // The actual error types might vary, so let's just ensure we caught the errors 74 | const nullFieldError = result.errors.find(e => e.property === 'nullField'); 75 | const undefinedFieldError = result.errors.find(e => e.property === 'undefinedField'); 76 | 77 | expect(nullFieldError).toBeDefined(); 78 | expect(undefinedFieldError).toBeDefined(); 79 | }); 80 | }); 81 | 82 | describe('Boundary Value Testing', () => { 83 | it('should handle empty arrays', () => { 84 | const nodeType = 'nodes-base.test'; 85 | const config = { 86 | arrayField: [] 87 | }; 88 | const properties = [ 89 | { name: 'arrayField', type: 'collection' } 90 | ]; 91 | 92 | const result = ConfigValidator.validate(nodeType, config, properties); 93 | 94 | expect(result.valid).toBe(true); 95 | }); 96 | 97 | it('should handle very large property arrays', () => { 98 | const nodeType = 'nodes-base.test'; 99 | const config = { field1: 'value1' }; 100 | const properties = Array(1000).fill(null).map((_, i) => ({ 101 | name: `field${i}`, 102 | type: 'string' 103 | })); 104 | 105 | const result = ConfigValidator.validate(nodeType, config, properties); 106 | 107 | expect(result.valid).toBe(true); 108 | }); 109 | 110 | it('should handle deeply nested displayOptions', () => { 111 | const nodeType = 'nodes-base.test'; 112 | const config = { 113 | level1: 'a', 114 | level2: 'b', 115 | level3: 'c', 116 | deepField: 'value' 117 | }; 118 | const properties = [ 119 | { name: 'level1', type: 'options', options: ['a', 'b'] }, 120 | { name: 'level2', type: 'options', options: ['a', 'b'], displayOptions: { show: { level1: ['a'] } } }, 121 | { name: 'level3', type: 'options', options: ['a', 'b', 'c'], displayOptions: { show: { level1: ['a'], level2: ['b'] } } }, 122 | { name: 'deepField', type: 'string', displayOptions: { show: { level1: ['a'], level2: ['b'], level3: ['c'] } } } 123 | ]; 124 | 125 | const result = ConfigValidator.validate(nodeType, config, properties); 126 | 127 | expect(result.visibleProperties).toContain('deepField'); 128 | }); 129 | 130 | it('should handle extremely long string values', () => { 131 | const nodeType = 'nodes-base.test'; 132 | const longString = 'a'.repeat(10000); 133 | const config = { 134 | longField: longString 135 | }; 136 | const properties = [ 137 | { name: 'longField', type: 'string' } 138 | ]; 139 | 140 | const result = ConfigValidator.validate(nodeType, config, properties); 141 | 142 | expect(result.valid).toBe(true); 143 | }); 144 | }); 145 | 146 | describe('Invalid Data Type Handling', () => { 147 | it('should handle NaN values', () => { 148 | const nodeType = 'nodes-base.test'; 149 | const config = { 150 | numberField: NaN 151 | }; 152 | const properties = [ 153 | { name: 'numberField', type: 'number' } 154 | ]; 155 | 156 | const result = ConfigValidator.validate(nodeType, config, properties); 157 | 158 | // NaN is technically type 'number' in JavaScript, so type validation passes 159 | // The validator might not have specific NaN checking, so we check for warnings 160 | // or just verify it doesn't crash 161 | expect(result).toBeDefined(); 162 | expect(() => result).not.toThrow(); 163 | }); 164 | 165 | it('should handle Infinity values', () => { 166 | const nodeType = 'nodes-base.test'; 167 | const config = { 168 | numberField: Infinity 169 | }; 170 | const properties = [ 171 | { name: 'numberField', type: 'number' } 172 | ]; 173 | 174 | const result = ConfigValidator.validate(nodeType, config, properties); 175 | 176 | // Infinity is technically a valid number in JavaScript 177 | // The validator might not flag it as an error, so just verify it handles it 178 | expect(result).toBeDefined(); 179 | expect(() => result).not.toThrow(); 180 | }); 181 | 182 | it('should handle objects when expecting primitives', () => { 183 | const nodeType = 'nodes-base.test'; 184 | const config = { 185 | stringField: { nested: 'object' }, 186 | numberField: { value: 123 } 187 | }; 188 | const properties = [ 189 | { name: 'stringField', type: 'string' }, 190 | { name: 'numberField', type: 'number' } 191 | ]; 192 | 193 | const result = ConfigValidator.validate(nodeType, config, properties); 194 | 195 | expect(result.errors).toHaveLength(2); 196 | expect(result.errors.every(e => e.type === 'invalid_type')).toBe(true); 197 | }); 198 | 199 | it('should handle circular references in config', () => { 200 | const nodeType = 'nodes-base.test'; 201 | const config: any = { field: 'value' }; 202 | config.circular = config; // Create circular reference 203 | const properties = [ 204 | { name: 'field', type: 'string' }, 205 | { name: 'circular', type: 'json' } 206 | ]; 207 | 208 | // Should not throw error 209 | const result = ConfigValidator.validate(nodeType, config, properties); 210 | 211 | expect(result).toBeDefined(); 212 | }); 213 | }); 214 | 215 | describe('Performance Boundaries', () => { 216 | it('should validate large config objects within reasonable time', () => { 217 | const nodeType = 'nodes-base.test'; 218 | const config: Record<string, any> = {}; 219 | const properties: any[] = []; 220 | 221 | // Create a large config with 1000 properties 222 | for (let i = 0; i < 1000; i++) { 223 | config[`field_${i}`] = `value_${i}`; 224 | properties.push({ 225 | name: `field_${i}`, 226 | type: 'string' 227 | }); 228 | } 229 | 230 | const startTime = Date.now(); 231 | const result = ConfigValidator.validate(nodeType, config, properties); 232 | const endTime = Date.now(); 233 | 234 | expect(result.valid).toBe(true); 235 | expect(endTime - startTime).toBeLessThan(1000); // Should complete within 1 second 236 | }); 237 | }); 238 | 239 | describe('Special Characters and Encoding', () => { 240 | it('should handle special characters in property values', () => { 241 | const nodeType = 'nodes-base.test'; 242 | const config = { 243 | specialField: 'Value with special chars: <>&"\'`\n\r\t' 244 | }; 245 | const properties = [ 246 | { name: 'specialField', type: 'string' } 247 | ]; 248 | 249 | const result = ConfigValidator.validate(nodeType, config, properties); 250 | 251 | expect(result.valid).toBe(true); 252 | }); 253 | 254 | it('should handle unicode characters', () => { 255 | const nodeType = 'nodes-base.test'; 256 | const config = { 257 | unicodeField: '🚀 Unicode: 你好世界 مرحبا بالعالم' 258 | }; 259 | const properties = [ 260 | { name: 'unicodeField', type: 'string' } 261 | ]; 262 | 263 | const result = ConfigValidator.validate(nodeType, config, properties); 264 | 265 | expect(result.valid).toBe(true); 266 | }); 267 | }); 268 | 269 | describe('Complex Validation Scenarios', () => { 270 | it('should handle conflicting displayOptions conditions', () => { 271 | const nodeType = 'nodes-base.test'; 272 | const config = { 273 | mode: 'both', 274 | showField: true, 275 | conflictField: 'value' 276 | }; 277 | const properties = [ 278 | { name: 'mode', type: 'options', options: ['show', 'hide', 'both'] }, 279 | { name: 'showField', type: 'boolean' }, 280 | { 281 | name: 'conflictField', 282 | type: 'string', 283 | displayOptions: { 284 | show: { mode: ['show'], showField: [true] }, 285 | hide: { mode: ['hide'] } 286 | } 287 | } 288 | ]; 289 | 290 | const result = ConfigValidator.validate(nodeType, config, properties); 291 | 292 | // With mode='both', the field visibility depends on implementation 293 | expect(result).toBeDefined(); 294 | }); 295 | 296 | it('should handle multiple validation profiles correctly', () => { 297 | const nodeType = 'nodes-base.code'; 298 | const config = { 299 | language: 'javascript', 300 | jsCode: 'const x = 1;' 301 | }; 302 | const properties = [ 303 | { name: 'language', type: 'options' }, 304 | { name: 'jsCode', type: 'string' } 305 | ]; 306 | 307 | // Should perform node-specific validation for Code nodes 308 | const result = ConfigValidator.validate(nodeType, config, properties); 309 | 310 | expect(result.warnings.some(w => 311 | w.message.includes('No return statement found') 312 | )).toBe(true); 313 | }); 314 | }); 315 | 316 | describe('Error Recovery and Resilience', () => { 317 | it('should continue validation after encountering errors', () => { 318 | const nodeType = 'nodes-base.test'; 319 | const config = { 320 | field1: 'invalid-for-number', 321 | field2: null, // Required field missing 322 | field3: 'valid' 323 | }; 324 | const properties = [ 325 | { name: 'field1', type: 'number' }, 326 | { name: 'field2', type: 'string', required: true }, 327 | { name: 'field3', type: 'string' } 328 | ]; 329 | 330 | const result = ConfigValidator.validate(nodeType, config, properties); 331 | 332 | // Should have errors for field1 and field2, but field3 should be validated 333 | expect(result.errors.length).toBeGreaterThanOrEqual(2); 334 | 335 | // Check that we have errors for field1 (type error) and field2 (required field) 336 | const field1Error = result.errors.find(e => e.property === 'field1'); 337 | const field2Error = result.errors.find(e => e.property === 'field2'); 338 | 339 | expect(field1Error).toBeDefined(); 340 | expect(field1Error?.type).toBe('invalid_type'); 341 | 342 | expect(field2Error).toBeDefined(); 343 | // field2 is null, which might be treated as invalid_type rather than missing_required 344 | expect(['missing_required', 'invalid_type']).toContain(field2Error?.type); 345 | 346 | expect(result.visibleProperties).toContain('field3'); 347 | }); 348 | 349 | it('should handle malformed property definitions gracefully', () => { 350 | const nodeType = 'nodes-base.test'; 351 | const config = { field: 'value' }; 352 | const properties = [ 353 | { name: 'field', type: 'string' }, 354 | { /* Malformed property without name */ type: 'string' } as any, 355 | { name: 'field2', /* Missing type */ } as any 356 | ]; 357 | 358 | // Should handle malformed properties without crashing 359 | // Note: null properties will cause errors in the current implementation 360 | const result = ConfigValidator.validate(nodeType, config, properties); 361 | expect(result).toBeDefined(); 362 | expect(result.valid).toBeDefined(); 363 | }); 364 | }); 365 | 366 | describe('validateBatch method implementation', () => { 367 | it('should validate multiple configs in batch if method exists', () => { 368 | // This test is for future implementation 369 | const configs = [ 370 | { nodeType: 'nodes-base.test', config: { field: 'value1' }, properties: [] }, 371 | { nodeType: 'nodes-base.test', config: { field: 'value2' }, properties: [] } 372 | ]; 373 | 374 | // If validateBatch method is implemented in the future 375 | if ('validateBatch' in ConfigValidator) { 376 | const results = (ConfigValidator as any).validateBatch(configs); 377 | expect(results).toHaveLength(2); 378 | } else { 379 | // For now, just validate individually 380 | const results = configs.map(c => 381 | ConfigValidator.validate(c.nodeType, c.config, c.properties) 382 | ); 383 | expect(results).toHaveLength(2); 384 | } 385 | }); 386 | }); 387 | }); ``` -------------------------------------------------------------------------------- /src/templates/template-service.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { DatabaseAdapter } from '../database/database-adapter'; 2 | import { TemplateRepository, StoredTemplate } from './template-repository'; 3 | import { logger } from '../utils/logger'; 4 | 5 | export interface TemplateInfo { 6 | id: number; 7 | name: string; 8 | description: string; 9 | author: { 10 | name: string; 11 | username: string; 12 | verified: boolean; 13 | }; 14 | nodes: string[]; 15 | views: number; 16 | created: string; 17 | url: string; 18 | metadata?: { 19 | categories: string[]; 20 | complexity: 'simple' | 'medium' | 'complex'; 21 | use_cases: string[]; 22 | estimated_setup_minutes: number; 23 | required_services: string[]; 24 | key_features: string[]; 25 | target_audience: string[]; 26 | }; 27 | } 28 | 29 | export interface TemplateWithWorkflow extends TemplateInfo { 30 | workflow: any; 31 | } 32 | 33 | export interface PaginatedResponse<T> { 34 | items: T[]; 35 | total: number; 36 | limit: number; 37 | offset: number; 38 | hasMore: boolean; 39 | } 40 | 41 | export interface TemplateMinimal { 42 | id: number; 43 | name: string; 44 | description: string; 45 | views: number; 46 | nodeCount: number; 47 | metadata?: { 48 | categories: string[]; 49 | complexity: 'simple' | 'medium' | 'complex'; 50 | use_cases: string[]; 51 | estimated_setup_minutes: number; 52 | required_services: string[]; 53 | key_features: string[]; 54 | target_audience: string[]; 55 | }; 56 | } 57 | 58 | export type TemplateField = 'id' | 'name' | 'description' | 'author' | 'nodes' | 'views' | 'created' | 'url' | 'metadata'; 59 | export type PartialTemplateInfo = Partial<TemplateInfo>; 60 | 61 | export class TemplateService { 62 | private repository: TemplateRepository; 63 | 64 | constructor(db: DatabaseAdapter) { 65 | this.repository = new TemplateRepository(db); 66 | } 67 | 68 | /** 69 | * List templates that use specific node types 70 | */ 71 | async listNodeTemplates(nodeTypes: string[], limit: number = 10, offset: number = 0): Promise<PaginatedResponse<TemplateInfo>> { 72 | const templates = this.repository.getTemplatesByNodes(nodeTypes, limit, offset); 73 | const total = this.repository.getNodeTemplatesCount(nodeTypes); 74 | 75 | return { 76 | items: templates.map(this.formatTemplateInfo), 77 | total, 78 | limit, 79 | offset, 80 | hasMore: offset + limit < total 81 | }; 82 | } 83 | 84 | /** 85 | * Get a specific template with different detail levels 86 | */ 87 | async getTemplate(templateId: number, mode: 'nodes_only' | 'structure' | 'full' = 'full'): Promise<any> { 88 | const template = this.repository.getTemplate(templateId); 89 | if (!template) { 90 | return null; 91 | } 92 | 93 | const workflow = JSON.parse(template.workflow_json || '{}'); 94 | 95 | if (mode === 'nodes_only') { 96 | return { 97 | id: template.id, 98 | name: template.name, 99 | nodes: workflow.nodes?.map((n: any) => ({ 100 | type: n.type, 101 | name: n.name 102 | })) || [] 103 | }; 104 | } 105 | 106 | if (mode === 'structure') { 107 | return { 108 | id: template.id, 109 | name: template.name, 110 | nodes: workflow.nodes?.map((n: any) => ({ 111 | id: n.id, 112 | type: n.type, 113 | name: n.name, 114 | position: n.position 115 | })) || [], 116 | connections: workflow.connections || {} 117 | }; 118 | } 119 | 120 | // Full mode 121 | return { 122 | ...this.formatTemplateInfo(template), 123 | workflow 124 | }; 125 | } 126 | 127 | /** 128 | * Search templates by query 129 | */ 130 | async searchTemplates(query: string, limit: number = 20, offset: number = 0, fields?: string[]): Promise<PaginatedResponse<PartialTemplateInfo>> { 131 | const templates = this.repository.searchTemplates(query, limit, offset); 132 | const total = this.repository.getSearchCount(query); 133 | 134 | // If fields are specified, filter the template info 135 | const items = fields 136 | ? templates.map(t => this.formatTemplateWithFields(t, fields)) 137 | : templates.map(t => this.formatTemplateInfo(t)); 138 | 139 | return { 140 | items, 141 | total, 142 | limit, 143 | offset, 144 | hasMore: offset + limit < total 145 | }; 146 | } 147 | 148 | /** 149 | * Get templates for a specific task 150 | */ 151 | async getTemplatesForTask(task: string, limit: number = 10, offset: number = 0): Promise<PaginatedResponse<TemplateInfo>> { 152 | const templates = this.repository.getTemplatesForTask(task, limit, offset); 153 | const total = this.repository.getTaskTemplatesCount(task); 154 | 155 | return { 156 | items: templates.map(this.formatTemplateInfo), 157 | total, 158 | limit, 159 | offset, 160 | hasMore: offset + limit < total 161 | }; 162 | } 163 | 164 | /** 165 | * List all templates with minimal data 166 | */ 167 | async listTemplates(limit: number = 10, offset: number = 0, sortBy: 'views' | 'created_at' | 'name' = 'views', includeMetadata: boolean = false): Promise<PaginatedResponse<TemplateMinimal>> { 168 | const templates = this.repository.getAllTemplates(limit, offset, sortBy); 169 | const total = this.repository.getTemplateCount(); 170 | 171 | const items = templates.map(t => { 172 | const item: TemplateMinimal = { 173 | id: t.id, 174 | name: t.name, 175 | description: t.description, // Always include description 176 | views: t.views, 177 | nodeCount: JSON.parse(t.nodes_used).length 178 | }; 179 | 180 | // Optionally include metadata 181 | if (includeMetadata && t.metadata_json) { 182 | try { 183 | item.metadata = JSON.parse(t.metadata_json); 184 | } catch (error) { 185 | logger.warn(`Failed to parse metadata for template ${t.id}:`, error); 186 | } 187 | } 188 | 189 | return item; 190 | }); 191 | 192 | return { 193 | items, 194 | total, 195 | limit, 196 | offset, 197 | hasMore: offset + limit < total 198 | }; 199 | } 200 | 201 | /** 202 | * List available tasks 203 | */ 204 | listAvailableTasks(): string[] { 205 | return [ 206 | 'ai_automation', 207 | 'data_sync', 208 | 'webhook_processing', 209 | 'email_automation', 210 | 'slack_integration', 211 | 'data_transformation', 212 | 'file_processing', 213 | 'scheduling', 214 | 'api_integration', 215 | 'database_operations' 216 | ]; 217 | } 218 | 219 | /** 220 | * Search templates by metadata filters 221 | */ 222 | async searchTemplatesByMetadata( 223 | filters: { 224 | category?: string; 225 | complexity?: 'simple' | 'medium' | 'complex'; 226 | maxSetupMinutes?: number; 227 | minSetupMinutes?: number; 228 | requiredService?: string; 229 | targetAudience?: string; 230 | }, 231 | limit: number = 20, 232 | offset: number = 0 233 | ): Promise<PaginatedResponse<TemplateInfo>> { 234 | const templates = this.repository.searchTemplatesByMetadata(filters, limit, offset); 235 | const total = this.repository.getMetadataSearchCount(filters); 236 | 237 | return { 238 | items: templates.map(this.formatTemplateInfo.bind(this)), 239 | total, 240 | limit, 241 | offset, 242 | hasMore: offset + limit < total 243 | }; 244 | } 245 | 246 | /** 247 | * Get available categories from template metadata 248 | */ 249 | async getAvailableCategories(): Promise<string[]> { 250 | return this.repository.getAvailableCategories(); 251 | } 252 | 253 | /** 254 | * Get available target audiences from template metadata 255 | */ 256 | async getAvailableTargetAudiences(): Promise<string[]> { 257 | return this.repository.getAvailableTargetAudiences(); 258 | } 259 | 260 | /** 261 | * Get templates by category 262 | */ 263 | async getTemplatesByCategory( 264 | category: string, 265 | limit: number = 10, 266 | offset: number = 0 267 | ): Promise<PaginatedResponse<TemplateInfo>> { 268 | const templates = this.repository.getTemplatesByCategory(category, limit, offset); 269 | const total = this.repository.getMetadataSearchCount({ category }); 270 | 271 | return { 272 | items: templates.map(this.formatTemplateInfo.bind(this)), 273 | total, 274 | limit, 275 | offset, 276 | hasMore: offset + limit < total 277 | }; 278 | } 279 | 280 | /** 281 | * Get templates by complexity level 282 | */ 283 | async getTemplatesByComplexity( 284 | complexity: 'simple' | 'medium' | 'complex', 285 | limit: number = 10, 286 | offset: number = 0 287 | ): Promise<PaginatedResponse<TemplateInfo>> { 288 | const templates = this.repository.getTemplatesByComplexity(complexity, limit, offset); 289 | const total = this.repository.getMetadataSearchCount({ complexity }); 290 | 291 | return { 292 | items: templates.map(this.formatTemplateInfo.bind(this)), 293 | total, 294 | limit, 295 | offset, 296 | hasMore: offset + limit < total 297 | }; 298 | } 299 | 300 | /** 301 | * Get template statistics 302 | */ 303 | async getTemplateStats(): Promise<Record<string, any>> { 304 | return this.repository.getTemplateStats(); 305 | } 306 | 307 | /** 308 | * Fetch and update templates from n8n.io 309 | * @param mode - 'rebuild' to clear and rebuild, 'update' to add only new templates 310 | */ 311 | async fetchAndUpdateTemplates( 312 | progressCallback?: (message: string, current: number, total: number) => void, 313 | mode: 'rebuild' | 'update' = 'rebuild' 314 | ): Promise<void> { 315 | try { 316 | // Dynamically import fetcher only when needed (requires axios) 317 | const { TemplateFetcher } = await import('./template-fetcher'); 318 | const fetcher = new TemplateFetcher(); 319 | 320 | // Get existing template IDs if in update mode 321 | let existingIds: Set<number> = new Set(); 322 | let sinceDate: Date | undefined; 323 | 324 | if (mode === 'update') { 325 | existingIds = this.repository.getExistingTemplateIds(); 326 | logger.info(`Update mode: Found ${existingIds.size} existing templates in database`); 327 | 328 | // Get most recent template date and fetch only templates from last 2 weeks 329 | const mostRecentDate = this.repository.getMostRecentTemplateDate(); 330 | if (mostRecentDate) { 331 | // Fetch templates from 2 weeks before the most recent template 332 | sinceDate = new Date(mostRecentDate); 333 | sinceDate.setDate(sinceDate.getDate() - 14); 334 | logger.info(`Update mode: Fetching templates since ${sinceDate.toISOString().split('T')[0]} (2 weeks before most recent)`); 335 | } else { 336 | // No templates yet, fetch from last 2 weeks 337 | sinceDate = new Date(); 338 | sinceDate.setDate(sinceDate.getDate() - 14); 339 | logger.info(`Update mode: No existing templates, fetching from last 2 weeks`); 340 | } 341 | } else { 342 | // Clear existing templates in rebuild mode 343 | this.repository.clearTemplates(); 344 | logger.info('Rebuild mode: Cleared existing templates'); 345 | } 346 | 347 | // Fetch template list 348 | logger.info(`Fetching template list from n8n.io (mode: ${mode})`); 349 | const templates = await fetcher.fetchTemplates((current, total) => { 350 | progressCallback?.('Fetching template list', current, total); 351 | }, sinceDate); 352 | 353 | logger.info(`Found ${templates.length} templates matching date criteria`); 354 | 355 | // Filter to only new templates if in update mode 356 | let templatesToFetch = templates; 357 | if (mode === 'update') { 358 | templatesToFetch = templates.filter(t => !existingIds.has(t.id)); 359 | logger.info(`Update mode: ${templatesToFetch.length} new templates to fetch (skipping ${templates.length - templatesToFetch.length} existing)`); 360 | 361 | if (templatesToFetch.length === 0) { 362 | logger.info('No new templates to fetch'); 363 | progressCallback?.('No new templates', 0, 0); 364 | return; 365 | } 366 | } 367 | 368 | // Fetch details for each template 369 | logger.info(`Fetching details for ${templatesToFetch.length} templates`); 370 | const details = await fetcher.fetchAllTemplateDetails(templatesToFetch, (current, total) => { 371 | progressCallback?.('Fetching template details', current, total); 372 | }); 373 | 374 | // Save to database 375 | logger.info('Saving templates to database'); 376 | let saved = 0; 377 | for (const template of templatesToFetch) { 378 | const detail = details.get(template.id); 379 | if (detail) { 380 | this.repository.saveTemplate(template, detail); 381 | saved++; 382 | } 383 | } 384 | 385 | logger.info(`Successfully saved ${saved} templates to database`); 386 | 387 | // Rebuild FTS5 index after bulk import 388 | if (saved > 0) { 389 | logger.info('Rebuilding FTS5 index for templates'); 390 | this.repository.rebuildTemplateFTS(); 391 | } 392 | 393 | progressCallback?.('Complete', saved, saved); 394 | } catch (error) { 395 | logger.error('Error fetching templates:', error); 396 | throw error; 397 | } 398 | } 399 | 400 | /** 401 | * Format stored template for API response 402 | */ 403 | private formatTemplateInfo(template: StoredTemplate): TemplateInfo { 404 | const info: TemplateInfo = { 405 | id: template.id, 406 | name: template.name, 407 | description: template.description, 408 | author: { 409 | name: template.author_name, 410 | username: template.author_username, 411 | verified: template.author_verified === 1 412 | }, 413 | nodes: JSON.parse(template.nodes_used), 414 | views: template.views, 415 | created: template.created_at, 416 | url: template.url 417 | }; 418 | 419 | // Include metadata if available 420 | if (template.metadata_json) { 421 | try { 422 | info.metadata = JSON.parse(template.metadata_json); 423 | } catch (error) { 424 | logger.warn(`Failed to parse metadata for template ${template.id}:`, error); 425 | } 426 | } 427 | 428 | return info; 429 | } 430 | 431 | /** 432 | * Format template with only specified fields 433 | */ 434 | private formatTemplateWithFields(template: StoredTemplate, fields: string[]): PartialTemplateInfo { 435 | const fullInfo = this.formatTemplateInfo(template); 436 | const result: PartialTemplateInfo = {}; 437 | 438 | // Only include requested fields 439 | for (const field of fields) { 440 | if (field in fullInfo) { 441 | (result as any)[field] = (fullInfo as any)[field]; 442 | } 443 | } 444 | 445 | return result; 446 | } 447 | } ``` -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- ```yaml 1 | name: Test Suite 2 | on: 3 | push: 4 | branches: [main, feat/comprehensive-testing-suite] 5 | paths-ignore: 6 | - '**.md' 7 | - '**.txt' 8 | - 'docs/**' 9 | - 'examples/**' 10 | - '.github/FUNDING.yml' 11 | - '.github/ISSUE_TEMPLATE/**' 12 | - '.github/pull_request_template.md' 13 | - '.gitignore' 14 | - 'LICENSE*' 15 | - 'ATTRIBUTION.md' 16 | - 'SECURITY.md' 17 | - 'CODE_OF_CONDUCT.md' 18 | pull_request: 19 | branches: [main] 20 | paths-ignore: 21 | - '**.md' 22 | - '**.txt' 23 | - 'docs/**' 24 | - 'examples/**' 25 | - '.github/FUNDING.yml' 26 | - '.github/ISSUE_TEMPLATE/**' 27 | - '.github/pull_request_template.md' 28 | - '.gitignore' 29 | - 'LICENSE*' 30 | - 'ATTRIBUTION.md' 31 | - 'SECURITY.md' 32 | - 'CODE_OF_CONDUCT.md' 33 | 34 | permissions: 35 | contents: read 36 | issues: write 37 | pull-requests: write 38 | checks: write 39 | 40 | jobs: 41 | test: 42 | runs-on: ubuntu-latest 43 | timeout-minutes: 10 # Add a 10-minute timeout to prevent hanging 44 | steps: 45 | - uses: actions/checkout@v4 46 | 47 | - uses: actions/setup-node@v4 48 | with: 49 | node-version: 20 50 | cache: 'npm' 51 | 52 | - name: Install dependencies 53 | run: npm ci 54 | 55 | # Verify test environment setup 56 | - name: Verify test environment 57 | run: | 58 | echo "Current directory: $(pwd)" 59 | echo "Checking for .env.test file:" 60 | ls -la .env.test || echo ".env.test not found!" 61 | echo "First few lines of .env.test:" 62 | head -5 .env.test || echo "Cannot read .env.test" 63 | 64 | # Run unit tests first (without MSW) 65 | - name: Run unit tests with coverage 66 | run: npm run test:unit -- --coverage --coverage.thresholds.lines=0 --coverage.thresholds.functions=0 --coverage.thresholds.branches=0 --coverage.thresholds.statements=0 --reporter=default --reporter=junit 67 | env: 68 | CI: true 69 | 70 | # Run integration tests separately (with MSW setup) 71 | - name: Run integration tests 72 | run: npm run test:integration -- --reporter=default --reporter=junit 73 | env: 74 | CI: true 75 | N8N_API_URL: ${{ secrets.N8N_API_URL }} 76 | N8N_API_KEY: ${{ secrets.N8N_API_KEY }} 77 | N8N_TEST_WEBHOOK_GET_URL: ${{ secrets.N8N_TEST_WEBHOOK_GET_URL }} 78 | N8N_TEST_WEBHOOK_POST_URL: ${{ secrets.N8N_TEST_WEBHOOK_POST_URL }} 79 | N8N_TEST_WEBHOOK_PUT_URL: ${{ secrets.N8N_TEST_WEBHOOK_PUT_URL }} 80 | N8N_TEST_WEBHOOK_DELETE_URL: ${{ secrets.N8N_TEST_WEBHOOK_DELETE_URL }} 81 | 82 | # Generate test summary 83 | - name: Generate test summary 84 | if: always() 85 | run: node scripts/generate-test-summary.js 86 | 87 | # Generate detailed reports 88 | - name: Generate detailed reports 89 | if: always() 90 | run: node scripts/generate-detailed-reports.js 91 | 92 | # Upload test results artifacts 93 | - name: Upload test results 94 | if: always() 95 | uses: actions/upload-artifact@v4 96 | with: 97 | name: test-results-${{ github.run_number }}-${{ github.run_attempt }} 98 | path: | 99 | test-results/ 100 | test-summary.md 101 | test-reports/ 102 | retention-days: 30 103 | if-no-files-found: warn 104 | 105 | # Upload coverage artifacts 106 | - name: Upload coverage reports 107 | if: always() 108 | uses: actions/upload-artifact@v4 109 | with: 110 | name: coverage-${{ github.run_number }}-${{ github.run_attempt }} 111 | path: | 112 | coverage/ 113 | retention-days: 30 114 | if-no-files-found: warn 115 | 116 | # Upload coverage to Codecov 117 | - name: Upload coverage to Codecov 118 | if: always() 119 | uses: codecov/codecov-action@v4 120 | with: 121 | token: ${{ secrets.CODECOV_TOKEN }} 122 | files: ./coverage/lcov.info 123 | flags: unittests 124 | name: codecov-umbrella 125 | fail_ci_if_error: false 126 | verbose: true 127 | 128 | # Run linting 129 | - name: Run linting 130 | run: npm run lint 131 | 132 | # Run type checking 133 | - name: Run type checking 134 | run: npm run typecheck 135 | 136 | # Run benchmarks 137 | - name: Run benchmarks 138 | id: benchmarks 139 | run: npm run benchmark:ci 140 | continue-on-error: true 141 | 142 | # Upload benchmark results 143 | - name: Upload benchmark results 144 | if: always() && steps.benchmarks.outcome != 'skipped' 145 | uses: actions/upload-artifact@v4 146 | with: 147 | name: benchmark-results-${{ github.run_number }}-${{ github.run_attempt }} 148 | path: | 149 | benchmark-results.json 150 | retention-days: 30 151 | if-no-files-found: warn 152 | 153 | # Create test report comment for PRs 154 | - name: Create test report comment 155 | if: github.event_name == 'pull_request' && always() 156 | uses: actions/github-script@v7 157 | continue-on-error: true 158 | with: 159 | script: | 160 | const fs = require('fs'); 161 | let summary = '## Test Results\n\nTest summary generation failed.'; 162 | 163 | try { 164 | if (fs.existsSync('test-summary.md')) { 165 | summary = fs.readFileSync('test-summary.md', 'utf8'); 166 | } 167 | } catch (error) { 168 | console.error('Error reading test summary:', error); 169 | } 170 | 171 | try { 172 | // Find existing comment 173 | const { data: comments } = await github.rest.issues.listComments({ 174 | owner: context.repo.owner, 175 | repo: context.repo.repo, 176 | issue_number: context.issue.number, 177 | }); 178 | 179 | const botComment = comments.find(comment => 180 | comment.user.type === 'Bot' && 181 | comment.body.includes('## Test Results') 182 | ); 183 | 184 | if (botComment) { 185 | // Update existing comment 186 | await github.rest.issues.updateComment({ 187 | owner: context.repo.owner, 188 | repo: context.repo.repo, 189 | comment_id: botComment.id, 190 | body: summary 191 | }); 192 | } else { 193 | // Create new comment 194 | await github.rest.issues.createComment({ 195 | owner: context.repo.owner, 196 | repo: context.repo.repo, 197 | issue_number: context.issue.number, 198 | body: summary 199 | }); 200 | } 201 | } catch (error) { 202 | console.error('Failed to create/update PR comment:', error.message); 203 | console.log('This is likely due to insufficient permissions for external PRs.'); 204 | console.log('Test results have been saved to the job summary instead.'); 205 | } 206 | 207 | # Generate job summary 208 | - name: Generate job summary 209 | if: always() 210 | run: | 211 | echo "# Test Run Summary" >> $GITHUB_STEP_SUMMARY 212 | echo "" >> $GITHUB_STEP_SUMMARY 213 | 214 | if [ -f test-summary.md ]; then 215 | cat test-summary.md >> $GITHUB_STEP_SUMMARY 216 | else 217 | echo "Test summary generation failed." >> $GITHUB_STEP_SUMMARY 218 | fi 219 | 220 | echo "" >> $GITHUB_STEP_SUMMARY 221 | echo "## 📥 Download Artifacts" >> $GITHUB_STEP_SUMMARY 222 | echo "" >> $GITHUB_STEP_SUMMARY 223 | echo "- [Test Results](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})" >> $GITHUB_STEP_SUMMARY 224 | echo "- [Coverage Report](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})" >> $GITHUB_STEP_SUMMARY 225 | echo "- [Benchmark Results](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})" >> $GITHUB_STEP_SUMMARY 226 | 227 | # Store test metadata 228 | - name: Store test metadata 229 | if: always() 230 | run: | 231 | cat > test-metadata.json << EOF 232 | { 233 | "run_id": "${{ github.run_id }}", 234 | "run_number": "${{ github.run_number }}", 235 | "run_attempt": "${{ github.run_attempt }}", 236 | "sha": "${{ github.sha }}", 237 | "ref": "${{ github.ref }}", 238 | "event_name": "${{ github.event_name }}", 239 | "repository": "${{ github.repository }}", 240 | "actor": "${{ github.actor }}", 241 | "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", 242 | "node_version": "$(node --version)", 243 | "npm_version": "$(npm --version)" 244 | } 245 | EOF 246 | 247 | - name: Upload test metadata 248 | if: always() 249 | uses: actions/upload-artifact@v4 250 | with: 251 | name: test-metadata-${{ github.run_number }}-${{ github.run_attempt }} 252 | path: test-metadata.json 253 | retention-days: 30 254 | 255 | # Separate job to process and publish test results 256 | publish-results: 257 | needs: test 258 | runs-on: ubuntu-latest 259 | if: always() 260 | permissions: 261 | checks: write 262 | pull-requests: write 263 | steps: 264 | - uses: actions/checkout@v4 265 | 266 | # Download all artifacts 267 | - name: Download all artifacts 268 | uses: actions/download-artifact@v4 269 | with: 270 | path: artifacts 271 | 272 | # Publish test results as checks 273 | - name: Publish test results 274 | uses: dorny/test-reporter@v1 275 | if: always() 276 | continue-on-error: true 277 | with: 278 | name: Test Results 279 | path: 'artifacts/test-results-*/test-results/junit.xml' 280 | reporter: java-junit 281 | fail-on-error: false 282 | fail-on-empty: false 283 | 284 | # Create a combined artifact with all results 285 | - name: Create combined results artifact 286 | if: always() 287 | run: | 288 | mkdir -p combined-results 289 | cp -r artifacts/* combined-results/ 2>/dev/null || true 290 | 291 | # Create index file 292 | cat > combined-results/index.html << 'EOF' 293 | <!DOCTYPE html> 294 | <html> 295 | <head> 296 | <title>n8n-mcp Test Results</title> 297 | <style> 298 | body { font-family: Arial, sans-serif; margin: 40px; } 299 | h1 { color: #333; } 300 | .section { margin: 20px 0; padding: 20px; border: 1px solid #ddd; border-radius: 5px; } 301 | a { color: #0066cc; text-decoration: none; } 302 | a:hover { text-decoration: underline; } 303 | </style> 304 | </head> 305 | <body> 306 | <h1>n8n-mcp Test Results</h1> 307 | <div class="section"> 308 | <h2>Test Reports</h2> 309 | <ul> 310 | <li><a href="test-results-${{ github.run_number }}-${{ github.run_attempt }}/test-reports/report.html">📊 Detailed HTML Report</a></li> 311 | <li><a href="test-results-${{ github.run_number }}-${{ github.run_attempt }}/test-results/html/index.html">📈 Vitest HTML Report</a></li> 312 | <li><a href="test-results-${{ github.run_number }}-${{ github.run_attempt }}/test-reports/report.md">📄 Markdown Report</a></li> 313 | <li><a href="test-results-${{ github.run_number }}-${{ github.run_attempt }}/test-summary.md">📝 PR Summary</a></li> 314 | <li><a href="test-results-${{ github.run_number }}-${{ github.run_attempt }}/test-results/junit.xml">🔧 JUnit XML</a></li> 315 | <li><a href="test-results-${{ github.run_number }}-${{ github.run_attempt }}/test-results/results.json">🔢 JSON Results</a></li> 316 | <li><a href="test-results-${{ github.run_number }}-${{ github.run_attempt }}/test-reports/report.json">📊 Full JSON Report</a></li> 317 | </ul> 318 | </div> 319 | <div class="section"> 320 | <h2>Coverage Reports</h2> 321 | <ul> 322 | <li><a href="coverage-${{ github.run_number }}-${{ github.run_attempt }}/html/index.html">HTML Coverage Report</a></li> 323 | <li><a href="coverage-${{ github.run_number }}-${{ github.run_attempt }}/lcov.info">LCOV Report</a></li> 324 | <li><a href="coverage-${{ github.run_number }}-${{ github.run_attempt }}/coverage-summary.json">Coverage Summary JSON</a></li> 325 | </ul> 326 | </div> 327 | <div class="section"> 328 | <h2>Benchmark Results</h2> 329 | <ul> 330 | <li><a href="benchmark-results-${{ github.run_number }}-${{ github.run_attempt }}/benchmark-results.json">Benchmark Results JSON</a></li> 331 | </ul> 332 | </div> 333 | <div class="section"> 334 | <h2>Metadata</h2> 335 | <ul> 336 | <li><a href="test-metadata-${{ github.run_number }}-${{ github.run_attempt }}/test-metadata.json">Test Run Metadata</a></li> 337 | </ul> 338 | </div> 339 | <div class="section"> 340 | <p><em>Generated at $(date -u +%Y-%m-%dT%H:%M:%SZ)</em></p> 341 | <p><em>Run: #${{ github.run_number }} | SHA: ${{ github.sha }}</em></p> 342 | </div> 343 | </body> 344 | </html> 345 | EOF 346 | 347 | - name: Upload combined results 348 | if: always() 349 | uses: actions/upload-artifact@v4 350 | with: 351 | name: all-test-results-${{ github.run_number }} 352 | path: combined-results/ 353 | retention-days: 90 ``` -------------------------------------------------------------------------------- /docs/DOCKER_README.md: -------------------------------------------------------------------------------- ```markdown 1 | # Docker Deployment Guide for n8n-MCP 2 | 3 | This guide provides comprehensive instructions for deploying n8n-MCP using Docker. 4 | 5 | ## 🚀 Quick Start 6 | 7 | ### Prerequisites 8 | - Docker Engine 20.10+ (Docker Desktop on Windows/macOS, or Docker Engine on Linux) 9 | - Docker Compose V2 10 | - (Optional) openssl for generating auth tokens 11 | 12 | ### 1. HTTP Server Mode (Recommended) 13 | 14 | The simplest way to deploy n8n-MCP is using Docker Compose with HTTP mode: 15 | 16 | ```bash 17 | # Clone the repository 18 | git clone https://github.com/czlonkowski/n8n-mcp.git 19 | cd n8n-mcp 20 | 21 | # Create .env file with auth token 22 | cat > .env << EOF 23 | AUTH_TOKEN=$(openssl rand -base64 32) 24 | USE_FIXED_HTTP=true 25 | EOF 26 | 27 | # Start the server 28 | docker compose up -d 29 | 30 | # Check logs 31 | docker compose logs -f 32 | 33 | # Test the health endpoint 34 | curl http://localhost:3000/health 35 | ``` 36 | 37 | ### 2. Using Pre-built Images 38 | 39 | Pre-built images are available on GitHub Container Registry: 40 | 41 | ```bash 42 | # Pull the latest image (~280MB optimized) 43 | docker pull ghcr.io/czlonkowski/n8n-mcp:latest 44 | 45 | # Run with HTTP mode 46 | docker run -d \ 47 | --name n8n-mcp \ 48 | -e MCP_MODE=http \ 49 | -e USE_FIXED_HTTP=true \ 50 | -e AUTH_TOKEN=your-secure-token \ 51 | -p 3000:3000 \ 52 | ghcr.io/czlonkowski/n8n-mcp:latest 53 | ``` 54 | 55 | ## 📋 Configuration Options 56 | 57 | ### Environment Variables 58 | 59 | | Variable | Description | Default | Required | 60 | |----------|-------------|---------|----------| 61 | | `MCP_MODE` | Server mode: `stdio` or `http` | `stdio` | No | 62 | | `AUTH_TOKEN` | Bearer token for HTTP authentication | - | Yes (HTTP mode)* | 63 | | `AUTH_TOKEN_FILE` | Path to file containing auth token (v2.7.5+) | - | Yes (HTTP mode)* | 64 | | `PORT` | HTTP server port | `3000` | No | 65 | | `NODE_ENV` | Environment: `development` or `production` | `production` | No | 66 | | `LOG_LEVEL` | Logging level: `debug`, `info`, `warn`, `error` | `info` | No | 67 | | `NODE_DB_PATH` | Custom database path (v2.7.16+) | `/app/data/nodes.db` | No | 68 | | `AUTH_RATE_LIMIT_WINDOW` | Rate limit window in ms (v2.16.3+) | `900000` (15 min) | No | 69 | | `AUTH_RATE_LIMIT_MAX` | Max auth attempts per window (v2.16.3+) | `20` | No | 70 | | `WEBHOOK_SECURITY_MODE` | SSRF protection: `strict`/`moderate`/`permissive` (v2.16.3+) | `strict` | No | 71 | 72 | *Either `AUTH_TOKEN` or `AUTH_TOKEN_FILE` must be set for HTTP mode. If both are set, `AUTH_TOKEN` takes precedence. 73 | 74 | ### Configuration File Support (v2.8.2+) 75 | 76 | You can mount a JSON configuration file to set environment variables: 77 | 78 | ```bash 79 | # Create config file 80 | cat > config.json << EOF 81 | { 82 | "MCP_MODE": "http", 83 | "AUTH_TOKEN": "your-secure-token", 84 | "LOG_LEVEL": "info", 85 | "N8N_API_URL": "https://your-n8n-instance.com", 86 | "N8N_API_KEY": "your-api-key" 87 | } 88 | EOF 89 | 90 | # Run with config file 91 | docker run -d \ 92 | --name n8n-mcp \ 93 | -v $(pwd)/config.json:/app/config.json:ro \ 94 | -p 3000:3000 \ 95 | ghcr.io/czlonkowski/n8n-mcp:latest 96 | ``` 97 | 98 | The config file supports: 99 | - All standard environment variables 100 | - Nested objects (flattened with underscore separators) 101 | - Arrays, booleans, numbers, and strings 102 | - Secure handling with command injection prevention 103 | - Dangerous variable blocking for security 104 | 105 | ### Docker Compose Configuration 106 | 107 | The default `docker-compose.yml` provides: 108 | - Automatic restart on failure 109 | - Named volume for data persistence 110 | - Memory limits (512MB max, 256MB reserved) 111 | - Health checks every 30 seconds 112 | - Container labels for organization 113 | 114 | ### Custom Configuration 115 | 116 | Create a `docker-compose.override.yml` for local customizations: 117 | 118 | ```yaml 119 | # docker-compose.override.yml 120 | services: 121 | n8n-mcp: 122 | ports: 123 | - "8080:3000" # Use different port 124 | environment: 125 | LOG_LEVEL: debug 126 | NODE_ENV: development 127 | volumes: 128 | - ./custom-data:/app/data # Use local directory 129 | ``` 130 | 131 | ## 🔧 Usage Modes 132 | 133 | ### HTTP Mode (Remote Access) 134 | 135 | Perfect for cloud deployments and remote access: 136 | 137 | ```bash 138 | # Start in HTTP mode 139 | docker run -d \ 140 | --name n8n-mcp-http \ 141 | -e MCP_MODE=http \ 142 | -e AUTH_TOKEN=your-secure-token \ 143 | -p 3000:3000 \ 144 | ghcr.io/czlonkowski/n8n-mcp:latest 145 | ``` 146 | 147 | Configure Claude Desktop with mcp-remote: 148 | ```json 149 | { 150 | "mcpServers": { 151 | "n8n-remote": { 152 | "command": "npx", 153 | "args": [ 154 | "-y", 155 | "@modelcontextprotocol/mcp-remote@latest", 156 | "connect", 157 | "http://your-server:3000/mcp" 158 | ], 159 | "env": { 160 | "MCP_AUTH_TOKEN": "your-secure-token" 161 | } 162 | } 163 | } 164 | } 165 | ``` 166 | 167 | ### Stdio Mode (Local Direct Access) 168 | 169 | For local Claude Desktop integration without HTTP: 170 | 171 | ```bash 172 | # Run in stdio mode (interactive) 173 | docker run --rm -i --init \ 174 | -e MCP_MODE=stdio \ 175 | -v n8n-mcp-data:/app/data \ 176 | ghcr.io/czlonkowski/n8n-mcp:latest 177 | ``` 178 | 179 | ### Server Mode (Command Line) 180 | 181 | You can also use the `serve` command to start in HTTP mode: 182 | 183 | ```bash 184 | # Using the serve command (v2.8.2+) 185 | docker run -d \ 186 | --name n8n-mcp \ 187 | -e AUTH_TOKEN=your-secure-token \ 188 | -p 3000:3000 \ 189 | ghcr.io/czlonkowski/n8n-mcp:latest serve 190 | ``` 191 | 192 | Configure Claude Desktop: 193 | ```json 194 | { 195 | "mcpServers": { 196 | "n8n-docker": { 197 | "command": "docker", 198 | "args": [ 199 | "run", 200 | "--rm", 201 | "-i", 202 | "--init", 203 | "-e", "MCP_MODE=stdio", 204 | "-v", "n8n-mcp-data:/app/data", 205 | "ghcr.io/czlonkowski/n8n-mcp:latest" 206 | ] 207 | } 208 | } 209 | } 210 | ``` 211 | 212 | ## 🏗️ Building from Source 213 | 214 | ### Build Locally 215 | 216 | ```bash 217 | # Clone repository 218 | git clone https://github.com/czlonkowski/n8n-mcp.git 219 | cd n8n-mcp 220 | 221 | # Build image 222 | docker build -t n8n-mcp:local . 223 | 224 | # Run your local build 225 | docker run -d \ 226 | --name n8n-mcp-local \ 227 | -e MCP_MODE=http \ 228 | -e AUTH_TOKEN=test-token \ 229 | -p 3000:3000 \ 230 | n8n-mcp:local 231 | ``` 232 | 233 | ### Multi-architecture Build 234 | 235 | Build for multiple platforms: 236 | 237 | ```bash 238 | # Enable buildx 239 | docker buildx create --use 240 | 241 | # Build for amd64 and arm64 242 | docker buildx build \ 243 | --platform linux/amd64,linux/arm64 \ 244 | -t n8n-mcp:multiarch \ 245 | --load \ 246 | . 247 | ``` 248 | 249 | ## 🔍 Health Monitoring 250 | 251 | ### Health Check Endpoint 252 | 253 | The container includes a health check that runs every 30 seconds: 254 | 255 | ```bash 256 | # Check health status 257 | curl http://localhost:3000/health 258 | ``` 259 | 260 | Response example: 261 | ```json 262 | { 263 | "status": "healthy", 264 | "uptime": 120.5, 265 | "memory": { 266 | "used": "8.5 MB", 267 | "rss": "45.2 MB", 268 | "external": "1.2 MB" 269 | }, 270 | "version": "2.3.0", 271 | "mode": "http", 272 | "database": { 273 | "adapter": "better-sqlite3", 274 | "ready": true 275 | } 276 | } 277 | ``` 278 | 279 | ### Docker Health Status 280 | 281 | ```bash 282 | # Check container health 283 | docker ps --format "table {{.Names}}\t{{.Status}}" 284 | 285 | # View health check logs 286 | docker inspect n8n-mcp | jq '.[0].State.Health' 287 | ``` 288 | 289 | ## 🔒 Security Features (v2.16.3+) 290 | 291 | ### Rate Limiting 292 | 293 | Protects against brute force authentication attacks: 294 | 295 | ```bash 296 | # Configure in .env or docker-compose.yml 297 | AUTH_RATE_LIMIT_WINDOW=900000 # 15 minutes in milliseconds 298 | AUTH_RATE_LIMIT_MAX=20 # 20 attempts per IP per window 299 | ``` 300 | 301 | ### SSRF Protection 302 | 303 | Prevents Server-Side Request Forgery when using webhook triggers: 304 | 305 | ```bash 306 | # For production (blocks localhost + private IPs + cloud metadata) 307 | WEBHOOK_SECURITY_MODE=strict 308 | 309 | # For local development with local n8n instance 310 | WEBHOOK_SECURITY_MODE=moderate 311 | 312 | # For internal testing only (allows private IPs) 313 | WEBHOOK_SECURITY_MODE=permissive 314 | ``` 315 | 316 | **Note:** Cloud metadata endpoints (169.254.169.254, metadata.google.internal, etc.) are ALWAYS blocked in all modes. 317 | 318 | ## 🔒 Authentication 319 | 320 | ### Authentication 321 | 322 | n8n-MCP supports two authentication methods for HTTP mode: 323 | 324 | #### Method 1: AUTH_TOKEN (Environment Variable) 325 | - Set the token directly as an environment variable 326 | - Simple and straightforward for basic deployments 327 | - Always use a strong token (minimum 32 characters) 328 | 329 | ```bash 330 | # Generate secure token 331 | openssl rand -base64 32 332 | 333 | # Use in Docker 334 | docker run -e AUTH_TOKEN=your-secure-token ... 335 | ``` 336 | 337 | #### Method 2: AUTH_TOKEN_FILE (File Path) - NEW in v2.7.5 338 | - Read token from a file (Docker secrets compatible) 339 | - More secure for production deployments 340 | - Prevents token exposure in process lists 341 | 342 | ```bash 343 | # Create token file 344 | echo "your-secure-token" > /path/to/token.txt 345 | 346 | # Use with Docker secrets 347 | docker run -e AUTH_TOKEN_FILE=/run/secrets/auth_token ... 348 | ``` 349 | 350 | #### Best Practices 351 | - Never commit tokens to version control 352 | - Rotate tokens regularly 353 | - Use AUTH_TOKEN_FILE with Docker secrets for production 354 | - Ensure token files have restricted permissions (600) 355 | 356 | ### Network Security 357 | 358 | For production deployments: 359 | 360 | 1. **Use HTTPS** - Put a reverse proxy (nginx, Caddy) in front 361 | 2. **Firewall** - Restrict access to trusted IPs only 362 | 3. **VPN** - Consider VPN access for internal use 363 | 364 | Example with Caddy: 365 | ``` 366 | your-domain.com { 367 | reverse_proxy n8n-mcp:3000 368 | basicauth * { 369 | admin $2a$14$... # bcrypt hash 370 | } 371 | } 372 | ``` 373 | 374 | ### Container Security 375 | 376 | - Runs as non-root user (uid 1001) 377 | - Read-only root filesystem compatible 378 | - No unnecessary packages installed 379 | - Regular security updates via GitHub Actions 380 | 381 | ## 📊 Resource Management 382 | 383 | ### Memory Limits 384 | 385 | Default limits in docker-compose.yml: 386 | - Maximum: 512MB 387 | - Reserved: 256MB 388 | 389 | Adjust based on your needs: 390 | ```yaml 391 | services: 392 | n8n-mcp: 393 | deploy: 394 | resources: 395 | limits: 396 | memory: 1G 397 | reservations: 398 | memory: 512M 399 | ``` 400 | 401 | ### Volume Management 402 | 403 | ```bash 404 | # List volumes 405 | docker volume ls | grep n8n-mcp 406 | 407 | # Inspect volume 408 | docker volume inspect n8n-mcp-data 409 | 410 | # Backup data 411 | docker run --rm \ 412 | -v n8n-mcp-data:/source:ro \ 413 | -v $(pwd):/backup \ 414 | alpine tar czf /backup/n8n-mcp-backup.tar.gz -C /source . 415 | 416 | # Restore data 417 | docker run --rm \ 418 | -v n8n-mcp-data:/target \ 419 | -v $(pwd):/backup:ro \ 420 | alpine tar xzf /backup/n8n-mcp-backup.tar.gz -C /target 421 | ``` 422 | 423 | ### Custom Database Path (v2.7.16+) 424 | 425 | You can specify a custom database location using `NODE_DB_PATH`: 426 | 427 | ```bash 428 | # Use custom path within mounted volume 429 | docker run -d \ 430 | --name n8n-mcp \ 431 | -e MCP_MODE=http \ 432 | -e AUTH_TOKEN=your-token \ 433 | -e NODE_DB_PATH=/app/data/custom/my-nodes.db \ 434 | -v n8n-mcp-data:/app/data \ 435 | -p 3000:3000 \ 436 | ghcr.io/czlonkowski/n8n-mcp:latest 437 | ``` 438 | 439 | **Important Notes:** 440 | - The path must end with `.db` 441 | - For data persistence, ensure the path is within a mounted volume 442 | - Paths outside mounted volumes will be lost on container restart 443 | - The directory will be created automatically if it doesn't exist 444 | 445 | ## 🐛 Troubleshooting 446 | 447 | ### Common Issues 448 | 449 | #### Container Exits Immediately 450 | ```bash 451 | # Check logs 452 | docker logs n8n-mcp 453 | 454 | # Common causes: 455 | # - Missing AUTH_TOKEN in HTTP mode 456 | # - Database initialization failure 457 | # - Port already in use 458 | ``` 459 | 460 | #### Database Not Initialized 461 | ```bash 462 | # Manually initialize database 463 | docker exec n8n-mcp node dist/scripts/rebuild.js 464 | 465 | # Or recreate container with fresh volume 466 | docker compose down -v 467 | docker compose up -d 468 | ``` 469 | 470 | #### Permission Errors 471 | ```bash 472 | # Fix volume permissions 473 | docker exec n8n-mcp chown -R nodejs:nodejs /app/data 474 | ``` 475 | 476 | ### Debug Mode 477 | 478 | Enable debug logging: 479 | ```bash 480 | docker run -d \ 481 | --name n8n-mcp-debug \ 482 | -e MCP_MODE=http \ 483 | -e AUTH_TOKEN=test \ 484 | -e LOG_LEVEL=debug \ 485 | -p 3000:3000 \ 486 | ghcr.io/czlonkowski/n8n-mcp:latest 487 | ``` 488 | 489 | ### Container Shell Access 490 | 491 | ```bash 492 | # Access running container 493 | docker exec -it n8n-mcp sh 494 | 495 | # Run as root for debugging 496 | docker exec -it -u root n8n-mcp sh 497 | ``` 498 | 499 | ## 🚀 Production Deployment 500 | 501 | ### Recommended Setup 502 | 503 | 1. **Use Docker Compose** for easier management 504 | 2. **Enable HTTPS** with reverse proxy 505 | 3. **Set up monitoring** (Prometheus, Grafana) 506 | 4. **Configure backups** for the data volume 507 | 5. **Use secrets management** for AUTH_TOKEN 508 | 509 | ### Example Production Stack 510 | 511 | ```yaml 512 | # docker-compose.prod.yml 513 | services: 514 | n8n-mcp: 515 | image: ghcr.io/czlonkowski/n8n-mcp:latest 516 | restart: always 517 | environment: 518 | MCP_MODE: http 519 | AUTH_TOKEN_FILE: /run/secrets/auth_token 520 | NODE_ENV: production 521 | secrets: 522 | - auth_token 523 | networks: 524 | - internal 525 | deploy: 526 | resources: 527 | limits: 528 | memory: 1G 529 | reservations: 530 | memory: 512M 531 | 532 | nginx: 533 | image: nginx:alpine 534 | restart: always 535 | ports: 536 | - "443:443" 537 | volumes: 538 | - ./nginx.conf:/etc/nginx/nginx.conf:ro 539 | - ./certs:/etc/nginx/certs:ro 540 | networks: 541 | - internal 542 | - external 543 | 544 | networks: 545 | internal: 546 | external: 547 | 548 | secrets: 549 | auth_token: 550 | file: ./secrets/auth_token.txt 551 | ``` 552 | 553 | ## 📦 Available Images 554 | 555 | - `ghcr.io/czlonkowski/n8n-mcp:latest` - Latest stable release 556 | - `ghcr.io/czlonkowski/n8n-mcp:2.3.0` - Specific version 557 | - `ghcr.io/czlonkowski/n8n-mcp:main-abc123` - Development builds 558 | 559 | ### Image Details 560 | 561 | - Base: `node:22-alpine` 562 | - Size: ~280MB compressed 563 | - Features: Pre-built database with all node information 564 | - Database: Complete SQLite with 525+ nodes 565 | - Architectures: `linux/amd64`, `linux/arm64` 566 | - Updated: Automatically via GitHub Actions 567 | 568 | ## 🔄 Updates and Maintenance 569 | 570 | ### Updating 571 | 572 | ```bash 573 | # Pull latest image 574 | docker compose pull 575 | 576 | # Recreate container 577 | docker compose up -d 578 | 579 | # View update logs 580 | docker compose logs -f 581 | ``` 582 | 583 | ### Automatic Updates (Watchtower) 584 | 585 | ```yaml 586 | # Add to docker-compose.yml 587 | services: 588 | watchtower: 589 | image: containrrr/watchtower 590 | volumes: 591 | - /var/run/docker.sock:/var/run/docker.sock 592 | command: --interval 86400 n8n-mcp 593 | ``` 594 | 595 | ## 📚 Additional Resources 596 | 597 | - [Main Documentation](./docs/README.md) 598 | - [HTTP Deployment Guide](./docs/HTTP_DEPLOYMENT.md) 599 | - [Troubleshooting Guide](./docs/TROUBLESHOOTING.md) 600 | - [Installation Guide](./docs/INSTALLATION.md) 601 | 602 | ## 🤝 Support 603 | 604 | - Issues: [GitHub Issues](https://github.com/czlonkowski/n8n-mcp/issues) 605 | - Discussions: [GitHub Discussions](https://github.com/czlonkowski/n8n-mcp/discussions) 606 | 607 | --- 608 | 609 | *Last updated: July 2025 - Docker implementation v1.1* ```