This is page 23 of 60. 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 │ ├── CI_TEST_INFRASTRUCTURE.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 │ │ ├── skills.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 │ ├── expression-utils.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 │ │ │ ├── expression-utils.test.ts │ │ │ ├── fixed-collection-validator.test.ts │ │ │ ├── n8n-errors.test.ts │ │ │ ├── node-type-normalizer.test.ts │ │ │ ├── node-type-utils.test.ts │ │ │ ├── node-utils.test.ts │ │ │ ├── simple-cache-memory-leak-fix.test.ts │ │ │ ├── ssrf-protection.test.ts │ │ │ └── template-node-resolver.test.ts │ │ └── validation-fixes.test.ts │ └── utils │ ├── assertions.ts │ ├── builders │ │ └── workflow.builder.ts │ ├── data-generators.ts │ ├── database-utils.ts │ ├── README.md │ └── test-helpers.ts ├── thumbnail.png ├── tsconfig.build.json ├── tsconfig.json ├── types │ ├── mcp.d.ts │ └── test-env.d.ts ├── verify-telemetry-fix.js ├── versioned-nodes.md ├── vitest.config.benchmark.ts ├── vitest.config.integration.ts └── vitest.config.ts ``` # Files -------------------------------------------------------------------------------- /src/utils/fixed-collection-validator.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Generic utility for validating and fixing fixedCollection structures in n8n nodes 3 | * Prevents the "propertyValues[itemName] is not iterable" error 4 | */ 5 | 6 | // Type definitions for node configurations 7 | export type NodeConfigValue = string | number | boolean | null | undefined | NodeConfig | NodeConfigValue[]; 8 | 9 | export interface NodeConfig { 10 | [key: string]: NodeConfigValue; 11 | } 12 | 13 | export interface FixedCollectionPattern { 14 | nodeType: string; 15 | property: string; 16 | subProperty?: string; 17 | expectedStructure: string; 18 | invalidPatterns: string[]; 19 | } 20 | 21 | export interface FixedCollectionValidationResult { 22 | isValid: boolean; 23 | errors: Array<{ 24 | pattern: string; 25 | message: string; 26 | fix: string; 27 | }>; 28 | autofix?: NodeConfig | NodeConfigValue[]; 29 | } 30 | 31 | export class FixedCollectionValidator { 32 | /** 33 | * Type guard to check if value is a NodeConfig 34 | */ 35 | private static isNodeConfig(value: NodeConfigValue): value is NodeConfig { 36 | return typeof value === 'object' && value !== null && !Array.isArray(value); 37 | } 38 | 39 | /** 40 | * Safely get nested property value 41 | */ 42 | private static getNestedValue(obj: NodeConfig, path: string): NodeConfigValue | undefined { 43 | const parts = path.split('.'); 44 | let current: NodeConfigValue = obj; 45 | 46 | for (const part of parts) { 47 | if (!this.isNodeConfig(current)) { 48 | return undefined; 49 | } 50 | current = current[part]; 51 | } 52 | 53 | return current; 54 | } 55 | /** 56 | * Known problematic patterns for various n8n nodes 57 | */ 58 | private static readonly KNOWN_PATTERNS: FixedCollectionPattern[] = [ 59 | // Conditional nodes (already fixed) 60 | { 61 | nodeType: 'switch', 62 | property: 'rules', 63 | expectedStructure: 'rules.values array', 64 | invalidPatterns: ['rules.conditions', 'rules.conditions.values'] 65 | }, 66 | { 67 | nodeType: 'if', 68 | property: 'conditions', 69 | expectedStructure: 'conditions array/object', 70 | invalidPatterns: ['conditions.values'] 71 | }, 72 | { 73 | nodeType: 'filter', 74 | property: 'conditions', 75 | expectedStructure: 'conditions array/object', 76 | invalidPatterns: ['conditions.values'] 77 | }, 78 | // New nodes identified by research 79 | { 80 | nodeType: 'summarize', 81 | property: 'fieldsToSummarize', 82 | subProperty: 'values', 83 | expectedStructure: 'fieldsToSummarize.values array', 84 | invalidPatterns: ['fieldsToSummarize.values.values'] 85 | }, 86 | { 87 | nodeType: 'comparedatasets', 88 | property: 'mergeByFields', 89 | subProperty: 'values', 90 | expectedStructure: 'mergeByFields.values array', 91 | invalidPatterns: ['mergeByFields.values.values'] 92 | }, 93 | { 94 | nodeType: 'sort', 95 | property: 'sortFieldsUi', 96 | subProperty: 'sortField', 97 | expectedStructure: 'sortFieldsUi.sortField array', 98 | invalidPatterns: ['sortFieldsUi.sortField.values'] 99 | }, 100 | { 101 | nodeType: 'aggregate', 102 | property: 'fieldsToAggregate', 103 | subProperty: 'fieldToAggregate', 104 | expectedStructure: 'fieldsToAggregate.fieldToAggregate array', 105 | invalidPatterns: ['fieldsToAggregate.fieldToAggregate.values'] 106 | }, 107 | { 108 | nodeType: 'set', 109 | property: 'fields', 110 | subProperty: 'values', 111 | expectedStructure: 'fields.values array', 112 | invalidPatterns: ['fields.values.values'] 113 | }, 114 | { 115 | nodeType: 'html', 116 | property: 'extractionValues', 117 | subProperty: 'values', 118 | expectedStructure: 'extractionValues.values array', 119 | invalidPatterns: ['extractionValues.values.values'] 120 | }, 121 | { 122 | nodeType: 'httprequest', 123 | property: 'body', 124 | subProperty: 'parameters', 125 | expectedStructure: 'body.parameters array', 126 | invalidPatterns: ['body.parameters.values'] 127 | }, 128 | { 129 | nodeType: 'airtable', 130 | property: 'sort', 131 | subProperty: 'sortField', 132 | expectedStructure: 'sort.sortField array', 133 | invalidPatterns: ['sort.sortField.values'] 134 | } 135 | ]; 136 | 137 | /** 138 | * Validate a node configuration for fixedCollection issues 139 | * Includes protection against circular references 140 | */ 141 | static validate( 142 | nodeType: string, 143 | config: NodeConfig 144 | ): FixedCollectionValidationResult { 145 | // Early return for non-object configs 146 | if (typeof config !== 'object' || config === null || Array.isArray(config)) { 147 | return { isValid: true, errors: [] }; 148 | } 149 | 150 | const normalizedNodeType = this.normalizeNodeType(nodeType); 151 | const pattern = this.getPatternForNode(normalizedNodeType); 152 | 153 | if (!pattern) { 154 | return { isValid: true, errors: [] }; 155 | } 156 | 157 | const result: FixedCollectionValidationResult = { 158 | isValid: true, 159 | errors: [] 160 | }; 161 | 162 | // Check for invalid patterns 163 | for (const invalidPattern of pattern.invalidPatterns) { 164 | if (this.hasInvalidStructure(config, invalidPattern)) { 165 | result.isValid = false; 166 | result.errors.push({ 167 | pattern: invalidPattern, 168 | message: `Invalid structure for nodes-base.${pattern.nodeType} node: found nested "${invalidPattern}" but expected "${pattern.expectedStructure}". This causes "propertyValues[itemName] is not iterable" error in n8n.`, 169 | fix: this.generateFixMessage(pattern) 170 | }); 171 | 172 | // Generate autofix 173 | if (!result.autofix) { 174 | result.autofix = this.generateAutofix(config, pattern); 175 | } 176 | } 177 | } 178 | 179 | return result; 180 | } 181 | 182 | /** 183 | * Apply autofix to a configuration 184 | */ 185 | static applyAutofix( 186 | config: NodeConfig, 187 | pattern: FixedCollectionPattern 188 | ): NodeConfig | NodeConfigValue[] { 189 | const fixedConfig = this.generateAutofix(config, pattern); 190 | // For If/Filter nodes, the autofix might return just the values array 191 | if (pattern.nodeType === 'if' || pattern.nodeType === 'filter') { 192 | const conditions = config.conditions; 193 | if (conditions && typeof conditions === 'object' && !Array.isArray(conditions) && 'values' in conditions) { 194 | const values = conditions.values; 195 | if (values !== undefined && values !== null && 196 | (Array.isArray(values) || typeof values === 'object')) { 197 | return values as NodeConfig | NodeConfigValue[]; 198 | } 199 | } 200 | } 201 | return fixedConfig; 202 | } 203 | 204 | /** 205 | * Normalize node type to handle various formats 206 | */ 207 | private static normalizeNodeType(nodeType: string): string { 208 | return nodeType 209 | .replace('n8n-nodes-base.', '') 210 | .replace('nodes-base.', '') 211 | .replace('@n8n/n8n-nodes-langchain.', '') 212 | .toLowerCase(); 213 | } 214 | 215 | /** 216 | * Get pattern configuration for a specific node type 217 | */ 218 | private static getPatternForNode(nodeType: string): FixedCollectionPattern | undefined { 219 | return this.KNOWN_PATTERNS.find(p => p.nodeType === nodeType); 220 | } 221 | 222 | /** 223 | * Check if configuration has an invalid structure 224 | * Includes circular reference protection 225 | */ 226 | private static hasInvalidStructure( 227 | config: NodeConfig, 228 | pattern: string 229 | ): boolean { 230 | const parts = pattern.split('.'); 231 | let current: NodeConfigValue = config; 232 | const visited = new WeakSet<object>(); 233 | 234 | for (const part of parts) { 235 | // Check for null/undefined 236 | if (current === null || current === undefined) { 237 | return false; 238 | } 239 | 240 | // Check if it's an object (but not an array for property access) 241 | if (typeof current !== 'object' || Array.isArray(current)) { 242 | return false; 243 | } 244 | 245 | // Check for circular reference 246 | if (visited.has(current)) { 247 | return false; // Circular reference detected, invalid structure 248 | } 249 | visited.add(current); 250 | 251 | // Check if property exists (using hasOwnProperty to avoid prototype pollution) 252 | if (!Object.prototype.hasOwnProperty.call(current, part)) { 253 | return false; 254 | } 255 | 256 | const nextValue = (current as NodeConfig)[part]; 257 | if (typeof nextValue !== 'object' || nextValue === null) { 258 | // If we have more parts to traverse but current value is not an object, invalid structure 259 | if (parts.indexOf(part) < parts.length - 1) { 260 | return false; 261 | } 262 | } 263 | current = nextValue as NodeConfig; 264 | } 265 | 266 | return true; 267 | } 268 | 269 | /** 270 | * Generate a fix message for the specific pattern 271 | */ 272 | private static generateFixMessage(pattern: FixedCollectionPattern): string { 273 | switch (pattern.nodeType) { 274 | case 'switch': 275 | return 'Use: { "rules": { "values": [{ "conditions": {...}, "outputKey": "output1" }] } }'; 276 | case 'if': 277 | case 'filter': 278 | return 'Use: { "conditions": {...} } or { "conditions": [...] } directly, not nested under "values"'; 279 | case 'summarize': 280 | return 'Use: { "fieldsToSummarize": { "values": [...] } } not nested values.values'; 281 | case 'comparedatasets': 282 | return 'Use: { "mergeByFields": { "values": [...] } } not nested values.values'; 283 | case 'sort': 284 | return 'Use: { "sortFieldsUi": { "sortField": [...] } } not sortField.values'; 285 | case 'aggregate': 286 | return 'Use: { "fieldsToAggregate": { "fieldToAggregate": [...] } } not fieldToAggregate.values'; 287 | case 'set': 288 | return 'Use: { "fields": { "values": [...] } } not nested values.values'; 289 | case 'html': 290 | return 'Use: { "extractionValues": { "values": [...] } } not nested values.values'; 291 | case 'httprequest': 292 | return 'Use: { "body": { "parameters": [...] } } not parameters.values'; 293 | case 'airtable': 294 | return 'Use: { "sort": { "sortField": [...] } } not sortField.values'; 295 | default: 296 | return `Use ${pattern.expectedStructure} structure`; 297 | } 298 | } 299 | 300 | /** 301 | * Generate autofix for invalid structures 302 | */ 303 | private static generateAutofix( 304 | config: NodeConfig, 305 | pattern: FixedCollectionPattern 306 | ): NodeConfig | NodeConfigValue[] { 307 | const fixedConfig = { ...config }; 308 | 309 | switch (pattern.nodeType) { 310 | case 'switch': { 311 | const rules = config.rules; 312 | if (this.isNodeConfig(rules)) { 313 | const conditions = rules.conditions; 314 | if (this.isNodeConfig(conditions) && 'values' in conditions) { 315 | const values = conditions.values; 316 | fixedConfig.rules = { 317 | values: Array.isArray(values) 318 | ? values.map((condition, index) => ({ 319 | conditions: condition, 320 | outputKey: `output${index + 1}` 321 | })) 322 | : [{ 323 | conditions: values, 324 | outputKey: 'output1' 325 | }] 326 | }; 327 | } else if (conditions) { 328 | fixedConfig.rules = { 329 | values: [{ 330 | conditions: conditions, 331 | outputKey: 'output1' 332 | }] 333 | }; 334 | } 335 | } 336 | break; 337 | } 338 | 339 | case 'if': 340 | case 'filter': { 341 | const conditions = config.conditions; 342 | if (this.isNodeConfig(conditions) && 'values' in conditions) { 343 | const values = conditions.values; 344 | if (values !== undefined && values !== null && 345 | (Array.isArray(values) || typeof values === 'object')) { 346 | return values as NodeConfig | NodeConfigValue[]; 347 | } 348 | } 349 | break; 350 | } 351 | 352 | case 'summarize': { 353 | const fieldsToSummarize = config.fieldsToSummarize; 354 | if (this.isNodeConfig(fieldsToSummarize)) { 355 | const values = fieldsToSummarize.values; 356 | if (this.isNodeConfig(values) && 'values' in values) { 357 | fixedConfig.fieldsToSummarize = { 358 | values: values.values 359 | }; 360 | } 361 | } 362 | break; 363 | } 364 | 365 | case 'comparedatasets': { 366 | const mergeByFields = config.mergeByFields; 367 | if (this.isNodeConfig(mergeByFields)) { 368 | const values = mergeByFields.values; 369 | if (this.isNodeConfig(values) && 'values' in values) { 370 | fixedConfig.mergeByFields = { 371 | values: values.values 372 | }; 373 | } 374 | } 375 | break; 376 | } 377 | 378 | case 'sort': { 379 | const sortFieldsUi = config.sortFieldsUi; 380 | if (this.isNodeConfig(sortFieldsUi)) { 381 | const sortField = sortFieldsUi.sortField; 382 | if (this.isNodeConfig(sortField) && 'values' in sortField) { 383 | fixedConfig.sortFieldsUi = { 384 | sortField: sortField.values 385 | }; 386 | } 387 | } 388 | break; 389 | } 390 | 391 | case 'aggregate': { 392 | const fieldsToAggregate = config.fieldsToAggregate; 393 | if (this.isNodeConfig(fieldsToAggregate)) { 394 | const fieldToAggregate = fieldsToAggregate.fieldToAggregate; 395 | if (this.isNodeConfig(fieldToAggregate) && 'values' in fieldToAggregate) { 396 | fixedConfig.fieldsToAggregate = { 397 | fieldToAggregate: fieldToAggregate.values 398 | }; 399 | } 400 | } 401 | break; 402 | } 403 | 404 | case 'set': { 405 | const fields = config.fields; 406 | if (this.isNodeConfig(fields)) { 407 | const values = fields.values; 408 | if (this.isNodeConfig(values) && 'values' in values) { 409 | fixedConfig.fields = { 410 | values: values.values 411 | }; 412 | } 413 | } 414 | break; 415 | } 416 | 417 | case 'html': { 418 | const extractionValues = config.extractionValues; 419 | if (this.isNodeConfig(extractionValues)) { 420 | const values = extractionValues.values; 421 | if (this.isNodeConfig(values) && 'values' in values) { 422 | fixedConfig.extractionValues = { 423 | values: values.values 424 | }; 425 | } 426 | } 427 | break; 428 | } 429 | 430 | case 'httprequest': { 431 | const body = config.body; 432 | if (this.isNodeConfig(body)) { 433 | const parameters = body.parameters; 434 | if (this.isNodeConfig(parameters) && 'values' in parameters) { 435 | fixedConfig.body = { 436 | ...body, 437 | parameters: parameters.values 438 | }; 439 | } 440 | } 441 | break; 442 | } 443 | 444 | case 'airtable': { 445 | const sort = config.sort; 446 | if (this.isNodeConfig(sort)) { 447 | const sortField = sort.sortField; 448 | if (this.isNodeConfig(sortField) && 'values' in sortField) { 449 | fixedConfig.sort = { 450 | sortField: sortField.values 451 | }; 452 | } 453 | } 454 | break; 455 | } 456 | } 457 | 458 | return fixedConfig; 459 | } 460 | 461 | /** 462 | * Get all known patterns (for testing and documentation) 463 | * Returns a deep copy to prevent external modifications 464 | */ 465 | static getAllPatterns(): FixedCollectionPattern[] { 466 | return this.KNOWN_PATTERNS.map(pattern => ({ 467 | ...pattern, 468 | invalidPatterns: [...pattern.invalidPatterns] 469 | })); 470 | } 471 | 472 | /** 473 | * Check if a node type is susceptible to fixedCollection issues 474 | */ 475 | static isNodeSusceptible(nodeType: string): boolean { 476 | const normalizedType = this.normalizeNodeType(nodeType); 477 | return this.KNOWN_PATTERNS.some(p => p.nodeType === normalizedType); 478 | } 479 | } ``` -------------------------------------------------------------------------------- /tests/unit/parsers/node-parser-outputs.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { NodeParser } from '@/parsers/node-parser'; 3 | import { PropertyExtractor } from '@/parsers/property-extractor'; 4 | 5 | // Mock PropertyExtractor 6 | vi.mock('@/parsers/property-extractor'); 7 | 8 | describe('NodeParser - Output Extraction', () => { 9 | let parser: NodeParser; 10 | let mockPropertyExtractor: any; 11 | 12 | beforeEach(() => { 13 | vi.clearAllMocks(); 14 | 15 | mockPropertyExtractor = { 16 | extractProperties: vi.fn().mockReturnValue([]), 17 | extractCredentials: vi.fn().mockReturnValue([]), 18 | detectAIToolCapability: vi.fn().mockReturnValue(false), 19 | extractOperations: vi.fn().mockReturnValue([]) 20 | }; 21 | 22 | (PropertyExtractor as any).mockImplementation(() => mockPropertyExtractor); 23 | 24 | parser = new NodeParser(); 25 | }); 26 | 27 | describe('extractOutputs method', () => { 28 | it('should extract outputs array from base description', () => { 29 | const outputs = [ 30 | { displayName: 'Done', description: 'Final results when loop completes' }, 31 | { displayName: 'Loop', description: 'Current batch data during iteration' } 32 | ]; 33 | 34 | const nodeDescription = { 35 | name: 'splitInBatches', 36 | displayName: 'Split In Batches', 37 | outputs 38 | }; 39 | 40 | const NodeClass = class { 41 | description = nodeDescription; 42 | }; 43 | 44 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 45 | 46 | expect(result.outputs).toEqual(outputs); 47 | expect(result.outputNames).toBeUndefined(); 48 | }); 49 | 50 | it('should extract outputNames array from base description', () => { 51 | const outputNames = ['done', 'loop']; 52 | 53 | const nodeDescription = { 54 | name: 'splitInBatches', 55 | displayName: 'Split In Batches', 56 | outputNames 57 | }; 58 | 59 | const NodeClass = class { 60 | description = nodeDescription; 61 | }; 62 | 63 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 64 | 65 | expect(result.outputNames).toEqual(outputNames); 66 | expect(result.outputs).toBeUndefined(); 67 | }); 68 | 69 | it('should extract both outputs and outputNames when both are present', () => { 70 | const outputs = [ 71 | { displayName: 'Done', description: 'Final results when loop completes' }, 72 | { displayName: 'Loop', description: 'Current batch data during iteration' } 73 | ]; 74 | const outputNames = ['done', 'loop']; 75 | 76 | const nodeDescription = { 77 | name: 'splitInBatches', 78 | displayName: 'Split In Batches', 79 | outputs, 80 | outputNames 81 | }; 82 | 83 | const NodeClass = class { 84 | description = nodeDescription; 85 | }; 86 | 87 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 88 | 89 | expect(result.outputs).toEqual(outputs); 90 | expect(result.outputNames).toEqual(outputNames); 91 | }); 92 | 93 | it('should convert single output to array format', () => { 94 | const singleOutput = { displayName: 'Output', description: 'Single output' }; 95 | 96 | const nodeDescription = { 97 | name: 'singleOutputNode', 98 | displayName: 'Single Output Node', 99 | outputs: singleOutput 100 | }; 101 | 102 | const NodeClass = class { 103 | description = nodeDescription; 104 | }; 105 | 106 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 107 | 108 | expect(result.outputs).toEqual([singleOutput]); 109 | }); 110 | 111 | it('should convert single outputName to array format', () => { 112 | const nodeDescription = { 113 | name: 'singleOutputNode', 114 | displayName: 'Single Output Node', 115 | outputNames: 'main' 116 | }; 117 | 118 | const NodeClass = class { 119 | description = nodeDescription; 120 | }; 121 | 122 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 123 | 124 | expect(result.outputNames).toEqual(['main']); 125 | }); 126 | 127 | it('should extract outputs from versioned node when not in base description', () => { 128 | const versionedOutputs = [ 129 | { displayName: 'True', description: 'Items that match condition' }, 130 | { displayName: 'False', description: 'Items that do not match condition' } 131 | ]; 132 | 133 | const NodeClass = class { 134 | description = { 135 | name: 'if', 136 | displayName: 'IF' 137 | // No outputs in base description 138 | }; 139 | 140 | nodeVersions = { 141 | 1: { 142 | description: { 143 | outputs: versionedOutputs 144 | } 145 | }, 146 | 2: { 147 | description: { 148 | outputs: versionedOutputs, 149 | outputNames: ['true', 'false'] 150 | } 151 | } 152 | }; 153 | }; 154 | 155 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 156 | 157 | // Should get outputs from latest version (2) 158 | expect(result.outputs).toEqual(versionedOutputs); 159 | expect(result.outputNames).toEqual(['true', 'false']); 160 | }); 161 | 162 | it('should handle node instantiation failure gracefully', () => { 163 | const NodeClass = class { 164 | // Static description that can be accessed when instantiation fails 165 | static description = { 166 | name: 'problematic', 167 | displayName: 'Problematic Node' 168 | }; 169 | 170 | constructor() { 171 | throw new Error('Cannot instantiate'); 172 | } 173 | }; 174 | 175 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 176 | 177 | expect(result.outputs).toBeUndefined(); 178 | expect(result.outputNames).toBeUndefined(); 179 | }); 180 | 181 | it('should return empty result when no outputs found anywhere', () => { 182 | const nodeDescription = { 183 | name: 'noOutputs', 184 | displayName: 'No Outputs Node' 185 | // No outputs or outputNames 186 | }; 187 | 188 | const NodeClass = class { 189 | description = nodeDescription; 190 | }; 191 | 192 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 193 | 194 | expect(result.outputs).toBeUndefined(); 195 | expect(result.outputNames).toBeUndefined(); 196 | }); 197 | 198 | it('should handle complex versioned node structure', () => { 199 | const NodeClass = class VersionedNodeType { 200 | baseDescription = { 201 | name: 'complexVersioned', 202 | displayName: 'Complex Versioned Node', 203 | defaultVersion: 3 204 | }; 205 | 206 | nodeVersions = { 207 | 1: { 208 | description: { 209 | outputs: [{ displayName: 'V1 Output' }] 210 | } 211 | }, 212 | 2: { 213 | description: { 214 | outputs: [ 215 | { displayName: 'V2 Output 1' }, 216 | { displayName: 'V2 Output 2' } 217 | ] 218 | } 219 | }, 220 | 3: { 221 | description: { 222 | outputs: [ 223 | { displayName: 'V3 True', description: 'True branch' }, 224 | { displayName: 'V3 False', description: 'False branch' } 225 | ], 226 | outputNames: ['true', 'false'] 227 | } 228 | } 229 | }; 230 | }; 231 | 232 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 233 | 234 | // Should use latest version (3) 235 | expect(result.outputs).toEqual([ 236 | { displayName: 'V3 True', description: 'True branch' }, 237 | { displayName: 'V3 False', description: 'False branch' } 238 | ]); 239 | expect(result.outputNames).toEqual(['true', 'false']); 240 | }); 241 | 242 | it('should prefer base description outputs over versioned when both exist', () => { 243 | const baseOutputs = [{ displayName: 'Base Output' }]; 244 | const versionedOutputs = [{ displayName: 'Versioned Output' }]; 245 | 246 | const NodeClass = class { 247 | description = { 248 | name: 'preferBase', 249 | displayName: 'Prefer Base', 250 | outputs: baseOutputs 251 | }; 252 | 253 | nodeVersions = { 254 | 1: { 255 | description: { 256 | outputs: versionedOutputs 257 | } 258 | } 259 | }; 260 | }; 261 | 262 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 263 | 264 | expect(result.outputs).toEqual(baseOutputs); 265 | }); 266 | 267 | it('should handle IF node with typical output structure', () => { 268 | const ifOutputs = [ 269 | { displayName: 'True', description: 'Items that match the condition' }, 270 | { displayName: 'False', description: 'Items that do not match the condition' } 271 | ]; 272 | 273 | const NodeClass = class { 274 | description = { 275 | name: 'if', 276 | displayName: 'IF', 277 | outputs: ifOutputs, 278 | outputNames: ['true', 'false'] 279 | }; 280 | }; 281 | 282 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 283 | 284 | expect(result.outputs).toEqual(ifOutputs); 285 | expect(result.outputNames).toEqual(['true', 'false']); 286 | }); 287 | 288 | it('should handle SplitInBatches node with counterintuitive output structure', () => { 289 | const splitInBatchesOutputs = [ 290 | { displayName: 'Done', description: 'Final results when loop completes' }, 291 | { displayName: 'Loop', description: 'Current batch data during iteration' } 292 | ]; 293 | 294 | const NodeClass = class { 295 | description = { 296 | name: 'splitInBatches', 297 | displayName: 'Split In Batches', 298 | outputs: splitInBatchesOutputs, 299 | outputNames: ['done', 'loop'] 300 | }; 301 | }; 302 | 303 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 304 | 305 | expect(result.outputs).toEqual(splitInBatchesOutputs); 306 | expect(result.outputNames).toEqual(['done', 'loop']); 307 | 308 | // Verify the counterintuitive order: done=0, loop=1 309 | expect(result.outputs).toBeDefined(); 310 | expect(result.outputNames).toBeDefined(); 311 | expect(result.outputs![0].displayName).toBe('Done'); 312 | expect(result.outputs![1].displayName).toBe('Loop'); 313 | expect(result.outputNames![0]).toBe('done'); 314 | expect(result.outputNames![1]).toBe('loop'); 315 | }); 316 | 317 | it('should handle Switch node with multiple outputs', () => { 318 | const switchOutputs = [ 319 | { displayName: 'Output 1', description: 'First branch' }, 320 | { displayName: 'Output 2', description: 'Second branch' }, 321 | { displayName: 'Output 3', description: 'Third branch' }, 322 | { displayName: 'Fallback', description: 'Default branch when no conditions match' } 323 | ]; 324 | 325 | const NodeClass = class { 326 | description = { 327 | name: 'switch', 328 | displayName: 'Switch', 329 | outputs: switchOutputs, 330 | outputNames: ['0', '1', '2', 'fallback'] 331 | }; 332 | }; 333 | 334 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 335 | 336 | expect(result.outputs).toEqual(switchOutputs); 337 | expect(result.outputNames).toEqual(['0', '1', '2', 'fallback']); 338 | }); 339 | 340 | it('should handle empty outputs array', () => { 341 | const NodeClass = class { 342 | description = { 343 | name: 'emptyOutputs', 344 | displayName: 'Empty Outputs', 345 | outputs: [], 346 | outputNames: [] 347 | }; 348 | }; 349 | 350 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 351 | 352 | expect(result.outputs).toEqual([]); 353 | expect(result.outputNames).toEqual([]); 354 | }); 355 | 356 | it('should handle mismatched outputs and outputNames arrays', () => { 357 | const outputs = [ 358 | { displayName: 'Output 1' }, 359 | { displayName: 'Output 2' } 360 | ]; 361 | const outputNames = ['first', 'second', 'third']; // One extra 362 | 363 | const NodeClass = class { 364 | description = { 365 | name: 'mismatched', 366 | displayName: 'Mismatched Arrays', 367 | outputs, 368 | outputNames 369 | }; 370 | }; 371 | 372 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 373 | 374 | expect(result.outputs).toEqual(outputs); 375 | expect(result.outputNames).toEqual(outputNames); 376 | }); 377 | }); 378 | 379 | describe('real-world node structures', () => { 380 | it('should handle actual n8n SplitInBatches node structure', () => { 381 | // This mimics the actual structure from n8n-nodes-base 382 | const NodeClass = class { 383 | description = { 384 | name: 'splitInBatches', 385 | displayName: 'Split In Batches', 386 | description: 'Split data into batches and iterate over each batch', 387 | icon: 'fa:th-large', 388 | group: ['transform'], 389 | version: 3, 390 | outputs: [ 391 | { 392 | displayName: 'Done', 393 | name: 'done', 394 | type: 'main', 395 | hint: 'Receives the final data after all batches have been processed' 396 | }, 397 | { 398 | displayName: 'Loop', 399 | name: 'loop', 400 | type: 'main', 401 | hint: 'Receives the current batch data during each iteration' 402 | } 403 | ], 404 | outputNames: ['done', 'loop'] 405 | }; 406 | }; 407 | 408 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 409 | 410 | expect(result.outputs).toHaveLength(2); 411 | expect(result.outputs).toBeDefined(); 412 | expect(result.outputs![0].displayName).toBe('Done'); 413 | expect(result.outputs![1].displayName).toBe('Loop'); 414 | expect(result.outputNames).toEqual(['done', 'loop']); 415 | }); 416 | 417 | it('should handle actual n8n IF node structure', () => { 418 | // This mimics the actual structure from n8n-nodes-base 419 | const NodeClass = class { 420 | description = { 421 | name: 'if', 422 | displayName: 'IF', 423 | description: 'Route items to different outputs based on conditions', 424 | icon: 'fa:map-signs', 425 | group: ['transform'], 426 | version: 2, 427 | outputs: [ 428 | { 429 | displayName: 'True', 430 | name: 'true', 431 | type: 'main', 432 | hint: 'Items that match the condition' 433 | }, 434 | { 435 | displayName: 'False', 436 | name: 'false', 437 | type: 'main', 438 | hint: 'Items that do not match the condition' 439 | } 440 | ], 441 | outputNames: ['true', 'false'] 442 | }; 443 | }; 444 | 445 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 446 | 447 | expect(result.outputs).toHaveLength(2); 448 | expect(result.outputs).toBeDefined(); 449 | expect(result.outputs![0].displayName).toBe('True'); 450 | expect(result.outputs![1].displayName).toBe('False'); 451 | expect(result.outputNames).toEqual(['true', 'false']); 452 | }); 453 | 454 | it('should handle single-output nodes like HTTP Request', () => { 455 | const NodeClass = class { 456 | description = { 457 | name: 'httpRequest', 458 | displayName: 'HTTP Request', 459 | description: 'Make HTTP requests', 460 | icon: 'fa:at', 461 | group: ['input'], 462 | version: 4 463 | // No outputs specified - single main output implied 464 | }; 465 | }; 466 | 467 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 468 | 469 | expect(result.outputs).toBeUndefined(); 470 | expect(result.outputNames).toBeUndefined(); 471 | }); 472 | }); 473 | }); ``` -------------------------------------------------------------------------------- /src/data/canonical-ai-tool-examples.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "description": "Canonical configuration examples for critical AI tools based on FINAL_AI_VALIDATION_SPEC.md", 3 | "version": "1.0.0", 4 | "examples": [ 5 | { 6 | "node_type": "@n8n/n8n-nodes-langchain.toolHttpRequest", 7 | "display_name": "HTTP Request Tool", 8 | "examples": [ 9 | { 10 | "name": "Weather API Tool", 11 | "use_case": "Fetch current weather data for AI Agent", 12 | "complexity": "simple", 13 | "parameters": { 14 | "method": "GET", 15 | "url": "https://api.weatherapi.com/v1/current.json?key={{$credentials.weatherApiKey}}&q={city}", 16 | "toolDescription": "Get current weather conditions for a city. Provide the city name (e.g., 'London', 'New York') and receive temperature, humidity, wind speed, and conditions.", 17 | "placeholderDefinitions": { 18 | "values": [ 19 | { 20 | "name": "city", 21 | "description": "Name of the city to get weather for", 22 | "type": "string" 23 | } 24 | ] 25 | }, 26 | "authentication": "predefinedCredentialType", 27 | "nodeCredentialType": "weatherApiApi" 28 | }, 29 | "credentials": { 30 | "weatherApiApi": { 31 | "id": "1", 32 | "name": "Weather API account" 33 | } 34 | }, 35 | "notes": "Example shows proper toolDescription, URL with placeholder, and credential configuration" 36 | }, 37 | { 38 | "name": "GitHub Issues Tool", 39 | "use_case": "Create GitHub issues from AI Agent conversations", 40 | "complexity": "medium", 41 | "parameters": { 42 | "method": "POST", 43 | "url": "https://api.github.com/repos/{owner}/{repo}/issues", 44 | "toolDescription": "Create a new GitHub issue. Requires owner (repo owner username), repo (repository name), title, and body. Returns the created issue URL and number.", 45 | "placeholderDefinitions": { 46 | "values": [ 47 | { 48 | "name": "owner", 49 | "description": "GitHub repository owner username", 50 | "type": "string" 51 | }, 52 | { 53 | "name": "repo", 54 | "description": "Repository name", 55 | "type": "string" 56 | }, 57 | { 58 | "name": "title", 59 | "description": "Issue title", 60 | "type": "string" 61 | }, 62 | { 63 | "name": "body", 64 | "description": "Issue description and details", 65 | "type": "string" 66 | } 67 | ] 68 | }, 69 | "sendBody": true, 70 | "specifyBody": "json", 71 | "jsonBody": "={{ { \"title\": $json.title, \"body\": $json.body } }}", 72 | "authentication": "predefinedCredentialType", 73 | "nodeCredentialType": "githubApi" 74 | }, 75 | "credentials": { 76 | "githubApi": { 77 | "id": "2", 78 | "name": "GitHub credentials" 79 | } 80 | }, 81 | "notes": "Example shows POST request with JSON body, multiple placeholders, and expressions" 82 | }, 83 | { 84 | "name": "Slack Message Tool", 85 | "use_case": "Send Slack messages from AI Agent", 86 | "complexity": "simple", 87 | "parameters": { 88 | "method": "POST", 89 | "url": "https://slack.com/api/chat.postMessage", 90 | "toolDescription": "Send a message to a Slack channel. Provide channel ID or name (e.g., '#general', 'C1234567890') and message text.", 91 | "placeholderDefinitions": { 92 | "values": [ 93 | { 94 | "name": "channel", 95 | "description": "Channel ID or name (e.g., #general)", 96 | "type": "string" 97 | }, 98 | { 99 | "name": "text", 100 | "description": "Message text to send", 101 | "type": "string" 102 | } 103 | ] 104 | }, 105 | "sendHeaders": true, 106 | "headerParameters": { 107 | "parameters": [ 108 | { 109 | "name": "Content-Type", 110 | "value": "application/json; charset=utf-8" 111 | }, 112 | { 113 | "name": "Authorization", 114 | "value": "=Bearer {{$credentials.slackApi.accessToken}}" 115 | } 116 | ] 117 | }, 118 | "sendBody": true, 119 | "specifyBody": "json", 120 | "jsonBody": "={{ { \"channel\": $json.channel, \"text\": $json.text } }}", 121 | "authentication": "predefinedCredentialType", 122 | "nodeCredentialType": "slackApi" 123 | }, 124 | "credentials": { 125 | "slackApi": { 126 | "id": "3", 127 | "name": "Slack account" 128 | } 129 | }, 130 | "notes": "Example shows headers with credential expressions and JSON body construction" 131 | } 132 | ] 133 | }, 134 | { 135 | "node_type": "@n8n/n8n-nodes-langchain.toolCode", 136 | "display_name": "Code Tool", 137 | "examples": [ 138 | { 139 | "name": "Calculate Shipping Cost", 140 | "use_case": "Calculate shipping costs based on weight and distance", 141 | "complexity": "simple", 142 | "parameters": { 143 | "name": "calculate_shipping_cost", 144 | "description": "Calculate shipping cost based on package weight (in kg) and distance (in km). Returns the cost in USD.", 145 | "language": "javaScript", 146 | "code": "const baseRate = 5;\nconst perKgRate = 2;\nconst perKmRate = 0.1;\n\nconst weight = $input.weight || 0;\nconst distance = $input.distance || 0;\n\nconst cost = baseRate + (weight * perKgRate) + (distance * perKmRate);\n\nreturn { cost: parseFloat(cost.toFixed(2)), currency: 'USD' };", 147 | "specifyInputSchema": true, 148 | "schemaType": "manual", 149 | "inputSchema": "{\n \"type\": \"object\",\n \"properties\": {\n \"weight\": {\n \"type\": \"number\",\n \"description\": \"Package weight in kilograms\"\n },\n \"distance\": {\n \"type\": \"number\",\n \"description\": \"Shipping distance in kilometers\"\n }\n },\n \"required\": [\"weight\", \"distance\"]\n}" 150 | }, 151 | "notes": "Example shows proper function naming, detailed description, input schema, and return value" 152 | }, 153 | { 154 | "name": "Format Customer Data", 155 | "use_case": "Transform and validate customer information", 156 | "complexity": "medium", 157 | "parameters": { 158 | "name": "format_customer_data", 159 | "description": "Format and validate customer data. Takes raw customer info (name, email, phone) and returns formatted object with validation status.", 160 | "language": "javaScript", 161 | "code": "const { name, email, phone } = $input;\n\n// Validation\nconst emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\nconst phoneRegex = /^\\+?[1-9]\\d{1,14}$/;\n\nconst errors = [];\nif (!emailRegex.test(email)) errors.push('Invalid email format');\nif (!phoneRegex.test(phone)) errors.push('Invalid phone format');\n\n// Formatting\nconst formatted = {\n name: name.trim(),\n email: email.toLowerCase().trim(),\n phone: phone.replace(/\\s/g, ''),\n valid: errors.length === 0,\n errors: errors\n};\n\nreturn formatted;", 162 | "specifyInputSchema": true, 163 | "schemaType": "manual", 164 | "inputSchema": "{\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\",\n \"description\": \"Customer full name\"\n },\n \"email\": {\n \"type\": \"string\",\n \"description\": \"Customer email address\"\n },\n \"phone\": {\n \"type\": \"string\",\n \"description\": \"Customer phone number\"\n }\n },\n \"required\": [\"name\", \"email\", \"phone\"]\n}" 165 | }, 166 | "notes": "Example shows data validation, formatting, and structured error handling" 167 | }, 168 | { 169 | "name": "Parse Date Range", 170 | "use_case": "Convert natural language date ranges to ISO format", 171 | "complexity": "medium", 172 | "parameters": { 173 | "name": "parse_date_range", 174 | "description": "Parse natural language date ranges (e.g., 'last 7 days', 'this month', 'Q1 2024') into start and end dates in ISO format.", 175 | "language": "javaScript", 176 | "code": "const input = $input.dateRange || '';\nconst now = new Date();\nlet start, end;\n\nif (input.includes('last') && input.includes('days')) {\n const days = parseInt(input.match(/\\d+/)[0]);\n start = new Date(now.getTime() - (days * 24 * 60 * 60 * 1000));\n end = now;\n} else if (input === 'this month') {\n start = new Date(now.getFullYear(), now.getMonth(), 1);\n end = new Date(now.getFullYear(), now.getMonth() + 1, 0);\n} else if (input === 'this year') {\n start = new Date(now.getFullYear(), 0, 1);\n end = new Date(now.getFullYear(), 11, 31);\n} else {\n throw new Error('Unsupported date range format');\n}\n\nreturn {\n startDate: start.toISOString().split('T')[0],\n endDate: end.toISOString().split('T')[0],\n daysCount: Math.ceil((end - start) / (24 * 60 * 60 * 1000))\n};", 177 | "specifyInputSchema": true, 178 | "schemaType": "manual", 179 | "inputSchema": "{\n \"type\": \"object\",\n \"properties\": {\n \"dateRange\": {\n \"type\": \"string\",\n \"description\": \"Natural language date range (e.g., 'last 7 days', 'this month')\"\n }\n },\n \"required\": [\"dateRange\"]\n}" 180 | }, 181 | "notes": "Example shows complex logic, error handling, and date manipulation" 182 | } 183 | ] 184 | }, 185 | { 186 | "node_type": "@n8n/n8n-nodes-langchain.agentTool", 187 | "display_name": "AI Agent Tool", 188 | "examples": [ 189 | { 190 | "name": "Research Specialist Agent", 191 | "use_case": "Specialized sub-agent for in-depth research tasks", 192 | "complexity": "medium", 193 | "parameters": { 194 | "name": "research_specialist", 195 | "description": "Expert research agent that can search multiple sources, synthesize information, and provide comprehensive analysis on any topic. Use this when you need detailed, well-researched information.", 196 | "promptType": "define", 197 | "text": "You are a research specialist. Your role is to:\n1. Search for relevant information from multiple sources\n2. Synthesize findings into a coherent analysis\n3. Cite your sources\n4. Highlight key insights and patterns\n\nProvide thorough, well-structured research that answers the user's question comprehensively.", 198 | "systemMessage": "You are a meticulous researcher focused on accuracy and completeness. Always cite sources and acknowledge limitations in available information." 199 | }, 200 | "connections": { 201 | "ai_languageModel": [ 202 | { 203 | "node": "OpenAI GPT-4", 204 | "type": "ai_languageModel", 205 | "index": 0 206 | } 207 | ], 208 | "ai_tool": [ 209 | { 210 | "node": "SerpApi Tool", 211 | "type": "ai_tool", 212 | "index": 0 213 | }, 214 | { 215 | "node": "Wikipedia Tool", 216 | "type": "ai_tool", 217 | "index": 0 218 | } 219 | ] 220 | }, 221 | "notes": "Example shows specialized sub-agent with custom prompt, specific system message, and multiple search tools" 222 | }, 223 | { 224 | "name": "Data Analysis Agent", 225 | "use_case": "Sub-agent for analyzing and visualizing data", 226 | "complexity": "complex", 227 | "parameters": { 228 | "name": "data_analyst", 229 | "description": "Data analysis specialist that can process datasets, calculate statistics, identify trends, and generate insights. Use for any data analysis or statistical questions.", 230 | "promptType": "auto", 231 | "systemMessage": "You are a data analyst with expertise in statistics and data interpretation. Break down complex datasets into understandable insights. Use the Code Tool to perform calculations when needed.", 232 | "maxIterations": 10 233 | }, 234 | "connections": { 235 | "ai_languageModel": [ 236 | { 237 | "node": "Anthropic Claude", 238 | "type": "ai_languageModel", 239 | "index": 0 240 | } 241 | ], 242 | "ai_tool": [ 243 | { 244 | "node": "Code Tool - Stats", 245 | "type": "ai_tool", 246 | "index": 0 247 | }, 248 | { 249 | "node": "HTTP Request Tool - Data API", 250 | "type": "ai_tool", 251 | "index": 0 252 | } 253 | ] 254 | }, 255 | "notes": "Example shows auto prompt type with specialized system message and analytical tools" 256 | } 257 | ] 258 | }, 259 | { 260 | "node_type": "@n8n/n8n-nodes-langchain.mcpClientTool", 261 | "display_name": "MCP Client Tool", 262 | "examples": [ 263 | { 264 | "name": "Filesystem MCP Tool", 265 | "use_case": "Access filesystem operations via MCP protocol", 266 | "complexity": "medium", 267 | "parameters": { 268 | "description": "Access file system operations through MCP. Can read files, list directories, create files, and search for content.", 269 | "mcpServer": { 270 | "transport": "stdio", 271 | "command": "npx", 272 | "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/directory"] 273 | }, 274 | "tool": "read_file" 275 | }, 276 | "notes": "Example shows stdio transport MCP server with filesystem access tool" 277 | }, 278 | { 279 | "name": "Puppeteer MCP Tool", 280 | "use_case": "Browser automation via MCP for AI Agents", 281 | "complexity": "complex", 282 | "parameters": { 283 | "description": "Control a web browser to navigate pages, take screenshots, and extract content. Useful for web scraping and automated testing.", 284 | "mcpServer": { 285 | "transport": "stdio", 286 | "command": "npx", 287 | "args": ["-y", "@modelcontextprotocol/server-puppeteer"] 288 | }, 289 | "tool": "puppeteer_navigate" 290 | }, 291 | "notes": "Example shows Puppeteer MCP server for browser automation" 292 | }, 293 | { 294 | "name": "Database MCP Tool", 295 | "use_case": "Query databases via MCP protocol", 296 | "complexity": "complex", 297 | "parameters": { 298 | "description": "Execute SQL queries and retrieve data from PostgreSQL databases. Supports SELECT, INSERT, UPDATE operations with proper escaping.", 299 | "mcpServer": { 300 | "transport": "sse", 301 | "url": "https://mcp-server.example.com/database" 302 | }, 303 | "tool": "execute_query" 304 | }, 305 | "notes": "Example shows SSE transport MCP server for remote database access" 306 | } 307 | ] 308 | } 309 | ] 310 | } 311 | ``` -------------------------------------------------------------------------------- /tests/unit/services/workflow-validator-with-mocks.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { WorkflowValidator } from '@/services/workflow-validator'; 3 | import { EnhancedConfigValidator } from '@/services/enhanced-config-validator'; 4 | 5 | // Mock logger to prevent console output 6 | vi.mock('@/utils/logger', () => ({ 7 | Logger: vi.fn().mockImplementation(() => ({ 8 | error: vi.fn(), 9 | warn: vi.fn(), 10 | info: vi.fn() 11 | })) 12 | })); 13 | 14 | describe('WorkflowValidator - Simple Unit Tests', () => { 15 | let validator: WorkflowValidator; 16 | 17 | // Create a simple mock repository 18 | const createMockRepository = (nodeData: Record<string, any>) => ({ 19 | getNode: vi.fn((type: string) => nodeData[type] || null), 20 | findSimilarNodes: vi.fn().mockReturnValue([]) 21 | }); 22 | 23 | // Create a simple mock validator class 24 | const createMockValidatorClass = (validationResult: any) => ({ 25 | validateWithMode: vi.fn().mockReturnValue(validationResult) 26 | }); 27 | 28 | beforeEach(() => { 29 | vi.clearAllMocks(); 30 | }); 31 | 32 | describe('Basic validation scenarios', () => { 33 | it('should pass validation for a webhook workflow with single node', async () => { 34 | // Arrange 35 | const nodeData = { 36 | 'n8n-nodes-base.webhook': { 37 | type: 'nodes-base.webhook', 38 | displayName: 'Webhook', 39 | name: 'webhook', 40 | version: 1, 41 | isVersioned: true, 42 | properties: [] 43 | }, 44 | 'nodes-base.webhook': { 45 | type: 'nodes-base.webhook', 46 | displayName: 'Webhook', 47 | name: 'webhook', 48 | version: 1, 49 | isVersioned: true, 50 | properties: [] 51 | } 52 | }; 53 | 54 | const mockRepository = createMockRepository(nodeData); 55 | const mockValidatorClass = createMockValidatorClass({ 56 | valid: true, 57 | errors: [], 58 | warnings: [], 59 | suggestions: [] 60 | }); 61 | 62 | validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any); 63 | 64 | const workflow = { 65 | name: 'Webhook Workflow', 66 | nodes: [ 67 | { 68 | id: '1', 69 | name: 'Webhook', 70 | type: 'n8n-nodes-base.webhook', 71 | typeVersion: 1, 72 | position: [250, 300] as [number, number], 73 | parameters: {} 74 | } 75 | ], 76 | connections: {} 77 | }; 78 | 79 | // Act 80 | const result = await validator.validateWorkflow(workflow as any); 81 | 82 | // Assert 83 | expect(result.valid).toBe(true); 84 | expect(result.errors).toHaveLength(0); 85 | // Single webhook node should just have a warning about no connections 86 | expect(result.warnings.some(w => w.message.includes('no connections'))).toBe(true); 87 | }); 88 | 89 | it('should fail validation for unknown node types', async () => { 90 | // Arrange 91 | const mockRepository = createMockRepository({}); // Empty node data 92 | const mockValidatorClass = createMockValidatorClass({ 93 | valid: true, 94 | errors: [], 95 | warnings: [], 96 | suggestions: [] 97 | }); 98 | 99 | validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any); 100 | 101 | const workflow = { 102 | name: 'Test Workflow', 103 | nodes: [ 104 | { 105 | id: '1', 106 | name: 'Unknown', 107 | type: 'n8n-nodes-base.unknownNode', 108 | position: [250, 300] as [number, number], 109 | parameters: {} 110 | } 111 | ], 112 | connections: {} 113 | }; 114 | 115 | // Act 116 | const result = await validator.validateWorkflow(workflow as any); 117 | 118 | // Assert 119 | expect(result.valid).toBe(false); 120 | // Check for either the error message or valid being false 121 | const hasUnknownNodeError = result.errors.some(e => 122 | e.message && (e.message.includes('Unknown node type') || e.message.includes('unknown-node-type')) 123 | ); 124 | expect(result.errors.length > 0 || hasUnknownNodeError).toBe(true); 125 | }); 126 | 127 | it('should detect duplicate node names', async () => { 128 | // Arrange 129 | const mockRepository = createMockRepository({}); 130 | const mockValidatorClass = createMockValidatorClass({ 131 | valid: true, 132 | errors: [], 133 | warnings: [], 134 | suggestions: [] 135 | }); 136 | 137 | validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any); 138 | 139 | const workflow = { 140 | name: 'Duplicate Names', 141 | nodes: [ 142 | { 143 | id: '1', 144 | name: 'HTTP Request', 145 | type: 'n8n-nodes-base.httpRequest', 146 | position: [250, 300] as [number, number], 147 | parameters: {} 148 | }, 149 | { 150 | id: '2', 151 | name: 'HTTP Request', // Duplicate name 152 | type: 'n8n-nodes-base.httpRequest', 153 | position: [450, 300] as [number, number], 154 | parameters: {} 155 | } 156 | ], 157 | connections: {} 158 | }; 159 | 160 | // Act 161 | const result = await validator.validateWorkflow(workflow as any); 162 | 163 | // Assert 164 | expect(result.valid).toBe(false); 165 | expect(result.errors.some(e => e.message.includes('Duplicate node name'))).toBe(true); 166 | }); 167 | 168 | it('should validate connections properly', async () => { 169 | // Arrange 170 | const nodeData = { 171 | 'n8n-nodes-base.manualTrigger': { 172 | type: 'nodes-base.manualTrigger', 173 | displayName: 'Manual Trigger', 174 | isVersioned: false, 175 | properties: [] 176 | }, 177 | 'nodes-base.manualTrigger': { 178 | type: 'nodes-base.manualTrigger', 179 | displayName: 'Manual Trigger', 180 | isVersioned: false, 181 | properties: [] 182 | }, 183 | 'n8n-nodes-base.set': { 184 | type: 'nodes-base.set', 185 | displayName: 'Set', 186 | version: 2, 187 | isVersioned: true, 188 | properties: [] 189 | }, 190 | 'nodes-base.set': { 191 | type: 'nodes-base.set', 192 | displayName: 'Set', 193 | version: 2, 194 | isVersioned: true, 195 | properties: [] 196 | } 197 | }; 198 | 199 | const mockRepository = createMockRepository(nodeData); 200 | const mockValidatorClass = createMockValidatorClass({ 201 | valid: true, 202 | errors: [], 203 | warnings: [], 204 | suggestions: [] 205 | }); 206 | 207 | validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any); 208 | 209 | const workflow = { 210 | name: 'Connected Workflow', 211 | nodes: [ 212 | { 213 | id: '1', 214 | name: 'Manual Trigger', 215 | type: 'n8n-nodes-base.manualTrigger', 216 | position: [250, 300] as [number, number], 217 | parameters: {} 218 | }, 219 | { 220 | id: '2', 221 | name: 'Set', 222 | type: 'n8n-nodes-base.set', 223 | typeVersion: 2, 224 | position: [450, 300] as [number, number], 225 | parameters: {} 226 | } 227 | ], 228 | connections: { 229 | 'Manual Trigger': { 230 | main: [[{ node: 'Set', type: 'main', index: 0 }]] 231 | } 232 | } 233 | }; 234 | 235 | // Act 236 | const result = await validator.validateWorkflow(workflow as any); 237 | 238 | // Assert 239 | expect(result.valid).toBe(true); 240 | expect(result.statistics.validConnections).toBe(1); 241 | expect(result.statistics.invalidConnections).toBe(0); 242 | }); 243 | 244 | it('should detect workflow cycles', async () => { 245 | // Arrange 246 | const nodeData = { 247 | 'n8n-nodes-base.set': { 248 | type: 'nodes-base.set', 249 | displayName: 'Set', 250 | isVersioned: true, 251 | version: 2, 252 | properties: [] 253 | }, 254 | 'nodes-base.set': { 255 | type: 'nodes-base.set', 256 | displayName: 'Set', 257 | isVersioned: true, 258 | version: 2, 259 | properties: [] 260 | } 261 | }; 262 | 263 | const mockRepository = createMockRepository(nodeData); 264 | const mockValidatorClass = createMockValidatorClass({ 265 | valid: true, 266 | errors: [], 267 | warnings: [], 268 | suggestions: [] 269 | }); 270 | 271 | validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any); 272 | 273 | const workflow = { 274 | name: 'Cyclic Workflow', 275 | nodes: [ 276 | { 277 | id: '1', 278 | name: 'Node A', 279 | type: 'n8n-nodes-base.set', 280 | typeVersion: 2, 281 | position: [250, 300] as [number, number], 282 | parameters: {} 283 | }, 284 | { 285 | id: '2', 286 | name: 'Node B', 287 | type: 'n8n-nodes-base.set', 288 | typeVersion: 2, 289 | position: [450, 300] as [number, number], 290 | parameters: {} 291 | } 292 | ], 293 | connections: { 294 | 'Node A': { 295 | main: [[{ node: 'Node B', type: 'main', index: 0 }]] 296 | }, 297 | 'Node B': { 298 | main: [[{ node: 'Node A', type: 'main', index: 0 }]] // Creates a cycle 299 | } 300 | } 301 | }; 302 | 303 | // Act 304 | const result = await validator.validateWorkflow(workflow as any); 305 | 306 | // Assert 307 | expect(result.valid).toBe(false); 308 | expect(result.errors.some(e => e.message.includes('cycle'))).toBe(true); 309 | }); 310 | 311 | it('should handle null workflow gracefully', async () => { 312 | // Arrange 313 | const mockRepository = createMockRepository({}); 314 | const mockValidatorClass = createMockValidatorClass({ 315 | valid: true, 316 | errors: [], 317 | warnings: [], 318 | suggestions: [] 319 | }); 320 | 321 | validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any); 322 | 323 | // Act 324 | const result = await validator.validateWorkflow(null as any); 325 | 326 | // Assert 327 | expect(result.valid).toBe(false); 328 | expect(result.errors[0].message).toContain('workflow is null or undefined'); 329 | }); 330 | 331 | it('should require connections for multi-node workflows', async () => { 332 | // Arrange 333 | const nodeData = { 334 | 'n8n-nodes-base.manualTrigger': { 335 | type: 'nodes-base.manualTrigger', 336 | displayName: 'Manual Trigger', 337 | properties: [] 338 | }, 339 | 'nodes-base.manualTrigger': { 340 | type: 'nodes-base.manualTrigger', 341 | displayName: 'Manual Trigger', 342 | properties: [] 343 | }, 344 | 'n8n-nodes-base.set': { 345 | type: 'nodes-base.set', 346 | displayName: 'Set', 347 | version: 2, 348 | isVersioned: true, 349 | properties: [] 350 | }, 351 | 'nodes-base.set': { 352 | type: 'nodes-base.set', 353 | displayName: 'Set', 354 | version: 2, 355 | isVersioned: true, 356 | properties: [] 357 | } 358 | }; 359 | 360 | const mockRepository = createMockRepository(nodeData); 361 | const mockValidatorClass = createMockValidatorClass({ 362 | valid: true, 363 | errors: [], 364 | warnings: [], 365 | suggestions: [] 366 | }); 367 | 368 | validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any); 369 | 370 | const workflow = { 371 | name: 'No Connections', 372 | nodes: [ 373 | { 374 | id: '1', 375 | name: 'Manual Trigger', 376 | type: 'n8n-nodes-base.manualTrigger', 377 | position: [250, 300] as [number, number], 378 | parameters: {} 379 | }, 380 | { 381 | id: '2', 382 | name: 'Set', 383 | type: 'n8n-nodes-base.set', 384 | typeVersion: 2, 385 | position: [450, 300] as [number, number], 386 | parameters: {} 387 | } 388 | ], 389 | connections: {} // No connections between nodes 390 | }; 391 | 392 | // Act 393 | const result = await validator.validateWorkflow(workflow as any); 394 | 395 | // Assert 396 | expect(result.valid).toBe(false); 397 | expect(result.errors.some(e => e.message.includes('Multi-node workflow has no connections'))).toBe(true); 398 | }); 399 | 400 | it('should validate typeVersion for versioned nodes', async () => { 401 | // Arrange 402 | const nodeData = { 403 | 'n8n-nodes-base.httpRequest': { 404 | type: 'nodes-base.httpRequest', 405 | displayName: 'HTTP Request', 406 | isVersioned: true, 407 | version: 3, // Latest version is 3 408 | properties: [] 409 | }, 410 | 'nodes-base.httpRequest': { 411 | type: 'nodes-base.httpRequest', 412 | displayName: 'HTTP Request', 413 | isVersioned: true, 414 | version: 3, 415 | properties: [] 416 | } 417 | }; 418 | 419 | const mockRepository = createMockRepository(nodeData); 420 | const mockValidatorClass = createMockValidatorClass({ 421 | valid: true, 422 | errors: [], 423 | warnings: [], 424 | suggestions: [] 425 | }); 426 | 427 | validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any); 428 | 429 | const workflow = { 430 | name: 'Version Test', 431 | nodes: [ 432 | { 433 | id: '1', 434 | name: 'HTTP Request', 435 | type: 'n8n-nodes-base.httpRequest', 436 | typeVersion: 2, // Outdated version 437 | position: [250, 300] as [number, number], 438 | parameters: {} 439 | } 440 | ], 441 | connections: {} 442 | }; 443 | 444 | // Act 445 | const result = await validator.validateWorkflow(workflow as any); 446 | 447 | // Assert 448 | expect(result.warnings.some(w => w.message.includes('Outdated typeVersion'))).toBe(true); 449 | }); 450 | 451 | it('should normalize and validate nodes-base prefix to find the node', async () => { 452 | // Arrange - Test that full-form types are normalized to short form to find the node 453 | // The repository only has the node under the SHORT normalized key (database format) 454 | const nodeData = { 455 | 'nodes-base.webhook': { // Repository has it under SHORT form (database format) 456 | type: 'nodes-base.webhook', 457 | displayName: 'Webhook', 458 | isVersioned: true, 459 | version: 2, 460 | properties: [] 461 | } 462 | }; 463 | 464 | // Mock repository that simulates the normalization behavior 465 | // After our changes, getNode is called with the already-normalized type (short form) 466 | const mockRepository = { 467 | getNode: vi.fn((type: string) => { 468 | // The validator now normalizes to short form before calling getNode 469 | // So getNode receives 'nodes-base.webhook' 470 | if (type === 'nodes-base.webhook') { 471 | return nodeData['nodes-base.webhook']; 472 | } 473 | return null; 474 | }), 475 | findSimilarNodes: vi.fn().mockReturnValue([]) 476 | }; 477 | 478 | const mockValidatorClass = createMockValidatorClass({ 479 | valid: true, 480 | errors: [], 481 | warnings: [], 482 | suggestions: [] 483 | }); 484 | 485 | validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any); 486 | 487 | const workflow = { 488 | name: 'Valid Alternative Prefix', 489 | nodes: [ 490 | { 491 | id: '1', 492 | name: 'Webhook', 493 | type: 'n8n-nodes-base.webhook', // Using the full-form prefix (will be normalized to short) 494 | position: [250, 300] as [number, number], 495 | parameters: {}, 496 | typeVersion: 2 497 | } 498 | ], 499 | connections: {} 500 | }; 501 | 502 | // Act 503 | const result = await validator.validateWorkflow(workflow as any); 504 | 505 | // Assert - The node should be found through normalization 506 | expect(result.valid).toBe(true); 507 | expect(result.errors).toHaveLength(0); 508 | 509 | // Verify the repository was called (once with original, once with normalized) 510 | expect(mockRepository.getNode).toHaveBeenCalled(); 511 | }); 512 | }); 513 | }); ``` -------------------------------------------------------------------------------- /tests/unit/mcp/lru-cache-behavior.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Comprehensive unit tests for LRU cache behavior in handlers-n8n-manager.ts 3 | * 4 | * This test file focuses specifically on cache behavior, TTL, eviction, and dispose callbacks 5 | */ 6 | 7 | import { describe, it, expect, beforeEach, afterEach, vi, Mock } from 'vitest'; 8 | import { LRUCache } from 'lru-cache'; 9 | import { createHash } from 'crypto'; 10 | import { getN8nApiClient } from '../../../src/mcp/handlers-n8n-manager'; 11 | import { InstanceContext, validateInstanceContext } from '../../../src/types/instance-context'; 12 | import { N8nApiClient } from '../../../src/services/n8n-api-client'; 13 | import { getN8nApiConfigFromContext } from '../../../src/config/n8n-api'; 14 | import { logger } from '../../../src/utils/logger'; 15 | 16 | // Mock dependencies 17 | vi.mock('../../../src/services/n8n-api-client'); 18 | vi.mock('../../../src/config/n8n-api'); 19 | vi.mock('../../../src/utils/logger'); 20 | vi.mock('../../../src/types/instance-context', async () => { 21 | const actual = await vi.importActual('../../../src/types/instance-context'); 22 | return { 23 | ...actual, 24 | validateInstanceContext: vi.fn() 25 | }; 26 | }); 27 | 28 | describe('LRU Cache Behavior Tests', () => { 29 | let mockN8nApiClient: Mock; 30 | let mockGetN8nApiConfigFromContext: Mock; 31 | let mockLogger: any; // Logger mock has complex type 32 | let mockValidateInstanceContext: Mock; 33 | 34 | beforeEach(() => { 35 | vi.resetAllMocks(); 36 | vi.resetModules(); 37 | vi.clearAllMocks(); 38 | 39 | mockN8nApiClient = vi.mocked(N8nApiClient); 40 | mockGetN8nApiConfigFromContext = vi.mocked(getN8nApiConfigFromContext); 41 | mockLogger = vi.mocked(logger); 42 | mockValidateInstanceContext = vi.mocked(validateInstanceContext); 43 | 44 | // Default mock returns valid config 45 | mockGetN8nApiConfigFromContext.mockReturnValue({ 46 | baseUrl: 'https://api.n8n.cloud', 47 | apiKey: 'test-key', 48 | timeout: 30000, 49 | maxRetries: 3 50 | }); 51 | 52 | // Default mock returns valid context validation 53 | mockValidateInstanceContext.mockReturnValue({ 54 | valid: true, 55 | errors: undefined 56 | }); 57 | 58 | // Force re-import of the module to get fresh cache state 59 | vi.resetModules(); 60 | }); 61 | 62 | afterEach(() => { 63 | vi.clearAllMocks(); 64 | }); 65 | 66 | describe('Cache Key Generation and Collision', () => { 67 | it('should generate different cache keys for different contexts', () => { 68 | const context1: InstanceContext = { 69 | n8nApiUrl: 'https://api1.n8n.cloud', 70 | n8nApiKey: 'key1', 71 | instanceId: 'instance1' 72 | }; 73 | 74 | const context2: InstanceContext = { 75 | n8nApiUrl: 'https://api2.n8n.cloud', 76 | n8nApiKey: 'key2', 77 | instanceId: 'instance2' 78 | }; 79 | 80 | // Generate expected hashes manually 81 | const hash1 = createHash('sha256') 82 | .update(`${context1.n8nApiUrl}:${context1.n8nApiKey}:${context1.instanceId}`) 83 | .digest('hex'); 84 | 85 | const hash2 = createHash('sha256') 86 | .update(`${context2.n8nApiUrl}:${context2.n8nApiKey}:${context2.instanceId}`) 87 | .digest('hex'); 88 | 89 | expect(hash1).not.toBe(hash2); 90 | 91 | // Create clients to verify different cache entries 92 | const client1 = getN8nApiClient(context1); 93 | const client2 = getN8nApiClient(context2); 94 | 95 | expect(mockN8nApiClient).toHaveBeenCalledTimes(2); 96 | }); 97 | 98 | it('should generate same cache key for identical contexts', () => { 99 | const context: InstanceContext = { 100 | n8nApiUrl: 'https://api.n8n.cloud', 101 | n8nApiKey: 'same-key', 102 | instanceId: 'same-instance' 103 | }; 104 | 105 | const client1 = getN8nApiClient(context); 106 | const client2 = getN8nApiClient(context); 107 | 108 | // Should only create one client (cache hit) 109 | expect(mockN8nApiClient).toHaveBeenCalledTimes(1); 110 | expect(client1).toBe(client2); 111 | }); 112 | 113 | it('should handle potential cache key collisions gracefully', () => { 114 | // Create contexts that might produce similar hashes but are valid 115 | const contexts = [ 116 | { 117 | n8nApiUrl: 'https://a.com', 118 | n8nApiKey: 'keyb', 119 | instanceId: 'c' 120 | }, 121 | { 122 | n8nApiUrl: 'https://ab.com', 123 | n8nApiKey: 'key', 124 | instanceId: 'bc' 125 | }, 126 | { 127 | n8nApiUrl: 'https://abc.com', 128 | n8nApiKey: 'differentkey', // Fixed: empty string causes config creation to fail 129 | instanceId: 'key' 130 | } 131 | ]; 132 | 133 | contexts.forEach((context, index) => { 134 | const client = getN8nApiClient(context); 135 | expect(client).toBeDefined(); 136 | }); 137 | 138 | // Each should create a separate client due to different hashes 139 | expect(mockN8nApiClient).toHaveBeenCalledTimes(3); 140 | }); 141 | }); 142 | 143 | describe('LRU Eviction Behavior', () => { 144 | it('should evict oldest entries when cache is full', async () => { 145 | const loggerDebugSpy = vi.spyOn(logger, 'debug'); 146 | 147 | // Create 101 different contexts to exceed max cache size of 100 148 | const contexts: InstanceContext[] = []; 149 | for (let i = 0; i < 101; i++) { 150 | contexts.push({ 151 | n8nApiUrl: 'https://api.n8n.cloud', 152 | n8nApiKey: `key-${i}`, 153 | instanceId: `instance-${i}` 154 | }); 155 | } 156 | 157 | // Create clients for all contexts 158 | contexts.forEach(context => { 159 | getN8nApiClient(context); 160 | }); 161 | 162 | // Should have called dispose callback for evicted entries 163 | expect(loggerDebugSpy).toHaveBeenCalledWith( 164 | 'Evicting API client from cache', 165 | expect.objectContaining({ 166 | cacheKey: expect.stringMatching(/^[a-f0-9]{8}\.\.\.$/i) 167 | }) 168 | ); 169 | 170 | // Verify dispose was called at least once 171 | expect(loggerDebugSpy).toHaveBeenCalled(); 172 | }); 173 | 174 | it('should maintain LRU order during access', () => { 175 | const contexts: InstanceContext[] = []; 176 | for (let i = 0; i < 5; i++) { 177 | contexts.push({ 178 | n8nApiUrl: 'https://api.n8n.cloud', 179 | n8nApiKey: `key-${i}`, 180 | instanceId: `instance-${i}` 181 | }); 182 | } 183 | 184 | // Create initial clients 185 | contexts.forEach(context => { 186 | getN8nApiClient(context); 187 | }); 188 | 189 | expect(mockN8nApiClient).toHaveBeenCalledTimes(5); 190 | 191 | // Access first context again (should move to most recent) 192 | getN8nApiClient(contexts[0]); 193 | 194 | // Should not create new client (cache hit) 195 | expect(mockN8nApiClient).toHaveBeenCalledTimes(5); 196 | }); 197 | 198 | it('should handle rapid successive access patterns', () => { 199 | const context: InstanceContext = { 200 | n8nApiUrl: 'https://api.n8n.cloud', 201 | n8nApiKey: 'rapid-access-key', 202 | instanceId: 'rapid-instance' 203 | }; 204 | 205 | // Rapidly access same context multiple times 206 | for (let i = 0; i < 10; i++) { 207 | getN8nApiClient(context); 208 | } 209 | 210 | // Should only create one client despite multiple accesses 211 | expect(mockN8nApiClient).toHaveBeenCalledTimes(1); 212 | }); 213 | }); 214 | 215 | describe('TTL (Time To Live) Behavior', () => { 216 | it('should respect TTL settings', async () => { 217 | const context: InstanceContext = { 218 | n8nApiUrl: 'https://api.n8n.cloud', 219 | n8nApiKey: 'ttl-test-key', 220 | instanceId: 'ttl-instance' 221 | }; 222 | 223 | // Create initial client 224 | const client1 = getN8nApiClient(context); 225 | expect(mockN8nApiClient).toHaveBeenCalledTimes(1); 226 | 227 | // Access again immediately (should hit cache) 228 | const client2 = getN8nApiClient(context); 229 | expect(mockN8nApiClient).toHaveBeenCalledTimes(1); 230 | expect(client1).toBe(client2); 231 | 232 | // Note: We can't easily test TTL expiration in unit tests 233 | // as it requires actual time passage, but we can verify 234 | // the updateAgeOnGet behavior 235 | }); 236 | 237 | it('should update age on cache access (updateAgeOnGet)', () => { 238 | const context: InstanceContext = { 239 | n8nApiUrl: 'https://api.n8n.cloud', 240 | n8nApiKey: 'age-update-key', 241 | instanceId: 'age-instance' 242 | }; 243 | 244 | // Create and access multiple times 245 | getN8nApiClient(context); 246 | getN8nApiClient(context); 247 | getN8nApiClient(context); 248 | 249 | // Should only create one client due to cache hits 250 | expect(mockN8nApiClient).toHaveBeenCalledTimes(1); 251 | }); 252 | }); 253 | 254 | describe('Dispose Callback Security and Logging', () => { 255 | it('should sanitize cache keys in dispose callback logs', () => { 256 | const loggerDebugSpy = vi.spyOn(logger, 'debug'); 257 | 258 | // Create enough contexts to trigger eviction 259 | const contexts: InstanceContext[] = []; 260 | for (let i = 0; i < 102; i++) { 261 | contexts.push({ 262 | n8nApiUrl: 'https://sensitive-api.n8n.cloud', 263 | n8nApiKey: `super-secret-key-${i}`, 264 | instanceId: `sensitive-instance-${i}` 265 | }); 266 | } 267 | 268 | // Create clients to trigger eviction 269 | contexts.forEach(context => { 270 | getN8nApiClient(context); 271 | }); 272 | 273 | // Verify dispose callback logs don't contain sensitive data 274 | const logCalls = loggerDebugSpy.mock.calls.filter(call => 275 | call[0] === 'Evicting API client from cache' 276 | ); 277 | 278 | logCalls.forEach(call => { 279 | const logData = call[1] as any; 280 | 281 | // Should only log partial cache key (first 8 chars + ...) 282 | expect(logData.cacheKey).toMatch(/^[a-f0-9]{8}\.\.\.$/i); 283 | 284 | // Should not contain any sensitive information 285 | const logString = JSON.stringify(call); 286 | expect(logString).not.toContain('super-secret-key'); 287 | expect(logString).not.toContain('sensitive-api'); 288 | expect(logString).not.toContain('sensitive-instance'); 289 | }); 290 | }); 291 | 292 | it('should handle dispose callback with undefined client', () => { 293 | const loggerDebugSpy = vi.spyOn(logger, 'debug'); 294 | 295 | // Create many contexts to trigger disposal 296 | for (let i = 0; i < 105; i++) { 297 | const context: InstanceContext = { 298 | n8nApiUrl: 'https://api.n8n.cloud', 299 | n8nApiKey: `disposal-key-${i}`, 300 | instanceId: `disposal-${i}` 301 | }; 302 | getN8nApiClient(context); 303 | } 304 | 305 | // Should handle disposal gracefully 306 | expect(() => { 307 | // The dispose callback should have been called 308 | expect(loggerDebugSpy).toHaveBeenCalled(); 309 | }).not.toThrow(); 310 | }); 311 | }); 312 | 313 | describe('Cache Memory Management', () => { 314 | it('should maintain consistent cache size limits', () => { 315 | // Create exactly 100 contexts (max cache size) 316 | const contexts: InstanceContext[] = []; 317 | for (let i = 0; i < 100; i++) { 318 | contexts.push({ 319 | n8nApiUrl: 'https://api.n8n.cloud', 320 | n8nApiKey: `memory-key-${i}`, 321 | instanceId: `memory-${i}` 322 | }); 323 | } 324 | 325 | // Create all clients 326 | contexts.forEach(context => { 327 | getN8nApiClient(context); 328 | }); 329 | 330 | // All should be cached 331 | expect(mockN8nApiClient).toHaveBeenCalledTimes(100); 332 | 333 | // Access all again - should hit cache 334 | contexts.forEach(context => { 335 | getN8nApiClient(context); 336 | }); 337 | 338 | // Should not create additional clients 339 | expect(mockN8nApiClient).toHaveBeenCalledTimes(100); 340 | }); 341 | 342 | it('should handle edge case of single cache entry', () => { 343 | const context: InstanceContext = { 344 | n8nApiUrl: 'https://api.n8n.cloud', 345 | n8nApiKey: 'single-key', 346 | instanceId: 'single-instance' 347 | }; 348 | 349 | // Create and access multiple times 350 | for (let i = 0; i < 5; i++) { 351 | getN8nApiClient(context); 352 | } 353 | 354 | expect(mockN8nApiClient).toHaveBeenCalledTimes(1); 355 | }); 356 | }); 357 | 358 | describe('Cache Configuration Validation', () => { 359 | it('should use reasonable cache limits', () => { 360 | // These values should match the actual cache configuration 361 | const MAX_CACHE_SIZE = 100; 362 | const TTL_MINUTES = 30; 363 | const TTL_MS = TTL_MINUTES * 60 * 1000; 364 | 365 | // Verify limits are reasonable 366 | expect(MAX_CACHE_SIZE).toBeGreaterThan(0); 367 | expect(MAX_CACHE_SIZE).toBeLessThanOrEqual(1000); 368 | expect(TTL_MS).toBeGreaterThan(0); 369 | expect(TTL_MS).toBeLessThanOrEqual(60 * 60 * 1000); // Max 1 hour 370 | }); 371 | }); 372 | 373 | describe('Cache Interaction with Validation', () => { 374 | it('should not cache when context validation fails', () => { 375 | // Reset mocks to ensure clean state for this test 376 | vi.clearAllMocks(); 377 | mockValidateInstanceContext.mockClear(); 378 | 379 | const invalidContext: InstanceContext = { 380 | n8nApiUrl: 'invalid-url', 381 | n8nApiKey: 'test-key', 382 | instanceId: 'invalid-instance' 383 | }; 384 | 385 | // Mock validation failure 386 | mockValidateInstanceContext.mockReturnValue({ 387 | valid: false, 388 | errors: ['Invalid n8nApiUrl format'] 389 | }); 390 | 391 | const client = getN8nApiClient(invalidContext); 392 | 393 | // Should not create client or cache anything 394 | expect(client).toBeNull(); 395 | expect(mockN8nApiClient).not.toHaveBeenCalled(); 396 | }); 397 | 398 | it('should handle cache when config creation fails', () => { 399 | const context: InstanceContext = { 400 | n8nApiUrl: 'https://api.n8n.cloud', 401 | n8nApiKey: 'test-key', 402 | instanceId: 'config-fail' 403 | }; 404 | 405 | // Mock config creation failure 406 | mockGetN8nApiConfigFromContext.mockReturnValue(null); 407 | 408 | const client = getN8nApiClient(context); 409 | 410 | expect(client).toBeNull(); 411 | }); 412 | }); 413 | 414 | describe('Complex Cache Scenarios', () => { 415 | it('should handle mixed valid and invalid contexts', () => { 416 | // Reset mocks to ensure clean state for this test 417 | vi.clearAllMocks(); 418 | mockValidateInstanceContext.mockClear(); 419 | 420 | // First, set up default valid behavior 421 | mockValidateInstanceContext.mockReturnValue({ 422 | valid: true, 423 | errors: undefined 424 | }); 425 | 426 | const validContext: InstanceContext = { 427 | n8nApiUrl: 'https://api.n8n.cloud', 428 | n8nApiKey: 'valid-key', 429 | instanceId: 'valid' 430 | }; 431 | 432 | const invalidContext: InstanceContext = { 433 | n8nApiUrl: 'invalid-url', 434 | n8nApiKey: 'key', 435 | instanceId: 'invalid' 436 | }; 437 | 438 | // Valid context should work 439 | const validClient = getN8nApiClient(validContext); 440 | expect(validClient).toBeDefined(); 441 | 442 | // Change mock for invalid context 443 | mockValidateInstanceContext.mockReturnValueOnce({ 444 | valid: false, 445 | errors: ['Invalid URL'] 446 | }); 447 | 448 | const invalidClient = getN8nApiClient(invalidContext); 449 | expect(invalidClient).toBeNull(); 450 | 451 | // Reset mock back to valid for subsequent calls 452 | mockValidateInstanceContext.mockReturnValue({ 453 | valid: true, 454 | errors: undefined 455 | }); 456 | 457 | // Valid context should still work (cache hit) 458 | const validClient2 = getN8nApiClient(validContext); 459 | expect(validClient2).toBe(validClient); 460 | }); 461 | 462 | it('should handle concurrent access to same cache key', () => { 463 | const context: InstanceContext = { 464 | n8nApiUrl: 'https://api.n8n.cloud', 465 | n8nApiKey: 'concurrent-key', 466 | instanceId: 'concurrent' 467 | }; 468 | 469 | // Simulate concurrent access 470 | const promises = Array(10).fill(null).map(() => 471 | Promise.resolve(getN8nApiClient(context)) 472 | ); 473 | 474 | return Promise.all(promises).then(clients => { 475 | // All should return the same cached client 476 | const firstClient = clients[0]; 477 | clients.forEach(client => { 478 | expect(client).toBe(firstClient); 479 | }); 480 | 481 | // Should only create one client 482 | expect(mockN8nApiClient).toHaveBeenCalledTimes(1); 483 | }); 484 | }); 485 | }); 486 | }); ``` -------------------------------------------------------------------------------- /tests/unit/templates/metadata-generator.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { MetadataGenerator, TemplateMetadataSchema, MetadataRequest } from '../../../src/templates/metadata-generator'; 3 | 4 | // Mock OpenAI 5 | vi.mock('openai', () => { 6 | return { 7 | default: vi.fn().mockImplementation(() => ({ 8 | chat: { 9 | completions: { 10 | create: vi.fn() 11 | } 12 | } 13 | })) 14 | }; 15 | }); 16 | 17 | describe('MetadataGenerator', () => { 18 | let generator: MetadataGenerator; 19 | 20 | beforeEach(() => { 21 | generator = new MetadataGenerator('test-api-key', 'gpt-5-mini-2025-08-07'); 22 | }); 23 | 24 | describe('createBatchRequest', () => { 25 | it('should create a valid batch request', () => { 26 | const template: MetadataRequest = { 27 | templateId: 123, 28 | name: 'Test Workflow', 29 | description: 'A test workflow', 30 | nodes: ['n8n-nodes-base.webhook', 'n8n-nodes-base.httpRequest', 'n8n-nodes-base.slack'] 31 | }; 32 | 33 | const request = generator.createBatchRequest(template); 34 | 35 | expect(request.custom_id).toBe('template-123'); 36 | expect(request.method).toBe('POST'); 37 | expect(request.url).toBe('/v1/chat/completions'); 38 | expect(request.body.model).toBe('gpt-5-mini-2025-08-07'); 39 | expect(request.body.response_format.type).toBe('json_schema'); 40 | expect(request.body.response_format.json_schema.strict).toBe(true); 41 | expect(request.body.messages).toHaveLength(2); 42 | }); 43 | 44 | it('should summarize nodes effectively', () => { 45 | const template: MetadataRequest = { 46 | templateId: 456, 47 | name: 'Complex Workflow', 48 | nodes: [ 49 | 'n8n-nodes-base.webhook', 50 | 'n8n-nodes-base.httpRequest', 51 | 'n8n-nodes-base.httpRequest', 52 | 'n8n-nodes-base.postgres', 53 | 'n8n-nodes-base.slack', 54 | '@n8n/n8n-nodes-langchain.agent' 55 | ] 56 | }; 57 | 58 | const request = generator.createBatchRequest(template); 59 | const userMessage = request.body.messages[1].content; 60 | 61 | expect(userMessage).toContain('Complex Workflow'); 62 | expect(userMessage).toContain('Nodes Used (6)'); 63 | expect(userMessage).toContain('HTTP/Webhooks'); 64 | }); 65 | }); 66 | 67 | describe('parseResult', () => { 68 | it('should parse a successful result', () => { 69 | const mockResult = { 70 | custom_id: 'template-789', 71 | response: { 72 | body: { 73 | choices: [{ 74 | message: { 75 | content: JSON.stringify({ 76 | categories: ['automation', 'integration'], 77 | complexity: 'medium', 78 | use_cases: ['API integration', 'Data sync'], 79 | estimated_setup_minutes: 30, 80 | required_services: ['Slack API'], 81 | key_features: ['Webhook triggers', 'API calls'], 82 | target_audience: ['developers'] 83 | }) 84 | }, 85 | finish_reason: 'stop' 86 | }] 87 | } 88 | } 89 | }; 90 | 91 | const result = generator.parseResult(mockResult); 92 | 93 | expect(result.templateId).toBe(789); 94 | expect(result.metadata.categories).toEqual(['automation', 'integration']); 95 | expect(result.metadata.complexity).toBe('medium'); 96 | expect(result.error).toBeUndefined(); 97 | }); 98 | 99 | it('should handle error results', () => { 100 | const mockResult = { 101 | custom_id: 'template-999', 102 | error: { 103 | message: 'API error' 104 | } 105 | }; 106 | 107 | const result = generator.parseResult(mockResult); 108 | 109 | expect(result.templateId).toBe(999); 110 | expect(result.error).toBe('API error'); 111 | expect(result.metadata).toBeDefined(); 112 | expect(result.metadata.complexity).toBe('medium'); // Default metadata 113 | }); 114 | 115 | it('should handle malformed responses', () => { 116 | const mockResult = { 117 | custom_id: 'template-111', 118 | response: { 119 | body: { 120 | choices: [{ 121 | message: { 122 | content: 'not valid json' 123 | }, 124 | finish_reason: 'stop' 125 | }] 126 | } 127 | } 128 | }; 129 | 130 | const result = generator.parseResult(mockResult); 131 | 132 | expect(result.templateId).toBe(111); 133 | expect(result.error).toContain('Unexpected token'); 134 | expect(result.metadata).toBeDefined(); 135 | }); 136 | }); 137 | 138 | describe('TemplateMetadataSchema', () => { 139 | it('should validate correct metadata', () => { 140 | const validMetadata = { 141 | categories: ['automation', 'integration'], 142 | complexity: 'simple' as const, 143 | use_cases: ['API calls', 'Data processing'], 144 | estimated_setup_minutes: 15, 145 | required_services: [], 146 | key_features: ['Fast processing'], 147 | target_audience: ['developers'] 148 | }; 149 | 150 | const result = TemplateMetadataSchema.safeParse(validMetadata); 151 | 152 | expect(result.success).toBe(true); 153 | }); 154 | 155 | it('should reject invalid complexity', () => { 156 | const invalidMetadata = { 157 | categories: ['automation'], 158 | complexity: 'very-hard', // Invalid 159 | use_cases: ['API calls'], 160 | estimated_setup_minutes: 15, 161 | required_services: [], 162 | key_features: ['Fast'], 163 | target_audience: ['developers'] 164 | }; 165 | 166 | const result = TemplateMetadataSchema.safeParse(invalidMetadata); 167 | 168 | expect(result.success).toBe(false); 169 | }); 170 | 171 | it('should enforce array limits', () => { 172 | const tooManyCategories = { 173 | categories: ['a', 'b', 'c', 'd', 'e', 'f'], // Max 5 174 | complexity: 'simple' as const, 175 | use_cases: ['API calls'], 176 | estimated_setup_minutes: 15, 177 | required_services: [], 178 | key_features: ['Fast'], 179 | target_audience: ['developers'] 180 | }; 181 | 182 | const result = TemplateMetadataSchema.safeParse(tooManyCategories); 183 | 184 | expect(result.success).toBe(false); 185 | }); 186 | 187 | it('should enforce time limits', () => { 188 | const tooLongSetup = { 189 | categories: ['automation'], 190 | complexity: 'complex' as const, 191 | use_cases: ['API calls'], 192 | estimated_setup_minutes: 500, // Max 480 193 | required_services: [], 194 | key_features: ['Fast'], 195 | target_audience: ['developers'] 196 | }; 197 | 198 | const result = TemplateMetadataSchema.safeParse(tooLongSetup); 199 | 200 | expect(result.success).toBe(false); 201 | }); 202 | }); 203 | 204 | describe('Input Sanitization and Security', () => { 205 | it('should handle malicious template names safely', () => { 206 | const maliciousTemplate: MetadataRequest = { 207 | templateId: 123, 208 | name: '<script>alert("xss")</script>', 209 | description: 'javascript:alert(1)', 210 | nodes: ['n8n-nodes-base.webhook'] 211 | }; 212 | 213 | const request = generator.createBatchRequest(maliciousTemplate); 214 | const userMessage = request.body.messages[1].content; 215 | 216 | // Should contain the malicious content as-is (OpenAI will handle it) 217 | // but should not cause any injection in our code 218 | expect(userMessage).toContain('<script>alert("xss")</script>'); 219 | expect(userMessage).toContain('javascript:alert(1)'); 220 | expect(request.body.model).toBe('gpt-5-mini-2025-08-07'); 221 | }); 222 | 223 | it('should handle extremely long template names', () => { 224 | const longName = 'A'.repeat(10000); // Very long name 225 | const template: MetadataRequest = { 226 | templateId: 456, 227 | name: longName, 228 | nodes: ['n8n-nodes-base.webhook'] 229 | }; 230 | 231 | const request = generator.createBatchRequest(template); 232 | 233 | expect(request.custom_id).toBe('template-456'); 234 | expect(request.body.messages[1].content).toContain(longName); 235 | }); 236 | 237 | it('should handle special characters in node names', () => { 238 | const template: MetadataRequest = { 239 | templateId: 789, 240 | name: 'Test Workflow', 241 | nodes: [ 242 | 'n8n-nodes-base.webhook', 243 | '@n8n/custom-node.with.dots', 244 | 'custom-package/node-with-slashes', 245 | 'node_with_underscore', 246 | 'node-with-unicode-名前' 247 | ] 248 | }; 249 | 250 | const request = generator.createBatchRequest(template); 251 | const userMessage = request.body.messages[1].content; 252 | 253 | expect(userMessage).toContain('HTTP/Webhooks'); 254 | expect(userMessage).toContain('custom-node.with.dots'); 255 | }); 256 | 257 | it('should handle empty or undefined descriptions safely', () => { 258 | const template: MetadataRequest = { 259 | templateId: 100, 260 | name: 'Test', 261 | description: undefined, 262 | nodes: ['n8n-nodes-base.webhook'] 263 | }; 264 | 265 | const request = generator.createBatchRequest(template); 266 | const userMessage = request.body.messages[1].content; 267 | 268 | // Should not include undefined or null in the message 269 | expect(userMessage).not.toContain('undefined'); 270 | expect(userMessage).not.toContain('null'); 271 | expect(userMessage).toContain('Test'); 272 | }); 273 | 274 | it('should limit context size for very large workflows', () => { 275 | const manyNodes = Array.from({ length: 1000 }, (_, i) => `n8n-nodes-base.node${i}`); 276 | const template: MetadataRequest = { 277 | templateId: 200, 278 | name: 'Huge Workflow', 279 | nodes: manyNodes, 280 | workflow: { 281 | nodes: Array.from({ length: 500 }, (_, i) => ({ id: `node${i}` })), 282 | connections: {} 283 | } 284 | }; 285 | 286 | const request = generator.createBatchRequest(template); 287 | const userMessage = request.body.messages[1].content; 288 | 289 | // Should handle large amounts of data gracefully 290 | expect(userMessage.length).toBeLessThan(50000); // Reasonable limit 291 | expect(userMessage).toContain('Huge Workflow'); 292 | }); 293 | }); 294 | 295 | describe('Error Handling and Edge Cases', () => { 296 | it('should handle malformed OpenAI responses', () => { 297 | const malformedResults = [ 298 | { 299 | custom_id: 'template-111', 300 | response: { 301 | body: { 302 | choices: [{ 303 | message: { 304 | content: '{"invalid": json syntax}' 305 | }, 306 | finish_reason: 'stop' 307 | }] 308 | } 309 | } 310 | }, 311 | { 312 | custom_id: 'template-222', 313 | response: { 314 | body: { 315 | choices: [{ 316 | message: { 317 | content: null 318 | }, 319 | finish_reason: 'stop' 320 | }] 321 | } 322 | } 323 | }, 324 | { 325 | custom_id: 'template-333', 326 | response: { 327 | body: { 328 | choices: [] 329 | } 330 | } 331 | } 332 | ]; 333 | 334 | malformedResults.forEach(result => { 335 | const parsed = generator.parseResult(result); 336 | expect(parsed.error).toBeDefined(); 337 | expect(parsed.metadata).toBeDefined(); 338 | expect(parsed.metadata.complexity).toBe('medium'); // Default metadata 339 | }); 340 | }); 341 | 342 | it('should handle Zod validation failures', () => { 343 | const invalidResponse = { 344 | custom_id: 'template-444', 345 | response: { 346 | body: { 347 | choices: [{ 348 | message: { 349 | content: JSON.stringify({ 350 | categories: ['too', 'many', 'categories', 'here', 'way', 'too', 'many'], 351 | complexity: 'invalid-complexity', 352 | use_cases: [], 353 | estimated_setup_minutes: -5, // Invalid negative time 354 | required_services: 'not-an-array', 355 | key_features: null, 356 | target_audience: ['too', 'many', 'audiences', 'here'] 357 | }) 358 | }, 359 | finish_reason: 'stop' 360 | }] 361 | } 362 | } 363 | }; 364 | 365 | const result = generator.parseResult(invalidResponse); 366 | 367 | expect(result.templateId).toBe(444); 368 | expect(result.error).toBeDefined(); 369 | expect(result.metadata).toEqual(generator['getDefaultMetadata']()); 370 | }); 371 | 372 | it('should handle network timeouts gracefully in generateSingle', async () => { 373 | // Create a new generator with mocked OpenAI client 374 | const mockClient = { 375 | chat: { 376 | completions: { 377 | create: vi.fn().mockRejectedValue(new Error('Request timed out')) 378 | } 379 | } 380 | }; 381 | 382 | // Override the client property using Object.defineProperty 383 | Object.defineProperty(generator, 'client', { 384 | value: mockClient, 385 | writable: true 386 | }); 387 | 388 | const template: MetadataRequest = { 389 | templateId: 555, 390 | name: 'Timeout Test', 391 | nodes: ['n8n-nodes-base.webhook'] 392 | }; 393 | 394 | const result = await generator.generateSingle(template); 395 | 396 | // Should return default metadata instead of throwing 397 | expect(result).toEqual(generator['getDefaultMetadata']()); 398 | }); 399 | }); 400 | 401 | describe('Node Summarization Logic', () => { 402 | it('should group similar nodes correctly', () => { 403 | const template: MetadataRequest = { 404 | templateId: 666, 405 | name: 'Complex Workflow', 406 | nodes: [ 407 | 'n8n-nodes-base.webhook', 408 | 'n8n-nodes-base.httpRequest', 409 | 'n8n-nodes-base.postgres', 410 | 'n8n-nodes-base.mysql', 411 | 'n8n-nodes-base.slack', 412 | 'n8n-nodes-base.gmail', 413 | '@n8n/n8n-nodes-langchain.openAi', 414 | '@n8n/n8n-nodes-langchain.agent', 415 | 'n8n-nodes-base.googleSheets', 416 | 'n8n-nodes-base.excel' 417 | ] 418 | }; 419 | 420 | const request = generator.createBatchRequest(template); 421 | const userMessage = request.body.messages[1].content; 422 | 423 | expect(userMessage).toContain('HTTP/Webhooks (2)'); 424 | expect(userMessage).toContain('Database (2)'); 425 | expect(userMessage).toContain('Communication (2)'); 426 | expect(userMessage).toContain('AI/ML (2)'); 427 | expect(userMessage).toContain('Spreadsheets (2)'); 428 | }); 429 | 430 | it('should handle unknown node types gracefully', () => { 431 | const template: MetadataRequest = { 432 | templateId: 777, 433 | name: 'Unknown Nodes', 434 | nodes: [ 435 | 'custom-package.unknownNode', 436 | 'another-package.weirdNodeType', 437 | 'someNodeTrigger', 438 | 'anotherNode' 439 | ] 440 | }; 441 | 442 | const request = generator.createBatchRequest(template); 443 | const userMessage = request.body.messages[1].content; 444 | 445 | // Should handle unknown nodes without crashing 446 | expect(userMessage).toContain('unknownNode'); 447 | expect(userMessage).toContain('weirdNodeType'); 448 | expect(userMessage).toContain('someNode'); // Trigger suffix removed 449 | }); 450 | 451 | it('should limit node summary length', () => { 452 | const manyNodes = Array.from({ length: 50 }, (_, i) => 453 | `n8n-nodes-base.customNode${i}` 454 | ); 455 | 456 | const template: MetadataRequest = { 457 | templateId: 888, 458 | name: 'Many Nodes', 459 | nodes: manyNodes 460 | }; 461 | 462 | const request = generator.createBatchRequest(template); 463 | const userMessage = request.body.messages[1].content; 464 | 465 | // Should limit to top 10 groups 466 | const summaryLine = userMessage.split('\n').find((line: string) => 467 | line.includes('Nodes Used (50)') 468 | ); 469 | 470 | expect(summaryLine).toBeDefined(); 471 | const nodeGroups = summaryLine!.split(': ')[1].split(', '); 472 | expect(nodeGroups.length).toBeLessThanOrEqual(10); 473 | }); 474 | }); 475 | }); ``` -------------------------------------------------------------------------------- /tests/integration/docker/docker-config.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; 2 | import { execSync, spawn } from 'child_process'; 3 | import path from 'path'; 4 | import fs from 'fs'; 5 | import os from 'os'; 6 | import { exec, waitForHealthy, isRunningInHttpMode, getProcessEnv } from './test-helpers'; 7 | 8 | // Skip tests if not in CI or if Docker is not available 9 | const SKIP_DOCKER_TESTS = process.env.CI !== 'true' && !process.env.RUN_DOCKER_TESTS; 10 | const describeDocker = SKIP_DOCKER_TESTS ? describe.skip : describe; 11 | 12 | // Helper to check if Docker is available 13 | async function isDockerAvailable(): Promise<boolean> { 14 | try { 15 | await exec('docker --version'); 16 | return true; 17 | } catch { 18 | return false; 19 | } 20 | } 21 | 22 | // Helper to generate unique container names 23 | function generateContainerName(suffix: string): string { 24 | return `n8n-mcp-test-${Date.now()}-${suffix}`; 25 | } 26 | 27 | // Helper to clean up containers 28 | async function cleanupContainer(containerName: string) { 29 | try { 30 | await exec(`docker stop ${containerName}`); 31 | await exec(`docker rm ${containerName}`); 32 | } catch { 33 | // Ignore errors - container might not exist 34 | } 35 | } 36 | 37 | describeDocker('Docker Config File Integration', () => { 38 | let tempDir: string; 39 | let dockerAvailable: boolean; 40 | const imageName = 'n8n-mcp-test:latest'; 41 | const containers: string[] = []; 42 | 43 | beforeAll(async () => { 44 | dockerAvailable = await isDockerAvailable(); 45 | if (!dockerAvailable) { 46 | console.warn('Docker not available, skipping Docker integration tests'); 47 | return; 48 | } 49 | 50 | // Check if image exists 51 | let imageExists = false; 52 | try { 53 | await exec(`docker image inspect ${imageName}`); 54 | imageExists = true; 55 | } catch { 56 | imageExists = false; 57 | } 58 | 59 | // Build test image if in CI or if explicitly requested or if image doesn't exist 60 | if (!imageExists || process.env.CI === 'true' || process.env.BUILD_DOCKER_TEST_IMAGE === 'true') { 61 | const projectRoot = path.resolve(__dirname, '../../../'); 62 | console.log('Building Docker image for tests...'); 63 | try { 64 | execSync(`docker build -t ${imageName} .`, { 65 | cwd: projectRoot, 66 | stdio: 'inherit' 67 | }); 68 | console.log('Docker image built successfully'); 69 | } catch (error) { 70 | console.error('Failed to build Docker image:', error); 71 | throw new Error('Docker image build failed - tests cannot continue'); 72 | } 73 | } else { 74 | console.log(`Using existing Docker image: ${imageName}`); 75 | } 76 | }, 60000); // Increase timeout to 60s for Docker build 77 | 78 | beforeEach(() => { 79 | tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docker-config-test-')); 80 | }); 81 | 82 | afterEach(async () => { 83 | // Clean up containers 84 | for (const container of containers) { 85 | await cleanupContainer(container); 86 | } 87 | containers.length = 0; 88 | 89 | // Clean up temp directory 90 | if (fs.existsSync(tempDir)) { 91 | fs.rmSync(tempDir, { recursive: true }); 92 | } 93 | }); 94 | 95 | describe('Config file loading', () => { 96 | it('should load config.json and set environment variables', async () => { 97 | if (!dockerAvailable) return; 98 | 99 | const containerName = generateContainerName('config-load'); 100 | containers.push(containerName); 101 | 102 | // Create config file 103 | const configPath = path.join(tempDir, 'config.json'); 104 | const config = { 105 | mcp_mode: 'http', 106 | auth_token: 'test-token-from-config', 107 | port: 3456, 108 | database: { 109 | path: '/data/custom.db' 110 | } 111 | }; 112 | fs.writeFileSync(configPath, JSON.stringify(config)); 113 | 114 | // Run container with config file mounted 115 | const { stdout } = await exec( 116 | `docker run --name ${containerName} -v "${configPath}:/app/config.json:ro" ${imageName} sh -c "env | grep -E '^(MCP_MODE|AUTH_TOKEN|PORT|DATABASE_PATH)=' | sort"` 117 | ); 118 | 119 | const envVars = stdout.trim().split('\n').reduce((acc, line) => { 120 | const [key, value] = line.split('='); 121 | acc[key] = value; 122 | return acc; 123 | }, {} as Record<string, string>); 124 | 125 | expect(envVars.MCP_MODE).toBe('http'); 126 | expect(envVars.AUTH_TOKEN).toBe('test-token-from-config'); 127 | expect(envVars.PORT).toBe('3456'); 128 | expect(envVars.DATABASE_PATH).toBe('/data/custom.db'); 129 | }); 130 | 131 | it('should give precedence to environment variables over config file', async () => { 132 | if (!dockerAvailable) return; 133 | 134 | const containerName = generateContainerName('env-precedence'); 135 | containers.push(containerName); 136 | 137 | // Create config file 138 | const configPath = path.join(tempDir, 'config.json'); 139 | const config = { 140 | mcp_mode: 'stdio', 141 | auth_token: 'config-token', 142 | custom_var: 'from-config' 143 | }; 144 | fs.writeFileSync(configPath, JSON.stringify(config)); 145 | 146 | // Run container with both env vars and config file 147 | const { stdout } = await exec( 148 | `docker run --name ${containerName} ` + 149 | `-e MCP_MODE=http ` + 150 | `-e AUTH_TOKEN=env-token ` + 151 | `-v "${configPath}:/app/config.json:ro" ` + 152 | `${imageName} sh -c "env | grep -E '^(MCP_MODE|AUTH_TOKEN|CUSTOM_VAR)=' | sort"` 153 | ); 154 | 155 | const envVars = stdout.trim().split('\n').reduce((acc, line) => { 156 | const [key, value] = line.split('='); 157 | acc[key] = value; 158 | return acc; 159 | }, {} as Record<string, string>); 160 | 161 | expect(envVars.MCP_MODE).toBe('http'); // From env var 162 | expect(envVars.AUTH_TOKEN).toBe('env-token'); // From env var 163 | expect(envVars.CUSTOM_VAR).toBe('from-config'); // From config file 164 | }); 165 | 166 | it('should handle missing config file gracefully', async () => { 167 | if (!dockerAvailable) return; 168 | 169 | const containerName = generateContainerName('no-config'); 170 | containers.push(containerName); 171 | 172 | // Run container without config file 173 | const { stdout, stderr } = await exec( 174 | `docker run --name ${containerName} ${imageName} echo "Container started successfully"` 175 | ); 176 | 177 | expect(stdout.trim()).toBe('Container started successfully'); 178 | expect(stderr).toBe(''); 179 | }); 180 | 181 | it('should handle invalid JSON in config file gracefully', async () => { 182 | if (!dockerAvailable) return; 183 | 184 | const containerName = generateContainerName('invalid-json'); 185 | containers.push(containerName); 186 | 187 | // Create invalid config file 188 | const configPath = path.join(tempDir, 'config.json'); 189 | fs.writeFileSync(configPath, '{ invalid json }'); 190 | 191 | // Container should still start despite invalid config 192 | const { stdout } = await exec( 193 | `docker run --name ${containerName} -v "${configPath}:/app/config.json:ro" ${imageName} echo "Started despite invalid config"` 194 | ); 195 | 196 | expect(stdout.trim()).toBe('Started despite invalid config'); 197 | }); 198 | }); 199 | 200 | describe('n8n-mcp serve command', () => { 201 | it('should automatically set MCP_MODE=http for "n8n-mcp serve" command', async () => { 202 | if (!dockerAvailable) return; 203 | 204 | const containerName = generateContainerName('serve-command'); 205 | containers.push(containerName); 206 | 207 | // Run container with n8n-mcp serve command 208 | // Start the container in detached mode 209 | await exec( 210 | `docker run -d --name ${containerName} -e AUTH_TOKEN=test-token -p 13001:3000 ${imageName} n8n-mcp serve` 211 | ); 212 | 213 | // Give it time to start 214 | await new Promise(resolve => setTimeout(resolve, 3000)); 215 | 216 | // Verify it's running in HTTP mode by checking the health endpoint 217 | const { stdout } = await exec( 218 | `docker exec ${containerName} curl -s http://localhost:3000/health || echo 'Server not responding'` 219 | ); 220 | 221 | // If HTTP mode is active, health endpoint should respond 222 | expect(stdout).toContain('ok'); 223 | }); 224 | 225 | it('should preserve additional arguments when using "n8n-mcp serve"', async () => { 226 | if (!dockerAvailable) return; 227 | 228 | const containerName = generateContainerName('serve-args'); 229 | containers.push(containerName); 230 | 231 | // Test that additional arguments are passed through 232 | // Note: This test is checking the command construction, not actual execution 233 | const result = await exec( 234 | `docker run --name ${containerName} ${imageName} sh -c "set -x; n8n-mcp serve --port 8080 2>&1 | grep -E 'node.*index.js.*--port.*8080' || echo 'Pattern not found'"` 235 | ); 236 | 237 | // The serve command should transform to node command with arguments preserved 238 | expect(result.stdout).toBeTruthy(); 239 | }); 240 | }); 241 | 242 | describe('Database initialization', () => { 243 | it('should initialize database when not present', async () => { 244 | if (!dockerAvailable) return; 245 | 246 | const containerName = generateContainerName('db-init'); 247 | containers.push(containerName); 248 | 249 | // Run container and check database initialization 250 | const { stdout } = await exec( 251 | `docker run --name ${containerName} ${imageName} sh -c "ls -la /app/data/nodes.db && echo 'Database initialized'"` 252 | ); 253 | 254 | expect(stdout).toContain('nodes.db'); 255 | expect(stdout).toContain('Database initialized'); 256 | }); 257 | 258 | it('should respect NODE_DB_PATH from config file', async () => { 259 | if (!dockerAvailable) return; 260 | 261 | const containerName = generateContainerName('custom-db-path'); 262 | containers.push(containerName); 263 | 264 | // Create config with custom database path 265 | const configPath = path.join(tempDir, 'config.json'); 266 | const config = { 267 | NODE_DB_PATH: '/app/data/custom/custom.db' // Use uppercase and a writable path 268 | }; 269 | fs.writeFileSync(configPath, JSON.stringify(config)); 270 | 271 | // Run container in detached mode to check environment after initialization 272 | // Set MCP_MODE=http so the server keeps running (stdio mode exits when stdin is closed in detached mode) 273 | await exec( 274 | `docker run -d --name ${containerName} -e MCP_MODE=http -e AUTH_TOKEN=test -v "${configPath}:/app/config.json:ro" ${imageName}` 275 | ); 276 | 277 | // Give it time to load config and start 278 | await new Promise(resolve => setTimeout(resolve, 2000)); 279 | 280 | // Check the actual process environment 281 | const { stdout } = await exec( 282 | `docker exec ${containerName} sh -c "cat /proc/1/environ | tr '\\0' '\\n' | grep NODE_DB_PATH || echo 'NODE_DB_PATH not found'"` 283 | ); 284 | 285 | expect(stdout.trim()).toBe('NODE_DB_PATH=/app/data/custom/custom.db'); 286 | }); 287 | }); 288 | 289 | describe('Authentication configuration', () => { 290 | it('should enforce AUTH_TOKEN requirement in HTTP mode', async () => { 291 | if (!dockerAvailable) return; 292 | 293 | const containerName = generateContainerName('auth-required'); 294 | containers.push(containerName); 295 | 296 | // Try to run in HTTP mode without auth token 297 | try { 298 | await exec( 299 | `docker run --name ${containerName} -e MCP_MODE=http ${imageName} echo "Should not reach here"` 300 | ); 301 | expect.fail('Container should have exited with error'); 302 | } catch (error: any) { 303 | expect(error.stderr).toContain('AUTH_TOKEN or AUTH_TOKEN_FILE is required for HTTP mode'); 304 | } 305 | }); 306 | 307 | it('should accept AUTH_TOKEN from config file', async () => { 308 | if (!dockerAvailable) return; 309 | 310 | const containerName = generateContainerName('auth-config'); 311 | containers.push(containerName); 312 | 313 | // Create config with auth token 314 | const configPath = path.join(tempDir, 'config.json'); 315 | const config = { 316 | mcp_mode: 'http', 317 | auth_token: 'config-auth-token' 318 | }; 319 | fs.writeFileSync(configPath, JSON.stringify(config)); 320 | 321 | // Run container with config file 322 | const { stdout } = await exec( 323 | `docker run --name ${containerName} -v "${configPath}:/app/config.json:ro" ${imageName} sh -c "env | grep AUTH_TOKEN"` 324 | ); 325 | 326 | expect(stdout.trim()).toBe('AUTH_TOKEN=config-auth-token'); 327 | }); 328 | }); 329 | 330 | describe('Security and permissions', () => { 331 | it('should handle malicious config values safely', async () => { 332 | if (!dockerAvailable) return; 333 | 334 | const containerName = generateContainerName('security-test'); 335 | containers.push(containerName); 336 | 337 | // Create config with potentially malicious values 338 | const configPath = path.join(tempDir, 'config.json'); 339 | const config = { 340 | malicious1: "'; echo 'hacked' > /tmp/hacked.txt; '", 341 | malicious2: "$( touch /tmp/command-injection.txt )", 342 | malicious3: "`touch /tmp/backtick-injection.txt`" 343 | }; 344 | fs.writeFileSync(configPath, JSON.stringify(config)); 345 | 346 | // Run container and check that no files were created 347 | const { stdout } = await exec( 348 | `docker run --name ${containerName} -v "${configPath}:/app/config.json:ro" ${imageName} sh -c "ls -la /tmp/ | grep -E '(hacked|injection)' || echo 'No malicious files created'"` 349 | ); 350 | 351 | expect(stdout.trim()).toBe('No malicious files created'); 352 | }); 353 | 354 | it('should run as non-root user by default', async () => { 355 | if (!dockerAvailable) return; 356 | 357 | const containerName = generateContainerName('non-root'); 358 | containers.push(containerName); 359 | 360 | // Check user inside container 361 | const { stdout } = await exec( 362 | `docker run --name ${containerName} ${imageName} whoami` 363 | ); 364 | 365 | expect(stdout.trim()).toBe('nodejs'); 366 | }); 367 | }); 368 | 369 | describe('Complex configuration scenarios', () => { 370 | it('should handle nested configuration with all supported types', async () => { 371 | if (!dockerAvailable) return; 372 | 373 | const containerName = generateContainerName('complex-config'); 374 | containers.push(containerName); 375 | 376 | // Create complex config 377 | const configPath = path.join(tempDir, 'config.json'); 378 | const config = { 379 | server: { 380 | http: { 381 | port: 8080, 382 | host: '0.0.0.0', 383 | ssl: { 384 | enabled: true, 385 | cert_path: '/certs/server.crt' 386 | } 387 | } 388 | }, 389 | features: { 390 | debug: false, 391 | metrics: true, 392 | logging: { 393 | level: 'info', 394 | format: 'json' 395 | } 396 | }, 397 | limits: { 398 | max_connections: 100, 399 | timeout_seconds: 30 400 | } 401 | }; 402 | fs.writeFileSync(configPath, JSON.stringify(config)); 403 | 404 | // Run container and verify all variables 405 | const { stdout } = await exec( 406 | `docker run --name ${containerName} -v "${configPath}:/app/config.json:ro" ${imageName} sh -c "env | grep -E '^(SERVER_|FEATURES_|LIMITS_)' | sort"` 407 | ); 408 | 409 | const lines = stdout.trim().split('\n'); 410 | const envVars = lines.reduce((acc, line) => { 411 | const [key, value] = line.split('='); 412 | acc[key] = value; 413 | return acc; 414 | }, {} as Record<string, string>); 415 | 416 | // Verify nested values are correctly flattened 417 | expect(envVars.SERVER_HTTP_PORT).toBe('8080'); 418 | expect(envVars.SERVER_HTTP_HOST).toBe('0.0.0.0'); 419 | expect(envVars.SERVER_HTTP_SSL_ENABLED).toBe('true'); 420 | expect(envVars.SERVER_HTTP_SSL_CERT_PATH).toBe('/certs/server.crt'); 421 | expect(envVars.FEATURES_DEBUG).toBe('false'); 422 | expect(envVars.FEATURES_METRICS).toBe('true'); 423 | expect(envVars.FEATURES_LOGGING_LEVEL).toBe('info'); 424 | expect(envVars.FEATURES_LOGGING_FORMAT).toBe('json'); 425 | expect(envVars.LIMITS_MAX_CONNECTIONS).toBe('100'); 426 | expect(envVars.LIMITS_TIMEOUT_SECONDS).toBe('30'); 427 | }); 428 | }); 429 | }); ``` -------------------------------------------------------------------------------- /tests/unit/docker/config-security.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 2 | import { execSync } from 'child_process'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import os from 'os'; 6 | 7 | describe('Config File Security Tests', () => { 8 | let tempDir: string; 9 | let configPath: string; 10 | const parseConfigPath = path.resolve(__dirname, '../../../docker/parse-config.js'); 11 | 12 | // Clean environment for tests - only include essential variables 13 | const cleanEnv = { 14 | PATH: process.env.PATH, 15 | HOME: process.env.HOME, 16 | NODE_ENV: process.env.NODE_ENV 17 | }; 18 | 19 | beforeEach(() => { 20 | tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'config-security-test-')); 21 | configPath = path.join(tempDir, 'config.json'); 22 | }); 23 | 24 | afterEach(() => { 25 | if (fs.existsSync(tempDir)) { 26 | fs.rmSync(tempDir, { recursive: true }); 27 | } 28 | }); 29 | 30 | describe('Command injection prevention', () => { 31 | it('should prevent basic command injection attempts', () => { 32 | const maliciousConfigs = [ 33 | { cmd: "'; echo 'hacked' > /tmp/hacked.txt; '" }, 34 | { cmd: '"; echo "hacked" > /tmp/hacked.txt; "' }, 35 | { cmd: '`echo hacked > /tmp/hacked.txt`' }, 36 | { cmd: '$(echo hacked > /tmp/hacked.txt)' }, 37 | { cmd: '| echo hacked > /tmp/hacked.txt' }, 38 | { cmd: '|| echo hacked > /tmp/hacked.txt' }, 39 | { cmd: '& echo hacked > /tmp/hacked.txt' }, 40 | { cmd: '&& echo hacked > /tmp/hacked.txt' }, 41 | { cmd: '; echo hacked > /tmp/hacked.txt' }, 42 | { cmd: '\n echo hacked > /tmp/hacked.txt \n' }, 43 | { cmd: '\r\n echo hacked > /tmp/hacked.txt \r\n' } 44 | ]; 45 | 46 | maliciousConfigs.forEach((config, index) => { 47 | fs.writeFileSync(configPath, JSON.stringify(config)); 48 | const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { 49 | encoding: 'utf8', 50 | env: cleanEnv 51 | }); 52 | 53 | // The output should safely quote the malicious content 54 | expect(output).toContain("export CMD='"); 55 | 56 | // Verify that the output contains a properly quoted export 57 | expect(output).toContain("export CMD='"); 58 | 59 | // Create a test script to verify safety 60 | const testScript = `#!/bin/sh 61 | set -e 62 | ${output} 63 | # If command injection worked, this would fail 64 | test -f /tmp/hacked.txt && exit 1 65 | echo "SUCCESS: No injection occurred" 66 | `; 67 | 68 | const tempScript = path.join(tempDir, `test-injection-${index}.sh`); 69 | fs.writeFileSync(tempScript, testScript); 70 | fs.chmodSync(tempScript, '755'); 71 | 72 | const result = execSync(tempScript, { encoding: 'utf8', env: cleanEnv }); 73 | expect(result.trim()).toBe('SUCCESS: No injection occurred'); 74 | 75 | // Double-check no files were created 76 | expect(fs.existsSync('/tmp/hacked.txt')).toBe(false); 77 | }); 78 | }); 79 | 80 | it('should handle complex nested injection attempts', () => { 81 | const config = { 82 | database: { 83 | host: "localhost'; DROP TABLE users; --", 84 | port: 5432, 85 | credentials: { 86 | password: "$( cat /etc/passwd )", 87 | backup_cmd: "`rm -rf /`" 88 | } 89 | }, 90 | scripts: { 91 | init: "#!/bin/bash\nrm -rf /\nexit 0" 92 | } 93 | }; 94 | fs.writeFileSync(configPath, JSON.stringify(config)); 95 | 96 | const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { 97 | encoding: 'utf8', 98 | env: cleanEnv 99 | }); 100 | 101 | // All values should be safely quoted 102 | expect(output).toContain("DATABASE_HOST='localhost'\"'\"'; DROP TABLE users; --'"); 103 | expect(output).toContain("DATABASE_CREDENTIALS_PASSWORD='$( cat /etc/passwd )'"); 104 | expect(output).toContain("DATABASE_CREDENTIALS_BACKUP_CMD='`rm -rf /`'"); 105 | expect(output).toContain("SCRIPTS_INIT='#!/bin/bash\nrm -rf /\nexit 0'"); 106 | }); 107 | 108 | it('should handle Unicode and special characters safely', () => { 109 | const config = { 110 | unicode: "Hello 世界 🌍", 111 | emoji: "🚀 Deploy! 🎉", 112 | special: "Line1\nLine2\tTab\rCarriage", 113 | quotes_mix: `It's a "test" with 'various' quotes`, 114 | backslash: "C:\\Users\\test\\path", 115 | regex: "^[a-zA-Z0-9]+$", 116 | json_string: '{"key": "value"}', 117 | xml_string: '<tag attr="value">content</tag>', 118 | sql_injection: "1' OR '1'='1", 119 | null_byte: "test\x00null", 120 | escape_sequences: "test\\n\\r\\t\\b\\f" 121 | }; 122 | fs.writeFileSync(configPath, JSON.stringify(config)); 123 | 124 | const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { 125 | encoding: 'utf8', 126 | env: cleanEnv 127 | }); 128 | 129 | // All special characters should be preserved within quotes 130 | expect(output).toContain("UNICODE='Hello 世界 🌍'"); 131 | expect(output).toContain("EMOJI='🚀 Deploy! 🎉'"); 132 | expect(output).toContain("SPECIAL='Line1\nLine2\tTab\rCarriage'"); 133 | expect(output).toContain("BACKSLASH='C:\\Users\\test\\path'"); 134 | expect(output).toContain("REGEX='^[a-zA-Z0-9]+$'"); 135 | expect(output).toContain("SQL_INJECTION='1'\"'\"' OR '\"'\"'1'\"'\"'='\"'\"'1'"); 136 | }); 137 | }); 138 | 139 | describe('Shell metacharacter handling', () => { 140 | it('should safely handle all shell metacharacters', () => { 141 | const config = { 142 | dollar: "$HOME $USER ${PATH}", 143 | backtick: "`date` `whoami`", 144 | parentheses: "$(date) $(whoami)", 145 | semicolon: "cmd1; cmd2; cmd3", 146 | ampersand: "cmd1 & cmd2 && cmd3", 147 | pipe: "cmd1 | cmd2 || cmd3", 148 | redirect: "cmd > file < input >> append", 149 | glob: "*.txt ?.log [a-z]*", 150 | tilde: "~/home ~/.config", 151 | exclamation: "!history !!", 152 | question: "file? test?", 153 | asterisk: "*.* *", 154 | brackets: "[abc] [0-9]", 155 | braces: "{a,b,c} ${var}", 156 | caret: "^pattern^replacement^", 157 | hash: "#comment # another", 158 | at: "@variable @{array}" 159 | }; 160 | fs.writeFileSync(configPath, JSON.stringify(config)); 161 | 162 | const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { 163 | encoding: 'utf8', 164 | env: cleanEnv 165 | }); 166 | 167 | // Verify all metacharacters are safely quoted 168 | const lines = output.trim().split('\n'); 169 | lines.forEach(line => { 170 | // Each line should be in the format: export KEY='value' 171 | expect(line).toMatch(/^export [A-Z_]+='.*'$/); 172 | }); 173 | 174 | // Test that the values are safe when evaluated 175 | const testScript = ` 176 | #!/bin/sh 177 | set -e 178 | ${output} 179 | # If any metacharacters were unescaped, these would fail 180 | test "\$DOLLAR" = '\$HOME \$USER \${PATH}' 181 | test "\$BACKTICK" = '\`date\` \`whoami\`' 182 | test "\$PARENTHESES" = '\$(date) \$(whoami)' 183 | test "\$SEMICOLON" = 'cmd1; cmd2; cmd3' 184 | test "\$PIPE" = 'cmd1 | cmd2 || cmd3' 185 | echo "SUCCESS: All metacharacters safely contained" 186 | `; 187 | 188 | const tempScript = path.join(tempDir, 'test-metachar.sh'); 189 | fs.writeFileSync(tempScript, testScript); 190 | fs.chmodSync(tempScript, '755'); 191 | 192 | const result = execSync(tempScript, { encoding: 'utf8', env: cleanEnv }); 193 | expect(result.trim()).toBe('SUCCESS: All metacharacters safely contained'); 194 | }); 195 | }); 196 | 197 | describe('Escaping edge cases', () => { 198 | it('should handle consecutive single quotes', () => { 199 | const config = { 200 | test1: "'''", 201 | test2: "It'''s", 202 | test3: "start'''middle'''end", 203 | test4: "''''''''", 204 | }; 205 | fs.writeFileSync(configPath, JSON.stringify(config)); 206 | 207 | const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { 208 | encoding: 'utf8', 209 | env: cleanEnv 210 | }); 211 | 212 | // Verify the escaping is correct 213 | expect(output).toContain(`TEST1=''"'"''"'"''"'"'`); 214 | expect(output).toContain(`TEST2='It'"'"''"'"''"'"'s'`); 215 | }); 216 | 217 | it('should handle empty and whitespace-only values', () => { 218 | const config = { 219 | empty: "", 220 | space: " ", 221 | spaces: " ", 222 | tab: "\t", 223 | newline: "\n", 224 | mixed_whitespace: " \t\n\r " 225 | }; 226 | fs.writeFileSync(configPath, JSON.stringify(config)); 227 | 228 | const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { 229 | encoding: 'utf8', 230 | env: cleanEnv 231 | }); 232 | 233 | expect(output).toContain("EMPTY=''"); 234 | expect(output).toContain("SPACE=' '"); 235 | expect(output).toContain("SPACES=' '"); 236 | expect(output).toContain("TAB='\t'"); 237 | expect(output).toContain("NEWLINE='\n'"); 238 | expect(output).toContain("MIXED_WHITESPACE=' \t\n\r '"); 239 | }); 240 | 241 | it('should handle very long values', () => { 242 | const longString = 'a'.repeat(10000) + "'; echo 'injection'; '" + 'b'.repeat(10000); 243 | const config = { 244 | long_value: longString 245 | }; 246 | fs.writeFileSync(configPath, JSON.stringify(config)); 247 | 248 | const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { 249 | encoding: 'utf8', 250 | env: cleanEnv 251 | }); 252 | 253 | expect(output).toContain('LONG_VALUE='); 254 | expect(output.length).toBeGreaterThan(20000); 255 | // The injection attempt should be safely quoted 256 | expect(output).toContain("'\"'\"'; echo '\"'\"'injection'\"'\"'; '\"'\"'"); 257 | }); 258 | }); 259 | 260 | describe('Environment variable name security', () => { 261 | it('should handle potentially dangerous key names', () => { 262 | const config = { 263 | "PATH": "should-not-override", 264 | "LD_PRELOAD": "dangerous", 265 | "valid_key": "safe_value", 266 | "123invalid": "should-be-skipped", 267 | "key-with-dash": "should-work", 268 | "key.with.dots": "should-work", 269 | "KEY WITH SPACES": "should-work" 270 | }; 271 | fs.writeFileSync(configPath, JSON.stringify(config)); 272 | 273 | const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { 274 | encoding: 'utf8', 275 | env: cleanEnv 276 | }); 277 | 278 | // Dangerous variables should be blocked 279 | expect(output).not.toContain("export PATH="); 280 | expect(output).not.toContain("export LD_PRELOAD="); 281 | 282 | // Valid keys should be converted to safe names 283 | expect(output).toContain("export VALID_KEY='safe_value'"); 284 | expect(output).toContain("export KEY_WITH_DASH='should-work'"); 285 | expect(output).toContain("export KEY_WITH_DOTS='should-work'"); 286 | expect(output).toContain("export KEY_WITH_SPACES='should-work'"); 287 | 288 | // Invalid starting with number should be prefixed with _ 289 | expect(output).toContain("export _123INVALID='should-be-skipped'"); 290 | }); 291 | }); 292 | 293 | describe('Real-world attack scenarios', () => { 294 | it('should prevent path traversal attempts', () => { 295 | const config = { 296 | file_path: "../../../etc/passwd", 297 | backup_location: "../../../../../../tmp/evil", 298 | template: "${../../secret.key}", 299 | include: "<?php include('/etc/passwd'); ?>" 300 | }; 301 | fs.writeFileSync(configPath, JSON.stringify(config)); 302 | 303 | const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { 304 | encoding: 'utf8', 305 | env: cleanEnv 306 | }); 307 | 308 | // Path traversal attempts should be preserved as strings, not resolved 309 | expect(output).toContain("FILE_PATH='../../../etc/passwd'"); 310 | expect(output).toContain("BACKUP_LOCATION='../../../../../../tmp/evil'"); 311 | expect(output).toContain("TEMPLATE='${../../secret.key}'"); 312 | expect(output).toContain("INCLUDE='<?php include('\"'\"'/etc/passwd'\"'\"'); ?>'"); 313 | }); 314 | 315 | it('should handle polyglot payloads safely', () => { 316 | const config = { 317 | // JavaScript/Shell polyglot 318 | polyglot1: "';alert(String.fromCharCode(88,83,83))//';alert(String.fromCharCode(88,83,83))//\";alert(String.fromCharCode(88,83,83))//\";alert(String.fromCharCode(88,83,83))//--></SCRIPT>\">'><SCRIPT>alert(String.fromCharCode(88,83,83))</SCRIPT>", 319 | // SQL/Shell polyglot 320 | polyglot2: "1' OR '1'='1' /*' or 1=1 # ' or 1=1-- ' or 1=1;--", 321 | // XML/Shell polyglot 322 | polyglot3: "<?xml version=\"1.0\"?><!DOCTYPE foo [<!ENTITY xxe SYSTEM \"file:///etc/passwd\">]><foo>&xxe;</foo>" 323 | }; 324 | fs.writeFileSync(configPath, JSON.stringify(config)); 325 | 326 | const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { 327 | encoding: 'utf8', 328 | env: cleanEnv 329 | }); 330 | 331 | // All polyglot payloads should be safely quoted 332 | const lines = output.trim().split('\n'); 333 | lines.forEach(line => { 334 | if (line.startsWith('export POLYGLOT')) { 335 | // Should be safely wrapped in single quotes with proper escaping 336 | expect(line).toMatch(/^export POLYGLOT[0-9]='.*'$/); 337 | // The dangerous content is there but safely quoted 338 | // What matters is that when evaluated, it's just a string 339 | } 340 | }); 341 | }); 342 | }); 343 | 344 | describe('Stress testing', () => { 345 | it('should handle deeply nested malicious structures', () => { 346 | const createNestedMalicious = (depth: number): any => { 347 | if (depth === 0) { 348 | return "'; rm -rf /; '"; 349 | } 350 | return { 351 | [`level${depth}`]: createNestedMalicious(depth - 1), 352 | [`inject${depth}`]: "$( echo 'level " + depth + "' )" 353 | }; 354 | }; 355 | 356 | const config = createNestedMalicious(10); 357 | fs.writeFileSync(configPath, JSON.stringify(config)); 358 | 359 | const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { 360 | encoding: 'utf8', 361 | env: cleanEnv 362 | }); 363 | 364 | // Should handle deep nesting without issues 365 | expect(output).toContain("LEVEL10_LEVEL9_LEVEL8"); 366 | expect(output).toContain("'\"'\"'; rm -rf /; '\"'\"'"); 367 | 368 | // All injection attempts should be quoted 369 | const lines = output.trim().split('\n'); 370 | lines.forEach(line => { 371 | if (line.includes('INJECT')) { 372 | expect(line).toContain("$( echo '\"'\"'level"); 373 | } 374 | }); 375 | }); 376 | 377 | it('should handle mixed attack vectors in single config', () => { 378 | const config = { 379 | normal_value: "This is safe", 380 | sql_injection: "1' OR '1'='1", 381 | cmd_injection: "; cat /etc/passwd", 382 | xxe_attempt: '<!ENTITY xxe SYSTEM "file:///etc/passwd">', 383 | code_injection: "${constructor.constructor('return process')().exit()}", 384 | format_string: "%s%s%s%s%s%s%s%s%s%s", 385 | buffer_overflow: "A".repeat(10000), 386 | null_injection: "test\x00admin", 387 | ldap_injection: "*)(&(1=1", 388 | xpath_injection: "' or '1'='1", 389 | template_injection: "{{7*7}}", 390 | ssti: "${7*7}", 391 | crlf_injection: "test\r\nSet-Cookie: admin=true", 392 | host_header: "evil.com\r\nX-Forwarded-Host: evil.com", 393 | cache_poisoning: "index.html%0d%0aContent-Length:%200%0d%0a%0d%0aHTTP/1.1%20200%20OK" 394 | }; 395 | fs.writeFileSync(configPath, JSON.stringify(config)); 396 | 397 | const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { 398 | encoding: 'utf8', 399 | env: cleanEnv 400 | }); 401 | 402 | // Verify each attack vector is safely handled 403 | expect(output).toContain("NORMAL_VALUE='This is safe'"); 404 | expect(output).toContain("SQL_INJECTION='1'\"'\"' OR '\"'\"'1'\"'\"'='\"'\"'1'"); 405 | expect(output).toContain("CMD_INJECTION='; cat /etc/passwd'"); 406 | expect(output).toContain("XXE_ATTEMPT='<!ENTITY xxe SYSTEM \"file:///etc/passwd\">'"); 407 | expect(output).toContain("CODE_INJECTION='${constructor.constructor('\"'\"'return process'\"'\"')().exit()}'"); 408 | 409 | // Verify no actual code execution occurs 410 | const evalTest = `${output}\necho "Test completed successfully"`; 411 | const result = execSync(evalTest, { shell: '/bin/sh', encoding: 'utf8' }); 412 | expect(result).toContain("Test completed successfully"); 413 | }); 414 | }); 415 | }); ``` -------------------------------------------------------------------------------- /tests/unit/mcp/get-node-essentials-examples.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; 2 | import { N8NDocumentationMCPServer } from '../../../src/mcp/server'; 3 | 4 | /** 5 | * Unit tests for get_node_essentials with includeExamples parameter 6 | * Testing P0-R3 feature: Template-based configuration examples with metadata 7 | */ 8 | 9 | describe('get_node_essentials with includeExamples', () => { 10 | let server: N8NDocumentationMCPServer; 11 | 12 | beforeEach(async () => { 13 | process.env.NODE_DB_PATH = ':memory:'; 14 | server = new N8NDocumentationMCPServer(); 15 | await (server as any).initialized; 16 | 17 | // Populate in-memory database with test nodes 18 | // NOTE: Database stores nodes in SHORT form (nodes-base.xxx, not n8n-nodes-base.xxx) 19 | const testNodes = [ 20 | { 21 | node_type: 'nodes-base.httpRequest', 22 | package_name: 'n8n-nodes-base', 23 | display_name: 'HTTP Request', 24 | description: 'Makes an HTTP request', 25 | category: 'Core Nodes', 26 | is_ai_tool: 0, 27 | is_trigger: 0, 28 | is_webhook: 0, 29 | is_versioned: 1, 30 | version: '1', 31 | properties_schema: JSON.stringify([]), 32 | operations: JSON.stringify([]) 33 | }, 34 | { 35 | node_type: 'nodes-base.webhook', 36 | package_name: 'n8n-nodes-base', 37 | display_name: 'Webhook', 38 | description: 'Starts workflow on webhook call', 39 | category: 'Core Nodes', 40 | is_ai_tool: 0, 41 | is_trigger: 1, 42 | is_webhook: 1, 43 | is_versioned: 1, 44 | version: '1', 45 | properties_schema: JSON.stringify([]), 46 | operations: JSON.stringify([]) 47 | }, 48 | { 49 | node_type: 'nodes-base.test', 50 | package_name: 'n8n-nodes-base', 51 | display_name: 'Test Node', 52 | description: 'Test node for examples', 53 | category: 'Core Nodes', 54 | is_ai_tool: 0, 55 | is_trigger: 0, 56 | is_webhook: 0, 57 | is_versioned: 1, 58 | version: '1', 59 | properties_schema: JSON.stringify([]), 60 | operations: JSON.stringify([]) 61 | } 62 | ]; 63 | 64 | // Insert test nodes into the in-memory database 65 | const db = (server as any).db; 66 | if (db) { 67 | const insertStmt = db.prepare(` 68 | INSERT INTO nodes ( 69 | node_type, package_name, display_name, description, category, 70 | is_ai_tool, is_trigger, is_webhook, is_versioned, version, 71 | properties_schema, operations 72 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 73 | `); 74 | 75 | for (const node of testNodes) { 76 | insertStmt.run( 77 | node.node_type, 78 | node.package_name, 79 | node.display_name, 80 | node.description, 81 | node.category, 82 | node.is_ai_tool, 83 | node.is_trigger, 84 | node.is_webhook, 85 | node.is_versioned, 86 | node.version, 87 | node.properties_schema, 88 | node.operations 89 | ); 90 | } 91 | } 92 | }); 93 | 94 | afterEach(() => { 95 | delete process.env.NODE_DB_PATH; 96 | }); 97 | 98 | describe('includeExamples parameter', () => { 99 | it('should not include examples when includeExamples is false', async () => { 100 | const result = await (server as any).getNodeEssentials('nodes-base.httpRequest', false); 101 | 102 | expect(result).toBeDefined(); 103 | expect(result.examples).toBeUndefined(); 104 | }); 105 | 106 | it('should not include examples when includeExamples is undefined', async () => { 107 | const result = await (server as any).getNodeEssentials('nodes-base.httpRequest', undefined); 108 | 109 | expect(result).toBeDefined(); 110 | expect(result.examples).toBeUndefined(); 111 | }); 112 | 113 | it('should include examples when includeExamples is true', async () => { 114 | const result = await (server as any).getNodeEssentials('nodes-base.httpRequest', true); 115 | 116 | expect(result).toBeDefined(); 117 | // Note: In-memory test database may not have template configs 118 | // This test validates the parameter is processed correctly 119 | }); 120 | 121 | it('should limit examples to top 3 per node', async () => { 122 | const result = await (server as any).getNodeEssentials('nodes-base.webhook', true); 123 | 124 | expect(result).toBeDefined(); 125 | if (result.examples) { 126 | expect(result.examples.length).toBeLessThanOrEqual(3); 127 | } 128 | }); 129 | }); 130 | 131 | describe('example data structure with metadata', () => { 132 | it('should return examples with full metadata structure', async () => { 133 | // Mock database to return example data with metadata 134 | const mockDb = (server as any).db; 135 | if (mockDb) { 136 | const originalPrepare = mockDb.prepare.bind(mockDb); 137 | mockDb.prepare = vi.fn((query: string) => { 138 | if (query.includes('template_node_configs')) { 139 | return { 140 | all: vi.fn(() => [ 141 | { 142 | parameters_json: JSON.stringify({ 143 | httpMethod: 'POST', 144 | path: 'webhook-test', 145 | responseMode: 'lastNode' 146 | }), 147 | template_name: 'Webhook Template', 148 | template_views: 2000, 149 | complexity: 'simple', 150 | use_cases: JSON.stringify(['webhook processing', 'API integration']), 151 | has_credentials: 0, 152 | has_expressions: 1 153 | } 154 | ]) 155 | }; 156 | } 157 | return originalPrepare(query); 158 | }); 159 | 160 | const result = await (server as any).getNodeEssentials('nodes-base.webhook', true); 161 | 162 | if (result.examples && result.examples.length > 0) { 163 | const example = result.examples[0]; 164 | 165 | // Verify structure 166 | expect(example).toHaveProperty('configuration'); 167 | expect(example).toHaveProperty('source'); 168 | expect(example).toHaveProperty('useCases'); 169 | expect(example).toHaveProperty('metadata'); 170 | 171 | // Verify source structure 172 | expect(example.source).toHaveProperty('template'); 173 | expect(example.source).toHaveProperty('views'); 174 | expect(example.source).toHaveProperty('complexity'); 175 | 176 | // Verify metadata structure 177 | expect(example.metadata).toHaveProperty('hasCredentials'); 178 | expect(example.metadata).toHaveProperty('hasExpressions'); 179 | 180 | // Verify types 181 | expect(typeof example.configuration).toBe('object'); 182 | expect(typeof example.source.template).toBe('string'); 183 | expect(typeof example.source.views).toBe('number'); 184 | expect(typeof example.source.complexity).toBe('string'); 185 | expect(Array.isArray(example.useCases)).toBe(true); 186 | expect(typeof example.metadata.hasCredentials).toBe('boolean'); 187 | expect(typeof example.metadata.hasExpressions).toBe('boolean'); 188 | } 189 | } 190 | }); 191 | 192 | it('should include complexity in source metadata', async () => { 193 | const mockDb = (server as any).db; 194 | if (mockDb) { 195 | const originalPrepare = mockDb.prepare.bind(mockDb); 196 | mockDb.prepare = vi.fn((query: string) => { 197 | if (query.includes('template_node_configs')) { 198 | return { 199 | all: vi.fn(() => [ 200 | { 201 | parameters_json: JSON.stringify({ url: 'https://api.example.com' }), 202 | template_name: 'Simple HTTP Request', 203 | template_views: 500, 204 | complexity: 'simple', 205 | use_cases: JSON.stringify([]), 206 | has_credentials: 0, 207 | has_expressions: 0 208 | }, 209 | { 210 | parameters_json: JSON.stringify({ 211 | url: '={{ $json.url }}', 212 | options: { timeout: 30000 } 213 | }), 214 | template_name: 'Complex HTTP Request', 215 | template_views: 300, 216 | complexity: 'complex', 217 | use_cases: JSON.stringify(['advanced API calls']), 218 | has_credentials: 1, 219 | has_expressions: 1 220 | } 221 | ]) 222 | }; 223 | } 224 | return originalPrepare(query); 225 | }); 226 | 227 | const result = await (server as any).getNodeEssentials('nodes-base.httpRequest', true); 228 | 229 | if (result.examples && result.examples.length >= 2) { 230 | expect(result.examples[0].source.complexity).toBe('simple'); 231 | expect(result.examples[1].source.complexity).toBe('complex'); 232 | } 233 | } 234 | }); 235 | 236 | it('should limit use cases to 2 items', async () => { 237 | const mockDb = (server as any).db; 238 | if (mockDb) { 239 | const originalPrepare = mockDb.prepare.bind(mockDb); 240 | mockDb.prepare = vi.fn((query: string) => { 241 | if (query.includes('template_node_configs')) { 242 | return { 243 | all: vi.fn(() => [ 244 | { 245 | parameters_json: JSON.stringify({}), 246 | template_name: 'Test Template', 247 | template_views: 100, 248 | complexity: 'medium', 249 | use_cases: JSON.stringify([ 250 | 'use case 1', 251 | 'use case 2', 252 | 'use case 3', 253 | 'use case 4' 254 | ]), 255 | has_credentials: 0, 256 | has_expressions: 0 257 | } 258 | ]) 259 | }; 260 | } 261 | return originalPrepare(query); 262 | }); 263 | 264 | const result = await (server as any).getNodeEssentials('nodes-base.test', true); 265 | 266 | if (result.examples && result.examples.length > 0) { 267 | expect(result.examples[0].useCases.length).toBeLessThanOrEqual(2); 268 | } 269 | } 270 | }); 271 | 272 | it('should handle empty use_cases gracefully', async () => { 273 | const mockDb = (server as any).db; 274 | if (mockDb) { 275 | const originalPrepare = mockDb.prepare.bind(mockDb); 276 | mockDb.prepare = vi.fn((query: string) => { 277 | if (query.includes('template_node_configs')) { 278 | return { 279 | all: vi.fn(() => [ 280 | { 281 | parameters_json: JSON.stringify({}), 282 | template_name: 'Test Template', 283 | template_views: 100, 284 | complexity: 'medium', 285 | use_cases: null, 286 | has_credentials: 0, 287 | has_expressions: 0 288 | } 289 | ]) 290 | }; 291 | } 292 | return originalPrepare(query); 293 | }); 294 | 295 | const result = await (server as any).getNodeEssentials('nodes-base.test', true); 296 | 297 | if (result.examples && result.examples.length > 0) { 298 | expect(result.examples[0].useCases).toEqual([]); 299 | } 300 | } 301 | }); 302 | }); 303 | 304 | describe('caching behavior with includeExamples', () => { 305 | it('should use different cache keys for with/without examples', async () => { 306 | const cache = (server as any).cache; 307 | const cacheGetSpy = vi.spyOn(cache, 'get'); 308 | 309 | // First call without examples 310 | await (server as any).getNodeEssentials('nodes-base.httpRequest', false); 311 | expect(cacheGetSpy).toHaveBeenCalledWith(expect.stringContaining('basic')); 312 | 313 | // Second call with examples 314 | await (server as any).getNodeEssentials('nodes-base.httpRequest', true); 315 | expect(cacheGetSpy).toHaveBeenCalledWith(expect.stringContaining('withExamples')); 316 | }); 317 | 318 | it('should cache results separately for different includeExamples values', async () => { 319 | // Call with examples 320 | const resultWithExamples1 = await (server as any).getNodeEssentials('nodes-base.httpRequest', true); 321 | 322 | // Call without examples 323 | const resultWithoutExamples = await (server as any).getNodeEssentials('nodes-base.httpRequest', false); 324 | 325 | // Call with examples again (should be cached) 326 | const resultWithExamples2 = await (server as any).getNodeEssentials('nodes-base.httpRequest', true); 327 | 328 | // Results with examples should match 329 | expect(resultWithExamples1).toEqual(resultWithExamples2); 330 | 331 | // Result without examples should not have examples 332 | expect(resultWithoutExamples.examples).toBeUndefined(); 333 | }); 334 | }); 335 | 336 | describe('backward compatibility', () => { 337 | it('should maintain backward compatibility when includeExamples not specified', async () => { 338 | const result = await (server as any).getNodeEssentials('nodes-base.httpRequest'); 339 | 340 | expect(result).toBeDefined(); 341 | expect(result.nodeType).toBeDefined(); 342 | expect(result.displayName).toBeDefined(); 343 | expect(result.examples).toBeUndefined(); 344 | }); 345 | 346 | it('should return same core data regardless of includeExamples value', async () => { 347 | const resultWithout = await (server as any).getNodeEssentials('nodes-base.httpRequest', false); 348 | const resultWith = await (server as any).getNodeEssentials('nodes-base.httpRequest', true); 349 | 350 | // Core fields should be identical 351 | expect(resultWithout.nodeType).toBe(resultWith.nodeType); 352 | expect(resultWithout.displayName).toBe(resultWith.displayName); 353 | expect(resultWithout.description).toBe(resultWith.description); 354 | }); 355 | }); 356 | 357 | describe('error handling', () => { 358 | it('should continue to work even if example fetch fails', async () => { 359 | const mockDb = (server as any).db; 360 | if (mockDb) { 361 | const originalPrepare = mockDb.prepare.bind(mockDb); 362 | mockDb.prepare = vi.fn((query: string) => { 363 | if (query.includes('template_node_configs')) { 364 | throw new Error('Database error'); 365 | } 366 | return originalPrepare(query); 367 | }); 368 | 369 | // Should not throw 370 | const result = await (server as any).getNodeEssentials('nodes-base.webhook', true); 371 | 372 | expect(result).toBeDefined(); 373 | expect(result.nodeType).toBeDefined(); 374 | // Examples should be empty array due to error (fallback behavior) 375 | expect(result.examples).toEqual([]); 376 | expect(result.examplesCount).toBe(0); 377 | } 378 | }); 379 | 380 | it('should handle malformed JSON in template configs gracefully', async () => { 381 | const mockDb = (server as any).db; 382 | if (mockDb) { 383 | const originalPrepare = mockDb.prepare.bind(mockDb); 384 | mockDb.prepare = vi.fn((query: string) => { 385 | if (query.includes('template_node_configs')) { 386 | return { 387 | all: vi.fn(() => [ 388 | { 389 | parameters_json: 'invalid json', 390 | template_name: 'Test', 391 | template_views: 100, 392 | complexity: 'medium', 393 | use_cases: 'also invalid', 394 | has_credentials: 0, 395 | has_expressions: 0 396 | } 397 | ]) 398 | }; 399 | } 400 | return originalPrepare(query); 401 | }); 402 | 403 | // Should not throw 404 | const result = await (server as any).getNodeEssentials('nodes-base.test', true); 405 | expect(result).toBeDefined(); 406 | } 407 | }); 408 | }); 409 | 410 | describe('performance', () => { 411 | it('should complete in reasonable time with examples', async () => { 412 | const start = Date.now(); 413 | await (server as any).getNodeEssentials('nodes-base.httpRequest', true); 414 | const duration = Date.now() - start; 415 | 416 | // Should complete under 100ms 417 | expect(duration).toBeLessThan(100); 418 | }); 419 | 420 | it('should not add significant overhead when includeExamples is false', async () => { 421 | const startWithout = Date.now(); 422 | await (server as any).getNodeEssentials('nodes-base.httpRequest', false); 423 | const durationWithout = Date.now() - startWithout; 424 | 425 | const startWith = Date.now(); 426 | await (server as any).getNodeEssentials('nodes-base.httpRequest', true); 427 | const durationWith = Date.now() - startWith; 428 | 429 | // Both should be fast 430 | expect(durationWithout).toBeLessThan(50); 431 | expect(durationWith).toBeLessThan(100); 432 | }); 433 | }); 434 | }); 435 | ```