This is page 21 of 46. Use http://codebase.md/czlonkowski/n8n-mcp?lines=false&page={x} to view the full context. # Directory Structure ``` ├── _config.yml ├── .claude │ └── agents │ ├── code-reviewer.md │ ├── context-manager.md │ ├── debugger.md │ ├── deployment-engineer.md │ ├── mcp-backend-engineer.md │ ├── n8n-mcp-tester.md │ ├── technical-researcher.md │ └── test-automator.md ├── .dockerignore ├── .env.docker ├── .env.example ├── .env.n8n.example ├── .env.test ├── .env.test.example ├── .github │ ├── ABOUT.md │ ├── BENCHMARK_THRESHOLDS.md │ ├── FUNDING.yml │ ├── gh-pages.yml │ ├── secret_scanning.yml │ └── workflows │ ├── benchmark-pr.yml │ ├── benchmark.yml │ ├── docker-build-fast.yml │ ├── docker-build-n8n.yml │ ├── docker-build.yml │ ├── release.yml │ ├── test.yml │ └── update-n8n-deps.yml ├── .gitignore ├── .npmignore ├── ATTRIBUTION.md ├── CHANGELOG.md ├── CLAUDE.md ├── codecov.yml ├── coverage.json ├── data │ ├── .gitkeep │ ├── nodes.db │ ├── nodes.db-shm │ ├── nodes.db-wal │ └── templates.db ├── deploy │ └── quick-deploy-n8n.sh ├── docker │ ├── docker-entrypoint.sh │ ├── n8n-mcp │ ├── parse-config.js │ └── README.md ├── docker-compose.buildkit.yml ├── docker-compose.extract.yml ├── docker-compose.n8n.yml ├── docker-compose.override.yml.example ├── docker-compose.test-n8n.yml ├── docker-compose.yml ├── Dockerfile ├── Dockerfile.railway ├── Dockerfile.test ├── docs │ ├── AUTOMATED_RELEASES.md │ ├── BENCHMARKS.md │ ├── CHANGELOG.md │ ├── CLAUDE_CODE_SETUP.md │ ├── CLAUDE_INTERVIEW.md │ ├── CODECOV_SETUP.md │ ├── CODEX_SETUP.md │ ├── CURSOR_SETUP.md │ ├── DEPENDENCY_UPDATES.md │ ├── DOCKER_README.md │ ├── DOCKER_TROUBLESHOOTING.md │ ├── FINAL_AI_VALIDATION_SPEC.md │ ├── FLEXIBLE_INSTANCE_CONFIGURATION.md │ ├── HTTP_DEPLOYMENT.md │ ├── img │ │ ├── cc_command.png │ │ ├── cc_connected.png │ │ ├── codex_connected.png │ │ ├── cursor_tut.png │ │ ├── Railway_api.png │ │ ├── Railway_server_address.png │ │ ├── vsc_ghcp_chat_agent_mode.png │ │ ├── vsc_ghcp_chat_instruction_files.png │ │ ├── vsc_ghcp_chat_thinking_tool.png │ │ └── windsurf_tut.png │ ├── INSTALLATION.md │ ├── LIBRARY_USAGE.md │ ├── local │ │ ├── DEEP_DIVE_ANALYSIS_2025-10-02.md │ │ ├── DEEP_DIVE_ANALYSIS_README.md │ │ ├── Deep_dive_p1_p2.md │ │ ├── integration-testing-plan.md │ │ ├── integration-tests-phase1-summary.md │ │ ├── N8N_AI_WORKFLOW_BUILDER_ANALYSIS.md │ │ ├── P0_IMPLEMENTATION_PLAN.md │ │ └── TEMPLATE_MINING_ANALYSIS.md │ ├── MCP_ESSENTIALS_README.md │ ├── MCP_QUICK_START_GUIDE.md │ ├── N8N_DEPLOYMENT.md │ ├── RAILWAY_DEPLOYMENT.md │ ├── README_CLAUDE_SETUP.md │ ├── README.md │ ├── tools-documentation-usage.md │ ├── VS_CODE_PROJECT_SETUP.md │ ├── WINDSURF_SETUP.md │ └── workflow-diff-examples.md ├── examples │ └── enhanced-documentation-demo.js ├── fetch_log.txt ├── LICENSE ├── MEMORY_N8N_UPDATE.md ├── MEMORY_TEMPLATE_UPDATE.md ├── monitor_fetch.sh ├── N8N_HTTP_STREAMABLE_SETUP.md ├── n8n-nodes.db ├── P0-R3-TEST-PLAN.md ├── package-lock.json ├── package.json ├── package.runtime.json ├── PRIVACY.md ├── railway.json ├── README.md ├── renovate.json ├── scripts │ ├── analyze-optimization.sh │ ├── audit-schema-coverage.ts │ ├── build-optimized.sh │ ├── compare-benchmarks.js │ ├── demo-optimization.sh │ ├── deploy-http.sh │ ├── deploy-to-vm.sh │ ├── export-webhook-workflows.ts │ ├── extract-changelog.js │ ├── extract-from-docker.js │ ├── extract-nodes-docker.sh │ ├── extract-nodes-simple.sh │ ├── format-benchmark-results.js │ ├── generate-benchmark-stub.js │ ├── generate-detailed-reports.js │ ├── generate-test-summary.js │ ├── http-bridge.js │ ├── mcp-http-client.js │ ├── migrate-nodes-fts.ts │ ├── migrate-tool-docs.ts │ ├── n8n-docs-mcp.service │ ├── nginx-n8n-mcp.conf │ ├── prebuild-fts5.ts │ ├── prepare-release.js │ ├── publish-npm-quick.sh │ ├── publish-npm.sh │ ├── quick-test.ts │ ├── run-benchmarks-ci.js │ ├── sync-runtime-version.js │ ├── test-ai-validation-debug.ts │ ├── test-code-node-enhancements.ts │ ├── test-code-node-fixes.ts │ ├── test-docker-config.sh │ ├── test-docker-fingerprint.ts │ ├── test-docker-optimization.sh │ ├── test-docker.sh │ ├── test-empty-connection-validation.ts │ ├── test-error-message-tracking.ts │ ├── test-error-output-validation.ts │ ├── test-error-validation.js │ ├── test-essentials.ts │ ├── test-expression-code-validation.ts │ ├── test-expression-format-validation.js │ ├── test-fts5-search.ts │ ├── test-fuzzy-fix.ts │ ├── test-fuzzy-simple.ts │ ├── test-helpers-validation.ts │ ├── test-http-search.ts │ ├── test-http.sh │ ├── test-jmespath-validation.ts │ ├── test-multi-tenant-simple.ts │ ├── test-multi-tenant.ts │ ├── test-n8n-integration.sh │ ├── test-node-info.js │ ├── test-node-type-validation.ts │ ├── test-nodes-base-prefix.ts │ ├── test-operation-validation.ts │ ├── test-optimized-docker.sh │ ├── test-release-automation.js │ ├── test-search-improvements.ts │ ├── test-security.ts │ ├── test-single-session.sh │ ├── test-sqljs-triggers.ts │ ├── test-telemetry-debug.ts │ ├── test-telemetry-direct.ts │ ├── test-telemetry-env.ts │ ├── test-telemetry-integration.ts │ ├── test-telemetry-no-select.ts │ ├── test-telemetry-security.ts │ ├── test-telemetry-simple.ts │ ├── test-typeversion-validation.ts │ ├── test-url-configuration.ts │ ├── test-user-id-persistence.ts │ ├── test-webhook-validation.ts │ ├── test-workflow-insert.ts │ ├── test-workflow-sanitizer.ts │ ├── test-workflow-tracking-debug.ts │ ├── update-and-publish-prep.sh │ ├── update-n8n-deps.js │ ├── update-readme-version.js │ ├── vitest-benchmark-json-reporter.js │ └── vitest-benchmark-reporter.ts ├── SECURITY.md ├── src │ ├── config │ │ └── n8n-api.ts │ ├── data │ │ └── canonical-ai-tool-examples.json │ ├── database │ │ ├── database-adapter.ts │ │ ├── migrations │ │ │ └── add-template-node-configs.sql │ │ ├── node-repository.ts │ │ ├── nodes.db │ │ ├── schema-optimized.sql │ │ └── schema.sql │ ├── errors │ │ └── validation-service-error.ts │ ├── http-server-single-session.ts │ ├── http-server.ts │ ├── index.ts │ ├── loaders │ │ └── node-loader.ts │ ├── mappers │ │ └── docs-mapper.ts │ ├── mcp │ │ ├── handlers-n8n-manager.ts │ │ ├── handlers-workflow-diff.ts │ │ ├── index.ts │ │ ├── server.ts │ │ ├── stdio-wrapper.ts │ │ ├── tool-docs │ │ │ ├── configuration │ │ │ │ ├── get-node-as-tool-info.ts │ │ │ │ ├── get-node-documentation.ts │ │ │ │ ├── get-node-essentials.ts │ │ │ │ ├── get-node-info.ts │ │ │ │ ├── get-property-dependencies.ts │ │ │ │ ├── index.ts │ │ │ │ └── search-node-properties.ts │ │ │ ├── discovery │ │ │ │ ├── get-database-statistics.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-ai-tools.ts │ │ │ │ ├── list-nodes.ts │ │ │ │ └── search-nodes.ts │ │ │ ├── guides │ │ │ │ ├── ai-agents-guide.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── system │ │ │ │ ├── index.ts │ │ │ │ ├── n8n-diagnostic.ts │ │ │ │ ├── n8n-health-check.ts │ │ │ │ ├── n8n-list-available-tools.ts │ │ │ │ └── tools-documentation.ts │ │ │ ├── templates │ │ │ │ ├── get-template.ts │ │ │ │ ├── get-templates-for-task.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-node-templates.ts │ │ │ │ ├── list-tasks.ts │ │ │ │ ├── search-templates-by-metadata.ts │ │ │ │ └── search-templates.ts │ │ │ ├── types.ts │ │ │ ├── validation │ │ │ │ ├── index.ts │ │ │ │ ├── validate-node-minimal.ts │ │ │ │ ├── validate-node-operation.ts │ │ │ │ ├── validate-workflow-connections.ts │ │ │ │ ├── validate-workflow-expressions.ts │ │ │ │ └── validate-workflow.ts │ │ │ └── workflow_management │ │ │ ├── index.ts │ │ │ ├── n8n-autofix-workflow.ts │ │ │ ├── n8n-create-workflow.ts │ │ │ ├── n8n-delete-execution.ts │ │ │ ├── n8n-delete-workflow.ts │ │ │ ├── n8n-get-execution.ts │ │ │ ├── n8n-get-workflow-details.ts │ │ │ ├── n8n-get-workflow-minimal.ts │ │ │ ├── n8n-get-workflow-structure.ts │ │ │ ├── n8n-get-workflow.ts │ │ │ ├── n8n-list-executions.ts │ │ │ ├── n8n-list-workflows.ts │ │ │ ├── n8n-trigger-webhook-workflow.ts │ │ │ ├── n8n-update-full-workflow.ts │ │ │ ├── n8n-update-partial-workflow.ts │ │ │ └── n8n-validate-workflow.ts │ │ ├── tools-documentation.ts │ │ ├── tools-n8n-friendly.ts │ │ ├── tools-n8n-manager.ts │ │ ├── tools.ts │ │ └── workflow-examples.ts │ ├── mcp-engine.ts │ ├── mcp-tools-engine.ts │ ├── n8n │ │ ├── MCPApi.credentials.ts │ │ └── MCPNode.node.ts │ ├── parsers │ │ ├── node-parser.ts │ │ ├── property-extractor.ts │ │ └── simple-parser.ts │ ├── scripts │ │ ├── debug-http-search.ts │ │ ├── extract-from-docker.ts │ │ ├── fetch-templates-robust.ts │ │ ├── fetch-templates.ts │ │ ├── rebuild-database.ts │ │ ├── rebuild-optimized.ts │ │ ├── rebuild.ts │ │ ├── sanitize-templates.ts │ │ ├── seed-canonical-ai-examples.ts │ │ ├── test-autofix-documentation.ts │ │ ├── test-autofix-workflow.ts │ │ ├── test-execution-filtering.ts │ │ ├── test-node-suggestions.ts │ │ ├── test-protocol-negotiation.ts │ │ ├── test-summary.ts │ │ ├── test-webhook-autofix.ts │ │ ├── validate.ts │ │ └── validation-summary.ts │ ├── services │ │ ├── ai-node-validator.ts │ │ ├── ai-tool-validators.ts │ │ ├── confidence-scorer.ts │ │ ├── config-validator.ts │ │ ├── enhanced-config-validator.ts │ │ ├── example-generator.ts │ │ ├── execution-processor.ts │ │ ├── expression-format-validator.ts │ │ ├── expression-validator.ts │ │ ├── n8n-api-client.ts │ │ ├── n8n-validation.ts │ │ ├── node-documentation-service.ts │ │ ├── node-sanitizer.ts │ │ ├── node-similarity-service.ts │ │ ├── node-specific-validators.ts │ │ ├── operation-similarity-service.ts │ │ ├── property-dependencies.ts │ │ ├── property-filter.ts │ │ ├── resource-similarity-service.ts │ │ ├── sqlite-storage-service.ts │ │ ├── task-templates.ts │ │ ├── universal-expression-validator.ts │ │ ├── workflow-auto-fixer.ts │ │ ├── workflow-diff-engine.ts │ │ └── workflow-validator.ts │ ├── telemetry │ │ ├── batch-processor.ts │ │ ├── config-manager.ts │ │ ├── early-error-logger.ts │ │ ├── error-sanitization-utils.ts │ │ ├── error-sanitizer.ts │ │ ├── event-tracker.ts │ │ ├── event-validator.ts │ │ ├── index.ts │ │ ├── performance-monitor.ts │ │ ├── rate-limiter.ts │ │ ├── startup-checkpoints.ts │ │ ├── telemetry-error.ts │ │ ├── telemetry-manager.ts │ │ ├── telemetry-types.ts │ │ └── workflow-sanitizer.ts │ ├── templates │ │ ├── batch-processor.ts │ │ ├── metadata-generator.ts │ │ ├── README.md │ │ ├── template-fetcher.ts │ │ ├── template-repository.ts │ │ └── template-service.ts │ ├── types │ │ ├── index.ts │ │ ├── instance-context.ts │ │ ├── n8n-api.ts │ │ ├── node-types.ts │ │ └── workflow-diff.ts │ └── utils │ ├── auth.ts │ ├── bridge.ts │ ├── cache-utils.ts │ ├── console-manager.ts │ ├── documentation-fetcher.ts │ ├── enhanced-documentation-fetcher.ts │ ├── error-handler.ts │ ├── example-generator.ts │ ├── fixed-collection-validator.ts │ ├── logger.ts │ ├── mcp-client.ts │ ├── n8n-errors.ts │ ├── node-source-extractor.ts │ ├── node-type-normalizer.ts │ ├── node-type-utils.ts │ ├── node-utils.ts │ ├── npm-version-checker.ts │ ├── protocol-version.ts │ ├── simple-cache.ts │ ├── ssrf-protection.ts │ ├── template-node-resolver.ts │ ├── template-sanitizer.ts │ ├── url-detector.ts │ ├── validation-schemas.ts │ └── version.ts ├── test-output.txt ├── test-reinit-fix.sh ├── tests │ ├── __snapshots__ │ │ └── .gitkeep │ ├── auth.test.ts │ ├── benchmarks │ │ ├── database-queries.bench.ts │ │ ├── index.ts │ │ ├── mcp-tools.bench.ts │ │ ├── mcp-tools.bench.ts.disabled │ │ ├── mcp-tools.bench.ts.skip │ │ ├── node-loading.bench.ts.disabled │ │ ├── README.md │ │ ├── search-operations.bench.ts.disabled │ │ └── validation-performance.bench.ts.disabled │ ├── bridge.test.ts │ ├── comprehensive-extraction-test.js │ ├── data │ │ └── .gitkeep │ ├── debug-slack-doc.js │ ├── demo-enhanced-documentation.js │ ├── docker-tests-README.md │ ├── error-handler.test.ts │ ├── examples │ │ └── using-database-utils.test.ts │ ├── extracted-nodes-db │ │ ├── database-import.json │ │ ├── extraction-report.json │ │ ├── insert-nodes.sql │ │ ├── n8n-nodes-base__Airtable.json │ │ ├── n8n-nodes-base__Discord.json │ │ ├── n8n-nodes-base__Function.json │ │ ├── n8n-nodes-base__HttpRequest.json │ │ ├── n8n-nodes-base__If.json │ │ ├── n8n-nodes-base__Slack.json │ │ ├── n8n-nodes-base__SplitInBatches.json │ │ └── n8n-nodes-base__Webhook.json │ ├── factories │ │ ├── node-factory.ts │ │ └── property-definition-factory.ts │ ├── fixtures │ │ ├── .gitkeep │ │ ├── database │ │ │ └── test-nodes.json │ │ ├── factories │ │ │ ├── node.factory.ts │ │ │ └── parser-node.factory.ts │ │ └── template-configs.ts │ ├── helpers │ │ └── env-helpers.ts │ ├── http-server-auth.test.ts │ ├── integration │ │ ├── ai-validation │ │ │ ├── ai-agent-validation.test.ts │ │ │ ├── ai-tool-validation.test.ts │ │ │ ├── chat-trigger-validation.test.ts │ │ │ ├── e2e-validation.test.ts │ │ │ ├── helpers.ts │ │ │ ├── llm-chain-validation.test.ts │ │ │ ├── README.md │ │ │ └── TEST_REPORT.md │ │ ├── ci │ │ │ └── database-population.test.ts │ │ ├── database │ │ │ ├── connection-management.test.ts │ │ │ ├── empty-database.test.ts │ │ │ ├── fts5-search.test.ts │ │ │ ├── node-fts5-search.test.ts │ │ │ ├── node-repository.test.ts │ │ │ ├── performance.test.ts │ │ │ ├── sqljs-memory-leak.test.ts │ │ │ ├── template-node-configs.test.ts │ │ │ ├── template-repository.test.ts │ │ │ ├── test-utils.ts │ │ │ └── transactions.test.ts │ │ ├── database-integration.test.ts │ │ ├── docker │ │ │ ├── docker-config.test.ts │ │ │ ├── docker-entrypoint.test.ts │ │ │ └── test-helpers.ts │ │ ├── flexible-instance-config.test.ts │ │ ├── mcp │ │ │ └── template-examples-e2e.test.ts │ │ ├── mcp-protocol │ │ │ ├── basic-connection.test.ts │ │ │ ├── error-handling.test.ts │ │ │ ├── performance.test.ts │ │ │ ├── protocol-compliance.test.ts │ │ │ ├── README.md │ │ │ ├── session-management.test.ts │ │ │ ├── test-helpers.ts │ │ │ ├── tool-invocation.test.ts │ │ │ └── workflow-error-validation.test.ts │ │ ├── msw-setup.test.ts │ │ ├── n8n-api │ │ │ ├── executions │ │ │ │ ├── delete-execution.test.ts │ │ │ │ ├── get-execution.test.ts │ │ │ │ ├── list-executions.test.ts │ │ │ │ └── trigger-webhook.test.ts │ │ │ ├── scripts │ │ │ │ └── cleanup-orphans.ts │ │ │ ├── system │ │ │ │ ├── diagnostic.test.ts │ │ │ │ ├── health-check.test.ts │ │ │ │ └── list-tools.test.ts │ │ │ ├── test-connection.ts │ │ │ ├── types │ │ │ │ └── mcp-responses.ts │ │ │ ├── utils │ │ │ │ ├── cleanup-helpers.ts │ │ │ │ ├── credentials.ts │ │ │ │ ├── factories.ts │ │ │ │ ├── fixtures.ts │ │ │ │ ├── mcp-context.ts │ │ │ │ ├── n8n-client.ts │ │ │ │ ├── node-repository.ts │ │ │ │ ├── response-types.ts │ │ │ │ ├── test-context.ts │ │ │ │ └── webhook-workflows.ts │ │ │ └── workflows │ │ │ ├── autofix-workflow.test.ts │ │ │ ├── create-workflow.test.ts │ │ │ ├── delete-workflow.test.ts │ │ │ ├── get-workflow-details.test.ts │ │ │ ├── get-workflow-minimal.test.ts │ │ │ ├── get-workflow-structure.test.ts │ │ │ ├── get-workflow.test.ts │ │ │ ├── list-workflows.test.ts │ │ │ ├── smart-parameters.test.ts │ │ │ ├── update-partial-workflow.test.ts │ │ │ ├── update-workflow.test.ts │ │ │ └── validate-workflow.test.ts │ │ ├── security │ │ │ ├── command-injection-prevention.test.ts │ │ │ └── rate-limiting.test.ts │ │ ├── setup │ │ │ ├── integration-setup.ts │ │ │ └── msw-test-server.ts │ │ ├── telemetry │ │ │ ├── docker-user-id-stability.test.ts │ │ │ └── mcp-telemetry.test.ts │ │ ├── templates │ │ │ └── metadata-operations.test.ts │ │ └── workflow-creation-node-type-format.test.ts │ ├── logger.test.ts │ ├── MOCKING_STRATEGY.md │ ├── mocks │ │ ├── n8n-api │ │ │ ├── data │ │ │ │ ├── credentials.ts │ │ │ │ ├── executions.ts │ │ │ │ └── workflows.ts │ │ │ ├── handlers.ts │ │ │ └── index.ts │ │ └── README.md │ ├── node-storage-export.json │ ├── setup │ │ ├── global-setup.ts │ │ ├── msw-setup.ts │ │ ├── TEST_ENV_DOCUMENTATION.md │ │ └── test-env.ts │ ├── test-database-extraction.js │ ├── test-direct-extraction.js │ ├── test-enhanced-documentation.js │ ├── test-enhanced-integration.js │ ├── test-mcp-extraction.js │ ├── test-mcp-server-extraction.js │ ├── test-mcp-tools-integration.js │ ├── test-node-documentation-service.js │ ├── test-node-list.js │ ├── test-package-info.js │ ├── test-parsing-operations.js │ ├── test-slack-node-complete.js │ ├── test-small-rebuild.js │ ├── test-sqlite-search.js │ ├── test-storage-system.js │ ├── unit │ │ ├── __mocks__ │ │ │ ├── n8n-nodes-base.test.ts │ │ │ ├── n8n-nodes-base.ts │ │ │ └── README.md │ │ ├── database │ │ │ ├── __mocks__ │ │ │ │ └── better-sqlite3.ts │ │ │ ├── database-adapter-unit.test.ts │ │ │ ├── node-repository-core.test.ts │ │ │ ├── node-repository-operations.test.ts │ │ │ ├── node-repository-outputs.test.ts │ │ │ ├── README.md │ │ │ └── template-repository-core.test.ts │ │ ├── docker │ │ │ ├── config-security.test.ts │ │ │ ├── edge-cases.test.ts │ │ │ ├── parse-config.test.ts │ │ │ └── serve-command.test.ts │ │ ├── errors │ │ │ └── validation-service-error.test.ts │ │ ├── examples │ │ │ └── using-n8n-nodes-base-mock.test.ts │ │ ├── flexible-instance-security-advanced.test.ts │ │ ├── flexible-instance-security.test.ts │ │ ├── http-server │ │ │ └── multi-tenant-support.test.ts │ │ ├── http-server-n8n-mode.test.ts │ │ ├── http-server-n8n-reinit.test.ts │ │ ├── http-server-session-management.test.ts │ │ ├── loaders │ │ │ └── node-loader.test.ts │ │ ├── mappers │ │ │ └── docs-mapper.test.ts │ │ ├── mcp │ │ │ ├── get-node-essentials-examples.test.ts │ │ │ ├── handlers-n8n-manager-simple.test.ts │ │ │ ├── handlers-n8n-manager.test.ts │ │ │ ├── handlers-workflow-diff.test.ts │ │ │ ├── lru-cache-behavior.test.ts │ │ │ ├── multi-tenant-tool-listing.test.ts.disabled │ │ │ ├── parameter-validation.test.ts │ │ │ ├── search-nodes-examples.test.ts │ │ │ ├── tools-documentation.test.ts │ │ │ └── tools.test.ts │ │ ├── monitoring │ │ │ └── cache-metrics.test.ts │ │ ├── MULTI_TENANT_TEST_COVERAGE.md │ │ ├── multi-tenant-integration.test.ts │ │ ├── parsers │ │ │ ├── node-parser-outputs.test.ts │ │ │ ├── node-parser.test.ts │ │ │ ├── property-extractor.test.ts │ │ │ └── simple-parser.test.ts │ │ ├── scripts │ │ │ └── fetch-templates-extraction.test.ts │ │ ├── services │ │ │ ├── ai-node-validator.test.ts │ │ │ ├── ai-tool-validators.test.ts │ │ │ ├── confidence-scorer.test.ts │ │ │ ├── config-validator-basic.test.ts │ │ │ ├── config-validator-edge-cases.test.ts │ │ │ ├── config-validator-node-specific.test.ts │ │ │ ├── config-validator-security.test.ts │ │ │ ├── debug-validator.test.ts │ │ │ ├── enhanced-config-validator-integration.test.ts │ │ │ ├── enhanced-config-validator-operations.test.ts │ │ │ ├── enhanced-config-validator.test.ts │ │ │ ├── example-generator.test.ts │ │ │ ├── execution-processor.test.ts │ │ │ ├── expression-format-validator.test.ts │ │ │ ├── expression-validator-edge-cases.test.ts │ │ │ ├── expression-validator.test.ts │ │ │ ├── fixed-collection-validation.test.ts │ │ │ ├── loop-output-edge-cases.test.ts │ │ │ ├── n8n-api-client.test.ts │ │ │ ├── n8n-validation.test.ts │ │ │ ├── node-sanitizer.test.ts │ │ │ ├── node-similarity-service.test.ts │ │ │ ├── node-specific-validators.test.ts │ │ │ ├── operation-similarity-service-comprehensive.test.ts │ │ │ ├── operation-similarity-service.test.ts │ │ │ ├── property-dependencies.test.ts │ │ │ ├── property-filter-edge-cases.test.ts │ │ │ ├── property-filter.test.ts │ │ │ ├── resource-similarity-service-comprehensive.test.ts │ │ │ ├── resource-similarity-service.test.ts │ │ │ ├── task-templates.test.ts │ │ │ ├── template-service.test.ts │ │ │ ├── universal-expression-validator.test.ts │ │ │ ├── validation-fixes.test.ts │ │ │ ├── workflow-auto-fixer.test.ts │ │ │ ├── workflow-diff-engine.test.ts │ │ │ ├── workflow-fixed-collection-validation.test.ts │ │ │ ├── workflow-validator-comprehensive.test.ts │ │ │ ├── workflow-validator-edge-cases.test.ts │ │ │ ├── workflow-validator-error-outputs.test.ts │ │ │ ├── workflow-validator-expression-format.test.ts │ │ │ ├── workflow-validator-loops-simple.test.ts │ │ │ ├── workflow-validator-loops.test.ts │ │ │ ├── workflow-validator-mocks.test.ts │ │ │ ├── workflow-validator-performance.test.ts │ │ │ ├── workflow-validator-with-mocks.test.ts │ │ │ └── workflow-validator.test.ts │ │ ├── telemetry │ │ │ ├── batch-processor.test.ts │ │ │ ├── config-manager.test.ts │ │ │ ├── event-tracker.test.ts │ │ │ ├── event-validator.test.ts │ │ │ ├── rate-limiter.test.ts │ │ │ ├── telemetry-error.test.ts │ │ │ ├── telemetry-manager.test.ts │ │ │ ├── v2.18.3-fixes-verification.test.ts │ │ │ └── workflow-sanitizer.test.ts │ │ ├── templates │ │ │ ├── batch-processor.test.ts │ │ │ ├── metadata-generator.test.ts │ │ │ ├── template-repository-metadata.test.ts │ │ │ └── template-repository-security.test.ts │ │ ├── test-env-example.test.ts │ │ ├── test-infrastructure.test.ts │ │ ├── types │ │ │ ├── instance-context-coverage.test.ts │ │ │ └── instance-context-multi-tenant.test.ts │ │ ├── utils │ │ │ ├── auth-timing-safe.test.ts │ │ │ ├── cache-utils.test.ts │ │ │ ├── console-manager.test.ts │ │ │ ├── database-utils.test.ts │ │ │ ├── fixed-collection-validator.test.ts │ │ │ ├── n8n-errors.test.ts │ │ │ ├── node-type-normalizer.test.ts │ │ │ ├── node-type-utils.test.ts │ │ │ ├── node-utils.test.ts │ │ │ ├── simple-cache-memory-leak-fix.test.ts │ │ │ ├── ssrf-protection.test.ts │ │ │ └── template-node-resolver.test.ts │ │ └── validation-fixes.test.ts │ └── utils │ ├── assertions.ts │ ├── builders │ │ └── workflow.builder.ts │ ├── data-generators.ts │ ├── database-utils.ts │ ├── README.md │ └── test-helpers.ts ├── thumbnail.png ├── tsconfig.build.json ├── tsconfig.json ├── types │ ├── mcp.d.ts │ └── test-env.d.ts ├── verify-telemetry-fix.js ├── versioned-nodes.md ├── vitest.config.benchmark.ts ├── vitest.config.integration.ts └── vitest.config.ts ``` # Files -------------------------------------------------------------------------------- /tests/unit/telemetry/telemetry-error.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { TelemetryError, TelemetryCircuitBreaker, TelemetryErrorAggregator } from '../../../src/telemetry/telemetry-error'; import { TelemetryErrorType } from '../../../src/telemetry/telemetry-types'; import { logger } from '../../../src/utils/logger'; // Mock logger to avoid console output in tests vi.mock('../../../src/utils/logger', () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), } })); describe('TelemetryError', () => { beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); describe('constructor', () => { it('should create error with all properties', () => { const context = { operation: 'test', detail: 'info' }; const error = new TelemetryError( TelemetryErrorType.NETWORK_ERROR, 'Test error', context, true ); expect(error.name).toBe('TelemetryError'); expect(error.message).toBe('Test error'); expect(error.type).toBe(TelemetryErrorType.NETWORK_ERROR); expect(error.context).toEqual(context); expect(error.retryable).toBe(true); expect(error.timestamp).toBeTypeOf('number'); }); it('should default retryable to false', () => { const error = new TelemetryError( TelemetryErrorType.VALIDATION_ERROR, 'Test error' ); expect(error.retryable).toBe(false); }); it('should handle undefined context', () => { const error = new TelemetryError( TelemetryErrorType.UNKNOWN_ERROR, 'Test error' ); expect(error.context).toBeUndefined(); }); it('should maintain proper prototype chain', () => { const error = new TelemetryError( TelemetryErrorType.NETWORK_ERROR, 'Test error' ); expect(error instanceof TelemetryError).toBe(true); expect(error instanceof Error).toBe(true); }); }); describe('toContext()', () => { it('should convert error to context object', () => { const context = { operation: 'flush', batch: 'events' }; const error = new TelemetryError( TelemetryErrorType.NETWORK_ERROR, 'Failed to flush', context, true ); const contextObj = error.toContext(); expect(contextObj).toEqual({ type: TelemetryErrorType.NETWORK_ERROR, message: 'Failed to flush', context, timestamp: error.timestamp, retryable: true }); }); }); describe('log()', () => { it('should log retryable errors as debug', () => { const error = new TelemetryError( TelemetryErrorType.NETWORK_ERROR, 'Retryable error', { attempt: 1 }, true ); error.log(); expect(logger.debug).toHaveBeenCalledWith( 'Retryable telemetry error:', expect.objectContaining({ type: TelemetryErrorType.NETWORK_ERROR, message: 'Retryable error', attempt: 1 }) ); }); it('should log non-retryable errors as debug', () => { const error = new TelemetryError( TelemetryErrorType.VALIDATION_ERROR, 'Non-retryable error', { field: 'user_id' }, false ); error.log(); expect(logger.debug).toHaveBeenCalledWith( 'Non-retryable telemetry error:', expect.objectContaining({ type: TelemetryErrorType.VALIDATION_ERROR, message: 'Non-retryable error', field: 'user_id' }) ); }); it('should handle errors without context', () => { const error = new TelemetryError( TelemetryErrorType.UNKNOWN_ERROR, 'Simple error' ); error.log(); expect(logger.debug).toHaveBeenCalledWith( 'Non-retryable telemetry error:', expect.objectContaining({ type: TelemetryErrorType.UNKNOWN_ERROR, message: 'Simple error' }) ); }); }); }); describe('TelemetryCircuitBreaker', () => { let circuitBreaker: TelemetryCircuitBreaker; beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); circuitBreaker = new TelemetryCircuitBreaker(3, 10000, 2); // 3 failures, 10s reset, 2 half-open requests }); afterEach(() => { vi.useRealTimers(); }); describe('shouldAllow()', () => { it('should allow requests in closed state', () => { expect(circuitBreaker.shouldAllow()).toBe(true); }); it('should open circuit after failure threshold', () => { // Record 3 failures to reach threshold for (let i = 0; i < 3; i++) { circuitBreaker.recordFailure(); } expect(circuitBreaker.shouldAllow()).toBe(false); expect(circuitBreaker.getState().state).toBe('open'); }); it('should transition to half-open after reset timeout', () => { // Open the circuit for (let i = 0; i < 3; i++) { circuitBreaker.recordFailure(); } expect(circuitBreaker.shouldAllow()).toBe(false); // Advance time past reset timeout vi.advanceTimersByTime(11000); // Should transition to half-open and allow request expect(circuitBreaker.shouldAllow()).toBe(true); expect(circuitBreaker.getState().state).toBe('half-open'); }); it('should limit requests in half-open state', () => { // Open the circuit for (let i = 0; i < 3; i++) { circuitBreaker.recordFailure(); } // Advance to half-open vi.advanceTimersByTime(11000); // Should allow limited number of requests (2 in our config) expect(circuitBreaker.shouldAllow()).toBe(true); expect(circuitBreaker.shouldAllow()).toBe(true); expect(circuitBreaker.shouldAllow()).toBe(true); // Note: simplified implementation allows all }); it('should not allow requests before reset timeout in open state', () => { // Open the circuit for (let i = 0; i < 3; i++) { circuitBreaker.recordFailure(); } // Advance time but not enough to reset vi.advanceTimersByTime(5000); expect(circuitBreaker.shouldAllow()).toBe(false); }); }); describe('recordSuccess()', () => { it('should reset failure count in closed state', () => { // Record some failures but not enough to open circuitBreaker.recordFailure(); circuitBreaker.recordFailure(); expect(circuitBreaker.getState().failureCount).toBe(2); // Success should reset count circuitBreaker.recordSuccess(); expect(circuitBreaker.getState().failureCount).toBe(0); }); it('should close circuit after successful half-open requests', () => { // Open the circuit for (let i = 0; i < 3; i++) { circuitBreaker.recordFailure(); } // Go to half-open vi.advanceTimersByTime(11000); circuitBreaker.shouldAllow(); // First half-open request circuitBreaker.shouldAllow(); // Second half-open request // The circuit breaker implementation requires success calls // to match the number of half-open requests configured circuitBreaker.recordSuccess(); // In current implementation, state remains half-open // This is a known behavior of the simplified circuit breaker expect(circuitBreaker.getState().state).toBe('half-open'); // After another success, it should close circuitBreaker.recordSuccess(); expect(circuitBreaker.getState().state).toBe('closed'); expect(circuitBreaker.getState().failureCount).toBe(0); expect(logger.debug).toHaveBeenCalledWith('Circuit breaker closed after successful recovery'); }); it('should not affect state when not in half-open after sufficient requests', () => { // Open circuit, go to half-open, make one request for (let i = 0; i < 3; i++) { circuitBreaker.recordFailure(); } vi.advanceTimersByTime(11000); circuitBreaker.shouldAllow(); // One half-open request // Record success but should not close yet (need 2 successful requests) circuitBreaker.recordSuccess(); expect(circuitBreaker.getState().state).toBe('half-open'); }); }); describe('recordFailure()', () => { it('should increment failure count in closed state', () => { circuitBreaker.recordFailure(); expect(circuitBreaker.getState().failureCount).toBe(1); circuitBreaker.recordFailure(); expect(circuitBreaker.getState().failureCount).toBe(2); }); it('should open circuit when threshold reached', () => { const error = new Error('Test error'); // Record failures to reach threshold circuitBreaker.recordFailure(error); circuitBreaker.recordFailure(error); expect(circuitBreaker.getState().state).toBe('closed'); circuitBreaker.recordFailure(error); expect(circuitBreaker.getState().state).toBe('open'); expect(logger.debug).toHaveBeenCalledWith( 'Circuit breaker opened after 3 failures', { error: 'Test error' } ); }); it('should immediately open from half-open on failure', () => { // Open circuit, go to half-open for (let i = 0; i < 3; i++) { circuitBreaker.recordFailure(); } vi.advanceTimersByTime(11000); circuitBreaker.shouldAllow(); // Failure in half-open should immediately open const error = new Error('Half-open failure'); circuitBreaker.recordFailure(error); expect(circuitBreaker.getState().state).toBe('open'); expect(logger.debug).toHaveBeenCalledWith( 'Circuit breaker opened from half-open state', { error: 'Half-open failure' } ); }); it('should handle failure without error object', () => { for (let i = 0; i < 3; i++) { circuitBreaker.recordFailure(); } expect(circuitBreaker.getState().state).toBe('open'); expect(logger.debug).toHaveBeenCalledWith( 'Circuit breaker opened after 3 failures', { error: undefined } ); }); }); describe('getState()', () => { it('should return current state information', () => { const state = circuitBreaker.getState(); expect(state).toEqual({ state: 'closed', failureCount: 0, canRetry: true }); }); it('should reflect state changes', () => { circuitBreaker.recordFailure(); circuitBreaker.recordFailure(); const state = circuitBreaker.getState(); expect(state).toEqual({ state: 'closed', failureCount: 2, canRetry: true }); // Open circuit circuitBreaker.recordFailure(); const openState = circuitBreaker.getState(); expect(openState).toEqual({ state: 'open', failureCount: 3, canRetry: false }); }); }); describe('reset()', () => { it('should reset circuit breaker to initial state', () => { // Open the circuit and advance time for (let i = 0; i < 3; i++) { circuitBreaker.recordFailure(); } vi.advanceTimersByTime(11000); circuitBreaker.shouldAllow(); // Go to half-open // Reset circuitBreaker.reset(); const state = circuitBreaker.getState(); expect(state).toEqual({ state: 'closed', failureCount: 0, canRetry: true }); }); }); describe('different configurations', () => { it('should work with custom failure threshold', () => { const customBreaker = new TelemetryCircuitBreaker(1, 5000, 1); // 1 failure threshold expect(customBreaker.getState().state).toBe('closed'); customBreaker.recordFailure(); expect(customBreaker.getState().state).toBe('open'); }); it('should work with custom half-open request count', () => { const customBreaker = new TelemetryCircuitBreaker(1, 5000, 3); // 3 half-open requests // Open and go to half-open customBreaker.recordFailure(); vi.advanceTimersByTime(6000); // Should allow 3 requests in half-open expect(customBreaker.shouldAllow()).toBe(true); expect(customBreaker.shouldAllow()).toBe(true); expect(customBreaker.shouldAllow()).toBe(true); expect(customBreaker.shouldAllow()).toBe(true); // Fourth also allowed in simplified implementation }); }); }); describe('TelemetryErrorAggregator', () => { let aggregator: TelemetryErrorAggregator; beforeEach(() => { aggregator = new TelemetryErrorAggregator(); vi.clearAllMocks(); }); describe('record()', () => { it('should record error and increment counter', () => { const error = new TelemetryError( TelemetryErrorType.NETWORK_ERROR, 'Network failure' ); aggregator.record(error); const stats = aggregator.getStats(); expect(stats.totalErrors).toBe(1); expect(stats.errorsByType[TelemetryErrorType.NETWORK_ERROR]).toBe(1); }); it('should increment counter for repeated error types', () => { const error1 = new TelemetryError( TelemetryErrorType.NETWORK_ERROR, 'First failure' ); const error2 = new TelemetryError( TelemetryErrorType.NETWORK_ERROR, 'Second failure' ); aggregator.record(error1); aggregator.record(error2); const stats = aggregator.getStats(); expect(stats.totalErrors).toBe(2); expect(stats.errorsByType[TelemetryErrorType.NETWORK_ERROR]).toBe(2); }); it('should maintain limited error detail history', () => { // Record more than max details (100) to test limiting for (let i = 0; i < 105; i++) { const error = new TelemetryError( TelemetryErrorType.VALIDATION_ERROR, `Error ${i}` ); aggregator.record(error); } const stats = aggregator.getStats(); expect(stats.totalErrors).toBe(105); expect(stats.recentErrors).toHaveLength(10); // Only last 10 }); it('should track different error types separately', () => { const networkError = new TelemetryError( TelemetryErrorType.NETWORK_ERROR, 'Network issue' ); const validationError = new TelemetryError( TelemetryErrorType.VALIDATION_ERROR, 'Validation issue' ); const rateLimitError = new TelemetryError( TelemetryErrorType.RATE_LIMIT_ERROR, 'Rate limit hit' ); aggregator.record(networkError); aggregator.record(networkError); aggregator.record(validationError); aggregator.record(rateLimitError); const stats = aggregator.getStats(); expect(stats.totalErrors).toBe(4); expect(stats.errorsByType[TelemetryErrorType.NETWORK_ERROR]).toBe(2); expect(stats.errorsByType[TelemetryErrorType.VALIDATION_ERROR]).toBe(1); expect(stats.errorsByType[TelemetryErrorType.RATE_LIMIT_ERROR]).toBe(1); }); }); describe('getStats()', () => { it('should return empty stats when no errors recorded', () => { const stats = aggregator.getStats(); expect(stats).toEqual({ totalErrors: 0, errorsByType: {}, mostCommonError: undefined, recentErrors: [] }); }); it('should identify most common error type', () => { const networkError = new TelemetryError( TelemetryErrorType.NETWORK_ERROR, 'Network issue' ); const validationError = new TelemetryError( TelemetryErrorType.VALIDATION_ERROR, 'Validation issue' ); // Network errors more frequent aggregator.record(networkError); aggregator.record(networkError); aggregator.record(networkError); aggregator.record(validationError); const stats = aggregator.getStats(); expect(stats.mostCommonError).toBe(TelemetryErrorType.NETWORK_ERROR); }); it('should return recent errors in order', () => { const error1 = new TelemetryError( TelemetryErrorType.NETWORK_ERROR, 'First error' ); const error2 = new TelemetryError( TelemetryErrorType.VALIDATION_ERROR, 'Second error' ); const error3 = new TelemetryError( TelemetryErrorType.RATE_LIMIT_ERROR, 'Third error' ); aggregator.record(error1); aggregator.record(error2); aggregator.record(error3); const stats = aggregator.getStats(); expect(stats.recentErrors).toHaveLength(3); expect(stats.recentErrors[0].message).toBe('First error'); expect(stats.recentErrors[1].message).toBe('Second error'); expect(stats.recentErrors[2].message).toBe('Third error'); }); it('should handle tie in most common error', () => { const networkError = new TelemetryError( TelemetryErrorType.NETWORK_ERROR, 'Network issue' ); const validationError = new TelemetryError( TelemetryErrorType.VALIDATION_ERROR, 'Validation issue' ); // Equal counts aggregator.record(networkError); aggregator.record(validationError); const stats = aggregator.getStats(); // Should return one of them (implementation dependent) expect(stats.mostCommonError).toBeDefined(); expect([TelemetryErrorType.NETWORK_ERROR, TelemetryErrorType.VALIDATION_ERROR]) .toContain(stats.mostCommonError); }); }); describe('reset()', () => { it('should clear all error data', () => { const error = new TelemetryError( TelemetryErrorType.NETWORK_ERROR, 'Test error' ); aggregator.record(error); // Verify data exists expect(aggregator.getStats().totalErrors).toBe(1); // Reset aggregator.reset(); // Verify cleared const stats = aggregator.getStats(); expect(stats).toEqual({ totalErrors: 0, errorsByType: {}, mostCommonError: undefined, recentErrors: [] }); }); }); describe('error detail management', () => { it('should preserve error context in details', () => { const context = { operation: 'flush', batchSize: 50 }; const error = new TelemetryError( TelemetryErrorType.NETWORK_ERROR, 'Network failure', context, true ); aggregator.record(error); const stats = aggregator.getStats(); expect(stats.recentErrors[0]).toEqual({ type: TelemetryErrorType.NETWORK_ERROR, message: 'Network failure', context, timestamp: error.timestamp, retryable: true }); }); it('should maintain error details queue with FIFO behavior', () => { // Add more than max to test queue behavior const errors = []; for (let i = 0; i < 15; i++) { const error = new TelemetryError( TelemetryErrorType.VALIDATION_ERROR, `Error ${i}` ); errors.push(error); aggregator.record(error); } const stats = aggregator.getStats(); // Should have last 10 errors (5-14) expect(stats.recentErrors).toHaveLength(10); expect(stats.recentErrors[0].message).toBe('Error 5'); expect(stats.recentErrors[9].message).toBe('Error 14'); }); }); }); ``` -------------------------------------------------------------------------------- /src/services/ai-tool-validators.ts: -------------------------------------------------------------------------------- ```typescript /** * AI Tool Sub-Node Validators * * Implements validation logic for all 13 AI tool sub-nodes from * docs/FINAL_AI_VALIDATION_SPEC.md * * Each validator checks configuration requirements, connections, and * parameters specific to that tool type. */ import { NodeTypeNormalizer } from '../utils/node-type-normalizer'; // Validation constants const MIN_DESCRIPTION_LENGTH_SHORT = 10; const MIN_DESCRIPTION_LENGTH_MEDIUM = 15; const MIN_DESCRIPTION_LENGTH_LONG = 20; const MAX_ITERATIONS_WARNING_THRESHOLD = 50; const MAX_TOPK_WARNING_THRESHOLD = 20; export interface WorkflowNode { id: string; name: string; type: string; position: [number, number]; parameters: any; credentials?: any; disabled?: boolean; typeVersion?: number; } export interface WorkflowJson { name?: string; nodes: WorkflowNode[]; connections: Record<string, any>; settings?: any; } export interface ReverseConnection { sourceName: string; sourceType: string; type: string; // main, ai_tool, ai_languageModel, etc. index: number; } export interface ValidationIssue { severity: 'error' | 'warning' | 'info'; nodeId?: string; nodeName?: string; message: string; code?: string; } /** * 1. HTTP Request Tool Validator * From spec lines 883-1123 */ export function validateHTTPRequestTool(node: WorkflowNode): ValidationIssue[] { const issues: ValidationIssue[] = []; // 1. Check toolDescription (REQUIRED) if (!node.parameters.toolDescription) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `HTTP Request Tool "${node.name}" has no toolDescription. Add a clear description to help the LLM know when to use this API.`, code: 'MISSING_TOOL_DESCRIPTION' }); } else if (node.parameters.toolDescription.trim().length < MIN_DESCRIPTION_LENGTH_MEDIUM) { issues.push({ severity: 'warning', nodeId: node.id, nodeName: node.name, message: `HTTP Request Tool "${node.name}" toolDescription is too short (minimum ${MIN_DESCRIPTION_LENGTH_MEDIUM} characters). Explain what API this calls and when to use it.` }); } // 2. Check URL (REQUIRED) if (!node.parameters.url) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `HTTP Request Tool "${node.name}" has no URL. Add the API endpoint URL.`, code: 'MISSING_URL' }); } else { // Validate URL protocol (must be http or https) try { const urlObj = new URL(node.parameters.url); if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `HTTP Request Tool "${node.name}" has invalid URL protocol "${urlObj.protocol}". Use http:// or https:// only.`, code: 'INVALID_URL_PROTOCOL' }); } } catch (e) { // URL parsing failed - invalid format // Only warn if it's not an n8n expression if (!node.parameters.url.includes('{{')) { issues.push({ severity: 'warning', nodeId: node.id, nodeName: node.name, message: `HTTP Request Tool "${node.name}" has potentially invalid URL format. Ensure it's a valid URL or n8n expression.` }); } } } // 3. Validate placeholders match definitions if (node.parameters.url || node.parameters.body || node.parameters.headers) { const placeholderRegex = /\{([^}]+)\}/g; const placeholders = new Set<string>(); // Extract placeholders from URL, body, headers [node.parameters.url, node.parameters.body, JSON.stringify(node.parameters.headers || {})].forEach(text => { if (text) { let match; while ((match = placeholderRegex.exec(text)) !== null) { placeholders.add(match[1]); } } }); // If placeholders exist in URL/body/headers if (placeholders.size > 0) { const definitions = node.parameters.placeholderDefinitions?.values || []; const definedNames = new Set(definitions.map((d: any) => d.name)); // If no placeholderDefinitions at all, warn if (!node.parameters.placeholderDefinitions) { issues.push({ severity: 'warning', nodeId: node.id, nodeName: node.name, message: `HTTP Request Tool "${node.name}" uses placeholders but has no placeholderDefinitions. Add definitions to describe the expected inputs.` }); } else { // Has placeholderDefinitions, check each placeholder for (const placeholder of placeholders) { if (!definedNames.has(placeholder)) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `HTTP Request Tool "${node.name}" Placeholder "${placeholder}" in URL but it's not defined in placeholderDefinitions.`, code: 'UNDEFINED_PLACEHOLDER' }); } } // Check for defined but unused placeholders for (const def of definitions) { if (!placeholders.has(def.name)) { issues.push({ severity: 'warning', nodeId: node.id, nodeName: node.name, message: `HTTP Request Tool "${node.name}" defines placeholder "${def.name}" but doesn't use it.` }); } } } } } // 4. Validate authentication if (node.parameters.authentication === 'predefinedCredentialType' && (!node.credentials || Object.keys(node.credentials).length === 0)) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `HTTP Request Tool "${node.name}" requires credentials but none are configured.`, code: 'MISSING_CREDENTIALS' }); } // 5. Validate HTTP method const validMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']; if (node.parameters.method && !validMethods.includes(node.parameters.method.toUpperCase())) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `HTTP Request Tool "${node.name}" has invalid HTTP method "${node.parameters.method}". Use one of: ${validMethods.join(', ')}.`, code: 'INVALID_HTTP_METHOD' }); } // 6. Validate body for POST/PUT/PATCH if (['POST', 'PUT', 'PATCH'].includes(node.parameters.method?.toUpperCase())) { if (!node.parameters.body && !node.parameters.jsonBody) { issues.push({ severity: 'warning', nodeId: node.id, nodeName: node.name, message: `HTTP Request Tool "${node.name}" uses ${node.parameters.method} but has no body. Consider adding a body or using GET instead.` }); } } return issues; } /** * 2. Code Tool Validator * From spec lines 1125-1393 */ export function validateCodeTool(node: WorkflowNode): ValidationIssue[] { const issues: ValidationIssue[] = []; // 1. Check toolDescription (REQUIRED) if (!node.parameters.toolDescription) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `Code Tool "${node.name}" has no toolDescription. Add one to help the LLM understand the tool's purpose.`, code: 'MISSING_TOOL_DESCRIPTION' }); } // 2. Check jsCode exists (REQUIRED) if (!node.parameters.jsCode || node.parameters.jsCode.trim().length === 0) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `Code Tool "${node.name}" code is empty. Add the JavaScript code to execute.`, code: 'MISSING_CODE' }); } // 3. Recommend input/output schema if (!node.parameters.inputSchema && !node.parameters.specifyInputSchema) { issues.push({ severity: 'warning', nodeId: node.id, nodeName: node.name, message: `Code Tool "${node.name}" has no input schema. Consider adding one to validate LLM inputs.` }); } return issues; } /** * 3. Vector Store Tool Validator * From spec lines 1395-1620 */ export function validateVectorStoreTool( node: WorkflowNode, reverseConnections: Map<string, ReverseConnection[]>, workflow: WorkflowJson ): ValidationIssue[] { const issues: ValidationIssue[] = []; // 1. Check toolDescription (REQUIRED) if (!node.parameters.toolDescription) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `Vector Store Tool "${node.name}" has no toolDescription. Add one to explain what data it searches.`, code: 'MISSING_TOOL_DESCRIPTION' }); } // 2. Validate topK parameter if specified if (node.parameters.topK !== undefined) { if (typeof node.parameters.topK !== 'number' || node.parameters.topK < 1) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `Vector Store Tool "${node.name}" has invalid topK value. Must be a positive number.`, code: 'INVALID_TOPK' }); } else if (node.parameters.topK > MAX_TOPK_WARNING_THRESHOLD) { issues.push({ severity: 'warning', nodeId: node.id, nodeName: node.name, message: `Vector Store Tool "${node.name}" has topK=${node.parameters.topK}. Large values (>${MAX_TOPK_WARNING_THRESHOLD}) may overwhelm the LLM context. Consider reducing to 10 or less.` }); } } return issues; } /** * 4. Workflow Tool Validator * From spec lines 1622-1831 (already complete in spec) */ export function validateWorkflowTool(node: WorkflowNode, reverseConnections?: Map<string, ReverseConnection[]>): ValidationIssue[] { const issues: ValidationIssue[] = []; // 1. Check toolDescription (REQUIRED) if (!node.parameters.toolDescription) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `Workflow Tool "${node.name}" has no toolDescription. Add one to help the LLM know when to use this tool.`, code: 'MISSING_TOOL_DESCRIPTION' }); } // 2. Check workflowId (REQUIRED) if (!node.parameters.workflowId) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `Workflow Tool "${node.name}" has no workflowId. Select a workflow to execute.`, code: 'MISSING_WORKFLOW_ID' }); } return issues; } /** * 5. AI Agent Tool Validator * From spec lines 1882-2122 */ export function validateAIAgentTool( node: WorkflowNode, reverseConnections: Map<string, ReverseConnection[]> ): ValidationIssue[] { const issues: ValidationIssue[] = []; // 1. Check toolDescription (REQUIRED) if (!node.parameters.toolDescription) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `AI Agent Tool "${node.name}" has no toolDescription. Add one to help the LLM know when to use this tool.`, code: 'MISSING_TOOL_DESCRIPTION' }); } // 2. Validate maxIterations if specified if (node.parameters.maxIterations !== undefined) { if (typeof node.parameters.maxIterations !== 'number' || node.parameters.maxIterations < 1) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `AI Agent Tool "${node.name}" has invalid maxIterations. Must be a positive number.`, code: 'INVALID_MAX_ITERATIONS' }); } else if (node.parameters.maxIterations > MAX_ITERATIONS_WARNING_THRESHOLD) { issues.push({ severity: 'warning', nodeId: node.id, nodeName: node.name, message: `AI Agent Tool "${node.name}" has maxIterations=${node.parameters.maxIterations}. Large values (>${MAX_ITERATIONS_WARNING_THRESHOLD}) may lead to long execution times.` }); } } return issues; } /** * 6. MCP Client Tool Validator * From spec lines 2124-2534 (already complete in spec) */ export function validateMCPClientTool(node: WorkflowNode): ValidationIssue[] { const issues: ValidationIssue[] = []; // 1. Check toolDescription (REQUIRED) if (!node.parameters.toolDescription) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `MCP Client Tool "${node.name}" has no toolDescription. Add one to help the LLM know when to use this tool.`, code: 'MISSING_TOOL_DESCRIPTION' }); } // 2. Check serverUrl (REQUIRED) if (!node.parameters.serverUrl) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `MCP Client Tool "${node.name}" has no serverUrl. Configure the MCP server URL.`, code: 'MISSING_SERVER_URL' }); } return issues; } /** * 7-8. Simple Tools (Calculator, Think) Validators * From spec lines 1868-2009 */ export function validateCalculatorTool(node: WorkflowNode): ValidationIssue[] { const issues: ValidationIssue[] = []; // Calculator Tool has a built-in description and is self-explanatory // toolDescription is optional - no validation needed return issues; } export function validateThinkTool(node: WorkflowNode): ValidationIssue[] { const issues: ValidationIssue[] = []; // Think Tool has a built-in description and is self-explanatory // toolDescription is optional - no validation needed return issues; } /** * 9-12. Search Tools Validators * From spec lines 1833-2139 */ export function validateSerpApiTool(node: WorkflowNode): ValidationIssue[] { const issues: ValidationIssue[] = []; // 1. Check toolDescription (REQUIRED) if (!node.parameters.toolDescription) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `SerpApi Tool "${node.name}" has no toolDescription. Add one to explain when to use Google search.`, code: 'MISSING_TOOL_DESCRIPTION' }); } // 2. Check credentials (RECOMMENDED) if (!node.credentials || !node.credentials.serpApiApi) { issues.push({ severity: 'warning', nodeId: node.id, nodeName: node.name, message: `SerpApi Tool "${node.name}" requires SerpApi credentials. Configure your API key.` }); } return issues; } export function validateWikipediaTool(node: WorkflowNode): ValidationIssue[] { const issues: ValidationIssue[] = []; // 1. Check toolDescription (REQUIRED) if (!node.parameters.toolDescription) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `Wikipedia Tool "${node.name}" has no toolDescription. Add one to explain when to use Wikipedia.`, code: 'MISSING_TOOL_DESCRIPTION' }); } // 2. Validate language if specified if (node.parameters.language) { const validLanguageCodes = /^[a-z]{2,3}$/; // ISO 639 codes if (!validLanguageCodes.test(node.parameters.language)) { issues.push({ severity: 'warning', nodeId: node.id, nodeName: node.name, message: `Wikipedia Tool "${node.name}" has potentially invalid language code "${node.parameters.language}". Use ISO 639 codes (e.g., "en", "es", "fr").` }); } } return issues; } export function validateSearXngTool(node: WorkflowNode): ValidationIssue[] { const issues: ValidationIssue[] = []; // 1. Check toolDescription (REQUIRED) if (!node.parameters.toolDescription) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `SearXNG Tool "${node.name}" has no toolDescription. Add one to explain when to use SearXNG.`, code: 'MISSING_TOOL_DESCRIPTION' }); } // 2. Check baseUrl (REQUIRED) if (!node.parameters.baseUrl) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `SearXNG Tool "${node.name}" has no baseUrl. Configure your SearXNG instance URL.`, code: 'MISSING_BASE_URL' }); } return issues; } export function validateWolframAlphaTool(node: WorkflowNode): ValidationIssue[] { const issues: ValidationIssue[] = []; // 1. Check credentials (REQUIRED) if (!node.credentials || (!node.credentials.wolframAlpha && !node.credentials.wolframAlphaApi)) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `WolframAlpha Tool "${node.name}" requires Wolfram|Alpha API credentials. Configure your App ID.`, code: 'MISSING_CREDENTIALS' }); } // 2. Check description (INFO) if (!node.parameters.description && !node.parameters.toolDescription) { issues.push({ severity: 'info', nodeId: node.id, nodeName: node.name, message: `WolframAlpha Tool "${node.name}" has no custom description. Add one to explain when to use Wolfram|Alpha for computational queries.` }); } return issues; } /** * Helper: Map node types to validator functions */ export const AI_TOOL_VALIDATORS = { 'nodes-langchain.toolHttpRequest': validateHTTPRequestTool, 'nodes-langchain.toolCode': validateCodeTool, 'nodes-langchain.toolVectorStore': validateVectorStoreTool, 'nodes-langchain.toolWorkflow': validateWorkflowTool, 'nodes-langchain.agentTool': validateAIAgentTool, 'nodes-langchain.mcpClientTool': validateMCPClientTool, 'nodes-langchain.toolCalculator': validateCalculatorTool, 'nodes-langchain.toolThink': validateThinkTool, 'nodes-langchain.toolSerpApi': validateSerpApiTool, 'nodes-langchain.toolWikipedia': validateWikipediaTool, 'nodes-langchain.toolSearXng': validateSearXngTool, 'nodes-langchain.toolWolframAlpha': validateWolframAlphaTool, } as const; /** * Check if a node type is an AI tool sub-node */ export function isAIToolSubNode(nodeType: string): boolean { const normalized = NodeTypeNormalizer.normalizeToFullForm(nodeType); return normalized in AI_TOOL_VALIDATORS; } /** * Validate an AI tool sub-node with the appropriate validator */ export function validateAIToolSubNode( node: WorkflowNode, nodeType: string, reverseConnections: Map<string, ReverseConnection[]>, workflow: WorkflowJson ): ValidationIssue[] { const normalized = NodeTypeNormalizer.normalizeToFullForm(nodeType); // Route to appropriate validator based on node type switch (normalized) { case 'nodes-langchain.toolHttpRequest': return validateHTTPRequestTool(node); case 'nodes-langchain.toolCode': return validateCodeTool(node); case 'nodes-langchain.toolVectorStore': return validateVectorStoreTool(node, reverseConnections, workflow); case 'nodes-langchain.toolWorkflow': return validateWorkflowTool(node); case 'nodes-langchain.agentTool': return validateAIAgentTool(node, reverseConnections); case 'nodes-langchain.mcpClientTool': return validateMCPClientTool(node); case 'nodes-langchain.toolCalculator': return validateCalculatorTool(node); case 'nodes-langchain.toolThink': return validateThinkTool(node); case 'nodes-langchain.toolSerpApi': return validateSerpApiTool(node); case 'nodes-langchain.toolWikipedia': return validateWikipediaTool(node); case 'nodes-langchain.toolSearXng': return validateSearXngTool(node); case 'nodes-langchain.toolWolframAlpha': return validateWolframAlphaTool(node); default: return []; } } ``` -------------------------------------------------------------------------------- /src/services/workflow-auto-fixer.ts: -------------------------------------------------------------------------------- ```typescript /** * Workflow Auto-Fixer Service * * Automatically generates fix operations for common workflow validation errors. * Converts validation results into diff operations that can be applied to fix the workflow. */ import crypto from 'crypto'; import { WorkflowValidationResult } from './workflow-validator'; import { ExpressionFormatIssue } from './expression-format-validator'; import { NodeSimilarityService } from './node-similarity-service'; import { NodeRepository } from '../database/node-repository'; import { WorkflowDiffOperation, UpdateNodeOperation } from '../types/workflow-diff'; import { WorkflowNode, Workflow } from '../types/n8n-api'; import { Logger } from '../utils/logger'; const logger = new Logger({ prefix: '[WorkflowAutoFixer]' }); export type FixConfidenceLevel = 'high' | 'medium' | 'low'; export type FixType = | 'expression-format' | 'typeversion-correction' | 'error-output-config' | 'node-type-correction' | 'webhook-missing-path'; export interface AutoFixConfig { applyFixes: boolean; fixTypes?: FixType[]; confidenceThreshold?: FixConfidenceLevel; maxFixes?: number; } export interface FixOperation { node: string; field: string; type: FixType; before: any; after: any; confidence: FixConfidenceLevel; description: string; } export interface AutoFixResult { operations: WorkflowDiffOperation[]; fixes: FixOperation[]; summary: string; stats: { total: number; byType: Record<FixType, number>; byConfidence: Record<FixConfidenceLevel, number>; }; } export interface NodeFormatIssue extends ExpressionFormatIssue { nodeName: string; nodeId: string; } /** * Type guard to check if an issue has node information */ export function isNodeFormatIssue(issue: ExpressionFormatIssue): issue is NodeFormatIssue { return 'nodeName' in issue && 'nodeId' in issue && typeof (issue as any).nodeName === 'string' && typeof (issue as any).nodeId === 'string'; } /** * Error with suggestions for node type issues */ export interface NodeTypeError { type: 'error'; nodeId?: string; nodeName?: string; message: string; suggestions?: Array<{ nodeType: string; confidence: number; reason: string; }>; } export class WorkflowAutoFixer { private readonly defaultConfig: AutoFixConfig = { applyFixes: false, confidenceThreshold: 'medium', maxFixes: 50 }; private similarityService: NodeSimilarityService | null = null; constructor(repository?: NodeRepository) { if (repository) { this.similarityService = new NodeSimilarityService(repository); } } /** * Generate fix operations from validation results */ generateFixes( workflow: Workflow, validationResult: WorkflowValidationResult, formatIssues: ExpressionFormatIssue[] = [], config: Partial<AutoFixConfig> = {} ): AutoFixResult { const fullConfig = { ...this.defaultConfig, ...config }; const operations: WorkflowDiffOperation[] = []; const fixes: FixOperation[] = []; // Create a map for quick node lookup const nodeMap = new Map<string, WorkflowNode>(); workflow.nodes.forEach(node => { nodeMap.set(node.name, node); nodeMap.set(node.id, node); }); // Process expression format issues (HIGH confidence) if (!fullConfig.fixTypes || fullConfig.fixTypes.includes('expression-format')) { this.processExpressionFormatFixes(formatIssues, nodeMap, operations, fixes); } // Process typeVersion errors (MEDIUM confidence) if (!fullConfig.fixTypes || fullConfig.fixTypes.includes('typeversion-correction')) { this.processTypeVersionFixes(validationResult, nodeMap, operations, fixes); } // Process error output configuration issues (MEDIUM confidence) if (!fullConfig.fixTypes || fullConfig.fixTypes.includes('error-output-config')) { this.processErrorOutputFixes(validationResult, nodeMap, workflow, operations, fixes); } // Process node type corrections (HIGH confidence only) if (!fullConfig.fixTypes || fullConfig.fixTypes.includes('node-type-correction')) { this.processNodeTypeFixes(validationResult, nodeMap, operations, fixes); } // Process webhook path fixes (HIGH confidence) if (!fullConfig.fixTypes || fullConfig.fixTypes.includes('webhook-missing-path')) { this.processWebhookPathFixes(validationResult, nodeMap, operations, fixes); } // Filter by confidence threshold const filteredFixes = this.filterByConfidence(fixes, fullConfig.confidenceThreshold); const filteredOperations = this.filterOperationsByFixes(operations, filteredFixes, fixes); // Apply max fixes limit const limitedFixes = filteredFixes.slice(0, fullConfig.maxFixes); const limitedOperations = this.filterOperationsByFixes(filteredOperations, limitedFixes, filteredFixes); // Generate summary const stats = this.calculateStats(limitedFixes); const summary = this.generateSummary(stats); return { operations: limitedOperations, fixes: limitedFixes, summary, stats }; } /** * Process expression format fixes (missing = prefix) */ private processExpressionFormatFixes( formatIssues: ExpressionFormatIssue[], nodeMap: Map<string, WorkflowNode>, operations: WorkflowDiffOperation[], fixes: FixOperation[] ): void { // Group fixes by node to create single update operation per node const fixesByNode = new Map<string, ExpressionFormatIssue[]>(); for (const issue of formatIssues) { // Process both errors and warnings for missing-prefix issues if (issue.issueType === 'missing-prefix') { // Use type guard to ensure we have node information if (!isNodeFormatIssue(issue)) { logger.warn('Expression format issue missing node information', { fieldPath: issue.fieldPath, issueType: issue.issueType }); continue; } const nodeName = issue.nodeName; if (!fixesByNode.has(nodeName)) { fixesByNode.set(nodeName, []); } fixesByNode.get(nodeName)!.push(issue); } } // Create update operations for each node for (const [nodeName, nodeIssues] of fixesByNode) { const node = nodeMap.get(nodeName); if (!node) continue; const updatedParameters = JSON.parse(JSON.stringify(node.parameters || {})); for (const issue of nodeIssues) { // Apply the fix to parameters // The fieldPath doesn't include node name, use as is const fieldPath = issue.fieldPath.split('.'); this.setNestedValue(updatedParameters, fieldPath, issue.correctedValue); fixes.push({ node: nodeName, field: issue.fieldPath, type: 'expression-format', before: issue.currentValue, after: issue.correctedValue, confidence: 'high', description: issue.explanation }); } // Create update operation const operation: UpdateNodeOperation = { type: 'updateNode', nodeId: nodeName, // Can be name or ID updates: { parameters: updatedParameters } }; operations.push(operation); } } /** * Process typeVersion fixes */ private processTypeVersionFixes( validationResult: WorkflowValidationResult, nodeMap: Map<string, WorkflowNode>, operations: WorkflowDiffOperation[], fixes: FixOperation[] ): void { for (const error of validationResult.errors) { if (error.message.includes('typeVersion') && error.message.includes('exceeds maximum')) { // Extract version info from error message const versionMatch = error.message.match(/typeVersion (\d+(?:\.\d+)?) exceeds maximum supported version (\d+(?:\.\d+)?)/); if (versionMatch) { const currentVersion = parseFloat(versionMatch[1]); const maxVersion = parseFloat(versionMatch[2]); const nodeName = error.nodeName || error.nodeId; if (!nodeName) continue; const node = nodeMap.get(nodeName); if (!node) continue; fixes.push({ node: nodeName, field: 'typeVersion', type: 'typeversion-correction', before: currentVersion, after: maxVersion, confidence: 'medium', description: `Corrected typeVersion from ${currentVersion} to maximum supported ${maxVersion}` }); const operation: UpdateNodeOperation = { type: 'updateNode', nodeId: nodeName, updates: { typeVersion: maxVersion } }; operations.push(operation); } } } } /** * Process error output configuration fixes */ private processErrorOutputFixes( validationResult: WorkflowValidationResult, nodeMap: Map<string, WorkflowNode>, workflow: Workflow, operations: WorkflowDiffOperation[], fixes: FixOperation[] ): void { for (const error of validationResult.errors) { if (error.message.includes('onError: \'continueErrorOutput\'') && error.message.includes('no error output connections')) { const nodeName = error.nodeName || error.nodeId; if (!nodeName) continue; const node = nodeMap.get(nodeName); if (!node) continue; // Remove the conflicting onError setting fixes.push({ node: nodeName, field: 'onError', type: 'error-output-config', before: 'continueErrorOutput', after: undefined, confidence: 'medium', description: 'Removed onError setting due to missing error output connections' }); const operation: UpdateNodeOperation = { type: 'updateNode', nodeId: nodeName, updates: { onError: undefined // This will remove the property } }; operations.push(operation); } } } /** * Process node type corrections for unknown nodes */ private processNodeTypeFixes( validationResult: WorkflowValidationResult, nodeMap: Map<string, WorkflowNode>, operations: WorkflowDiffOperation[], fixes: FixOperation[] ): void { // Only process if we have the similarity service if (!this.similarityService) { return; } for (const error of validationResult.errors) { // Type-safe check for unknown node type errors with suggestions const nodeError = error as NodeTypeError; if (error.message?.includes('Unknown node type:') && nodeError.suggestions) { // Only auto-fix if we have a high-confidence suggestion (>= 0.9) const highConfidenceSuggestion = nodeError.suggestions.find(s => s.confidence >= 0.9); if (highConfidenceSuggestion && nodeError.nodeId) { const node = nodeMap.get(nodeError.nodeId) || nodeMap.get(nodeError.nodeName || ''); if (node) { fixes.push({ node: node.name, field: 'type', type: 'node-type-correction', before: node.type, after: highConfidenceSuggestion.nodeType, confidence: 'high', description: `Fix node type: "${node.type}" → "${highConfidenceSuggestion.nodeType}" (${highConfidenceSuggestion.reason})` }); const operation: UpdateNodeOperation = { type: 'updateNode', nodeId: node.name, updates: { type: highConfidenceSuggestion.nodeType } }; operations.push(operation); } } } } } /** * Process webhook path fixes for webhook nodes missing path parameter */ private processWebhookPathFixes( validationResult: WorkflowValidationResult, nodeMap: Map<string, WorkflowNode>, operations: WorkflowDiffOperation[], fixes: FixOperation[] ): void { for (const error of validationResult.errors) { // Check for webhook path required error if (error.message === 'Webhook path is required') { const nodeName = error.nodeName || error.nodeId; if (!nodeName) continue; const node = nodeMap.get(nodeName); if (!node) continue; // Only fix webhook nodes if (!node.type?.includes('webhook')) continue; // Generate a unique UUID for both path and webhookId const webhookId = crypto.randomUUID(); // Check if we need to update typeVersion const currentTypeVersion = node.typeVersion || 1; const needsVersionUpdate = currentTypeVersion < 2.1; fixes.push({ node: nodeName, field: 'path', type: 'webhook-missing-path', before: undefined, after: webhookId, confidence: 'high', description: needsVersionUpdate ? `Generated webhook path and ID: ${webhookId} (also updating typeVersion to 2.1)` : `Generated webhook path and ID: ${webhookId}` }); // Create update operation with both path and webhookId // The updates object uses dot notation for nested properties const updates: Record<string, any> = { 'parameters.path': webhookId, 'webhookId': webhookId }; // Only update typeVersion if it's older than 2.1 if (needsVersionUpdate) { updates['typeVersion'] = 2.1; } const operation: UpdateNodeOperation = { type: 'updateNode', nodeId: nodeName, updates }; operations.push(operation); } } } /** * Set a nested value in an object using a path array * Includes validation to prevent silent failures */ private setNestedValue(obj: any, path: string[], value: any): void { if (!obj || typeof obj !== 'object') { throw new Error('Cannot set value on non-object'); } if (path.length === 0) { throw new Error('Cannot set value with empty path'); } try { let current = obj; for (let i = 0; i < path.length - 1; i++) { const key = path[i]; // Handle array indices if (key.includes('[')) { const matches = key.match(/^([^[]+)\[(\d+)\]$/); if (!matches) { throw new Error(`Invalid array notation: ${key}`); } const [, arrayKey, indexStr] = matches; const index = parseInt(indexStr, 10); if (isNaN(index) || index < 0) { throw new Error(`Invalid array index: ${indexStr}`); } if (!current[arrayKey]) { current[arrayKey] = []; } if (!Array.isArray(current[arrayKey])) { throw new Error(`Expected array at ${arrayKey}, got ${typeof current[arrayKey]}`); } while (current[arrayKey].length <= index) { current[arrayKey].push({}); } current = current[arrayKey][index]; } else { if (current[key] === null || current[key] === undefined) { current[key] = {}; } if (typeof current[key] !== 'object' || Array.isArray(current[key])) { throw new Error(`Cannot traverse through ${typeof current[key]} at ${key}`); } current = current[key]; } } // Set the final value const lastKey = path[path.length - 1]; if (lastKey.includes('[')) { const matches = lastKey.match(/^([^[]+)\[(\d+)\]$/); if (!matches) { throw new Error(`Invalid array notation: ${lastKey}`); } const [, arrayKey, indexStr] = matches; const index = parseInt(indexStr, 10); if (isNaN(index) || index < 0) { throw new Error(`Invalid array index: ${indexStr}`); } if (!current[arrayKey]) { current[arrayKey] = []; } if (!Array.isArray(current[arrayKey])) { throw new Error(`Expected array at ${arrayKey}, got ${typeof current[arrayKey]}`); } while (current[arrayKey].length <= index) { current[arrayKey].push(null); } current[arrayKey][index] = value; } else { current[lastKey] = value; } } catch (error) { logger.error('Failed to set nested value', { path: path.join('.'), error: error instanceof Error ? error.message : String(error) }); throw error; } } /** * Filter fixes by confidence level */ private filterByConfidence( fixes: FixOperation[], threshold?: FixConfidenceLevel ): FixOperation[] { if (!threshold) return fixes; const levels: FixConfidenceLevel[] = ['high', 'medium', 'low']; const thresholdIndex = levels.indexOf(threshold); return fixes.filter(fix => { const fixIndex = levels.indexOf(fix.confidence); return fixIndex <= thresholdIndex; }); } /** * Filter operations to match filtered fixes */ private filterOperationsByFixes( operations: WorkflowDiffOperation[], filteredFixes: FixOperation[], allFixes: FixOperation[] ): WorkflowDiffOperation[] { const fixedNodes = new Set(filteredFixes.map(f => f.node)); return operations.filter(op => { if (op.type === 'updateNode') { return fixedNodes.has(op.nodeId || ''); } return true; }); } /** * Calculate statistics about fixes */ private calculateStats(fixes: FixOperation[]): AutoFixResult['stats'] { const stats: AutoFixResult['stats'] = { total: fixes.length, byType: { 'expression-format': 0, 'typeversion-correction': 0, 'error-output-config': 0, 'node-type-correction': 0, 'webhook-missing-path': 0 }, byConfidence: { 'high': 0, 'medium': 0, 'low': 0 } }; for (const fix of fixes) { stats.byType[fix.type]++; stats.byConfidence[fix.confidence]++; } return stats; } /** * Generate a human-readable summary */ private generateSummary(stats: AutoFixResult['stats']): string { if (stats.total === 0) { return 'No fixes available'; } const parts: string[] = []; if (stats.byType['expression-format'] > 0) { parts.push(`${stats.byType['expression-format']} expression format ${stats.byType['expression-format'] === 1 ? 'error' : 'errors'}`); } if (stats.byType['typeversion-correction'] > 0) { parts.push(`${stats.byType['typeversion-correction']} version ${stats.byType['typeversion-correction'] === 1 ? 'issue' : 'issues'}`); } if (stats.byType['error-output-config'] > 0) { parts.push(`${stats.byType['error-output-config']} error output ${stats.byType['error-output-config'] === 1 ? 'configuration' : 'configurations'}`); } if (stats.byType['node-type-correction'] > 0) { parts.push(`${stats.byType['node-type-correction']} node type ${stats.byType['node-type-correction'] === 1 ? 'correction' : 'corrections'}`); } if (stats.byType['webhook-missing-path'] > 0) { parts.push(`${stats.byType['webhook-missing-path']} webhook ${stats.byType['webhook-missing-path'] === 1 ? 'path' : 'paths'}`); } if (parts.length === 0) { return `Fixed ${stats.total} ${stats.total === 1 ? 'issue' : 'issues'}`; } return `Fixed ${parts.join(', ')}`; } } ``` -------------------------------------------------------------------------------- /src/services/resource-similarity-service.ts: -------------------------------------------------------------------------------- ```typescript import { NodeRepository } from '../database/node-repository'; import { logger } from '../utils/logger'; import { ValidationServiceError } from '../errors/validation-service-error'; export interface ResourceSuggestion { value: string; confidence: number; reason: string; availableOperations?: string[]; } interface ResourcePattern { pattern: string; suggestion: string; confidence: number; reason: string; } export class ResourceSimilarityService { private static readonly CACHE_DURATION_MS = 5 * 60 * 1000; // 5 minutes private static readonly MIN_CONFIDENCE = 0.3; // 30% minimum confidence to suggest private static readonly MAX_SUGGESTIONS = 5; // Confidence thresholds for better code clarity private static readonly CONFIDENCE_THRESHOLDS = { EXACT: 1.0, VERY_HIGH: 0.95, HIGH: 0.8, MEDIUM: 0.6, MIN_SUBSTRING: 0.7 } as const; private repository: NodeRepository; private resourceCache: Map<string, { resources: any[], timestamp: number }> = new Map(); private suggestionCache: Map<string, ResourceSuggestion[]> = new Map(); private commonPatterns: Map<string, ResourcePattern[]>; constructor(repository: NodeRepository) { this.repository = repository; this.commonPatterns = this.initializeCommonPatterns(); } /** * Clean up expired cache entries to prevent memory leaks */ private cleanupExpiredEntries(): void { const now = Date.now(); // Clean resource cache for (const [key, value] of this.resourceCache.entries()) { if (now - value.timestamp >= ResourceSimilarityService.CACHE_DURATION_MS) { this.resourceCache.delete(key); } } // Clean suggestion cache - these don't have timestamps, so clear if cache is too large if (this.suggestionCache.size > 100) { // Keep only the most recent 50 entries const entries = Array.from(this.suggestionCache.entries()); this.suggestionCache.clear(); entries.slice(-50).forEach(([key, value]) => { this.suggestionCache.set(key, value); }); } } /** * Initialize common resource mistake patterns */ private initializeCommonPatterns(): Map<string, ResourcePattern[]> { const patterns = new Map<string, ResourcePattern[]>(); // Google Drive patterns patterns.set('googleDrive', [ { pattern: 'files', suggestion: 'file', confidence: 0.95, reason: 'Use singular "file" not plural' }, { pattern: 'folders', suggestion: 'folder', confidence: 0.95, reason: 'Use singular "folder" not plural' }, { pattern: 'permissions', suggestion: 'permission', confidence: 0.9, reason: 'Use singular form' }, { pattern: 'fileAndFolder', suggestion: 'fileFolder', confidence: 0.9, reason: 'Use "fileFolder" for combined operations' }, { pattern: 'driveFiles', suggestion: 'file', confidence: 0.8, reason: 'Use "file" for file operations' }, { pattern: 'sharedDrives', suggestion: 'drive', confidence: 0.85, reason: 'Use "drive" for shared drive operations' }, ]); // Slack patterns patterns.set('slack', [ { pattern: 'messages', suggestion: 'message', confidence: 0.95, reason: 'Use singular "message" not plural' }, { pattern: 'channels', suggestion: 'channel', confidence: 0.95, reason: 'Use singular "channel" not plural' }, { pattern: 'users', suggestion: 'user', confidence: 0.95, reason: 'Use singular "user" not plural' }, { pattern: 'msg', suggestion: 'message', confidence: 0.85, reason: 'Use full "message" not abbreviation' }, { pattern: 'dm', suggestion: 'message', confidence: 0.7, reason: 'Use "message" for direct messages' }, { pattern: 'conversation', suggestion: 'channel', confidence: 0.7, reason: 'Use "channel" for conversations' }, ]); // Database patterns (postgres, mysql, mongodb) patterns.set('database', [ { pattern: 'tables', suggestion: 'table', confidence: 0.95, reason: 'Use singular "table" not plural' }, { pattern: 'queries', suggestion: 'query', confidence: 0.95, reason: 'Use singular "query" not plural' }, { pattern: 'collections', suggestion: 'collection', confidence: 0.95, reason: 'Use singular "collection" not plural' }, { pattern: 'documents', suggestion: 'document', confidence: 0.95, reason: 'Use singular "document" not plural' }, { pattern: 'records', suggestion: 'record', confidence: 0.85, reason: 'Use "record" or "document"' }, { pattern: 'rows', suggestion: 'row', confidence: 0.9, reason: 'Use singular "row"' }, ]); // Google Sheets patterns patterns.set('googleSheets', [ { pattern: 'sheets', suggestion: 'sheet', confidence: 0.95, reason: 'Use singular "sheet" not plural' }, { pattern: 'spreadsheets', suggestion: 'spreadsheet', confidence: 0.95, reason: 'Use singular "spreadsheet"' }, { pattern: 'cells', suggestion: 'cell', confidence: 0.9, reason: 'Use singular "cell"' }, { pattern: 'ranges', suggestion: 'range', confidence: 0.9, reason: 'Use singular "range"' }, { pattern: 'worksheets', suggestion: 'sheet', confidence: 0.8, reason: 'Use "sheet" for worksheet operations' }, ]); // Email patterns patterns.set('email', [ { pattern: 'emails', suggestion: 'email', confidence: 0.95, reason: 'Use singular "email" not plural' }, { pattern: 'messages', suggestion: 'message', confidence: 0.9, reason: 'Use "message" for email operations' }, { pattern: 'mails', suggestion: 'email', confidence: 0.9, reason: 'Use "email" not "mail"' }, { pattern: 'attachments', suggestion: 'attachment', confidence: 0.95, reason: 'Use singular "attachment"' }, ]); // Generic plural/singular patterns patterns.set('generic', [ { pattern: 'items', suggestion: 'item', confidence: 0.9, reason: 'Use singular form' }, { pattern: 'objects', suggestion: 'object', confidence: 0.9, reason: 'Use singular form' }, { pattern: 'entities', suggestion: 'entity', confidence: 0.9, reason: 'Use singular form' }, { pattern: 'resources', suggestion: 'resource', confidence: 0.9, reason: 'Use singular form' }, { pattern: 'elements', suggestion: 'element', confidence: 0.9, reason: 'Use singular form' }, ]); return patterns; } /** * Find similar resources for an invalid resource using pattern matching * and Levenshtein distance algorithms * * @param nodeType - The n8n node type (e.g., 'nodes-base.googleDrive') * @param invalidResource - The invalid resource provided by the user * @param maxSuggestions - Maximum number of suggestions to return (default: 5) * @returns Array of resource suggestions sorted by confidence * * @example * findSimilarResources('nodes-base.googleDrive', 'files', 3) * // Returns: [{ value: 'file', confidence: 0.95, reason: 'Use singular "file" not plural' }] */ findSimilarResources( nodeType: string, invalidResource: string, maxSuggestions: number = ResourceSimilarityService.MAX_SUGGESTIONS ): ResourceSuggestion[] { // Clean up expired cache entries periodically if (Math.random() < 0.1) { // 10% chance to cleanup on each call this.cleanupExpiredEntries(); } // Check cache first const cacheKey = `${nodeType}:${invalidResource}`; if (this.suggestionCache.has(cacheKey)) { return this.suggestionCache.get(cacheKey)!; } const suggestions: ResourceSuggestion[] = []; // Get valid resources for the node const validResources = this.getNodeResources(nodeType); // Early termination for exact match - no suggestions needed for (const resource of validResources) { const resourceValue = this.getResourceValue(resource); if (resourceValue.toLowerCase() === invalidResource.toLowerCase()) { return []; // Valid resource, no suggestions needed } } // Check for exact pattern matches first const nodePatterns = this.getNodePatterns(nodeType); for (const pattern of nodePatterns) { if (pattern.pattern.toLowerCase() === invalidResource.toLowerCase()) { // Check if the suggested resource actually exists with type safety const exists = validResources.some(r => { const resourceValue = this.getResourceValue(r); return resourceValue === pattern.suggestion; }); if (exists) { suggestions.push({ value: pattern.suggestion, confidence: pattern.confidence, reason: pattern.reason }); } } } // Handle automatic plural/singular conversion const singularForm = this.toSingular(invalidResource); const pluralForm = this.toPlural(invalidResource); for (const resource of validResources) { const resourceValue = this.getResourceValue(resource); // Check for plural/singular match if (resourceValue === singularForm || resourceValue === pluralForm) { if (!suggestions.some(s => s.value === resourceValue)) { suggestions.push({ value: resourceValue, confidence: 0.9, reason: invalidResource.endsWith('s') ? 'Use singular form for resources' : 'Incorrect plural/singular form', availableOperations: typeof resource === 'object' ? resource.operations : undefined }); } } // Calculate similarity const similarity = this.calculateSimilarity(invalidResource, resourceValue); if (similarity >= ResourceSimilarityService.MIN_CONFIDENCE) { if (!suggestions.some(s => s.value === resourceValue)) { suggestions.push({ value: resourceValue, confidence: similarity, reason: this.getSimilarityReason(similarity, invalidResource, resourceValue), availableOperations: typeof resource === 'object' ? resource.operations : undefined }); } } } // Sort by confidence and limit suggestions.sort((a, b) => b.confidence - a.confidence); const topSuggestions = suggestions.slice(0, maxSuggestions); // Cache the result this.suggestionCache.set(cacheKey, topSuggestions); return topSuggestions; } /** * Type-safe extraction of resource value from various formats * @param resource - Resource object or string * @returns The resource value as a string */ private getResourceValue(resource: any): string { if (typeof resource === 'string') { return resource; } if (typeof resource === 'object' && resource !== null) { return resource.value || ''; } return ''; } /** * Get resources for a node with caching */ private getNodeResources(nodeType: string): any[] { // Cleanup cache periodically if (Math.random() < 0.05) { // 5% chance this.cleanupExpiredEntries(); } const cacheKey = nodeType; const cached = this.resourceCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < ResourceSimilarityService.CACHE_DURATION_MS) { return cached.resources; } const nodeInfo = this.repository.getNode(nodeType); if (!nodeInfo) return []; const resources: any[] = []; const resourceMap: Map<string, string[]> = new Map(); // Parse properties for resource fields try { const properties = nodeInfo.properties || []; for (const prop of properties) { if (prop.name === 'resource' && prop.options) { for (const option of prop.options) { resources.push({ value: option.value, name: option.name, operations: [] }); resourceMap.set(option.value, []); } } // Find operations for each resource if (prop.name === 'operation' && prop.displayOptions?.show?.resource) { const resourceValues = Array.isArray(prop.displayOptions.show.resource) ? prop.displayOptions.show.resource : [prop.displayOptions.show.resource]; for (const resourceValue of resourceValues) { if (resourceMap.has(resourceValue) && prop.options) { const ops = prop.options.map((op: any) => op.value); resourceMap.get(resourceValue)!.push(...ops); } } } } // Update resources with their operations for (const resource of resources) { if (resourceMap.has(resource.value)) { resource.operations = resourceMap.get(resource.value); } } // If no explicit resources, check for common patterns if (resources.length === 0) { // Some nodes don't have explicit resource fields const implicitResources = this.extractImplicitResources(properties); resources.push(...implicitResources); } } catch (error) { logger.warn(`Failed to extract resources for ${nodeType}:`, error); } // Cache and return this.resourceCache.set(cacheKey, { resources, timestamp: Date.now() }); return resources; } /** * Extract implicit resources from node properties */ private extractImplicitResources(properties: any[]): any[] { const resources: any[] = []; // Look for properties that suggest resources for (const prop of properties) { if (prop.name === 'operation' && prop.options) { // If there's no explicit resource field, operations might imply resources const resourceFromOps = this.inferResourceFromOperations(prop.options); if (resourceFromOps) { resources.push({ value: resourceFromOps, name: resourceFromOps.charAt(0).toUpperCase() + resourceFromOps.slice(1), operations: prop.options.map((op: any) => op.value) }); } } } return resources; } /** * Infer resource type from operations */ private inferResourceFromOperations(operations: any[]): string | null { // Common patterns in operation names that suggest resources const patterns = [ { keywords: ['file', 'upload', 'download'], resource: 'file' }, { keywords: ['folder', 'directory'], resource: 'folder' }, { keywords: ['message', 'send', 'reply'], resource: 'message' }, { keywords: ['channel', 'broadcast'], resource: 'channel' }, { keywords: ['user', 'member'], resource: 'user' }, { keywords: ['table', 'row', 'column'], resource: 'table' }, { keywords: ['document', 'doc'], resource: 'document' }, ]; for (const pattern of patterns) { for (const op of operations) { const opName = (op.value || op).toLowerCase(); if (pattern.keywords.some(keyword => opName.includes(keyword))) { return pattern.resource; } } } return null; } /** * Get patterns for a specific node type */ private getNodePatterns(nodeType: string): ResourcePattern[] { const patterns: ResourcePattern[] = []; // Add node-specific patterns if (nodeType.includes('googleDrive')) { patterns.push(...(this.commonPatterns.get('googleDrive') || [])); } else if (nodeType.includes('slack')) { patterns.push(...(this.commonPatterns.get('slack') || [])); } else if (nodeType.includes('postgres') || nodeType.includes('mysql') || nodeType.includes('mongodb')) { patterns.push(...(this.commonPatterns.get('database') || [])); } else if (nodeType.includes('googleSheets')) { patterns.push(...(this.commonPatterns.get('googleSheets') || [])); } else if (nodeType.includes('gmail') || nodeType.includes('email')) { patterns.push(...(this.commonPatterns.get('email') || [])); } // Always add generic patterns patterns.push(...(this.commonPatterns.get('generic') || [])); return patterns; } /** * Convert to singular form (simple heuristic) */ private toSingular(word: string): string { if (word.endsWith('ies')) { return word.slice(0, -3) + 'y'; } else if (word.endsWith('es')) { return word.slice(0, -2); } else if (word.endsWith('s') && !word.endsWith('ss')) { return word.slice(0, -1); } return word; } /** * Convert to plural form (simple heuristic) */ private toPlural(word: string): string { if (word.endsWith('y') && !['ay', 'ey', 'iy', 'oy', 'uy'].includes(word.slice(-2))) { return word.slice(0, -1) + 'ies'; } else if (word.endsWith('s') || word.endsWith('x') || word.endsWith('z') || word.endsWith('ch') || word.endsWith('sh')) { return word + 'es'; } else { return word + 's'; } } /** * Calculate similarity between two strings using Levenshtein distance */ private calculateSimilarity(str1: string, str2: string): number { const s1 = str1.toLowerCase(); const s2 = str2.toLowerCase(); // Exact match if (s1 === s2) return 1.0; // One is substring of the other if (s1.includes(s2) || s2.includes(s1)) { const ratio = Math.min(s1.length, s2.length) / Math.max(s1.length, s2.length); return Math.max(ResourceSimilarityService.CONFIDENCE_THRESHOLDS.MIN_SUBSTRING, ratio); } // Calculate Levenshtein distance const distance = this.levenshteinDistance(s1, s2); const maxLength = Math.max(s1.length, s2.length); // Convert distance to similarity let similarity = 1 - (distance / maxLength); // Boost confidence for single character typos and transpositions in short words if (distance === 1 && maxLength <= 5) { similarity = Math.max(similarity, 0.75); } else if (distance === 2 && maxLength <= 5) { // Boost for transpositions (e.g., "flie" -> "file") similarity = Math.max(similarity, 0.72); } return similarity; } /** * Calculate Levenshtein distance between two strings */ private levenshteinDistance(str1: string, str2: string): number { const m = str1.length; const n = str2.length; const dp: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0)); for (let i = 0; i <= m; i++) dp[i][0] = i; for (let j = 0; j <= n; j++) dp[0][j] = j; for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { if (str1[i - 1] === str2[j - 1]) { dp[i][j] = dp[i - 1][j - 1]; } else { dp[i][j] = Math.min( dp[i - 1][j] + 1, // deletion dp[i][j - 1] + 1, // insertion dp[i - 1][j - 1] + 1 // substitution ); } } } return dp[m][n]; } /** * Generate a human-readable reason for the similarity * @param confidence - Similarity confidence score * @param invalid - The invalid resource string * @param valid - The valid resource string * @returns Human-readable explanation of the similarity */ private getSimilarityReason(confidence: number, invalid: string, valid: string): string { const { VERY_HIGH, HIGH, MEDIUM } = ResourceSimilarityService.CONFIDENCE_THRESHOLDS; if (confidence >= VERY_HIGH) { return 'Almost exact match - likely a typo'; } else if (confidence >= HIGH) { return 'Very similar - common variation'; } else if (confidence >= MEDIUM) { return 'Similar resource name'; } else if (invalid.includes(valid) || valid.includes(invalid)) { return 'Partial match'; } else { return 'Possibly related resource'; } } /** * Clear caches */ clearCache(): void { this.resourceCache.clear(); this.suggestionCache.clear(); } } ``` -------------------------------------------------------------------------------- /tests/unit/parsers/property-extractor.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach } from 'vitest'; import { PropertyExtractor } from '@/parsers/property-extractor'; import { programmaticNodeFactory, declarativeNodeFactory, versionedNodeClassFactory, versionedNodeTypeClassFactory, nodeClassFactory, propertyFactory, stringPropertyFactory, numberPropertyFactory, booleanPropertyFactory, optionsPropertyFactory, collectionPropertyFactory, nestedPropertyFactory, resourcePropertyFactory, operationPropertyFactory, aiToolNodeFactory } from '@tests/fixtures/factories/parser-node.factory'; describe('PropertyExtractor', () => { let extractor: PropertyExtractor; beforeEach(() => { extractor = new PropertyExtractor(); }); describe('extractProperties', () => { it('should extract properties from programmatic node', () => { const nodeDefinition = programmaticNodeFactory.build(); const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const properties = extractor.extractProperties(NodeClass as any); expect(properties).toHaveLength(nodeDefinition.properties.length); expect(properties).toEqual(expect.arrayContaining( nodeDefinition.properties.map(prop => expect.objectContaining({ displayName: prop.displayName, name: prop.name, type: prop.type, default: prop.default })) )); }); it('should extract properties from versioned node latest version', () => { const versionedDef = versionedNodeClassFactory.build(); const NodeClass = class { nodeVersions = versionedDef.nodeVersions; baseDescription = versionedDef.baseDescription; }; const properties = extractor.extractProperties(NodeClass as any); // Should get properties from version 2 (latest) expect(properties).toHaveLength(versionedDef.nodeVersions![2].description.properties.length); }); it('should extract properties from instance with nodeVersions', () => { const NodeClass = class { description = { name: 'test' }; constructor() { (this as any).nodeVersions = { 1: { description: { properties: [propertyFactory.build({ name: 'v1prop' })] } }, 2: { description: { properties: [ propertyFactory.build({ name: 'v2prop1' }), propertyFactory.build({ name: 'v2prop2' }) ] } } }; } }; const properties = extractor.extractProperties(NodeClass as any); expect(properties).toHaveLength(2); expect(properties[0].name).toBe('v2prop1'); expect(properties[1].name).toBe('v2prop2'); }); it('should normalize properties to consistent structure', () => { const rawProperties = [ { displayName: 'Field 1', name: 'field1', type: 'string', default: 'value', description: 'Test field', required: true, displayOptions: { show: { resource: ['user'] } }, typeOptions: { multipleValues: true }, noDataExpression: false, extraField: 'should be removed' } ]; const NodeClass = nodeClassFactory.build({ description: { name: 'test', properties: rawProperties } }); const properties = extractor.extractProperties(NodeClass as any); expect(properties[0]).toEqual({ displayName: 'Field 1', name: 'field1', type: 'string', default: 'value', description: 'Test field', options: undefined, required: true, displayOptions: { show: { resource: ['user'] } }, typeOptions: { multipleValues: true }, noDataExpression: false }); expect(properties[0]).not.toHaveProperty('extraField'); }); it('should handle nodes without properties', () => { const NodeClass = nodeClassFactory.build({ description: { name: 'test', displayName: 'Test' // No properties field } }); const properties = extractor.extractProperties(NodeClass as any); expect(properties).toEqual([]); }); it('should handle failed instantiation', () => { const NodeClass = class { static description = { name: 'test', properties: [propertyFactory.build()] }; constructor() { throw new Error('Cannot instantiate'); } }; const properties = extractor.extractProperties(NodeClass as any); expect(properties).toHaveLength(1); // Should get static description property }); it('should extract from baseDescription when main description is missing', () => { const NodeClass = class { baseDescription = { properties: [ stringPropertyFactory.build({ name: 'baseProp' }) ] }; }; const properties = extractor.extractProperties(NodeClass as any); expect(properties).toHaveLength(1); expect(properties[0].name).toBe('baseProp'); }); it('should handle complex nested properties', () => { const nestedProp = nestedPropertyFactory.build(); const NodeClass = nodeClassFactory.build({ description: { name: 'test', properties: [nestedProp] } }); const properties = extractor.extractProperties(NodeClass as any); expect(properties).toHaveLength(1); expect(properties[0].type).toBe('collection'); expect(properties[0].options).toBeDefined(); }); it('should handle non-function node classes', () => { const nodeInstance = { description: { properties: [propertyFactory.build()] } }; const properties = extractor.extractProperties(nodeInstance as any); expect(properties).toHaveLength(1); }); }); describe('extractOperations', () => { it('should extract operations from declarative node routing', () => { const nodeDefinition = declarativeNodeFactory.build(); const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const operations = extractor.extractOperations(NodeClass as any); // Declarative node has 2 resources with 2 operations each = 4 total expect(operations.length).toBe(4); // Check that we have operations for each resource const userOps = operations.filter(op => op.resource === 'user'); const postOps = operations.filter(op => op.resource === 'post'); expect(userOps.length).toBe(2); // Create and Get expect(postOps.length).toBe(2); // Create and List // Verify operation structure expect(userOps[0]).toMatchObject({ resource: 'user', operation: expect.any(String), name: expect.any(String), action: expect.any(String) }); }); it('should extract operations when node has programmatic properties', () => { const operationProp = operationPropertyFactory.build(); const NodeClass = nodeClassFactory.build({ description: { name: 'test', properties: [operationProp] } }); const operations = extractor.extractOperations(NodeClass as any); expect(operations.length).toBe(operationProp.options!.length); operations.forEach((op, idx) => { expect(op).toMatchObject({ operation: operationProp.options![idx].value, name: operationProp.options![idx].name, description: operationProp.options![idx].description }); }); }); it('should extract operations when routing.operations structure exists', () => { const NodeClass = nodeClassFactory.build({ description: { name: 'test', routing: { operations: { create: { displayName: 'Create Item' }, update: { displayName: 'Update Item' }, delete: { displayName: 'Delete Item' } } } } }); const operations = extractor.extractOperations(NodeClass as any); // routing.operations is not currently extracted by the property extractor // It only extracts from routing.request structure expect(operations).toHaveLength(0); }); it('should handle operations when programmatic nodes have resource-based structure', () => { const resourceProp = resourcePropertyFactory.build(); const operationProp = { displayName: 'Operation', name: 'operation', type: 'options', displayOptions: { show: { resource: ['user', 'post'] } }, options: [ { name: 'Create', value: 'create', action: 'Create item' }, { name: 'Delete', value: 'delete', action: 'Delete item' } ] }; const NodeClass = nodeClassFactory.build({ description: { name: 'test', properties: [resourceProp, operationProp] } }); const operations = extractor.extractOperations(NodeClass as any); // PropertyExtractor only extracts operations, not resources // It should find the operation property and extract its options expect(operations).toHaveLength(operationProp.options.length); expect(operations[0]).toMatchObject({ operation: 'create', name: 'Create', description: undefined // action field is not mapped to description }); expect(operations[1]).toMatchObject({ operation: 'delete', name: 'Delete', description: undefined }); }); it('should return empty array when node has no operations', () => { const NodeClass = nodeClassFactory.build({ description: { name: 'test', properties: [stringPropertyFactory.build()] } }); const operations = extractor.extractOperations(NodeClass as any); expect(operations).toEqual([]); }); it('should extract operations when node has version structure', () => { const NodeClass = class { nodeVersions = { 1: { description: { properties: [] } }, 2: { description: { routing: { request: { resource: { options: [ { name: 'User', value: 'user' } ] }, operation: { options: { user: [ { name: 'Get', value: 'get', action: 'Get a user' } ] } } } } } } }; }; const operations = extractor.extractOperations(NodeClass as any); expect(operations).toHaveLength(1); expect(operations[0]).toMatchObject({ resource: 'user', operation: 'get', name: 'User - Get', action: 'Get a user' }); }); it('should handle extraction when property is named action instead of operation', () => { const actionProp = { displayName: 'Action', name: 'action', type: 'options', options: [ { name: 'Send', value: 'send' }, { name: 'Receive', value: 'receive' } ] }; const NodeClass = nodeClassFactory.build({ description: { name: 'test', properties: [actionProp] } }); const operations = extractor.extractOperations(NodeClass as any); expect(operations).toHaveLength(2); expect(operations[0].operation).toBe('send'); }); }); describe('detectAIToolCapability', () => { it('should detect AI capability when usableAsTool property is true', () => { const NodeClass = nodeClassFactory.build({ description: { name: 'test', usableAsTool: true } }); const isAITool = extractor.detectAIToolCapability(NodeClass as any); expect(isAITool).toBe(true); }); it('should detect AI capability when actions contain usableAsTool', () => { const NodeClass = nodeClassFactory.build({ description: { name: 'test', actions: [ { name: 'action1', usableAsTool: false }, { name: 'action2', usableAsTool: true } ] } }); const isAITool = extractor.detectAIToolCapability(NodeClass as any); expect(isAITool).toBe(true); }); it('should detect AI capability when versioned node has usableAsTool', () => { const NodeClass = { nodeVersions: { 1: { description: { usableAsTool: false } }, 2: { description: { usableAsTool: true } } } }; const isAITool = extractor.detectAIToolCapability(NodeClass as any); expect(isAITool).toBe(true); }); it('should detect AI capability when node name contains AI-related terms', () => { const aiNodeNames = ['openai', 'anthropic', 'huggingface', 'cohere', 'myai']; aiNodeNames.forEach(name => { const NodeClass = nodeClassFactory.build({ description: { name } }); const isAITool = extractor.detectAIToolCapability(NodeClass as any); expect(isAITool).toBe(true); }); }); it('should return false when node is not AI-related', () => { const NodeClass = nodeClassFactory.build({ description: { name: 'slack', usableAsTool: false } }); const isAITool = extractor.detectAIToolCapability(NodeClass as any); expect(isAITool).toBe(false); }); it('should return false when node has no description', () => { const NodeClass = class {}; const isAITool = extractor.detectAIToolCapability(NodeClass as any); expect(isAITool).toBe(false); }); }); describe('extractCredentials', () => { it('should extract credentials when node description contains them', () => { const credentials = [ { name: 'apiKey', required: true }, { name: 'oauth2', required: false } ]; const NodeClass = nodeClassFactory.build({ description: { name: 'test', credentials } }); const extracted = extractor.extractCredentials(NodeClass as any); expect(extracted).toEqual(credentials); }); it('should extract credentials when node has version structure', () => { const NodeClass = class { nodeVersions = { 1: { description: { credentials: [{ name: 'basic', required: true }] } }, 2: { description: { credentials: [ { name: 'oauth2', required: true }, { name: 'apiKey', required: false } ] } } }; }; const credentials = extractor.extractCredentials(NodeClass as any); expect(credentials).toHaveLength(2); expect(credentials[0].name).toBe('oauth2'); expect(credentials[1].name).toBe('apiKey'); }); it('should return empty array when node has no credentials', () => { const NodeClass = nodeClassFactory.build({ description: { name: 'test' // No credentials field } }); const credentials = extractor.extractCredentials(NodeClass as any); expect(credentials).toEqual([]); }); it('should extract credentials when only baseDescription has them', () => { const NodeClass = class { baseDescription = { credentials: [{ name: 'token', required: true }] }; }; const credentials = extractor.extractCredentials(NodeClass as any); expect(credentials).toHaveLength(1); expect(credentials[0].name).toBe('token'); }); it('should extract credentials when they are defined at instance level', () => { const NodeClass = class { constructor() { (this as any).description = { credentials: [ { name: 'jwt', required: true } ] }; } }; const credentials = extractor.extractCredentials(NodeClass as any); expect(credentials).toHaveLength(1); expect(credentials[0].name).toBe('jwt'); }); it('should return empty array when instantiation fails', () => { const NodeClass = class { constructor() { throw new Error('Cannot instantiate'); } }; const credentials = extractor.extractCredentials(NodeClass as any); expect(credentials).toEqual([]); }); }); describe('edge cases', () => { it('should handle extraction when properties are deeply nested', () => { const deepProperty = { displayName: 'Deep Options', name: 'deepOptions', type: 'collection', options: [ { displayName: 'Level 1', name: 'level1', type: 'collection', options: [ { displayName: 'Level 2', name: 'level2', type: 'collection', options: [ stringPropertyFactory.build({ name: 'deepValue' }) ] } ] } ] }; const NodeClass = nodeClassFactory.build({ description: { name: 'test', properties: [deepProperty] } }); const properties = extractor.extractProperties(NodeClass as any); expect(properties).toHaveLength(1); expect(properties[0].name).toBe('deepOptions'); expect(properties[0].options[0].options[0].options).toBeDefined(); }); it('should not throw when node structure has circular references', () => { const NodeClass = class { description: any = { name: 'test' }; constructor() { this.description.properties = [ { name: 'prop1', type: 'string', parentRef: this.description // Circular reference } ]; } }; // Should not throw or hang const properties = extractor.extractProperties(NodeClass as any); expect(properties).toBeDefined(); }); it('should extract from all sources when multiple operation types exist', () => { const NodeClass = nodeClassFactory.build({ description: { name: 'test', routing: { request: { resource: { options: [{ name: 'Resource1', value: 'res1' }] } }, operations: { custom: { displayName: 'Custom Op' } } }, properties: [ operationPropertyFactory.build() ] } }); const operations = extractor.extractOperations(NodeClass as any); // Should extract from all sources expect(operations.length).toBeGreaterThan(1); }); }); }); ``` -------------------------------------------------------------------------------- /tests/integration/database/node-repository.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import Database from 'better-sqlite3'; import { NodeRepository } from '../../../src/database/node-repository'; import { DatabaseAdapter } from '../../../src/database/database-adapter'; import { TestDatabase, TestDataGenerator, MOCK_NODES, createTestDatabaseAdapter } from './test-utils'; import { ParsedNode } from '../../../src/parsers/node-parser'; describe('NodeRepository Integration Tests', () => { let testDb: TestDatabase; let db: Database.Database; let repository: NodeRepository; let adapter: DatabaseAdapter; beforeEach(async () => { testDb = new TestDatabase({ mode: 'memory' }); db = await testDb.initialize(); adapter = createTestDatabaseAdapter(db); repository = new NodeRepository(adapter); }); afterEach(async () => { await testDb.cleanup(); }); describe('saveNode', () => { it('should save single node successfully', () => { const node = createParsedNode(MOCK_NODES.webhook); repository.saveNode(node); const saved = repository.getNode(node.nodeType); expect(saved).toBeTruthy(); expect(saved.nodeType).toBe(node.nodeType); expect(saved.displayName).toBe(node.displayName); }); it('should update existing nodes', () => { const node = createParsedNode(MOCK_NODES.webhook); // Save initial version repository.saveNode(node); // Update and save again const updated = { ...node, displayName: 'Updated Webhook' }; repository.saveNode(updated); const saved = repository.getNode(node.nodeType); expect(saved?.displayName).toBe('Updated Webhook'); // Should not create duplicate const count = repository.getNodeCount(); expect(count).toBe(1); }); it('should handle nodes with complex properties', () => { const complexNode: ParsedNode = { nodeType: 'n8n-nodes-base.complex', packageName: 'n8n-nodes-base', displayName: 'Complex Node', description: 'A complex node with many properties', category: 'automation', style: 'programmatic', isAITool: false, isTrigger: false, isWebhook: false, isVersioned: true, version: '1', documentation: 'Complex node documentation', properties: [ { displayName: 'Resource', name: 'resource', type: 'options', options: [ { name: 'User', value: 'user' }, { name: 'Post', value: 'post' } ], default: 'user' }, { displayName: 'Operation', name: 'operation', type: 'options', displayOptions: { show: { resource: ['user'] } }, options: [ { name: 'Create', value: 'create' }, { name: 'Get', value: 'get' } ] } ], operations: [ { resource: 'user', operation: 'create' }, { resource: 'user', operation: 'get' } ], credentials: [ { name: 'httpBasicAuth', required: false } ] }; repository.saveNode(complexNode); const saved = repository.getNode(complexNode.nodeType); expect(saved).toBeTruthy(); expect(saved.properties).toHaveLength(2); expect(saved.credentials).toHaveLength(1); expect(saved.operations).toHaveLength(2); }); it('should handle very large nodes', () => { const largeNode: ParsedNode = { nodeType: 'n8n-nodes-base.large', packageName: 'n8n-nodes-base', displayName: 'Large Node', description: 'A very large node', category: 'automation', style: 'programmatic', isAITool: false, isTrigger: false, isWebhook: false, isVersioned: true, version: '1', properties: Array.from({ length: 100 }, (_, i) => ({ displayName: `Property ${i}`, name: `prop${i}`, type: 'string', default: '' })), operations: [], credentials: [] }; repository.saveNode(largeNode); const saved = repository.getNode(largeNode.nodeType); expect(saved?.properties).toHaveLength(100); }); }); describe('getNode', () => { beforeEach(() => { repository.saveNode(createParsedNode(MOCK_NODES.webhook)); repository.saveNode(createParsedNode(MOCK_NODES.httpRequest)); }); it('should retrieve node by type', () => { const node = repository.getNode('n8n-nodes-base.webhook'); expect(node).toBeTruthy(); expect(node.displayName).toBe('Webhook'); expect(node.nodeType).toBe('n8n-nodes-base.webhook'); expect(node.package).toBe('n8n-nodes-base'); }); it('should return null for non-existent node', () => { const node = repository.getNode('n8n-nodes-base.nonExistent'); expect(node).toBeNull(); }); it('should handle special characters in node types', () => { const specialNode: ParsedNode = { nodeType: 'n8n-nodes-base.special-chars_v2.node', packageName: 'n8n-nodes-base', displayName: 'Special Node', description: 'Node with special characters', category: 'automation', style: 'programmatic', isAITool: false, isTrigger: false, isWebhook: false, isVersioned: true, version: '2', properties: [], operations: [], credentials: [] }; repository.saveNode(specialNode); const retrieved = repository.getNode(specialNode.nodeType); expect(retrieved).toBeTruthy(); }); }); describe('getAllNodes', () => { it('should return empty array when no nodes', () => { const nodes = repository.getAllNodes(); expect(nodes).toHaveLength(0); }); it('should return all nodes with limit', () => { const nodes = Array.from({ length: 20 }, (_, i) => createParsedNode({ ...MOCK_NODES.webhook, nodeType: `n8n-nodes-base.node${i}`, displayName: `Node ${i}` }) ); nodes.forEach(node => repository.saveNode(node)); const retrieved = repository.getAllNodes(10); expect(retrieved).toHaveLength(10); }); it('should return all nodes without limit', () => { const nodes = Array.from({ length: 20 }, (_, i) => createParsedNode({ ...MOCK_NODES.webhook, nodeType: `n8n-nodes-base.node${i}`, displayName: `Node ${i}` }) ); nodes.forEach(node => repository.saveNode(node)); const retrieved = repository.getAllNodes(); expect(retrieved).toHaveLength(20); }); it('should handle very large result sets efficiently', () => { const nodes = Array.from({ length: 1000 }, (_, i) => createParsedNode({ ...MOCK_NODES.webhook, nodeType: `n8n-nodes-base.node${i}`, displayName: `Node ${i}` }) ); const insertMany = db.transaction((nodes: ParsedNode[]) => { nodes.forEach(node => repository.saveNode(node)); }); const start = Date.now(); insertMany(nodes); const duration = Date.now() - start; expect(duration).toBeLessThan(1000); // Should complete in under 1 second const retrieved = repository.getAllNodes(); expect(retrieved).toHaveLength(1000); }); }); describe('getNodesByPackage', () => { beforeEach(() => { const nodes = [ createParsedNode({ ...MOCK_NODES.webhook, nodeType: 'n8n-nodes-base.node1', packageName: 'n8n-nodes-base' }), createParsedNode({ ...MOCK_NODES.webhook, nodeType: 'n8n-nodes-base.node2', packageName: 'n8n-nodes-base' }), createParsedNode({ ...MOCK_NODES.webhook, nodeType: '@n8n/n8n-nodes-langchain.node3', packageName: '@n8n/n8n-nodes-langchain' }) ]; nodes.forEach(node => repository.saveNode(node)); }); it('should filter nodes by package', () => { const baseNodes = repository.getNodesByPackage('n8n-nodes-base'); expect(baseNodes).toHaveLength(2); const langchainNodes = repository.getNodesByPackage('@n8n/n8n-nodes-langchain'); expect(langchainNodes).toHaveLength(1); }); it('should return empty array for non-existent package', () => { const nodes = repository.getNodesByPackage('non-existent-package'); expect(nodes).toHaveLength(0); }); }); describe('getNodesByCategory', () => { beforeEach(() => { const nodes = [ createParsedNode({ ...MOCK_NODES.webhook, nodeType: 'n8n-nodes-base.webhook', category: 'trigger' }), createParsedNode({ ...MOCK_NODES.webhook, nodeType: 'n8n-nodes-base.schedule', displayName: 'Schedule', category: 'trigger' }), createParsedNode({ ...MOCK_NODES.httpRequest, nodeType: 'n8n-nodes-base.httpRequest', category: 'automation' }) ]; nodes.forEach(node => repository.saveNode(node)); }); it('should filter nodes by category', () => { const triggers = repository.getNodesByCategory('trigger'); expect(triggers).toHaveLength(2); expect(triggers.every(n => n.category === 'trigger')).toBe(true); const automation = repository.getNodesByCategory('automation'); expect(automation).toHaveLength(1); expect(automation[0].category).toBe('automation'); }); }); describe('searchNodes', () => { beforeEach(() => { const nodes = [ createParsedNode({ ...MOCK_NODES.webhook, description: 'Starts the workflow when webhook is called' }), createParsedNode({ ...MOCK_NODES.httpRequest, description: 'Makes HTTP requests to external APIs' }), createParsedNode({ nodeType: 'n8n-nodes-base.emailSend', packageName: 'n8n-nodes-base', displayName: 'Send Email', description: 'Sends emails via SMTP protocol', category: 'communication', developmentStyle: 'programmatic', isAITool: false, isTrigger: false, isWebhook: false, isVersioned: true, version: '1', properties: [], operations: [], credentials: [] }) ]; nodes.forEach(node => repository.saveNode(node)); }); it('should search by node type', () => { const results = repository.searchNodes('webhook'); expect(results).toHaveLength(1); expect(results[0].nodeType).toBe('n8n-nodes-base.webhook'); }); it('should search by display name', () => { const results = repository.searchNodes('Send Email'); expect(results).toHaveLength(1); expect(results[0].nodeType).toBe('n8n-nodes-base.emailSend'); }); it('should search by description', () => { const results = repository.searchNodes('SMTP'); expect(results).toHaveLength(1); expect(results[0].nodeType).toBe('n8n-nodes-base.emailSend'); }); it('should handle OR mode (default)', () => { const results = repository.searchNodes('webhook email', 'OR'); expect(results).toHaveLength(2); const nodeTypes = results.map(r => r.nodeType); expect(nodeTypes).toContain('n8n-nodes-base.webhook'); expect(nodeTypes).toContain('n8n-nodes-base.emailSend'); }); it('should handle AND mode', () => { const results = repository.searchNodes('HTTP request', 'AND'); expect(results).toHaveLength(1); expect(results[0].nodeType).toBe('n8n-nodes-base.httpRequest'); }); it('should handle FUZZY mode', () => { const results = repository.searchNodes('HTT', 'FUZZY'); expect(results).toHaveLength(1); expect(results[0].nodeType).toBe('n8n-nodes-base.httpRequest'); }); it('should handle case-insensitive search', () => { const results = repository.searchNodes('WEBHOOK'); expect(results).toHaveLength(1); expect(results[0].nodeType).toBe('n8n-nodes-base.webhook'); }); it('should return empty array for no matches', () => { const results = repository.searchNodes('nonexistent'); expect(results).toHaveLength(0); }); it('should respect limit parameter', () => { // Add more nodes const nodes = Array.from({ length: 10 }, (_, i) => createParsedNode({ ...MOCK_NODES.webhook, nodeType: `n8n-nodes-base.test${i}`, displayName: `Test Node ${i}`, description: 'Test description' }) ); nodes.forEach(node => repository.saveNode(node)); const results = repository.searchNodes('test', 'OR', 5); expect(results).toHaveLength(5); }); }); describe('getAITools', () => { it('should return only AI tool nodes', () => { const nodes = [ createParsedNode({ ...MOCK_NODES.webhook, nodeType: 'n8n-nodes-base.webhook', isAITool: false }), createParsedNode({ ...MOCK_NODES.webhook, nodeType: '@n8n/n8n-nodes-langchain.agent', displayName: 'AI Agent', packageName: '@n8n/n8n-nodes-langchain', isAITool: true }), createParsedNode({ ...MOCK_NODES.webhook, nodeType: '@n8n/n8n-nodes-langchain.tool', displayName: 'AI Tool', packageName: '@n8n/n8n-nodes-langchain', isAITool: true }) ]; nodes.forEach(node => repository.saveNode(node)); const aiTools = repository.getAITools(); expect(aiTools).toHaveLength(2); expect(aiTools.every(node => node.package.includes('langchain'))).toBe(true); expect(aiTools[0].displayName).toBe('AI Agent'); expect(aiTools[1].displayName).toBe('AI Tool'); }); }); describe('getNodeCount', () => { it('should return correct node count', () => { expect(repository.getNodeCount()).toBe(0); repository.saveNode(createParsedNode(MOCK_NODES.webhook)); expect(repository.getNodeCount()).toBe(1); repository.saveNode(createParsedNode(MOCK_NODES.httpRequest)); expect(repository.getNodeCount()).toBe(2); }); }); describe('searchNodeProperties', () => { beforeEach(() => { const node: ParsedNode = { nodeType: 'n8n-nodes-base.complex', packageName: 'n8n-nodes-base', displayName: 'Complex Node', description: 'A complex node', category: 'automation', style: 'programmatic', isAITool: false, isTrigger: false, isWebhook: false, isVersioned: true, version: '1', properties: [ { displayName: 'Authentication', name: 'authentication', type: 'options', options: [ { name: 'Basic', value: 'basic' }, { name: 'OAuth2', value: 'oauth2' } ] }, { displayName: 'Headers', name: 'headers', type: 'collection', default: {}, options: [ { displayName: 'Header', name: 'header', type: 'string' } ] } ], operations: [], credentials: [] }; repository.saveNode(node); }); it('should find properties by name', () => { const results = repository.searchNodeProperties('n8n-nodes-base.complex', 'auth'); expect(results.length).toBeGreaterThan(0); expect(results.some(r => r.path.includes('authentication'))).toBe(true); }); it('should find nested properties', () => { const results = repository.searchNodeProperties('n8n-nodes-base.complex', 'header'); expect(results.length).toBeGreaterThan(0); }); it('should return empty array for non-existent node', () => { const results = repository.searchNodeProperties('non-existent', 'test'); expect(results).toHaveLength(0); }); }); describe('Transaction handling', () => { it('should handle errors gracefully', () => { // Test with a node that violates database constraints const invalidNode = { nodeType: '', // Empty string should violate PRIMARY KEY constraint packageName: null, // NULL should violate NOT NULL constraint displayName: null, // NULL should violate NOT NULL constraint description: '', category: 'automation', style: 'programmatic', isAITool: false, isTrigger: false, isWebhook: false, isVersioned: false, version: '1', properties: [], operations: [], credentials: [] } as any; expect(() => { repository.saveNode(invalidNode); }).toThrow(); // Repository should still be functional const count = repository.getNodeCount(); expect(count).toBe(0); }); it('should handle concurrent saves', () => { const node = createParsedNode(MOCK_NODES.webhook); // Simulate concurrent saves of the same node with different display names const promises = Array.from({ length: 10 }, (_, i) => { const updatedNode = { ...node, displayName: `Display ${i}` }; return Promise.resolve(repository.saveNode(updatedNode)); }); Promise.all(promises); // Should have only one node const count = repository.getNodeCount(); expect(count).toBe(1); // Should have the last update const saved = repository.getNode(node.nodeType); expect(saved).toBeTruthy(); }); }); describe('Performance characteristics', () => { it('should handle bulk operations efficiently', () => { const nodeCount = 1000; const nodes = Array.from({ length: nodeCount }, (_, i) => createParsedNode({ ...MOCK_NODES.webhook, nodeType: `n8n-nodes-base.node${i}`, displayName: `Node ${i}`, description: `Description for node ${i}` }) ); const insertMany = db.transaction((nodes: ParsedNode[]) => { nodes.forEach(node => repository.saveNode(node)); }); const start = Date.now(); insertMany(nodes); const saveDuration = Date.now() - start; expect(saveDuration).toBeLessThan(1000); // Should complete in under 1 second // Test search performance const searchStart = Date.now(); const results = repository.searchNodes('node', 'OR', 100); const searchDuration = Date.now() - searchStart; expect(searchDuration).toBeLessThan(50); // Search should be fast expect(results.length).toBe(100); // Respects limit }); }); }); // Helper function to create ParsedNode from test data function createParsedNode(data: any): ParsedNode { return { nodeType: data.nodeType, packageName: data.packageName, displayName: data.displayName, description: data.description || '', category: data.category || 'automation', style: data.developmentStyle || 'programmatic', isAITool: data.isAITool || false, isTrigger: data.isTrigger || false, isWebhook: data.isWebhook || false, isVersioned: data.isVersioned !== undefined ? data.isVersioned : true, version: data.version || '1', documentation: data.documentation || null, properties: data.properties || [], operations: data.operations || [], credentials: data.credentials || [] }; } ``` -------------------------------------------------------------------------------- /src/mcp/tool-docs/guides/ai-agents-guide.ts: -------------------------------------------------------------------------------- ```typescript import { ToolDocumentation } from '../types'; export const aiAgentsGuide: ToolDocumentation = { name: 'ai_agents_guide', category: 'guides', essentials: { description: 'Comprehensive guide to building AI Agent workflows in n8n. Covers architecture, connections, tools, validation, and best practices for production AI systems.', keyParameters: [], example: 'Use tools_documentation({topic: "ai_agents_guide"}) to access this guide', performance: 'N/A - Documentation only', tips: [ 'Start with Chat Trigger → AI Agent → Language Model pattern', 'Always connect language model BEFORE enabling AI Agent', 'Use proper toolDescription for all AI tools (15+ characters)', 'Validate workflows with n8n_validate_workflow before deployment', 'Use includeExamples=true when searching for AI nodes', 'Check FINAL_AI_VALIDATION_SPEC.md for detailed requirements' ] }, full: { description: `# Complete Guide to AI Agents in n8n This comprehensive guide covers everything you need to build production-ready AI Agent workflows in n8n. ## Table of Contents 1. [AI Agent Architecture](#architecture) 2. [Essential Connection Types](#connections) 3. [Building Your First AI Agent](#first-agent) 4. [AI Tools Deep Dive](#tools) 5. [Advanced Patterns](#advanced) 6. [Validation & Best Practices](#validation) 7. [Troubleshooting](#troubleshooting) --- ## 1. AI Agent Architecture {#architecture} ### Core Components An n8n AI Agent workflow typically consists of: 1. **Chat Trigger**: Entry point for user interactions - Webhook-based or manual trigger - Supports streaming responses (responseMode) - Passes user message to AI Agent 2. **AI Agent**: The orchestrator - Manages conversation flow - Decides when to use tools - Iterates until task is complete - Supports fallback models (v2.1+) 3. **Language Model**: The AI brain - OpenAI GPT-4, Claude, Gemini, etc. - Connected via ai_languageModel port - Can have primary + fallback for reliability 4. **Tools**: AI Agent's capabilities - HTTP Request, Code, Vector Store, etc. - Connected via ai_tool port - Each tool needs clear toolDescription 5. **Optional Components**: - Memory (conversation history) - Output Parser (structured responses) - Vector Store (knowledge retrieval) ### Connection Flow **CRITICAL**: AI connections flow TO the consumer (reversed from standard n8n): \`\`\` Standard n8n: [Source] --main--> [Target] AI pattern: [Language Model] --ai_languageModel--> [AI Agent] [HTTP Tool] --ai_tool--> [AI Agent] \`\`\` This is why you use \`sourceOutput: "ai_languageModel"\` when connecting components. --- ## 2. Essential Connection Types {#connections} ### The 8 AI Connection Types 1. **ai_languageModel** - FROM: OpenAI Chat Model, Anthropic, Google Gemini, etc. - TO: AI Agent, Basic LLM Chain - REQUIRED: Every AI Agent needs 1-2 language models - Example: \`{type: "addConnection", source: "OpenAI", target: "AI Agent", sourceOutput: "ai_languageModel"}\` 2. **ai_tool** - FROM: Any tool node (HTTP Request Tool, Code Tool, etc.) - TO: AI Agent - REQUIRED: At least 1 tool recommended - Example: \`{type: "addConnection", source: "HTTP Request Tool", target: "AI Agent", sourceOutput: "ai_tool"}\` 3. **ai_memory** - FROM: Window Buffer Memory, Conversation Summary, etc. - TO: AI Agent - OPTIONAL: 0-1 memory system - Enables conversation history tracking 4. **ai_outputParser** - FROM: Structured Output Parser, JSON Parser, etc. - TO: AI Agent - OPTIONAL: For structured responses - Must set hasOutputParser=true on AI Agent 5. **ai_embedding** - FROM: Embeddings OpenAI, Embeddings Google, etc. - TO: Vector Store (Pinecone, In-Memory, etc.) - REQUIRED: For vector-based retrieval 6. **ai_vectorStore** - FROM: Vector Store node - TO: Vector Store Tool - REQUIRED: For retrieval-augmented generation (RAG) 7. **ai_document** - FROM: Document Loader, Default Data Loader - TO: Vector Store - REQUIRED: Provides data for vector storage 8. **ai_textSplitter** - FROM: Text Splitter nodes - TO: Document processing chains - OPTIONAL: Chunk large documents ### Connection Examples \`\`\`typescript // Basic AI Agent setup n8n_update_partial_workflow({ id: "workflow_id", operations: [ // Connect language model (REQUIRED) { type: "addConnection", source: "OpenAI Chat Model", target: "AI Agent", sourceOutput: "ai_languageModel" }, // Connect tools { type: "addConnection", source: "HTTP Request Tool", target: "AI Agent", sourceOutput: "ai_tool" }, { type: "addConnection", source: "Code Tool", target: "AI Agent", sourceOutput: "ai_tool" }, // Add memory (optional) { type: "addConnection", source: "Window Buffer Memory", target: "AI Agent", sourceOutput: "ai_memory" } ] }) \`\`\` --- ## 3. Building Your First AI Agent {#first-agent} ### Step-by-Step Tutorial #### Step 1: Create Chat Trigger Use \`n8n_create_workflow\` or manually create a workflow with: \`\`\`typescript { name: "My First AI Agent", nodes: [ { id: "chat_trigger", name: "Chat Trigger", type: "@n8n/n8n-nodes-langchain.chatTrigger", position: [100, 100], parameters: { options: { responseMode: "lastNode" // or "streaming" for real-time } } } ], connections: {} } \`\`\` #### Step 2: Add Language Model \`\`\`typescript n8n_update_partial_workflow({ id: "workflow_id", operations: [ { type: "addNode", node: { name: "OpenAI Chat Model", type: "@n8n/n8n-nodes-langchain.lmChatOpenAi", position: [300, 50], parameters: { model: "gpt-4", temperature: 0.7 } } } ] }) \`\`\` #### Step 3: Add AI Agent \`\`\`typescript n8n_update_partial_workflow({ id: "workflow_id", operations: [ { type: "addNode", node: { name: "AI Agent", type: "@n8n/n8n-nodes-langchain.agent", position: [300, 150], parameters: { promptType: "auto", systemMessage: "You are a helpful assistant. Be concise and accurate." } } } ] }) \`\`\` #### Step 4: Connect Components \`\`\`typescript n8n_update_partial_workflow({ id: "workflow_id", operations: [ // Chat Trigger → AI Agent (main connection) { type: "addConnection", source: "Chat Trigger", target: "AI Agent" }, // Language Model → AI Agent (AI connection) { type: "addConnection", source: "OpenAI Chat Model", target: "AI Agent", sourceOutput: "ai_languageModel" } ] }) \`\`\` #### Step 5: Validate \`\`\`typescript n8n_validate_workflow({id: "workflow_id"}) \`\`\` --- ## 4. AI Tools Deep Dive {#tools} ### Tool Types and When to Use Them #### 1. HTTP Request Tool **Use when**: AI needs to call external APIs **Critical Requirements**: - \`toolDescription\`: Clear, 15+ character description - \`url\`: API endpoint (can include placeholders) - \`placeholderDefinitions\`: Define all {placeholders} - Proper authentication if needed **Example**: \`\`\`typescript { type: "addNode", node: { name: "GitHub Issues Tool", type: "@n8n/n8n-nodes-langchain.toolHttpRequest", position: [500, 100], parameters: { method: "POST", url: "https://api.github.com/repos/{owner}/{repo}/issues", toolDescription: "Create GitHub issues. Requires owner (username), repo (repository name), title, and body.", placeholderDefinitions: { values: [ {name: "owner", description: "Repository owner username"}, {name: "repo", description: "Repository name"}, {name: "title", description: "Issue title"}, {name: "body", description: "Issue description"} ] }, sendBody: true, jsonBody: "={{ { title: $json.title, body: $json.body } }}" } } } \`\`\` #### 2. Code Tool **Use when**: AI needs to run custom logic **Critical Requirements**: - \`name\`: Function name (alphanumeric + underscore) - \`description\`: 10+ character explanation - \`code\`: JavaScript or Python code - \`inputSchema\`: Define expected inputs (recommended) **Example**: \`\`\`typescript { type: "addNode", node: { name: "Calculate Shipping", type: "@n8n/n8n-nodes-langchain.toolCode", position: [500, 200], parameters: { name: "calculate_shipping", description: "Calculate shipping cost based on weight (kg) and distance (km)", language: "javaScript", code: "const cost = 5 + ($input.weight * 2) + ($input.distance * 0.1); return { cost };", specifyInputSchema: true, inputSchema: "{ \\"type\\": \\"object\\", \\"properties\\": { \\"weight\\": { \\"type\\": \\"number\\" }, \\"distance\\": { \\"type\\": \\"number\\" } } }" } } } \`\`\` #### 3. Vector Store Tool **Use when**: AI needs to search knowledge base **Setup**: Requires Vector Store + Embeddings + Documents **Example**: \`\`\`typescript // Step 1: Create Vector Store with embeddings and documents n8n_update_partial_workflow({ operations: [ {type: "addConnection", source: "Embeddings OpenAI", target: "Pinecone", sourceOutput: "ai_embedding"}, {type: "addConnection", source: "Document Loader", target: "Pinecone", sourceOutput: "ai_document"} ] }) // Step 2: Connect Vector Store to Vector Store Tool n8n_update_partial_workflow({ operations: [ {type: "addConnection", source: "Pinecone", target: "Vector Store Tool", sourceOutput: "ai_vectorStore"} ] }) // Step 3: Connect tool to AI Agent n8n_update_partial_workflow({ operations: [ {type: "addConnection", source: "Vector Store Tool", target: "AI Agent", sourceOutput: "ai_tool"} ] }) \`\`\` #### 4. AI Agent Tool (Sub-Agents) **Use when**: Need specialized expertise **Example**: Research specialist sub-agent \`\`\`typescript { type: "addNode", node: { name: "Research Specialist", type: "@n8n/n8n-nodes-langchain.agentTool", position: [500, 300], parameters: { name: "research_specialist", description: "Expert researcher that searches multiple sources and synthesizes information. Use for detailed research tasks.", systemMessage: "You are a research specialist. Search thoroughly, cite sources, and provide comprehensive analysis." } } } \`\`\` #### 5. MCP Client Tool **Use when**: Need to use Model Context Protocol servers **Example**: Filesystem access \`\`\`typescript { type: "addNode", node: { name: "Filesystem Tool", type: "@n8n/n8n-nodes-langchain.mcpClientTool", position: [500, 400], parameters: { description: "Access file system to read files, list directories, and search content", mcpServer: { transport: "stdio", command: "npx", args: ["-y", "@modelcontextprotocol/server-filesystem", "/allowed/path"] }, tool: "read_file" } } } \`\`\` --- ## 5. Advanced Patterns {#advanced} ### Pattern 1: Streaming Responses For real-time user experience: \`\`\`typescript // Set Chat Trigger to streaming mode { parameters: { options: { responseMode: "streaming" } } } // CRITICAL: AI Agent must NOT have main output connections in streaming mode // Responses stream back through Chat Trigger automatically \`\`\` **Validation will fail if**: - Chat Trigger has streaming but target is not AI Agent - AI Agent in streaming mode has main output connections ### Pattern 2: Fallback Language Models For production reliability (requires AI Agent v2.1+): \`\`\`typescript n8n_update_partial_workflow({ operations: [ // Primary model { type: "addConnection", source: "OpenAI GPT-4", target: "AI Agent", sourceOutput: "ai_languageModel", targetIndex: 0 }, // Fallback model { type: "addConnection", source: "Anthropic Claude", target: "AI Agent", sourceOutput: "ai_languageModel", targetIndex: 1 } ] }) // Enable fallback on AI Agent { type: "updateNode", nodeName: "AI Agent", updates: { "parameters.needsFallback": true } } \`\`\` ### Pattern 3: RAG (Retrieval-Augmented Generation) Complete knowledge base setup: \`\`\`typescript // 1. Load documents {type: "addConnection", source: "PDF Loader", target: "Text Splitter", sourceOutput: "ai_document"} // 2. Split and embed {type: "addConnection", source: "Text Splitter", target: "Vector Store"} {type: "addConnection", source: "Embeddings", target: "Vector Store", sourceOutput: "ai_embedding"} // 3. Create search tool {type: "addConnection", source: "Vector Store", target: "Vector Store Tool", sourceOutput: "ai_vectorStore"} // 4. Give tool to agent {type: "addConnection", source: "Vector Store Tool", target: "AI Agent", sourceOutput: "ai_tool"} \`\`\` ### Pattern 4: Multi-Agent Systems Specialized sub-agents for complex tasks: \`\`\`typescript // Create sub-agents with specific expertise [ {name: "research_agent", description: "Deep research specialist"}, {name: "data_analyst", description: "Data analysis expert"}, {name: "writer_agent", description: "Content writing specialist"} ].forEach(agent => { // Add as AI Agent Tool to main coordinator agent { type: "addConnection", source: agent.name, target: "Coordinator Agent", sourceOutput: "ai_tool" } }) \`\`\` --- ## 6. Validation & Best Practices {#validation} ### Always Validate Before Deployment \`\`\`typescript const result = n8n_validate_workflow({id: "workflow_id"}) if (!result.valid) { console.log("Errors:", result.errors) console.log("Warnings:", result.warnings) console.log("Suggestions:", result.suggestions) } \`\`\` ### Common Validation Errors 1. **MISSING_LANGUAGE_MODEL** - Problem: AI Agent has no ai_languageModel connection - Fix: Connect a language model before creating AI Agent 2. **MISSING_TOOL_DESCRIPTION** - Problem: HTTP Request Tool has no toolDescription - Fix: Add clear description (15+ characters) 3. **STREAMING_WITH_MAIN_OUTPUT** - Problem: AI Agent in streaming mode has outgoing main connections - Fix: Remove main connections when using streaming 4. **FALLBACK_MISSING_SECOND_MODEL** - Problem: needsFallback=true but only 1 language model - Fix: Add second language model or disable needsFallback ### Best Practices Checklist ✅ **Before Creating AI Agent**: - [ ] Language model is connected first - [ ] At least one tool is prepared (or will be added) - [ ] System message is thoughtful and specific ✅ **For Each Tool**: - [ ] Has toolDescription/description (15+ characters) - [ ] toolDescription explains WHEN to use the tool - [ ] All required parameters are configured - [ ] Credentials are set up if needed ✅ **For Production**: - [ ] Workflow validated with n8n_validate_workflow - [ ] Tested with real user queries - [ ] Fallback model configured for reliability - [ ] Error handling in place - [ ] maxIterations set appropriately (default 10, max 50) --- ## 7. Troubleshooting {#troubleshooting} ### Problem: "AI Agent has no language model" **Cause**: Connection created AFTER AI Agent or using wrong sourceOutput **Solution**: \`\`\`typescript n8n_update_partial_workflow({ operations: [ { type: "addConnection", source: "OpenAI Chat Model", target: "AI Agent", sourceOutput: "ai_languageModel" // ← CRITICAL } ] }) \`\`\` ### Problem: "Tool has no description" **Cause**: HTTP Request Tool or Code Tool missing toolDescription/description **Solution**: \`\`\`typescript { type: "updateNode", nodeName: "HTTP Request Tool", updates: { "parameters.toolDescription": "Call weather API to get current conditions for a city" } } \`\`\` ### Problem: "Streaming mode not working" **Causes**: 1. Chat Trigger not set to streaming 2. AI Agent has main output connections 3. Target of Chat Trigger is not AI Agent **Solution**: \`\`\`typescript // 1. Set Chat Trigger to streaming { type: "updateNode", nodeName: "Chat Trigger", updates: { "parameters.options.responseMode": "streaming" } } // 2. Remove AI Agent main outputs { type: "removeConnection", source: "AI Agent", target: "Any Output Node" } \`\`\` ### Problem: "Agent keeps looping" **Cause**: Tool not returning proper response or agent stuck in reasoning loop **Solutions**: 1. Set maxIterations lower: \`"parameters.maxIterations": 5\` 2. Improve tool descriptions to be more specific 3. Add system message guidance: "Use tools efficiently, don't repeat actions" --- ## Quick Reference ### Essential Tools | Tool | Purpose | Key Parameters | |------|---------|----------------| | HTTP Request Tool | API calls | toolDescription, url, placeholders | | Code Tool | Custom logic | name, description, code, inputSchema | | Vector Store Tool | Knowledge search | description, topK | | AI Agent Tool | Sub-agents | name, description, systemMessage | | MCP Client Tool | MCP protocol | description, mcpServer, tool | ### Connection Quick Codes \`\`\`typescript // Language Model → AI Agent sourceOutput: "ai_languageModel" // Tool → AI Agent sourceOutput: "ai_tool" // Memory → AI Agent sourceOutput: "ai_memory" // Parser → AI Agent sourceOutput: "ai_outputParser" // Embeddings → Vector Store sourceOutput: "ai_embedding" // Vector Store → Vector Store Tool sourceOutput: "ai_vectorStore" \`\`\` ### Validation Command \`\`\`typescript n8n_validate_workflow({id: "workflow_id"}) \`\`\` --- ## Related Resources - **FINAL_AI_VALIDATION_SPEC.md**: Complete validation rules - **n8n_update_partial_workflow**: Workflow modification tool - **search_nodes({query: "AI", includeExamples: true})**: Find AI nodes with examples - **get_node_essentials({nodeType: "...", includeExamples: true})**: Node details with examples --- *This guide is part of the n8n-mcp documentation system. For questions or issues, refer to the validation spec or use tools_documentation() for specific topics.*`, parameters: {}, returns: 'Complete AI Agents guide with architecture, patterns, validation, and troubleshooting', examples: [ 'tools_documentation({topic: "ai_agents_guide"}) - Full guide', 'tools_documentation({topic: "ai_agents_guide", depth: "essentials"}) - Quick reference', 'When user asks about AI Agents, Chat Trigger, or building AI workflows → Point to this guide' ], useCases: [ 'Learning AI Agent architecture in n8n', 'Understanding AI connection types and patterns', 'Building first AI Agent workflow step-by-step', 'Implementing advanced patterns (streaming, fallback, RAG, multi-agent)', 'Troubleshooting AI workflow issues', 'Validating AI workflows before deployment', 'Quick reference for connection types and tools' ], performance: 'N/A - Static documentation', bestPractices: [ 'Reference this guide when users ask about AI Agents', 'Point to specific sections based on user needs', 'Combine with search_nodes(includeExamples=true) for working examples', 'Validate workflows after following guide instructions', 'Use FINAL_AI_VALIDATION_SPEC.md for detailed requirements' ], pitfalls: [ 'This is a guide, not an executable tool', 'Always validate workflows after making changes', 'AI connections require sourceOutput parameter', 'Streaming mode has specific constraints', 'Some features require specific AI Agent versions (v2.1+ for fallback)' ], relatedTools: [ 'n8n_create_workflow', 'n8n_update_partial_workflow', 'n8n_validate_workflow', 'search_nodes', 'get_node_essentials', 'list_ai_tools' ] } }; ``` -------------------------------------------------------------------------------- /tests/unit/database/node-repository-operations.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, vi } from 'vitest'; import { NodeRepository } from '@/database/node-repository'; import { DatabaseAdapter, PreparedStatement, RunResult } from '@/database/database-adapter'; // Mock DatabaseAdapter for testing the new operation methods class MockDatabaseAdapter implements DatabaseAdapter { private statements = new Map<string, MockPreparedStatement>(); private mockNodes = new Map<string, any>(); prepare = vi.fn((sql: string) => { if (!this.statements.has(sql)) { this.statements.set(sql, new MockPreparedStatement(sql, this.mockNodes)); } return this.statements.get(sql)!; }); exec = vi.fn(); close = vi.fn(); pragma = vi.fn(); transaction = vi.fn((fn: () => any) => fn()); checkFTS5Support = vi.fn(() => true); inTransaction = false; // Test helper to set mock data _setMockNode(nodeType: string, value: any) { this.mockNodes.set(nodeType, value); } } class MockPreparedStatement implements PreparedStatement { run = vi.fn((...params: any[]): RunResult => ({ changes: 1, lastInsertRowid: 1 })); get = vi.fn(); all = vi.fn(() => []); iterate = vi.fn(); pluck = vi.fn(() => this); expand = vi.fn(() => this); raw = vi.fn(() => this); columns = vi.fn(() => []); bind = vi.fn(() => this); constructor(private sql: string, private mockNodes: Map<string, any>) { // Configure get() to return node data if (sql.includes('SELECT * FROM nodes WHERE node_type = ?')) { this.get = vi.fn((nodeType: string) => this.mockNodes.get(nodeType)); } // Configure all() for getAllNodes if (sql.includes('SELECT * FROM nodes ORDER BY display_name')) { this.all = vi.fn(() => Array.from(this.mockNodes.values())); } } } describe('NodeRepository - Operations and Resources', () => { let repository: NodeRepository; let mockAdapter: MockDatabaseAdapter; beforeEach(() => { mockAdapter = new MockDatabaseAdapter(); repository = new NodeRepository(mockAdapter); }); describe('getNodeOperations', () => { it('should extract operations from array format', () => { const mockNode = { node_type: 'nodes-base.httpRequest', display_name: 'HTTP Request', operations: JSON.stringify([ { name: 'get', displayName: 'GET' }, { name: 'post', displayName: 'POST' } ]), properties_schema: '[]', credentials_required: '[]' }; mockAdapter._setMockNode('nodes-base.httpRequest', mockNode); const operations = repository.getNodeOperations('nodes-base.httpRequest'); expect(operations).toEqual([ { name: 'get', displayName: 'GET' }, { name: 'post', displayName: 'POST' } ]); }); it('should extract operations from object format grouped by resource', () => { const mockNode = { node_type: 'nodes-base.slack', display_name: 'Slack', operations: JSON.stringify({ message: [ { name: 'send', displayName: 'Send Message' }, { name: 'update', displayName: 'Update Message' } ], channel: [ { name: 'create', displayName: 'Create Channel' }, { name: 'archive', displayName: 'Archive Channel' } ] }), properties_schema: '[]', credentials_required: '[]' }; mockAdapter._setMockNode('nodes-base.slack', mockNode); const allOperations = repository.getNodeOperations('nodes-base.slack'); const messageOperations = repository.getNodeOperations('nodes-base.slack', 'message'); expect(allOperations).toHaveLength(4); expect(messageOperations).toEqual([ { name: 'send', displayName: 'Send Message' }, { name: 'update', displayName: 'Update Message' } ]); }); it('should extract operations from properties with operation field', () => { const mockNode = { node_type: 'nodes-base.googleSheets', display_name: 'Google Sheets', operations: '[]', properties_schema: JSON.stringify([ { name: 'resource', type: 'options', options: [{ name: 'sheet', displayName: 'Sheet' }] }, { name: 'operation', type: 'options', displayOptions: { show: { resource: ['sheet'] } }, options: [ { name: 'append', displayName: 'Append Row' }, { name: 'read', displayName: 'Read Rows' } ] } ]), credentials_required: '[]' }; mockAdapter._setMockNode('nodes-base.googleSheets', mockNode); const operations = repository.getNodeOperations('nodes-base.googleSheets'); expect(operations).toEqual([ { name: 'append', displayName: 'Append Row' }, { name: 'read', displayName: 'Read Rows' } ]); }); it('should filter operations by resource when specified', () => { const mockNode = { node_type: 'nodes-base.googleSheets', display_name: 'Google Sheets', operations: '[]', properties_schema: JSON.stringify([ { name: 'operation', type: 'options', displayOptions: { show: { resource: ['sheet'] } }, options: [ { name: 'append', displayName: 'Append Row' } ] }, { name: 'operation', type: 'options', displayOptions: { show: { resource: ['cell'] } }, options: [ { name: 'update', displayName: 'Update Cell' } ] } ]), credentials_required: '[]' }; mockAdapter._setMockNode('nodes-base.googleSheets', mockNode); const sheetOperations = repository.getNodeOperations('nodes-base.googleSheets', 'sheet'); const cellOperations = repository.getNodeOperations('nodes-base.googleSheets', 'cell'); expect(sheetOperations).toEqual([{ name: 'append', displayName: 'Append Row' }]); expect(cellOperations).toEqual([{ name: 'update', displayName: 'Update Cell' }]); }); it('should return empty array for non-existent node', () => { const operations = repository.getNodeOperations('nodes-base.nonexistent'); expect(operations).toEqual([]); }); it('should handle nodes without operations', () => { const mockNode = { node_type: 'nodes-base.simple', display_name: 'Simple Node', operations: '[]', properties_schema: '[]', credentials_required: '[]' }; mockAdapter._setMockNode('nodes-base.simple', mockNode); const operations = repository.getNodeOperations('nodes-base.simple'); expect(operations).toEqual([]); }); it('should handle malformed operations JSON gracefully', () => { const mockNode = { node_type: 'nodes-base.broken', display_name: 'Broken Node', operations: '{invalid json}', properties_schema: '[]', credentials_required: '[]' }; mockAdapter._setMockNode('nodes-base.broken', mockNode); const operations = repository.getNodeOperations('nodes-base.broken'); expect(operations).toEqual([]); }); }); describe('getNodeResources', () => { it('should extract resources from properties', () => { const mockNode = { node_type: 'nodes-base.slack', display_name: 'Slack', operations: '[]', properties_schema: JSON.stringify([ { name: 'resource', type: 'options', options: [ { name: 'message', displayName: 'Message' }, { name: 'channel', displayName: 'Channel' }, { name: 'user', displayName: 'User' } ] } ]), credentials_required: '[]' }; mockAdapter._setMockNode('nodes-base.slack', mockNode); const resources = repository.getNodeResources('nodes-base.slack'); expect(resources).toEqual([ { name: 'message', displayName: 'Message' }, { name: 'channel', displayName: 'Channel' }, { name: 'user', displayName: 'User' } ]); }); it('should return empty array for node without resources', () => { const mockNode = { node_type: 'nodes-base.simple', display_name: 'Simple Node', operations: '[]', properties_schema: JSON.stringify([ { name: 'url', type: 'string' } ]), credentials_required: '[]' }; mockAdapter._setMockNode('nodes-base.simple', mockNode); const resources = repository.getNodeResources('nodes-base.simple'); expect(resources).toEqual([]); }); it('should return empty array for non-existent node', () => { const resources = repository.getNodeResources('nodes-base.nonexistent'); expect(resources).toEqual([]); }); it('should handle multiple resource properties', () => { const mockNode = { node_type: 'nodes-base.multi', display_name: 'Multi Resource Node', operations: '[]', properties_schema: JSON.stringify([ { name: 'resource', type: 'options', options: [{ name: 'type1', displayName: 'Type 1' }] }, { name: 'resource', type: 'options', options: [{ name: 'type2', displayName: 'Type 2' }] } ]), credentials_required: '[]' }; mockAdapter._setMockNode('nodes-base.multi', mockNode); const resources = repository.getNodeResources('nodes-base.multi'); expect(resources).toEqual([ { name: 'type1', displayName: 'Type 1' }, { name: 'type2', displayName: 'Type 2' } ]); }); }); describe('getOperationsForResource', () => { it('should return operations for specific resource', () => { const mockNode = { node_type: 'nodes-base.slack', display_name: 'Slack', operations: '[]', properties_schema: JSON.stringify([ { name: 'operation', type: 'options', displayOptions: { show: { resource: ['message'] } }, options: [ { name: 'send', displayName: 'Send Message' }, { name: 'update', displayName: 'Update Message' } ] }, { name: 'operation', type: 'options', displayOptions: { show: { resource: ['channel'] } }, options: [ { name: 'create', displayName: 'Create Channel' } ] } ]), credentials_required: '[]' }; mockAdapter._setMockNode('nodes-base.slack', mockNode); const messageOps = repository.getOperationsForResource('nodes-base.slack', 'message'); const channelOps = repository.getOperationsForResource('nodes-base.slack', 'channel'); const nonExistentOps = repository.getOperationsForResource('nodes-base.slack', 'nonexistent'); expect(messageOps).toEqual([ { name: 'send', displayName: 'Send Message' }, { name: 'update', displayName: 'Update Message' } ]); expect(channelOps).toEqual([ { name: 'create', displayName: 'Create Channel' } ]); expect(nonExistentOps).toEqual([]); }); it('should handle array format for resource display options', () => { const mockNode = { node_type: 'nodes-base.multi', display_name: 'Multi Node', operations: '[]', properties_schema: JSON.stringify([ { name: 'operation', type: 'options', displayOptions: { show: { resource: ['message', 'channel'] // Array format } }, options: [ { name: 'list', displayName: 'List Items' } ] } ]), credentials_required: '[]' }; mockAdapter._setMockNode('nodes-base.multi', mockNode); const messageOps = repository.getOperationsForResource('nodes-base.multi', 'message'); const channelOps = repository.getOperationsForResource('nodes-base.multi', 'channel'); const otherOps = repository.getOperationsForResource('nodes-base.multi', 'other'); expect(messageOps).toEqual([{ name: 'list', displayName: 'List Items' }]); expect(channelOps).toEqual([{ name: 'list', displayName: 'List Items' }]); expect(otherOps).toEqual([]); }); it('should return empty array for non-existent node', () => { const operations = repository.getOperationsForResource('nodes-base.nonexistent', 'message'); expect(operations).toEqual([]); }); it('should handle string format for single resource', () => { const mockNode = { node_type: 'nodes-base.single', display_name: 'Single Node', operations: '[]', properties_schema: JSON.stringify([ { name: 'operation', type: 'options', displayOptions: { show: { resource: 'document' // String format } }, options: [ { name: 'create', displayName: 'Create Document' } ] } ]), credentials_required: '[]' }; mockAdapter._setMockNode('nodes-base.single', mockNode); const operations = repository.getOperationsForResource('nodes-base.single', 'document'); expect(operations).toEqual([{ name: 'create', displayName: 'Create Document' }]); }); }); describe('getAllOperations', () => { it('should collect operations from all nodes', () => { const mockNodes = [ { node_type: 'nodes-base.httpRequest', display_name: 'HTTP Request', operations: JSON.stringify([{ name: 'execute' }]), properties_schema: '[]', credentials_required: '[]' }, { node_type: 'nodes-base.slack', display_name: 'Slack', operations: JSON.stringify([{ name: 'send' }]), properties_schema: '[]', credentials_required: '[]' }, { node_type: 'nodes-base.empty', display_name: 'Empty Node', operations: '[]', properties_schema: '[]', credentials_required: '[]' } ]; mockNodes.forEach(node => { mockAdapter._setMockNode(node.node_type, node); }); const allOperations = repository.getAllOperations(); expect(allOperations.size).toBe(2); // Only nodes with operations expect(allOperations.get('nodes-base.httpRequest')).toEqual([{ name: 'execute' }]); expect(allOperations.get('nodes-base.slack')).toEqual([{ name: 'send' }]); expect(allOperations.has('nodes-base.empty')).toBe(false); }); it('should handle empty node list', () => { const allOperations = repository.getAllOperations(); expect(allOperations.size).toBe(0); }); }); describe('getAllResources', () => { it('should collect resources from all nodes', () => { const mockNodes = [ { node_type: 'nodes-base.slack', display_name: 'Slack', operations: '[]', properties_schema: JSON.stringify([ { name: 'resource', options: [{ name: 'message' }, { name: 'channel' }] } ]), credentials_required: '[]' }, { node_type: 'nodes-base.sheets', display_name: 'Google Sheets', operations: '[]', properties_schema: JSON.stringify([ { name: 'resource', options: [{ name: 'sheet' }] } ]), credentials_required: '[]' }, { node_type: 'nodes-base.simple', display_name: 'Simple Node', operations: '[]', properties_schema: '[]', // No resources credentials_required: '[]' } ]; mockNodes.forEach(node => { mockAdapter._setMockNode(node.node_type, node); }); const allResources = repository.getAllResources(); expect(allResources.size).toBe(2); // Only nodes with resources expect(allResources.get('nodes-base.slack')).toEqual([ { name: 'message' }, { name: 'channel' } ]); expect(allResources.get('nodes-base.sheets')).toEqual([{ name: 'sheet' }]); expect(allResources.has('nodes-base.simple')).toBe(false); }); it('should handle empty node list', () => { const allResources = repository.getAllResources(); expect(allResources.size).toBe(0); }); }); describe('edge cases and error handling', () => { it('should handle null or undefined properties gracefully', () => { const mockNode = { node_type: 'nodes-base.null', display_name: 'Null Node', operations: null, properties_schema: null, credentials_required: null }; mockAdapter._setMockNode('nodes-base.null', mockNode); const operations = repository.getNodeOperations('nodes-base.null'); const resources = repository.getNodeResources('nodes-base.null'); expect(operations).toEqual([]); expect(resources).toEqual([]); }); it('should handle complex nested operation properties', () => { const mockNode = { node_type: 'nodes-base.complex', display_name: 'Complex Node', operations: '[]', properties_schema: JSON.stringify([ { name: 'operation', type: 'options', displayOptions: { show: { resource: ['message'], mode: ['advanced'] } }, options: [ { name: 'complexOperation', displayName: 'Complex Operation' } ] } ]), credentials_required: '[]' }; mockAdapter._setMockNode('nodes-base.complex', mockNode); const operations = repository.getNodeOperations('nodes-base.complex'); expect(operations).toEqual([{ name: 'complexOperation', displayName: 'Complex Operation' }]); }); it('should handle operations with mixed data types', () => { const mockNode = { node_type: 'nodes-base.mixed', display_name: 'Mixed Node', operations: JSON.stringify({ string_operation: 'invalid', // Should be array valid_operations: [{ name: 'valid' }], nested_object: { inner: [{ name: 'nested' }] } }), properties_schema: '[]', credentials_required: '[]' }; mockAdapter._setMockNode('nodes-base.mixed', mockNode); const operations = repository.getNodeOperations('nodes-base.mixed'); expect(operations).toEqual([{ name: 'valid' }]); // Only valid array operations }); it('should handle very deeply nested properties', () => { const deepProperties = [ { name: 'resource', options: [{ name: 'deep', displayName: 'Deep Resource' }], nested: { level1: { level2: { operations: [{ name: 'deep_operation' }] } } } } ]; const mockNode = { node_type: 'nodes-base.deep', display_name: 'Deep Node', operations: '[]', properties_schema: JSON.stringify(deepProperties), credentials_required: '[]' }; mockAdapter._setMockNode('nodes-base.deep', mockNode); const resources = repository.getNodeResources('nodes-base.deep'); expect(resources).toEqual([{ name: 'deep', displayName: 'Deep Resource' }]); }); }); }); ``` -------------------------------------------------------------------------------- /tests/unit/templates/template-repository-security.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, vi } from 'vitest'; import { TemplateRepository } from '../../../src/templates/template-repository'; import { DatabaseAdapter, PreparedStatement, RunResult } from '../../../src/database/database-adapter'; // Mock logger vi.mock('../../../src/utils/logger', () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } })); // Mock template sanitizer vi.mock('../../../src/utils/template-sanitizer', () => { class MockTemplateSanitizer { sanitizeWorkflow = vi.fn((workflow) => ({ sanitized: workflow, wasModified: false })); detectTokens = vi.fn(() => []); } return { TemplateSanitizer: MockTemplateSanitizer }; }); // Create mock database adapter class MockDatabaseAdapter implements DatabaseAdapter { private statements = new Map<string, MockPreparedStatement>(); private execCalls: string[] = []; private _fts5Support = true; prepare = vi.fn((sql: string) => { if (!this.statements.has(sql)) { this.statements.set(sql, new MockPreparedStatement(sql)); } return this.statements.get(sql)!; }); exec = vi.fn((sql: string) => { this.execCalls.push(sql); }); close = vi.fn(); pragma = vi.fn(); transaction = vi.fn((fn: () => any) => fn()); checkFTS5Support = vi.fn(() => this._fts5Support); inTransaction = false; // Test helpers _setFTS5Support(supported: boolean) { this._fts5Support = supported; } _getStatement(sql: string) { return this.statements.get(sql); } _getExecCalls() { return this.execCalls; } _clearExecCalls() { this.execCalls = []; } } class MockPreparedStatement implements PreparedStatement { public mockResults: any[] = []; public capturedParams: any[][] = []; run = vi.fn((...params: any[]): RunResult => { this.capturedParams.push(params); return { changes: 1, lastInsertRowid: 1 }; }); get = vi.fn((...params: any[]) => { this.capturedParams.push(params); return this.mockResults[0] || null; }); all = vi.fn((...params: any[]) => { this.capturedParams.push(params); return this.mockResults; }); iterate = vi.fn(); pluck = vi.fn(() => this); expand = vi.fn(() => this); raw = vi.fn(() => this); columns = vi.fn(() => []); bind = vi.fn(() => this); constructor(private sql: string) {} // Test helpers _setMockResults(results: any[]) { this.mockResults = results; } _getCapturedParams() { return this.capturedParams; } } describe('TemplateRepository - Security Tests', () => { let repository: TemplateRepository; let mockAdapter: MockDatabaseAdapter; beforeEach(() => { vi.clearAllMocks(); mockAdapter = new MockDatabaseAdapter(); repository = new TemplateRepository(mockAdapter); }); describe('SQL Injection Prevention', () => { describe('searchTemplatesByMetadata', () => { it('should prevent SQL injection in category parameter', () => { const maliciousCategory = "'; DROP TABLE templates; --"; const stmt = new MockPreparedStatement(''); stmt._setMockResults([]); mockAdapter.prepare = vi.fn().mockReturnValue(stmt); repository.searchTemplatesByMetadata({ category: maliciousCategory}, 10, 0); // Should use parameterized queries, not inject SQL const capturedParams = stmt._getCapturedParams(); expect(capturedParams.length).toBeGreaterThan(0); // The parameter should be the sanitized version (JSON.stringify then slice to remove quotes) const expectedParam = JSON.stringify(maliciousCategory).slice(1, -1); // capturedParams[0] is the first call's parameters array expect(capturedParams[0][0]).toBe(expectedParam); // Verify the SQL doesn't contain the malicious content directly const prepareCall = mockAdapter.prepare.mock.calls[0][0]; expect(prepareCall).not.toContain('DROP TABLE'); expect(prepareCall).toContain("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'"); }); it('should prevent SQL injection in requiredService parameter', () => { const maliciousService = "'; UNION SELECT * FROM sqlite_master; --"; const stmt = new MockPreparedStatement(''); stmt._setMockResults([]); mockAdapter.prepare = vi.fn().mockReturnValue(stmt); repository.searchTemplatesByMetadata({ requiredService: maliciousService}, 10, 0); const capturedParams = stmt._getCapturedParams(); const expectedParam = JSON.stringify(maliciousService).slice(1, -1); // capturedParams[0] is the first call's parameters array expect(capturedParams[0][0]).toBe(expectedParam); const prepareCall = mockAdapter.prepare.mock.calls[0][0]; expect(prepareCall).not.toContain('UNION SELECT'); expect(prepareCall).toContain("json_extract(metadata_json, '$.required_services') LIKE '%' || ? || '%'"); }); it('should prevent SQL injection in targetAudience parameter', () => { const maliciousAudience = "administrators'; DELETE FROM templates WHERE '1'='1"; const stmt = new MockPreparedStatement(''); stmt._setMockResults([]); mockAdapter.prepare = vi.fn().mockReturnValue(stmt); repository.searchTemplatesByMetadata({ targetAudience: maliciousAudience}, 10, 0); const capturedParams = stmt._getCapturedParams(); const expectedParam = JSON.stringify(maliciousAudience).slice(1, -1); // capturedParams[0] is the first call's parameters array expect(capturedParams[0][0]).toBe(expectedParam); const prepareCall = mockAdapter.prepare.mock.calls[0][0]; expect(prepareCall).not.toContain('DELETE FROM'); expect(prepareCall).toContain("json_extract(metadata_json, '$.target_audience') LIKE '%' || ? || '%'"); }); it('should safely handle special characters in parameters', () => { const specialChars = "test'with\"quotes\\and%wildcards_and[brackets]"; const stmt = new MockPreparedStatement(''); stmt._setMockResults([]); mockAdapter.prepare = vi.fn().mockReturnValue(stmt); repository.searchTemplatesByMetadata({ category: specialChars}, 10, 0); const capturedParams = stmt._getCapturedParams(); const expectedParam = JSON.stringify(specialChars).slice(1, -1); // capturedParams[0] is the first call's parameters array expect(capturedParams[0][0]).toBe(expectedParam); // Should use parameterized query const prepareCall = mockAdapter.prepare.mock.calls[0][0]; expect(prepareCall).toContain("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'"); }); it('should prevent injection through numeric parameters', () => { const stmt = new MockPreparedStatement(''); stmt._setMockResults([]); mockAdapter.prepare = vi.fn().mockReturnValue(stmt); // Try to inject through numeric parameters repository.searchTemplatesByMetadata({maxSetupMinutes: 999999999, // Large number minSetupMinutes: -999999999 // Negative number }, 10, 0); const capturedParams = stmt._getCapturedParams(); // capturedParams[0] is the first call's parameters array expect(capturedParams[0]).toContain(999999999); expect(capturedParams[0]).toContain(-999999999); // Should use CAST and parameterized queries const prepareCall = mockAdapter.prepare.mock.calls[0][0]; expect(prepareCall).toContain('CAST(json_extract(metadata_json, \'$.estimated_setup_minutes\') AS INTEGER)'); }); }); describe('getMetadataSearchCount', () => { it('should use parameterized queries for count operations', () => { const maliciousCategory = "'; DROP TABLE templates; SELECT COUNT(*) FROM sqlite_master WHERE name LIKE '%"; const stmt = new MockPreparedStatement(''); stmt._setMockResults([{ count: 0 }]); mockAdapter.prepare = vi.fn().mockReturnValue(stmt); repository.getMetadataSearchCount({ category: maliciousCategory }); const capturedParams = stmt._getCapturedParams(); const expectedParam = JSON.stringify(maliciousCategory).slice(1, -1); // capturedParams[0] is the first call's parameters array expect(capturedParams[0][0]).toBe(expectedParam); const prepareCall = mockAdapter.prepare.mock.calls[0][0]; expect(prepareCall).not.toContain('DROP TABLE'); expect(prepareCall).toContain('SELECT COUNT(*) as count FROM templates'); }); }); describe('updateTemplateMetadata', () => { it('should safely handle metadata with special characters', () => { const maliciousMetadata = { categories: ["automation'; DROP TABLE templates; --"], complexity: "simple", use_cases: ['SQL injection"test'], estimated_setup_minutes: 30, required_services: ['api"with\\"quotes'], key_features: ["feature's test"], target_audience: ['developers\\administrators'] }; const stmt = new MockPreparedStatement(''); mockAdapter.prepare = vi.fn().mockReturnValue(stmt); repository.updateTemplateMetadata(123, maliciousMetadata); const capturedParams = stmt._getCapturedParams(); expect(capturedParams[0][0]).toBe(JSON.stringify(maliciousMetadata)); expect(capturedParams[0][1]).toBe(123); // Should use parameterized UPDATE const prepareCall = mockAdapter.prepare.mock.calls[0][0]; expect(prepareCall).toContain('UPDATE templates'); expect(prepareCall).toContain('metadata_json = ?'); expect(prepareCall).toContain('WHERE id = ?'); expect(prepareCall).not.toContain('DROP TABLE'); }); }); describe('batchUpdateMetadata', () => { it('should safely handle batch updates with malicious data', () => { const maliciousData = new Map(); maliciousData.set(1, { categories: ["'; DROP TABLE templates; --"] }); maliciousData.set(2, { categories: ["normal category"] }); const stmt = new MockPreparedStatement(''); mockAdapter.prepare = vi.fn().mockReturnValue(stmt); repository.batchUpdateMetadata(maliciousData); const capturedParams = stmt._getCapturedParams(); expect(capturedParams).toHaveLength(2); // Both calls should be parameterized const firstJson = capturedParams[0][0]; const secondJson = capturedParams[1][0]; expect(firstJson).toContain("'; DROP TABLE templates; --"); // Should be JSON-encoded expect(capturedParams[0][1]).toBe(1); expect(secondJson).toContain('normal category'); expect(capturedParams[1][1]).toBe(2); }); }); }); describe('JSON Extraction Security', () => { it('should safely extract categories from JSON', () => { const stmt = new MockPreparedStatement(''); stmt._setMockResults([]); mockAdapter.prepare = vi.fn().mockReturnValue(stmt); repository.getUniqueCategories(); const prepareCall = mockAdapter.prepare.mock.calls[0][0]; expect(prepareCall).toContain('json_each(metadata_json, \'$.categories\')'); expect(prepareCall).not.toContain('eval('); expect(prepareCall).not.toContain('exec('); }); it('should safely extract target audiences from JSON', () => { const stmt = new MockPreparedStatement(''); stmt._setMockResults([]); mockAdapter.prepare = vi.fn().mockReturnValue(stmt); repository.getUniqueTargetAudiences(); const prepareCall = mockAdapter.prepare.mock.calls[0][0]; expect(prepareCall).toContain('json_each(metadata_json, \'$.target_audience\')'); expect(prepareCall).not.toContain('eval('); }); it('should safely handle complex JSON structures', () => { const stmt = new MockPreparedStatement(''); stmt._setMockResults([]); mockAdapter.prepare = vi.fn().mockReturnValue(stmt); repository.getTemplatesByCategory('test'); const prepareCall = mockAdapter.prepare.mock.calls[0][0]; expect(prepareCall).toContain("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'"); const capturedParams = stmt._getCapturedParams(); // Check if parameters were captured expect(capturedParams.length).toBeGreaterThan(0); // Find the parameter that contains 'test' const testParam = capturedParams[0].find((p: any) => typeof p === 'string' && p.includes('test')); expect(testParam).toBe('test'); }); }); describe('Input Validation and Sanitization', () => { it('should handle null and undefined parameters safely', () => { const stmt = new MockPreparedStatement(''); stmt._setMockResults([]); mockAdapter.prepare = vi.fn().mockReturnValue(stmt); repository.searchTemplatesByMetadata({ category: undefined as any, complexity: null as any}, 10, 0); // Should not break and should exclude undefined/null filters const prepareCall = mockAdapter.prepare.mock.calls[0][0]; expect(prepareCall).toContain('metadata_json IS NOT NULL'); expect(prepareCall).not.toContain('undefined'); expect(prepareCall).not.toContain('null'); }); it('should handle empty string parameters', () => { const stmt = new MockPreparedStatement(''); stmt._setMockResults([]); mockAdapter.prepare = vi.fn().mockReturnValue(stmt); repository.searchTemplatesByMetadata({ category: '', requiredService: '', targetAudience: ''}, 10, 0); // Empty strings should still be processed (might be valid searches) const capturedParams = stmt._getCapturedParams(); const expectedParam = JSON.stringify("").slice(1, -1); // Results in empty string // Check if parameters were captured expect(capturedParams.length).toBeGreaterThan(0); // Check if empty string parameters are present const hasEmptyString = capturedParams[0].includes(expectedParam); expect(hasEmptyString).toBe(true); }); it('should validate numeric ranges', () => { const stmt = new MockPreparedStatement(''); stmt._setMockResults([]); mockAdapter.prepare = vi.fn().mockReturnValue(stmt); repository.searchTemplatesByMetadata({ maxSetupMinutes: Number.MAX_SAFE_INTEGER, minSetupMinutes: Number.MIN_SAFE_INTEGER}, 10, 0); // Should handle extreme values without breaking const capturedParams = stmt._getCapturedParams(); expect(capturedParams[0]).toContain(Number.MAX_SAFE_INTEGER); expect(capturedParams[0]).toContain(Number.MIN_SAFE_INTEGER); }); it('should handle Unicode and international characters', () => { const unicodeCategory = '自動化'; // Japanese for "automation" const emojiAudience = '👩💻 developers'; const stmt = new MockPreparedStatement(''); stmt._setMockResults([]); mockAdapter.prepare = vi.fn().mockReturnValue(stmt); repository.searchTemplatesByMetadata({ category: unicodeCategory, targetAudience: emojiAudience}, 10, 0); const capturedParams = stmt._getCapturedParams(); const expectedCategoryParam = JSON.stringify(unicodeCategory).slice(1, -1); const expectedAudienceParam = JSON.stringify(emojiAudience).slice(1, -1); // capturedParams[0] is the first call's parameters array expect(capturedParams[0][0]).toBe(expectedCategoryParam); expect(capturedParams[0][1]).toBe(expectedAudienceParam); }); }); describe('Database Schema Security', () => { it('should use proper column names without injection', () => { const stmt = new MockPreparedStatement(''); stmt._setMockResults([]); mockAdapter.prepare = vi.fn().mockReturnValue(stmt); repository.searchTemplatesByMetadata({ category: 'test'}, 10, 0); const prepareCall = mockAdapter.prepare.mock.calls[0][0]; // Should reference proper column names expect(prepareCall).toContain('metadata_json'); expect(prepareCall).toContain('templates'); // Should not contain dynamic column names that could be injected expect(prepareCall).not.toMatch(/SELECT \* FROM \w+;/); expect(prepareCall).not.toContain('information_schema'); expect(prepareCall).not.toContain('sqlite_master'); }); it('should use proper JSON path syntax', () => { const stmt = new MockPreparedStatement(''); stmt._setMockResults([]); mockAdapter.prepare = vi.fn().mockReturnValue(stmt); repository.getUniqueCategories(); const prepareCall = mockAdapter.prepare.mock.calls[0][0]; // Should use safe JSON path syntax expect(prepareCall).toContain('$.categories'); expect(prepareCall).not.toContain('$['); expect(prepareCall).not.toContain('eval('); }); }); describe('Transaction Safety', () => { it('should handle transaction rollback on metadata update errors', () => { const stmt = new MockPreparedStatement(''); stmt.run = vi.fn().mockImplementation(() => { throw new Error('Database error'); }); mockAdapter.prepare = vi.fn().mockReturnValue(stmt); const maliciousData = new Map(); maliciousData.set(1, { categories: ["'; DROP TABLE templates; --"] }); expect(() => { repository.batchUpdateMetadata(maliciousData); }).toThrow('Database error'); // The error is thrown when running the statement, not during transaction setup // So we just verify that the error was thrown correctly }); }); describe('Error Message Security', () => { it('should not expose sensitive information in error messages', () => { const stmt = new MockPreparedStatement(''); stmt.get = vi.fn().mockImplementation(() => { throw new Error('SQLITE_ERROR: syntax error near "DROP TABLE"'); }); mockAdapter.prepare = vi.fn().mockReturnValue(stmt); expect(() => { repository.getMetadataSearchCount({ category: "'; DROP TABLE templates; --" }); }).toThrow(); // Should throw, but not expose SQL details }); }); describe('Performance and DoS Protection', () => { it('should handle large limit values safely', () => { const stmt = new MockPreparedStatement(''); stmt._setMockResults([]); mockAdapter.prepare = vi.fn().mockReturnValue(stmt); repository.searchTemplatesByMetadata({}, 999999999, 0); // Very large limit const capturedParams = stmt._getCapturedParams(); // Check if parameters were captured expect(capturedParams.length).toBeGreaterThan(0); // Check if the large limit value is present (might be capped) const hasLargeLimit = capturedParams[0].includes(999999999) || capturedParams[0].includes(20); expect(hasLargeLimit).toBe(true); // Should still work but might be limited by database constraints expect(mockAdapter.prepare).toHaveBeenCalled(); }); it('should handle very long string parameters', () => { const veryLongString = 'a'.repeat(100000); // 100KB string const stmt = new MockPreparedStatement(''); stmt._setMockResults([]); mockAdapter.prepare = vi.fn().mockReturnValue(stmt); repository.searchTemplatesByMetadata({ category: veryLongString}, 10, 0); const capturedParams = stmt._getCapturedParams(); expect(capturedParams[0][0]).toContain(veryLongString); // Should handle without breaking expect(mockAdapter.prepare).toHaveBeenCalled(); }); }); }); ```