diff --git a/.github/workflows/build-unit-test-pr-comment.yml b/.github/workflows/build-unit-test-pr-comment.yml new file mode 100644 index 00000000000..3932172e91f --- /dev/null +++ b/.github/workflows/build-unit-test-pr-comment.yml @@ -0,0 +1,97 @@ +name: Trigger build/unit tests on PR comment + +on: + issue_comment: + types: [created] + +permissions: + pull-requests: read + contents: read + actions: write + +jobs: + validate_and_dispatch: + name: Validate user and dispatch CI workflow + if: github.event.issue.pull_request && startsWith(github.event.comment.body, '/build-unit-test') + runs-on: ubuntu-latest + steps: + - name: Validate user permissions and collect PR data + id: check_permissions + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const commenter = context.actor; + const body = (context.payload.comment.body || '').trim(); + const isCommand = body.startsWith('/build-unit-test'); + const allowedPermissions = ['admin', 'write', 'maintain']; + const commentId = context.payload.comment.id; + const { owner, repo } = context.repo; + + async function react(content) { + try { + await github.rest.reactions.createForIssueComment({ + owner, + repo, + comment_id: commentId, + content, + }); + } catch (error) { + console.log(`Failed to add reaction '${content}': ${error.message}`); + } + } + + core.setOutput('proceed', 'false'); + core.setOutput('headSha', ''); + core.setOutput('prNumber', ''); + + if (!context.payload.issue.pull_request || !isCommand) { + console.log('Comment is not /build-unit-test on a pull request. Skipping.'); + return; + } + + let permission; + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username: commenter, + }); + permission = data.permission; + } catch (error) { + console.log(`Could not verify permissions for @${commenter}: ${error.message}`); + await react('confused'); + return; + } + + if (!allowedPermissions.includes(permission)) { + console.log(`User @${commenter} has '${permission}' permission; requires admin/write/maintain.`); + await react('-1'); + return; + } + + try { + const prNumber = context.issue.number; + const { data: pr } = await github.rest.pulls.get({ + owner, + repo, + pull_number: prNumber, + }); + await react('+1'); + core.setOutput('proceed', 'true'); + core.setOutput('headSha', pr.head.sha); + core.setOutput('prNumber', String(prNumber)); + } catch (error) { + console.log(`Failed to fetch PR details for PR #${context.issue.number}: ${error.message}`); + await react('confused'); + } + + - name: Dispatch build/unit test workflow + if: ${{ steps.check_permissions.outputs.proceed == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HEAD_SHA: ${{ steps.check_permissions.outputs.headSha }} + run: | + gh workflow run ci-manual-build-unit-tests.yml \ + --repo "${{ github.repository }}" \ + -f ref="${HEAD_SHA}" diff --git a/.github/workflows/ci-manual-build-unit-tests.yml b/.github/workflows/ci-manual-build-unit-tests.yml new file mode 100644 index 00000000000..2a4adccd99e --- /dev/null +++ b/.github/workflows/ci-manual-build-unit-tests.yml @@ -0,0 +1,55 @@ +name: Build, unit test and lint (manual trigger) + +on: + workflow_dispatch: + inputs: + ref: + description: Commit SHA or ref to check out + required: true + +jobs: + install-and-build: + name: Install & Build + runs-on: blacksmith-2vcpu-ubuntu-2204 + env: + NODE_OPTIONS: '--max-old-space-size=6144' + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + ref: ${{ inputs.ref }} + + - name: Setup and Build + uses: n8n-io/n8n/.github/actions/setup-nodejs-blacksmith@f5fbbbe0a28a886451c886cac6b49192a39b0eea # v1.104.1 + + - name: Run format check + run: pnpm format:check + + - name: Run typecheck + run: pnpm typecheck + + unit-tests: + name: Unit tests + needs: install-and-build + uses: ./.github/workflows/units-tests-reusable.yml + with: + ref: ${{ inputs.ref }} + collectCoverage: true + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + lint: + name: Lint + needs: install-and-build + uses: ./.github/workflows/linting-reusable.yml + with: + ref: ${{ inputs.ref }} + + post-build-unit-tests: + name: Build & Unit Tests - Checks + runs-on: ubuntu-latest + needs: [install-and-build, unit-tests, lint] + if: always() + steps: + - name: Fail if any job failed + if: needs.install-and-build.result == 'failure' || needs.unit-tests.result == 'failure' || needs.lint.result == 'failure' + run: exit 1 diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 306a8bf0f53..47dbedda7aa 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -26,6 +26,7 @@ jobs: publish-to-npm: name: Publish to NPM runs-on: ubuntu-latest + if: github.event.pull_request.merged == true timeout-minutes: 20 permissions: id-token: write diff --git a/CHANGELOG.md b/CHANGELOG.md index 50a5488fab0..b8e75379dbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,49 @@ +# [1.120.0](https://github.com/n8n-io/n8n/compare/n8n@1.119.0...n8n@1.120.0) (2025-11-10) + + +### Bug Fixes + +* **ai-builder:** Add support for node versions in searching, adding and updating nodes ([#21488](https://github.com/n8n-io/n8n/issues/21488)) ([8270f37](https://github.com/n8n-io/n8n/commit/8270f37df5fa1d14b1eb2f16606a9128aeab74e3)) +* **ai-builder:** Reduce "workflow state too big" errors ([#21542](https://github.com/n8n-io/n8n/issues/21542)) ([e5d7fb9](https://github.com/n8n-io/n8n/commit/e5d7fb971d6f8f68480dc8c0ac55c6852e248dbe)) +* **API:** Fix returning role as slug on the users api handler ([#21490](https://github.com/n8n-io/n8n/issues/21490)) ([941a54e](https://github.com/n8n-io/n8n/commit/941a54e723768c317d82d034737af6c33e4107b6)) +* Change unicode range to support more characters in expression parser ([#21394](https://github.com/n8n-io/n8n/issues/21394)) ([0a799e1](https://github.com/n8n-io/n8n/commit/0a799e1cabe17518dc6aa5a36fc303e79505492c)) +* **Code Node:** Update error message when using `.item` in `Run once for all items` mode ([#21416](https://github.com/n8n-io/n8n/issues/21416)) ([306972d](https://github.com/n8n-io/n8n/commit/306972d914c3f698ec1f43e2a0dc0839f06395d0)) +* **core:** Column size for token column ([#21609](https://github.com/n8n-io/n8n/issues/21609)) ([8504beb](https://github.com/n8n-io/n8n/commit/8504beb154ef6ec2e6892cca851b0efdae366ddb)) +* **core:** Include role in user-invite-email-click ([#21546](https://github.com/n8n-io/n8n/issues/21546)) ([27fd768](https://github.com/n8n-io/n8n/commit/27fd768deb9a1fc4e38cc8a524d224049c210da8)) +* **core:** Insights fix same day queries ([#21574](https://github.com/n8n-io/n8n/issues/21574)) ([c100736](https://github.com/n8n-io/n8n/commit/c1007367458f1b0554c4f2b00f6fd907ef23d000)) +* **core:** Insights use time aware range when end date is today, and start of day for past ranges ([#21540](https://github.com/n8n-io/n8n/issues/21540)) ([4dc58aa](https://github.com/n8n-io/n8n/commit/4dc58aacf851ab41039a12f8c96eacbe57b6b2cb)) +* **editor:** Ensure license activation modal works when used without EULA ([#21681](https://github.com/n8n-io/n8n/issues/21681)) ([4e70050](https://github.com/n8n-io/n8n/commit/4e70050ab250417c12dc018d2ae972018ff4cb85)) +* **editor:** Fix button image link in easy AI template sticky note for UK users ([#21527](https://github.com/n8n-io/n8n/issues/21527)) ([74a0b51](https://github.com/n8n-io/n8n/commit/74a0b51c4636b1760a10c5cb83bebbfdbcb8fca7)) +* **editor:** Fix hanging logs panel tooltip ([#21631](https://github.com/n8n-io/n8n/issues/21631)) ([53efa28](https://github.com/n8n-io/n8n/commit/53efa2842ebc3b6e015a718e383f6ad4b1cbb107)) +* **editor:** Fix main button create variable disable state based on scopes ([#21521](https://github.com/n8n-io/n8n/issues/21521)) ([d2e623e](https://github.com/n8n-io/n8n/commit/d2e623e2050e20e6cc44cb0233958ff0186e1e1e)) +* **editor:** Fix preview for json output with long values ([#21412](https://github.com/n8n-io/n8n/issues/21412)) ([f354200](https://github.com/n8n-io/n8n/commit/f354200c84e6c9e43ec34bda56781b58ac61cf44)) +* **editor:** Limit telemetry event size to 32kb ([#21312](https://github.com/n8n-io/n8n/issues/21312)) ([b68d3bf](https://github.com/n8n-io/n8n/commit/b68d3bf534fb2e1e4a9c7d5e469852a586185bd2)) +* **editor:** Log view doesn't scroll in manual execution ([#21529](https://github.com/n8n-io/n8n/issues/21529)) ([6945e21](https://github.com/n8n-io/n8n/commit/6945e214233fdd3635d1bcb03968350a5d0905df)) +* **Embeddings AWS Bedrock Node, AWS Bedrock Chat Model Node:** Fix HTTP proxy ([#21509](https://github.com/n8n-io/n8n/issues/21509)) ([53d91ee](https://github.com/n8n-io/n8n/commit/53d91ee89fbb6d28c63f841ca1b1acd21d6ab66f)) +* Ensure workflows and folders updatedAt/createdAt aren't mixed up in project sorting ([#21484](https://github.com/n8n-io/n8n/issues/21484)) ([d9d36bf](https://github.com/n8n-io/n8n/commit/d9d36bf28f361d2c333b375744b4d7b51619e5a9)) +* **Google Workspace Admin Node:** Include `changePasswordAtNextLogin`, `password` in update ([#21522](https://github.com/n8n-io/n8n/issues/21522)) ([477ffea](https://github.com/n8n-io/n8n/commit/477ffea4ced772de7aa06033536c074a4720089a)) +* Prevent multiple api requests when changing workflow owner ([#21335](https://github.com/n8n-io/n8n/issues/21335)) ([b610e55](https://github.com/n8n-io/n8n/commit/b610e550f76723a33872c8408e9d506112a1c589)) +* **SendGrid Node:** Use `/scopes` for credential testing ([#21499](https://github.com/n8n-io/n8n/issues/21499)) ([c5db57f](https://github.com/n8n-io/n8n/commit/c5db57fd8b7fd32009aacb24e12357a6120eeca1)) +* **Slack Node:** Prevent invalid array arg on team join ([#20382](https://github.com/n8n-io/n8n/issues/20382)) ([afd40c6](https://github.com/n8n-io/n8n/commit/afd40c67093e551fc5c16dd39c57bf54446e32e3)) + + +### Features + +* Add support for mysql / mariadb ([#21525](https://github.com/n8n-io/n8n/issues/21525)) ([9bcad5a](https://github.com/n8n-io/n8n/commit/9bcad5ae2d63cdff0218636a845a1c7556dbb957)) +* Add unit tests for getAttributesFromLoginResponse and handleSamlLogin ([#21678](https://github.com/n8n-io/n8n/issues/21678)) ([9e240d6](https://github.com/n8n-io/n8n/commit/9e240d6d748381a441c9f02ea5311da3f229f74b)) +* Allow CORS in the discovery endpoints ([#21602](https://github.com/n8n-io/n8n/issues/21602)) ([3070e44](https://github.com/n8n-io/n8n/commit/3070e446bfec16c743cc2ac58c6ca9a9bd9106ee)) +* **core:** Adapt breaking changes report data to UI needs ([#21442](https://github.com/n8n-io/n8n/issues/21442)) ([a2a484e](https://github.com/n8n-io/n8n/commit/a2a484ecf23b7c64f9cb98737de0ae202d0b70dc)) +* **core:** Add OAuth to MCP server ([#21469](https://github.com/n8n-io/n8n/issues/21469)) ([cd167ac](https://github.com/n8n-io/n8n/commit/cd167ac6db5d30b21b9dbcee2ca870cfcfa0bcbe)) +* **core:** Add workflow descriptions ([#21526](https://github.com/n8n-io/n8n/issues/21526)) ([ecc6706](https://github.com/n8n-io/n8n/commit/ecc67062a435a1924efbc0f6be7a03d4076fae19)) +* **core:** Just in time role provisioning for SAML login ([#21387](https://github.com/n8n-io/n8n/issues/21387)) ([2eb1de6](https://github.com/n8n-io/n8n/commit/2eb1de6c82cd3d642ff3bbb50489c9c1b552ca4a)) +* **editor:** Data size warning in AI Logs input/output sections ([#21555](https://github.com/n8n-io/n8n/issues/21555)) ([09f91a8](https://github.com/n8n-io/n8n/commit/09f91a8f45b702d168b466e183c0f09ce9e7fda4)) +* **Extract from File Node:** Add `Skip Records With Errors` option ([#21347](https://github.com/n8n-io/n8n/issues/21347)) ([0ccf470](https://github.com/n8n-io/n8n/commit/0ccf47044a2ba5b94140bfdd2ba36b868091288d)) +* Provide data export of access settings when enabling JIT ([#21532](https://github.com/n8n-io/n8n/issues/21532)) ([146e4ad](https://github.com/n8n-io/n8n/commit/146e4ad268c7d0e17af66bd9a00599e034e9ee54)) +* **Redis Node:** Add list length (LLEN) operation ([#21420](https://github.com/n8n-io/n8n/issues/21420)) ([b0df438](https://github.com/n8n-io/n8n/commit/b0df43828604c23b6d95ed10465e2951b251355e)) +* Use experiment feature flag for SSO provisioning (no changelog) ([#21494](https://github.com/n8n-io/n8n/issues/21494)) ([a2d6c8d](https://github.com/n8n-io/n8n/commit/a2d6c8d65f82b475d891a71382641404bbe36b05)) + + + # [1.119.0](https://github.com/n8n-io/n8n/compare/n8n@1.118.0...n8n@1.119.0) (2025-11-03) diff --git a/package.json b/package.json index 554595cd17c..14a9e2cf4dc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-monorepo", - "version": "1.119.0", + "version": "1.120.0", "private": true, "engines": { "node": ">=22.16", @@ -105,6 +105,7 @@ "tsconfig-paths": "^4.2.0", "typescript": "catalog:", "vue-tsc": "^2.2.8", + "gaxios": ">=7.1.1", "google-gax": "^4.3.7", "ws": ">=8.17.1", "brace-expansion@1": "1.1.12", diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/chains/test-case-generator.ts b/packages/@n8n/ai-workflow-builder.ee/evaluations/chains/test-case-generator.ts index e05dd53e518..2dc777dd503 100644 --- a/packages/@n8n/ai-workflow-builder.ee/evaluations/chains/test-case-generator.ts +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/chains/test-case-generator.ts @@ -95,52 +95,52 @@ export async function generateTestCases( export const basicTestCases: TestCase[] = [ { - id: 'invoice-pipeline', - name: 'Invoice processing pipeline', + id: 'multi-agent-research', + name: 'Multi-agent research workflow', prompt: - 'Create an invoice parsing workflow using n8n forms. Extract key information (vendor, date, amount, line items) using AI, validate the data, and store structured information in Airtable. Generate a weekly spending report every Sunday at 6 PM using AI analysis and send via email.', - }, - { - id: 'ai-news-digest', - name: 'Daily AI news digest', - prompt: - 'Create a workflow that fetches the latest AI news every morning at 8 AM. It should aggregate news from multiple sources, use LLM to summarize the top 5 stories, generate a relevant image using AI, and send everything as a structured Telegram message with article links. I should be able to chat about the news with the LLM so at least 40 last messages should be stored.', - }, - { - id: 'rag-assistant', - name: 'RAG knowledge assistant', - prompt: - 'Build a pipeline that accepts PDF, CSV, or JSON files through an n8n form. Chunk documents into 1000-token segments, generate embeddings, and store in a vector database. Use the filename as the document key and add metadata including upload date and file type. Include a chatbot that can answer questions based on a knowledge base.', + 'Create a multi-agent AI workflow using GPT-4.1-mini where several agents work together to research a topic, fact-check the findings, and write a report that\'s sent as an HTML email. One agent should gather recent, credible information about the topic. Another agent should verify the facts and only mark something as "verified" if it appears in at least two independent sources. A third agent should combine the verified information into a clear, well-written report under 1,000 words. A final agent should edit and format the report to make it look clean and professional in the body of the email. Use Gmail to send the report.', }, { id: 'email-summary', name: 'Summarize emails with AI', prompt: - 'Build a workflow that retrieves the last 50 emails from multiple email accounts. Merge all emails, perform AI analysis to identify action items, priorities, and sentiment. Generate a brief summary and send to Slack with categorized insights and recommended actions.', + 'Create an automation that runs on Monday mornings. It reads my Gmail inbox from the weekend, analyzes them with GPT-4.1-mini to find action items and priorities, and emails me a structured email using Gmail.', }, { - id: 'youtube-auto-chapters', - name: 'YouTube video chapters', + id: 'ai-news-digest', + name: 'Daily AI news digest', prompt: - "I want to build an n8n workflow that automatically creates YouTube chapter timestamps by analyzing the video captions. When I trigger it manually, it should take a video ID as input, fetch the existing video metadata and captions from YouTube, use an AI language model like Google Gemini to parse the transcript into chapters with timestamps, and then update the video's description with these chapters appended. The goal is to save time and improve SEO by automating the whole process.", + 'Build an automation that runs every night 8pm. Use the NewsAPI "/everything" endpoint to search for AI-related news from the day. Pick the top 5 articles and use OpenAI GPT-4.1-mini to summarize each in two sentences. Generate an image using OpenAI based on the top article\'s summary. Send a structured Telegram message.', }, { - id: 'pizza-delivery', - name: 'Pizza delivery chatbot', + id: 'daily-weather-report', + name: 'Daily weather report', prompt: - "I need an n8n workflow that creates a chatbot for my pizza delivery service. The bot should be able to answer customer questions about our pizza menu, take their orders accurately by capturing pizza type, quantity, and customer details, and also provide real-time updates when customers ask about their order status. It should use OpenAI's gpt-4.1-mini to handle conversations and integrate with HTTP APIs to get product info and manage orders. The workflow must maintain conversation context so the chatbot feels natural and can process multiple user queries sequentially.", + 'Create an automation that checks the weather for my location every morning at 5 a.m using OpenWeather. Send me a short weather report by email using Gmail. Use OpenAI GPT-4.1-mini to write a short, fun formatted email body by adding personality when describing the weather and how the day might feel. Include all details relevant to decide on my plans and clothes for the day.', + }, + { + id: 'invoice-pipeline', + name: 'Invoice processing pipeline', + prompt: + 'Create an invoice processing workflow using an n8n Form. When a user submits an invoice file (PDF or image) with their email address, use OpenAI GPT-4.1-mini to extract invoice data. Then, validate the date format is correct, the currency is valid, and the total amount is greater than zero. If validation fails, email the user a clear error message that explains which check failed from my Gmail. If the data passes validation, store the structured result in a datatable plus email the user. Every Monday morning, generate a weekly spending report using GPT-4.1-mini based on stored invoices and send a clean email using Gmail.', + }, + { + id: 'rag-assistant', + name: 'RAG knowledge assistant', + prompt: + 'Build an automation that creates a document-to-chat RAG pipeline. The workflow starts with an n8n Form where a user uploads one or more files (PDF, CSV, or JSON). Each upload should trigger a process that reads the file, splits it into chunks, and generates embeddings using OpenAI GPT-4.1-mini model, saved in one Pinecone table. Add a second part of the workflow for querying: use a Chat Message Trigger to act as a chatbot interface. When a user sends a question, retrieve the top 5 most relevant chunks from Pinecone, pass them into GPT-4.1-mini as context, and have it answer naturally using only the retrieved information. If a question can\'t be answered confidently, the bot should respond with: "I couldn\'t find that in the uploaded documents." Log each chat interaction in a Data Table with the user query, matched file(s), and timestamp. Send a daily summary email through Gmail showing total questions asked, top files referenced, and any failed lookups.', }, { id: 'lead-qualification', name: 'Lead qualification and call scheduling', prompt: - 'Create a form with fields for email, company, and role. Build an automation that processes form submissions, enrich with company data from their website, uses AI to qualify the lead, sends data to Google Sheets. For high-score leads it should also schedule a 15-min call in a free slot in my calendar and send a confirmation email to both me and the lead.', + 'Create an n8n form with a lead generation form I can embed on my website homepage. Build an automation that processes form submissions, uses AI to qualify the lead, sends data to an n8n data table. For high-score leads, it should also email them to offer to schedule a 15-min call in a free slot in my calendar.', }, { - id: 'multi-agent-research', - name: 'Multi-agent research workflow', + id: 'youtube-auto-chapters', + name: 'YouTube video chapters', prompt: - 'Create a multi-agent AI workflow where different AI agents collaborate to research a topic, fact-check information, and compile comprehensive reports.', + "Build an n8n workflow that automatically generates YouTube chapter timestamps from video captions. Use the n8n chat trigger for me to enter the URL of the YouTube video. Use the YouTube Get a video node to get the video title, description, and existing metadata. Use the YouTube Captions API to download the transcript for the given video ID. Send the transcript to AI agent using Anthropic's Claude model. Prompt the model to identify topic shifts and return structured output in timestamp - chapter format. Append the generated chapter list to the existing video description. Use the YouTube Update a video node to update the video description. Respond back with the updates using the respond to chat node.", }, { id: 'google-sheets-processing', diff --git a/packages/@n8n/ai-workflow-builder.ee/package.json b/packages/@n8n/ai-workflow-builder.ee/package.json index 9098cd20b02..007269b4f6e 100644 --- a/packages/@n8n/ai-workflow-builder.ee/package.json +++ b/packages/@n8n/ai-workflow-builder.ee/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/ai-workflow-builder", - "version": "0.29.0", + "version": "0.30.0", "scripts": { "clean": "rimraf dist .turbo", "typecheck": "tsc --noEmit", diff --git a/packages/@n8n/ai-workflow-builder.ee/src/constants.ts b/packages/@n8n/ai-workflow-builder.ee/src/constants.ts index 49259b71453..0c0aaa7a660 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/constants.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/constants.ts @@ -35,4 +35,4 @@ export const MAX_WORKFLOW_LENGTH_TOKENS = 30_000; * Average character-to-token ratio for Anthropic models. * Used for rough token count estimation from character counts. */ -export const AVG_CHARS_PER_TOKEN_ANTHROPIC = 2.5; +export const AVG_CHARS_PER_TOKEN_ANTHROPIC = 3.5; diff --git a/packages/@n8n/ai-workflow-builder.ee/src/test/workflow-builder-agent.test.ts b/packages/@n8n/ai-workflow-builder.ee/src/test/workflow-builder-agent.test.ts index c222756ae38..de94498f27f 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/test/workflow-builder-agent.test.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/test/workflow-builder-agent.test.ts @@ -354,6 +354,111 @@ describe('WorkflowBuilderAgent', () => { const state = createMockState('Short message', 'Custom Name', 1); expect(shouldModifyState(state, autoCompactThresholdTokens)).toBe('agent'); }); + + it('should return "auto_compact_messages" when messages with workflow context exceed threshold', () => { + // Use a smaller threshold for this test to make it easier to exceed + const smallThreshold = 5000; // tokens + + // Create a long message that when combined with workflow context will exceed threshold + // With AVG_CHARS_PER_TOKEN_ANTHROPIC = 3.5, we need ~17500 characters to exceed 5000 tokens + const longMessage = 'x'.repeat(10000); + const state = createMockState(longMessage, 'Custom Name', 1, 10); + + // Add substantial workflow data that will be included in context + state.workflowJSON = { + nodes: Array.from({ length: 50 }, (_, i) => ({ + id: `node-${i}`, + name: `Node ${i}`, + type: 'n8n-nodes-base.testNode', + typeVersion: 1, + position: [i * 100, i * 100] as [number, number], + parameters: { data: 'x'.repeat(200) }, + })), + connections: {}, + name: 'Test Workflow', + }; + + expect(shouldModifyState(state, smallThreshold)).toBe('auto_compact_messages'); + }); + + it('should return "agent" when messages with workflow context stay below threshold', () => { + const shortMessage = 'Create a simple workflow'; + const state = createMockState(shortMessage, 'Custom Name', 1, 2); + + // Small workflow that won't push us over threshold + state.workflowJSON = { + nodes: [ + { + id: 'node-1', + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [0, 0] as [number, number], + parameters: {}, + }, + ], + connections: {}, + name: 'Simple Workflow', + }; + + expect(shouldModifyState(state, autoCompactThresholdTokens)).toBe('agent'); + }); + + it('should consider execution data and schema in token estimation', () => { + // Use a smaller threshold for this test + const smallThreshold = 3000; // tokens + + const message = 'x'.repeat(3000); + const state = createMockState(message, 'Custom Name', 1, 5); + + // Add execution data and schema that contribute to token count + state.workflowContext = { + ...state.workflowContext, + executionData: { + runData: { + 'node-1': [ + { + data: { + main: [[{ json: { data: 'x'.repeat(2000) } }]], + }, + executionTime: 100, + startTime: Date.now(), + executionIndex: 0, + source: [], + }, + ], + }, + }, + executionSchema: [ + { + nodeName: 'node-1', + schema: { + type: 'object', + value: [ + { key: 'field1', type: 'string', value: 'x'.repeat(1000), path: '.field1' }, + { key: 'field2', type: 'string', value: 'x'.repeat(1000), path: '.field2' }, + ], + path: '', + }, + }, + ], + }; + + state.workflowJSON = { + nodes: Array.from({ length: 20 }, (_, i) => ({ + id: `node-${i}`, + name: `Node ${i}`, + type: 'n8n-nodes-base.testNode', + typeVersion: 1, + position: [i * 100, i * 100] as [number, number], + parameters: { data: 'x'.repeat(100) }, + })), + connections: {}, + name: 'Complex Workflow', + }; + + expect(shouldModifyState(state, smallThreshold)).toBe('auto_compact_messages'); + }); }); }); }); diff --git a/packages/@n8n/ai-workflow-builder.ee/src/workflow-builder-agent.ts b/packages/@n8n/ai-workflow-builder.ee/src/workflow-builder-agent.ts index 850930e23be..31fd4d71396 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/workflow-builder-agent.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/workflow-builder-agent.ts @@ -78,8 +78,14 @@ export function shouldModifyState( return 'create_workflow_name'; } + const workflowContextToAppend = getWorkflowContext(state); + // Check if we should auto-compact based on token count - const estimatedTokens = estimateTokenCountFromMessages(messages); + const estimatedTokens = estimateTokenCountFromMessages([ + ...messages, + // appended later to last message + new HumanMessage(workflowContextToAppend), + ]); if (estimatedTokens > autoCompactThresholdTokens) { return 'auto_compact_messages'; } @@ -87,6 +93,32 @@ export function shouldModifyState( return 'agent'; } +function getWorkflowContext(state: typeof WorkflowState.State) { + const trimmedWorkflow = trimWorkflowJSON(state.workflowJSON); + const executionData = state.workflowContext?.executionData ?? {}; + const executionSchema = state.workflowContext?.executionSchema ?? []; + const workflowContext = [ + '', + '', + JSON.stringify(trimmedWorkflow), + '', + '', + 'Note: Large property values of the nodes in the workflow JSON above may be trimmed to fit within token limits.', + 'Use get_node_parameter tool to get full details when needed.', + '', + '', + '', + JSON.stringify(executionData), + '', + '', + '', + JSON.stringify(executionSchema), + '', + ].join('\n'); + + return workflowContext; +} + export interface WorkflowBuilderAgentConfig { parsedNodeTypes: INodeTypeDescription[]; llmSimpleTask: BaseChatModel; @@ -168,9 +200,6 @@ export class WorkflowBuilderAgent { } const hasPreviousSummary = state.previousSummary && state.previousSummary !== 'EMPTY'; - const trimmedWorkflow = trimWorkflowJSON(state.workflowJSON); - const executionData = state.workflowContext?.executionData ?? {}; - const executionSchema = state.workflowContext?.executionSchema ?? []; const prompt = await mainAgentPrompt.invoke({ ...state, @@ -182,24 +211,7 @@ export class WorkflowBuilderAgent { previousSummary: hasPreviousSummary ? state.previousSummary : '', }); - const workflowContext = [ - '', - '', - JSON.stringify(trimmedWorkflow, null, 2), - '', - '', - 'Note: Large property values of the nodes in the workflow JSON above may be trimmed to fit within token limits.', - 'Use get_node_parameter tool to get full details when needed.', - '', - '', - '', - JSON.stringify(executionData, null, 2), - '', - '', - '', - JSON.stringify(executionSchema, null, 2), - '', - ].join('\n'); + const workflowContext = getWorkflowContext(state); // Optimize prompts for Anthropic's caching by: // 1. Finding all user/tool message positions (cache breakpoints) diff --git a/packages/@n8n/api-types/package.json b/packages/@n8n/api-types/package.json index 3231bf09534..f0122f600d2 100644 --- a/packages/@n8n/api-types/package.json +++ b/packages/@n8n/api-types/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/api-types", - "version": "0.53.0", + "version": "0.54.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 2a980ebcc0e..f54a4341ab3 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -100,4 +100,9 @@ export { AddDataTableRowsDto } from './data-table/add-data-table-rows.dto'; export { AddDataTableColumnDto } from './data-table/add-data-table-column.dto'; export { MoveDataTableColumnDto } from './data-table/move-data-table-column.dto'; +export { + OAuthClientResponseDto, + ListOAuthClientsResponseDto, + DeleteOAuthClientResponseDto, +} from './oauth/oauth-client.dto'; export { ProvisioningConfigDto, ProvisioningConfigPatchDto } from './provisioning/config.dto'; diff --git a/packages/@n8n/api-types/src/dto/oauth/index.ts b/packages/@n8n/api-types/src/dto/oauth/index.ts new file mode 100644 index 00000000000..67fb51efbb6 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/oauth/index.ts @@ -0,0 +1 @@ +export * from './oauth-client.dto'; diff --git a/packages/@n8n/api-types/src/dto/oauth/oauth-client.dto.ts b/packages/@n8n/api-types/src/dto/oauth/oauth-client.dto.ts new file mode 100644 index 00000000000..8b1bef34330 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/oauth/oauth-client.dto.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +/** + * DTO for OAuth client response (excludes sensitive data like clientSecret) + */ +export class OAuthClientResponseDto extends Z.class({ + id: z.string(), + name: z.string(), + redirectUris: z.array(z.string()), + grantTypes: z.array(z.string()), + tokenEndpointAuthMethod: z.string(), + createdAt: z.string().datetime(), // Using string for date serialization over HTTP + updatedAt: z.string().datetime(), +}) {} + +/** + * DTO for listing OAuth clients response + */ +export class ListOAuthClientsResponseDto extends Z.class({ + data: z.array( + z.object({ + id: z.string(), + name: z.string(), + redirectUris: z.array(z.string()), + grantTypes: z.array(z.string()), + tokenEndpointAuthMethod: z.string(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + }), + ), + count: z.number(), +}) {} + +/** + * DTO for deleting an OAuth client response + */ +export class DeleteOAuthClientResponseDto extends Z.class({ + success: z.boolean(), + message: z.string(), +}) {} diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index 42a1b9ea3aa..a42ee6d1067 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -46,7 +46,6 @@ export interface IEnterpriseSettings { showNonProdBanner: boolean; debugInEditor: boolean; binaryDataS3: boolean; - workflowHistory: boolean; workerView: boolean; advancedPermissions: boolean; apiKeyScopes: boolean; diff --git a/packages/@n8n/backend-common/package.json b/packages/@n8n/backend-common/package.json index f4c66494667..0b585b95d63 100644 --- a/packages/@n8n/backend-common/package.json +++ b/packages/@n8n/backend-common/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/backend-common", - "version": "0.29.0", + "version": "0.30.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/backend-common/src/license-state.ts b/packages/@n8n/backend-common/src/license-state.ts index dc3afe261e6..60a1284ab69 100644 --- a/packages/@n8n/backend-common/src/license-state.ts +++ b/packages/@n8n/backend-common/src/license-state.ts @@ -130,10 +130,6 @@ export class LicenseState { return this.isLicensed('feat:externalSecrets'); } - isWorkflowHistoryLicensed() { - return this.isLicensed('feat:workflowHistory'); - } - isAPIDisabled() { return this.isLicensed('feat:apiDisabled'); } diff --git a/packages/@n8n/backend-common/src/modules/module-registry.ts b/packages/@n8n/backend-common/src/modules/module-registry.ts index 0fa576aff16..a119745e181 100644 --- a/packages/@n8n/backend-common/src/modules/module-registry.ts +++ b/packages/@n8n/backend-common/src/modules/module-registry.ts @@ -35,6 +35,7 @@ export class ModuleRegistry { 'data-table', 'provisioning', 'breaking-changes', + 'mcp', ]; private readonly activeModules: string[] = []; diff --git a/packages/@n8n/backend-test-utils/package.json b/packages/@n8n/backend-test-utils/package.json index 4a019aa21e7..abfaf11f83f 100644 --- a/packages/@n8n/backend-test-utils/package.json +++ b/packages/@n8n/backend-test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/backend-test-utils", - "version": "0.22.0", + "version": "0.23.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/backend-test-utils/src/test-db.ts b/packages/@n8n/backend-test-utils/src/test-db.ts index 2cd55af2361..3a1a657ce79 100644 --- a/packages/@n8n/backend-test-utils/src/test-db.ts +++ b/packages/@n8n/backend-test-utils/src/test-db.ts @@ -82,7 +82,12 @@ type EntityName = | 'DataTable' | 'DataTableColumn' | 'ChatHubSession' - | 'ChatHubMessage'; + | 'ChatHubMessage' + | 'OAuthClient' + | 'AuthorizationCode' + | 'AccessToken' + | 'RefreshToken' + | 'UserConsent'; /** * Truncate specific DB tables in a test DB. diff --git a/packages/@n8n/config/package.json b/packages/@n8n/config/package.json index 6f0bac31304..7019a25aa68 100644 --- a/packages/@n8n/config/package.json +++ b/packages/@n8n/config/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/config", - "version": "1.61.0", + "version": "1.62.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/config/src/configs/runners.config.ts b/packages/@n8n/config/src/configs/runners.config.ts index 5ea099dfa53..90f643cdaf3 100644 --- a/packages/@n8n/config/src/configs/runners.config.ts +++ b/packages/@n8n/config/src/configs/runners.config.ts @@ -59,6 +59,14 @@ export class TaskRunnersConfig { @Env('N8N_RUNNERS_TASK_TIMEOUT') taskTimeout: number = 300; // 5 minutes + /** + * How long (in seconds) a task request can wait for a runner to become + * available before timing out. This prevents workflows from hanging + * indefinitely when no runners are available. Must be greater than 0. + */ + @Env('N8N_RUNNERS_TASK_REQUEST_TIMEOUT') + taskRequestTimeout: number = 60; + /** How often (in seconds) the runner must send a heartbeat to the broker, else the task will be aborted. (In internal mode, the runner will also be restarted.) Must be greater than 0. */ @Env('N8N_RUNNERS_HEARTBEAT_INTERVAL') heartbeatInterval: number = 30; diff --git a/packages/@n8n/config/src/configs/workflow-history.config.ts b/packages/@n8n/config/src/configs/workflow-history.config.ts index c2295d89789..2abbdb19cae 100644 --- a/packages/@n8n/config/src/configs/workflow-history.config.ts +++ b/packages/@n8n/config/src/configs/workflow-history.config.ts @@ -2,10 +2,6 @@ import { Config, Env } from '../decorators'; @Config export class WorkflowHistoryConfig { - /** Whether to save workflow history versions. */ - @Env('N8N_WORKFLOW_HISTORY_ENABLED') - enabled: boolean = true; - /** Time (in hours) to keep workflow history versions for. `-1` means forever. */ @Env('N8N_WORKFLOW_HISTORY_PRUNE_TIME') pruneTime: number = -1; diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index a28ba4312d8..aa7b6b1ee9b 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -265,6 +265,7 @@ describe('GlobalConfig', () => { maxOldSpaceSize: '', maxConcurrency: 10, taskTimeout: 300, + taskRequestTimeout: 60, heartbeatInterval: 30, insecureMode: false, isNativePythonRunnerEnabled: false, @@ -361,7 +362,6 @@ describe('GlobalConfig', () => { disabled: false, }, workflowHistory: { - enabled: true, pruneTime: -1, }, sso: { diff --git a/packages/@n8n/constants/src/index.ts b/packages/@n8n/constants/src/index.ts index 18068ae1588..dcfb6a2a96e 100644 --- a/packages/@n8n/constants/src/index.ts +++ b/packages/@n8n/constants/src/index.ts @@ -17,7 +17,6 @@ export const LICENSE_FEATURES = { API_DISABLED: 'feat:apiDisabled', EXTERNAL_SECRETS: 'feat:externalSecrets', SHOW_NON_PROD_BANNER: 'feat:showNonProdBanner', - WORKFLOW_HISTORY: 'feat:workflowHistory', DEBUG_IN_EDITOR: 'feat:debugInEditor', BINARY_DATA_S3: 'feat:binaryDataS3', MULTIPLE_MAIN_INSTANCES: 'feat:multipleMainInstances', @@ -54,6 +53,7 @@ export const LICENSE_QUOTAS = { } as const; export const UNLIMITED_LICENSE_QUOTA = -1; +export const DEFAULT_WORKFLOW_HISTORY_PRUNE_LIMIT = 24; export type BooleanLicenseFeature = (typeof LICENSE_FEATURES)[keyof typeof LICENSE_FEATURES]; export type NumericLicenseFeature = (typeof LICENSE_QUOTAS)[keyof typeof LICENSE_QUOTAS]; diff --git a/packages/@n8n/db/package.json b/packages/@n8n/db/package.json index 38134b6aa12..13aa23e54cf 100644 --- a/packages/@n8n/db/package.json +++ b/packages/@n8n/db/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/db", - "version": "0.30.0", + "version": "0.31.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/db/src/entities/types-db.ts b/packages/@n8n/db/src/entities/types-db.ts index 0b65b2f8952..b9782f38394 100644 --- a/packages/@n8n/db/src/entities/types-db.ts +++ b/packages/@n8n/db/src/entities/types-db.ts @@ -217,7 +217,14 @@ export namespace ListQueryDb { * Slim workflow returned from a list query operation. */ export namespace Workflow { - type OptionalBaseFields = 'name' | 'active' | 'versionId' | 'createdAt' | 'updatedAt' | 'tags'; + type OptionalBaseFields = + | 'name' + | 'active' + | 'versionId' + | 'createdAt' + | 'updatedAt' + | 'tags' + | 'description'; type BaseFields = Pick & Partial>; diff --git a/packages/@n8n/db/src/entities/workflow-entity.ts b/packages/@n8n/db/src/entities/workflow-entity.ts index 7d1469e3fa3..026a0efeb12 100644 --- a/packages/@n8n/db/src/entities/workflow-entity.ts +++ b/packages/@n8n/db/src/entities/workflow-entity.ts @@ -32,6 +32,9 @@ export class WorkflowEntity extends WithTimestampsAndStringId implements IWorkfl @Column({ length: 128 }) name: string; + @Column({ type: 'text', nullable: true }) + description: string | null; + @Column() active: boolean; diff --git a/packages/@n8n/db/src/migrations/common/1760116750277-CreateOAuthEntities.ts b/packages/@n8n/db/src/migrations/common/1760116750277-CreateOAuthEntities.ts new file mode 100644 index 00000000000..6a1bf79e3fa --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1760116750277-CreateOAuthEntities.ts @@ -0,0 +1,108 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +export class CreateOAuthEntities1760116750277 implements ReversibleMigration { + async up({ schemaBuilder: { createTable, column } }: MigrationContext) { + // Create oauth_clients table + await createTable('oauth_clients').withColumns( + column('id').varchar().primary.notNull, + column('name').varchar(255).notNull, + column('redirectUris').json.notNull, + column('grantTypes').json.notNull, + column('clientSecret').varchar(255), + column('clientSecretExpiresAt').bigint, + column('tokenEndpointAuthMethod') + .varchar(255) + .notNull.default("'none'") + .comment('Possible values: none, client_secret_basic or client_secret_post'), + ).withTimestamps; + + // Create oauth_authorization_codes table + await createTable('oauth_authorization_codes') + .withColumns( + column('code').varchar(255).primary.notNull, + column('clientId').varchar().notNull, + column('userId').uuid.notNull, + column('redirectUri').varchar(255).notNull, + column('codeChallenge').varchar(255).notNull, + column('codeChallengeMethod').varchar(255).notNull, + column('expiresAt').bigint.notNull.comment('Unix timestamp in milliseconds'), + column('state').varchar(255), // Should be nullable + column('used').bool.notNull.default(false), + ) + .withForeignKey('clientId', { + tableName: 'oauth_clients', + columnName: 'id', + onDelete: 'CASCADE', + }) + .withForeignKey('userId', { + tableName: 'user', + columnName: 'id', + onDelete: 'CASCADE', + }).withTimestamps; + + // Create oauth_access_tokens table + await createTable('oauth_access_tokens') + .withColumns( + column('token').varchar().primary.notNull, + column('clientId').varchar().notNull, + column('userId').uuid.notNull, + ) + .withForeignKey('clientId', { + tableName: 'oauth_clients', + columnName: 'id', + onDelete: 'CASCADE', + }) + .withForeignKey('userId', { + tableName: 'user', + columnName: 'id', + onDelete: 'CASCADE', + }); + + // Create oauth_refresh_tokens table + await createTable('oauth_refresh_tokens') + .withColumns( + column('token').varchar(255).primary.notNull, + column('clientId').varchar().notNull, + column('userId').uuid.notNull, + column('expiresAt').bigint.notNull.comment('Unix timestamp in milliseconds'), + ) + .withForeignKey('clientId', { + tableName: 'oauth_clients', + columnName: 'id', + onDelete: 'CASCADE', + }) + .withForeignKey('userId', { + tableName: 'user', + columnName: 'id', + onDelete: 'CASCADE', + }).withTimestamps; + + // Create oauth_user_consents table + await createTable('oauth_user_consents') + .withColumns( + column('id').int.primary.autoGenerate2.notNull, + column('userId').uuid.notNull, + column('clientId').varchar().notNull, + column('grantedAt').bigint.notNull.comment('Unix timestamp in milliseconds'), + ) + .withForeignKey('clientId', { + tableName: 'oauth_clients', + columnName: 'id', + onDelete: 'CASCADE', + }) + .withForeignKey('userId', { + tableName: 'user', + columnName: 'id', + onDelete: 'CASCADE', + }) + .withUniqueConstraintOn(['userId', 'clientId']); + } + + async down({ schemaBuilder: { dropTable } }: MigrationContext) { + await dropTable('oauth_user_consents'); + await dropTable('oauth_refresh_tokens'); + await dropTable('oauth_access_tokens'); + await dropTable('oauth_authorization_codes'); + await dropTable('oauth_clients'); + } +} diff --git a/packages/@n8n/db/src/migrations/common/1762177736257-AddWorkflowDescriptionColumn.ts b/packages/@n8n/db/src/migrations/common/1762177736257-AddWorkflowDescriptionColumn.ts new file mode 100644 index 00000000000..b59338d2bb3 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1762177736257-AddWorkflowDescriptionColumn.ts @@ -0,0 +1,11 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +export class AddWorkflowDescriptionColumn1762177736257 implements ReversibleMigration { + async up({ schemaBuilder: { addColumns, column } }: MigrationContext) { + await addColumns('workflow_entity', [column('description').text]); + } + + async down({ schemaBuilder: { dropColumns } }: MigrationContext) { + await dropColumns('workflow_entity', ['description']); + } +} diff --git a/packages/@n8n/db/src/migrations/common/1762763704614-BackfillMissingWorkflowHistoryRecords.ts b/packages/@n8n/db/src/migrations/common/1762763704614-BackfillMissingWorkflowHistoryRecords.ts new file mode 100644 index 00000000000..6a4480603f0 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1762763704614-BackfillMissingWorkflowHistoryRecords.ts @@ -0,0 +1,76 @@ +import type { IrreversibleMigration, MigrationContext } from '../migration-types'; + +export class BackfillMissingWorkflowHistoryRecords1762763704614 implements IrreversibleMigration { + /** + * 1. Generate versionIds for workflows with NULL versionId (only possible for manual inserts) + * 2. Create workflow_history records for all workflows missing them + * 3. Make versionId NOT NULL to ensure data consistency + */ + async up({ escape, runQuery, schemaBuilder }: MigrationContext) { + const workflowTable = escape.tableName('workflow_entity'); + const historyTable = escape.tableName('workflow_history'); + const versionIdColumn = escape.columnName('versionId'); + const idColumn = escape.columnName('id'); + const workflowIdColumn = escape.columnName('workflowId'); + const nodesColumn = escape.columnName('nodes'); + const connectionsColumn = escape.columnName('connections'); + const authorsColumn = escape.columnName('authors'); + const createdAtColumn = escape.columnName('createdAt'); + const updatedAtColumn = escape.columnName('updatedAt'); + + // Step 1: Generate versionIds for workflows that have NULL or empty versionId + const workflowsWithoutVersionId = await runQuery>(` + SELECT ${idColumn} as id + FROM ${workflowTable} + WHERE ${versionIdColumn} IS NULL OR ${versionIdColumn} = '' + `); + + // Running in a loop to avoid using DB-specific syntax for generating UUIDs + for (const workflow of workflowsWithoutVersionId) { + const versionId = crypto.randomUUID(); + await runQuery( + ` + UPDATE ${workflowTable} + SET ${versionIdColumn} = :versionId + WHERE ${idColumn} = :id + `, + { versionId, id: workflow.id }, + ); + } + + // Step 2: Create workflow_history records for workflows missing them + await runQuery( + ` + INSERT INTO ${historyTable} ( + ${versionIdColumn}, + ${workflowIdColumn}, + ${authorsColumn}, + ${nodesColumn}, + ${connectionsColumn}, + ${createdAtColumn}, + ${updatedAtColumn} + ) + SELECT + w.${versionIdColumn}, + w.${idColumn}, + :authors, + w.${nodesColumn}, + w.${connectionsColumn}, + :createdAt, + :updatedAt + FROM ${workflowTable} w + LEFT JOIN ${historyTable} wh + ON w.${versionIdColumn} = wh.${versionIdColumn} + WHERE wh.${versionIdColumn} IS NULL + `, + { + authors: 'system migration', + createdAt: new Date(), + updatedAt: new Date(), + }, + ); + + // Step 3: Make versionId NOT NULL + await schemaBuilder.addNotNull('workflow_entity', 'versionId'); + } +} diff --git a/packages/@n8n/db/src/migrations/common/1762310956274-AddWorkflowVersionIdToExecutionData.ts b/packages/@n8n/db/src/migrations/common/1762858574621-AddWorkflowVersionIdToExecutionData.ts similarity index 86% rename from packages/@n8n/db/src/migrations/common/1762310956274-AddWorkflowVersionIdToExecutionData.ts rename to packages/@n8n/db/src/migrations/common/1762858574621-AddWorkflowVersionIdToExecutionData.ts index 6b550e03da4..a79d47c91b1 100644 --- a/packages/@n8n/db/src/migrations/common/1762310956274-AddWorkflowVersionIdToExecutionData.ts +++ b/packages/@n8n/db/src/migrations/common/1762858574621-AddWorkflowVersionIdToExecutionData.ts @@ -1,6 +1,6 @@ import type { MigrationContext, ReversibleMigration } from '../migration-types'; -export class AddWorkflowVersionIdToExecutionData1762310956274 implements ReversibleMigration { +export class AddWorkflowVersionIdToExecutionData1762858574621 implements ReversibleMigration { async up({ schemaBuilder: { addColumns, column } }: MigrationContext) { await addColumns('execution_data', [column('workflowVersionId').varchar()]); } diff --git a/packages/@n8n/db/src/migrations/mysqldb/index.ts b/packages/@n8n/db/src/migrations/mysqldb/index.ts index f431d19803d..87b41d478c8 100644 --- a/packages/@n8n/db/src/migrations/mysqldb/index.ts +++ b/packages/@n8n/db/src/migrations/mysqldb/index.ts @@ -1,8 +1,3 @@ -import { AddWorkflowVersionIdToExecutionData1762310956274 } from './../common/1762310956274-AddWorkflowVersionIdToExecutionData'; -import { DropUnusedChatHubColumns1760965142113 } from './1760965142113-DropUnusedChatHubColumns'; -import { AddAudienceColumnToApiKeys1758731786132 } from '../common/1758731786132-AddAudienceColumnToApiKey'; -import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000000-CreateWorkflowDependencyTable'; -import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns'; import { InitialMigration1588157391238 } from './1588157391238-InitialMigration'; import { WebhookModel1592447867632 } from './1592447867632-WebhookModel'; import { CreateIndexStoppedAt1594902918301 } from './1594902918301-CreateIndexStoppedAt'; @@ -49,12 +44,15 @@ import { MigrateTestDefinitionKeyToString1731582748663 } from './1731582748663-M import { CreateTestMetricTable1732271325258 } from './1732271325258-CreateTestMetricTable'; import { AddStatsColumnsToTestRun1736172058779 } from './1736172058779-AddStatsColumnsToTestRun'; import { FixTestDefinitionPrimaryKey1739873751194 } from './1739873751194-FixTestDefinitionPrimaryKey'; +import { UpdateParentFolderIdColumn1740445074052 } from './1740445074052-UpdateParentFolderIdColumn'; import { AddProjectIdToVariableTable1758794506893 } from './1758794506893-AddProjectIdToVariableTable'; +import { DropUnusedChatHubColumns1760965142113 } from './1760965142113-DropUnusedChatHubColumns'; import { AddWorkflowVersionColumn1761047826451 } from './1761047826451-AddWorkflowVersionColumn'; import { ChangeDependencyInfoToJson1761655473000 } from './1761655473000-ChangeDependencyInfoToJson'; import { CreateLdapEntities1674509946020 } from '../common/1674509946020-CreateLdapEntities'; import { PurgeInvalidWorkflowConnections1675940580449 } from '../common/1675940580449-PurgeInvalidWorkflowConnections'; import { RemoveResetPasswordColumns1690000000030 } from '../common/1690000000030-RemoveResetPasswordColumns'; +import { AddMfaColumns1690000000030 } from '../common/1690000000040-AddMfaColumns'; import { CreateWorkflowNameIndex1691088862123 } from '../common/1691088862123-CreateWorkflowNameIndex'; import { CreateWorkflowHistoryTable1692967111175 } from '../common/1692967111175-CreateWorkflowHistoryTable'; import { ExecutionSoftDelete1693491613982 } from '../common/1693491613982-ExecutionSoftDelete'; @@ -100,16 +98,21 @@ import { AddRolesTables1750252139167 } from '../common/1750252139167-AddRolesTab import { LinkRoleToUserTable1750252139168 } from '../common/1750252139168-LinkRoleToUserTable'; import { RemoveOldRoleColumn1750252139170 } from '../common/1750252139170-RemoveOldRoleColumn'; import { AddInputsOutputsToTestCaseExecution1752669793000 } from '../common/1752669793000-AddInputsOutputsToTestCaseExecution'; +import { LinkRoleToProjectRelationTable1753953244168 } from '../common/1753953244168-LinkRoleToProjectRelationTable'; import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables'; import { ReplaceDataStoreTablesWithDataTables1754475614602 } from '../common/1754475614602-ReplaceDataStoreTablesWithDataTables'; import { AddTimestampsToRoleAndRoleIndexes1756906557570 } from '../common/1756906557570-AddTimestampsToRoleAndRoleIndexes'; +import { AddAudienceColumnToApiKeys1758731786132 } from '../common/1758731786132-AddAudienceColumnToApiKey'; import { ChangeValueTypesForInsights1759399811000 } from '../common/1759399811000-ChangeValueTypesForInsights'; import { CreateChatHubTables1760019379982 } from '../common/1760019379982-CreateChatHubTables'; import { CreateChatHubAgentTable1760020000000 } from '../common/1760020000000-CreateChatHubAgentTable'; import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRoleNames'; +import { CreateOAuthEntities1760116750277 } from '../common/1760116750277-CreateOAuthEntities'; +import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000000-CreateWorkflowDependencyTable'; +import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn'; +import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords'; +import { AddWorkflowVersionIdToExecutionData1762858574621 } from '../common/1762858574621-AddWorkflowVersionIdToExecutionData'; import type { Migration } from '../migration-types'; -import { UpdateParentFolderIdColumn1740445074052 } from '../mysqldb/1740445074052-UpdateParentFolderIdColumn'; -import { LinkRoleToProjectRelationTable1753953244168 } from './../common/1753953244168-LinkRoleToProjectRelationTable'; export const mysqlMigrations: Migration[] = [ InitialMigration1588157391238, @@ -222,5 +225,8 @@ export const mysqlMigrations: Migration[] = [ DropUnusedChatHubColumns1760965142113, AddWorkflowVersionColumn1761047826451, ChangeDependencyInfoToJson1761655473000, - AddWorkflowVersionIdToExecutionData1762310956274, + AddWorkflowDescriptionColumn1762177736257, + CreateOAuthEntities1760116750277, + BackfillMissingWorkflowHistoryRecords1762763704614, + AddWorkflowVersionIdToExecutionData1762858574621, ]; diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index d69263380f1..f5d9bc4a01b 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -1,6 +1,3 @@ -import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns'; -import { AddInputsOutputsToTestCaseExecution1752669793000 } from './../common/1752669793000-AddInputsOutputsToTestCaseExecution'; -import { LinkRoleToProjectRelationTable1753953244168 } from './../common/1753953244168-LinkRoleToProjectRelationTable'; import { InitialMigration1587669153312 } from './1587669153312-InitialMigration'; import { WebhookModel1589476000887 } from './1589476000887-WebhookModel'; import { CreateIndexStoppedAt1594828256133 } from './1594828256133-CreateIndexStoppedAt'; @@ -51,6 +48,7 @@ import { ChangeDependencyInfoToJson1761655473000 } from './1761655473000-ChangeD import { CreateLdapEntities1674509946020 } from '../common/1674509946020-CreateLdapEntities'; import { PurgeInvalidWorkflowConnections1675940580449 } from '../common/1675940580449-PurgeInvalidWorkflowConnections'; import { RemoveResetPasswordColumns1690000000030 } from '../common/1690000000030-RemoveResetPasswordColumns'; +import { AddMfaColumns1690000000030 } from '../common/1690000000040-AddMfaColumns'; import { CreateWorkflowNameIndex1691088862123 } from '../common/1691088862123-CreateWorkflowNameIndex'; import { CreateWorkflowHistoryTable1692967111175 } from '../common/1692967111175-CreateWorkflowHistoryTable'; import { ExecutionSoftDelete1693491613982 } from '../common/1693491613982-ExecutionSoftDelete'; @@ -97,6 +95,8 @@ import { AddScopeTables1750252139166 } from '../common/1750252139166-AddScopeTab import { AddRolesTables1750252139167 } from '../common/1750252139167-AddRolesTables'; import { LinkRoleToUserTable1750252139168 } from '../common/1750252139168-LinkRoleToUserTable'; import { RemoveOldRoleColumn1750252139170 } from '../common/1750252139170-RemoveOldRoleColumn'; +import { AddInputsOutputsToTestCaseExecution1752669793000 } from '../common/1752669793000-AddInputsOutputsToTestCaseExecution'; +import { LinkRoleToProjectRelationTable1753953244168 } from '../common/1753953244168-LinkRoleToProjectRelationTable'; import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables'; import { ReplaceDataStoreTablesWithDataTables1754475614602 } from '../common/1754475614602-ReplaceDataStoreTablesWithDataTables'; import { AddTimestampsToRoleAndRoleIndexes1756906557570 } from '../common/1756906557570-AddTimestampsToRoleAndRoleIndexes'; @@ -105,9 +105,12 @@ import { ChangeValueTypesForInsights1759399811000 } from '../common/175939981100 import { CreateChatHubTables1760019379982 } from '../common/1760019379982-CreateChatHubTables'; import { CreateChatHubAgentTable1760020000000 } from '../common/1760020000000-CreateChatHubAgentTable'; import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRoleNames'; +import { CreateOAuthEntities1760116750277 } from '../common/1760116750277-CreateOAuthEntities'; import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000000-CreateWorkflowDependencyTable'; import { DropUnusedChatHubColumns1760965142113 } from '../common/1760965142113-DropUnusedChatHubColumns'; -import { AddWorkflowVersionIdToExecutionData1762310956274 } from '../common/1762310956274-AddWorkflowVersionIdToExecutionData'; +import { AddWorkflowVersionIdToExecutionData1762858574621 } from '../common/1762858574621-AddWorkflowVersionIdToExecutionData'; +import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn'; +import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords'; import type { Migration } from '../migration-types'; export const postgresMigrations: Migration[] = [ @@ -220,5 +223,8 @@ export const postgresMigrations: Migration[] = [ DropUnusedChatHubColumns1760965142113, AddWorkflowVersionColumn1761047826451, ChangeDependencyInfoToJson1761655473000, - AddWorkflowVersionIdToExecutionData1762310956274, + AddWorkflowDescriptionColumn1762177736257, + CreateOAuthEntities1760116750277, + BackfillMissingWorkflowHistoryRecords1762763704614, + AddWorkflowVersionIdToExecutionData1762858574621, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index 2d06a9448db..5817a728d5d 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -1,4 +1,3 @@ -import { AddAudienceColumnToApiKeys1758731786132 } from './../common/1758731786132-AddAudienceColumnToApiKey'; import { InitialMigration1588102412422 } from './1588102412422-InitialMigration'; import { WebhookModel1592445003908 } from './1592445003908-WebhookModel'; import { CreateIndexStoppedAt1594825041918 } from './1594825041918-CreateIndexStoppedAt'; @@ -44,6 +43,7 @@ import { MigrateTestDefinitionKeyToString1731582748663 } from './1731582748663-M import { CreateFolderTable1738709609940 } from './1738709609940-CreateFolderTable'; import { UpdateParentFolderIdColumn1740445074052 } from './1740445074052-UpdateParentFolderIdColumn'; import { AddScopesColumnToApiKeys1742918400000 } from './1742918400000-AddScopesColumnToApiKeys'; +import { AddProjectIdToVariableTable1758794506893 } from './1758794506893-AddProjectIdToVariableTable'; import { AddWorkflowVersionColumn1761047826451 } from './1761047826451-AddWorkflowVersionColumn'; import { ChangeDependencyInfoToJson1761655473000 } from './1761655473000-ChangeDependencyInfoToJson'; import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames'; @@ -93,19 +93,22 @@ import { AddRolesTables1750252139167 } from '../common/1750252139167-AddRolesTab import { LinkRoleToUserTable1750252139168 } from '../common/1750252139168-LinkRoleToUserTable'; import { RemoveOldRoleColumn1750252139170 } from '../common/1750252139170-RemoveOldRoleColumn'; import { AddInputsOutputsToTestCaseExecution1752669793000 } from '../common/1752669793000-AddInputsOutputsToTestCaseExecution'; +import { LinkRoleToProjectRelationTable1753953244168 } from '../common/1753953244168-LinkRoleToProjectRelationTable'; import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables'; import { ReplaceDataStoreTablesWithDataTables1754475614602 } from '../common/1754475614602-ReplaceDataStoreTablesWithDataTables'; import { AddTimestampsToRoleAndRoleIndexes1756906557570 } from '../common/1756906557570-AddTimestampsToRoleAndRoleIndexes'; +import { AddAudienceColumnToApiKeys1758731786132 } from '../common/1758731786132-AddAudienceColumnToApiKey'; import { ChangeValueTypesForInsights1759399811000 } from '../common/1759399811000-ChangeValueTypesForInsights'; -import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRoleNames'; -import type { Migration } from '../migration-types'; -import { LinkRoleToProjectRelationTable1753953244168 } from './../common/1753953244168-LinkRoleToProjectRelationTable'; -import { AddProjectIdToVariableTable1758794506893 } from './1758794506893-AddProjectIdToVariableTable'; import { CreateChatHubTables1760019379982 } from '../common/1760019379982-CreateChatHubTables'; import { CreateChatHubAgentTable1760020000000 } from '../common/1760020000000-CreateChatHubAgentTable'; +import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRoleNames'; +import { CreateOAuthEntities1760116750277 } from '../common/1760116750277-CreateOAuthEntities'; import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000000-CreateWorkflowDependencyTable'; import { DropUnusedChatHubColumns1760965142113 } from '../common/1760965142113-DropUnusedChatHubColumns'; -import { AddWorkflowVersionIdToExecutionData1762310956274 } from '../common/1762310956274-AddWorkflowVersionIdToExecutionData'; +import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn'; +import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords'; +import { AddWorkflowVersionIdToExecutionData1762858574621 } from '../common/1762858574621-AddWorkflowVersionIdToExecutionData'; +import type { Migration } from '../migration-types'; const sqliteMigrations: Migration[] = [ InitialMigration1588102412422, @@ -214,7 +217,10 @@ const sqliteMigrations: Migration[] = [ DropUnusedChatHubColumns1760965142113, AddWorkflowVersionColumn1761047826451, ChangeDependencyInfoToJson1761655473000, - AddWorkflowVersionIdToExecutionData1762310956274, + AddWorkflowDescriptionColumn1762177736257, + CreateOAuthEntities1760116750277, + BackfillMissingWorkflowHistoryRecords1762763704614, + AddWorkflowVersionIdToExecutionData1762858574621, ]; export { sqliteMigrations }; diff --git a/packages/@n8n/db/src/repositories/user.repository.ts b/packages/@n8n/db/src/repositories/user.repository.ts index 0ebe9ef2b7d..f20e992fd46 100644 --- a/packages/@n8n/db/src/repositories/user.repository.ts +++ b/packages/@n8n/db/src/repositories/user.repository.ts @@ -12,9 +12,15 @@ export class UserRepository extends Repository { super(User, dataSource.manager); } - async findManyByIds(userIds: string[]) { + async findManyByIds( + userIds: string[], + options?: { + includeRole: boolean; + }, + ) { return await this.find({ where: { id: In(userIds) }, + relations: options?.includeRole ? ['role'] : undefined, }); } diff --git a/packages/@n8n/db/src/repositories/workflow-history.repository.ts b/packages/@n8n/db/src/repositories/workflow-history.repository.ts index 06e69b2fed0..9127e271fdc 100644 --- a/packages/@n8n/db/src/repositories/workflow-history.repository.ts +++ b/packages/@n8n/db/src/repositories/workflow-history.repository.ts @@ -1,7 +1,7 @@ import { Service } from '@n8n/di'; import { DataSource, LessThan, Repository } from '@n8n/typeorm'; -import { WorkflowHistory } from '../entities'; +import { WorkflowHistory, WorkflowEntity } from '../entities'; @Service() export class WorkflowHistoryRepository extends Repository { @@ -12,4 +12,24 @@ export class WorkflowHistoryRepository extends Repository { async deleteEarlierThan(date: Date) { return await this.delete({ createdAt: LessThan(date) }); } + + /** + * Delete workflow history records earlier than a given date, except for current workflow versions. + */ + async deleteEarlierThanExceptCurrent(date: Date) { + const currentVersionIdsSubquery = this.manager + .createQueryBuilder() + .subQuery() + .select('w.versionId') + .from(WorkflowEntity, 'w') + .getQuery(); + + return await this.manager + .createQueryBuilder() + .delete() + .from(WorkflowHistory) + .where('createdAt < :date', { date }) + .andWhere(`versionId NOT IN (${currentVersionIdsSubquery})`) + .execute(); + } } diff --git a/packages/@n8n/db/src/repositories/workflow.repository.ts b/packages/@n8n/db/src/repositories/workflow.repository.ts index 1ba40bfb522..a07e3f8efec 100644 --- a/packages/@n8n/db/src/repositories/workflow.repository.ts +++ b/packages/@n8n/db/src/repositories/workflow.repository.ts @@ -147,27 +147,56 @@ export class WorkflowRepository extends Repository { } private buildBaseUnionQuery(workflowIds: string[], options: ListQuery.Options = {}) { - const subQueryParameters: ListQuery.Options = { + // Common fields for both folders and workflows + const commonFields = { + createdAt: true, + updatedAt: true, + id: true, + name: true, + } as const; + + // Transform `query` => `name` for folder repository + const folderFilter = options.filter ? { ...options.filter } : undefined; + if (folderFilter?.query) { + folderFilter.name = folderFilter.query; + } + + const folderQueryParameters: ListQuery.Options = { + select: commonFields, + filter: folderFilter, + }; + + const workflowQueryParameters: ListQuery.Options = { select: { - createdAt: true, + ...commonFields, + description: true, + // For some reason the order of updatedAt and createdAt here is load-bearing + // and the generated sql queries below risk switching up the order otherwise + // depending on whether this code is called for a project or the overview + // A proper fix would sort the columnNames here and in the folder and workflow queries + // but that risks breaking other use cases + // https://linear.app/n8n/issue/ADO-4376/tech-debt-investigate-and-fix-root-cause-of-incorrect-sql-column updatedAt: true, + createdAt: true, id: true, name: true, }, filter: options.filter, }; - const columnNames = [...Object.keys(subQueryParameters.select ?? {}), 'resource']; + // For union, we need to have the same columns, so add NULL as description for folders + const columnNames = [...Object.keys(workflowQueryParameters.select ?? {}), 'resource']; const [sortByColumn, sortByDirection] = this.parseSortingParams( options.sortBy ?? 'updatedAt:asc', ); const foldersQuery = this.folderRepository - .getManyQuery(subQueryParameters) + .getManyQuery(folderQueryParameters) + .addSelect('NULL', 'description') // Add NULL for description in folders .addSelect("'folder'", 'resource'); - const workflowsQuery = this.getManyQuery(workflowIds, subQueryParameters).addSelect( + const workflowsQuery = this.getManyQuery(workflowIds, workflowQueryParameters).addSelect( "'workflow'", 'resource', ); @@ -290,7 +319,7 @@ export class WorkflowRepository extends Repository { typeof options.filter?.parentFolderId === 'string' && options.filter.parentFolderId !== PROJECT_ROOT && typeof options.filter?.projectId === 'string' && - options.filter.name + options.filter.query ) { const folderIds = await this.folderRepository.getAllFolderIdsInHierarchy( options.filter.parentFolderId, @@ -472,10 +501,14 @@ export class WorkflowRepository extends Repository { qb: SelectQueryBuilder, filter: ListQuery.Options['filter'], ): void { - if (typeof filter?.name === 'string' && filter.name !== '') { - qb.andWhere('LOWER(workflow.name) LIKE :name', { - name: `%${filter.name.toLowerCase()}%`, - }); + const searchValue = filter?.query; + + if (typeof searchValue === 'string' && searchValue !== '') { + const searchTerm = `%${searchValue.toLowerCase()}%`; + qb.andWhere( + "(LOWER(workflow.name) LIKE :searchTerm OR LOWER(COALESCE(workflow.description, '')) LIKE :searchTerm)", + { searchTerm }, + ); } } @@ -607,6 +640,7 @@ export class WorkflowRepository extends Repository { 'workflow.updatedAt', 'workflow.versionId', 'workflow.settings', + 'workflow.description', ]); return; } diff --git a/packages/@n8n/decorators/package.json b/packages/@n8n/decorators/package.json index 6fc8c56af61..9013b16eb3c 100644 --- a/packages/@n8n/decorators/package.json +++ b/packages/@n8n/decorators/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/decorators", - "version": "0.29.0", + "version": "0.30.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/decorators/src/controller/index.ts b/packages/@n8n/decorators/src/controller/index.ts index 879f4f5076d..6b91dcd6a7e 100644 --- a/packages/@n8n/decorators/src/controller/index.ts +++ b/packages/@n8n/decorators/src/controller/index.ts @@ -1,9 +1,9 @@ export { Body, Query, Param } from './args'; export { RestController } from './rest-controller'; export { RootLevelController } from './root-level-controller'; -export { Get, Post, Put, Patch, Delete } from './route'; +export { Get, Post, Put, Patch, Delete, Head, Options } from './route'; export { Middleware } from './middleware'; export { ControllerRegistryMetadata } from './controller-registry-metadata'; export { Licensed } from './licensed'; export { GlobalScope, ProjectScope } from './scoped'; -export type { AccessScope, Controller, RateLimit } from './types'; +export type { AccessScope, Controller, RateLimit, StaticRouterMetadata } from './types'; diff --git a/packages/@n8n/decorators/src/controller/route.ts b/packages/@n8n/decorators/src/controller/route.ts index f0b8490bd14..02f89968460 100644 --- a/packages/@n8n/decorators/src/controller/route.ts +++ b/packages/@n8n/decorators/src/controller/route.ts @@ -42,3 +42,5 @@ export const Post = RouteFactory('post'); export const Put = RouteFactory('put'); export const Patch = RouteFactory('patch'); export const Delete = RouteFactory('delete'); +export const Head = RouteFactory('head'); +export const Options = RouteFactory('options'); diff --git a/packages/@n8n/decorators/src/controller/types.ts b/packages/@n8n/decorators/src/controller/types.ts index 7d127360576..b9ebfc445bb 100644 --- a/packages/@n8n/decorators/src/controller/types.ts +++ b/packages/@n8n/decorators/src/controller/types.ts @@ -1,9 +1,9 @@ import type { BooleanLicenseFeature } from '@n8n/constants'; import type { Constructable } from '@n8n/di'; import type { Scope } from '@n8n/permissions'; -import type { RequestHandler } from 'express'; +import type { RequestHandler, Router } from 'express'; -export type Method = 'get' | 'post' | 'put' | 'patch' | 'delete'; +export type Method = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head' | 'options'; export type Arg = { type: 'body' | 'query' } | { type: 'param'; key: string }; @@ -40,8 +40,29 @@ export interface RouteMetadata { licenseFeature?: BooleanLicenseFeature; accessScope?: AccessScope; args: Arg[]; + router?: Router; } +/** + * Metadata for static routers mounted on a controller. + * Picks relevant fields from RouteMetadata and makes router required. + */ +export type StaticRouterMetadata = { + path: string; + router: Router; +} & Partial< + Pick< + RouteMetadata, + | 'skipAuth' + | 'allowSkipPreviewAuth' + | 'allowSkipMFA' + | 'middlewares' + | 'rateLimit' + | 'licenseFeature' + | 'accessScope' + > +>; + export interface ControllerMetadata { basePath: `/${string}`; // If true, the controller will be registered on the root path without the any prefix diff --git a/packages/@n8n/decorators/src/redactable.ts b/packages/@n8n/decorators/src/redactable.ts index 3f54253a031..cc9b1d6dae2 100644 --- a/packages/@n8n/decorators/src/redactable.ts +++ b/packages/@n8n/decorators/src/redactable.ts @@ -5,7 +5,7 @@ type UserLike = { email?: string; firstName?: string; lastName?: string; - role: { + role?: { slug: string; }; }; @@ -24,7 +24,7 @@ function toRedactable(userLike: UserLike) { _email: userLike.email, _firstName: userLike.firstName, _lastName: userLike.lastName, - globalRole: userLike.role.slug, + globalRole: userLike.role?.slug, }; } diff --git a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAwsBedrock/EmbeddingsAwsBedrock.node.ts b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAwsBedrock/EmbeddingsAwsBedrock.node.ts index 545662bbdd1..dedd31e5e0f 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAwsBedrock/EmbeddingsAwsBedrock.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAwsBedrock/EmbeddingsAwsBedrock.node.ts @@ -1,4 +1,10 @@ +import type { BedrockRuntimeClientConfig } from '@aws-sdk/client-bedrock-runtime'; +import { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime'; import { BedrockEmbeddings } from '@langchain/aws'; +import { NodeHttpHandler } from '@smithy/node-http-handler'; +import { getNodeProxyAgent } from '@utils/httpProxyAgent'; +import { logWrapper } from '@utils/logWrapper'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; import { NodeConnectionTypes, type INodeType, @@ -7,9 +13,6 @@ import { type SupplyData, } from 'n8n-workflow'; -import { logWrapper } from '@utils/logWrapper'; -import { getConnectionHintNoticeField } from '@utils/sharedFields'; - export class EmbeddingsAwsBedrock implements INodeType { description: INodeTypeDescription = { displayName: 'Embeddings AWS Bedrock', @@ -104,18 +107,37 @@ export class EmbeddingsAwsBedrock implements INodeType { }; async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { - const credentials = await this.getCredentials('aws'); + const credentials = await this.getCredentials<{ + region: string; + secretAccessKey: string; + accessKeyId: string; + sessionToken: string; + }>('aws'); const modelName = this.getNodeParameter('model', itemIndex) as string; + const clientConfig: BedrockRuntimeClientConfig = { + region: credentials.region, + credentials: { + secretAccessKey: credentials.secretAccessKey, + accessKeyId: credentials.accessKeyId, + sessionToken: credentials.sessionToken, + }, + }; + + const proxyAgent = getNodeProxyAgent(); + if (proxyAgent) { + clientConfig.requestHandler = new NodeHttpHandler({ + httpAgent: proxyAgent, + httpsAgent: proxyAgent, + }); + } + + const client = new BedrockRuntimeClient(clientConfig); const embeddings = new BedrockEmbeddings({ - region: credentials.region as string, + client, model: modelName, maxRetries: 3, - credentials: { - secretAccessKey: credentials.secretAccessKey as string, - accessKeyId: credentials.accessKeyId as string, - sessionToken: credentials.sessionToken as string, - }, + region: credentials.region, }); return { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts index 903d973d50a..7859c4dba7b 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts @@ -1,4 +1,9 @@ +import type { BedrockRuntimeClientConfig } from '@aws-sdk/client-bedrock-runtime'; +import { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime'; import { ChatBedrockConverse } from '@langchain/aws'; +import { NodeHttpHandler } from '@smithy/node-http-handler'; +import { getNodeProxyAgent } from '@utils/httpProxyAgent'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; import { NodeConnectionTypes, type INodeType, @@ -7,9 +12,6 @@ import { type SupplyData, } from 'n8n-workflow'; -import { getProxyAgent } from '@utils/httpProxyAgent'; -import { getConnectionHintNoticeField } from '@utils/sharedFields'; - import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; import { N8nLlmTracing } from '../N8nLlmTracing'; @@ -222,26 +224,45 @@ export class LmChatAwsBedrock implements INodeType { }; async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { - const credentials = await this.getCredentials('aws'); + const credentials = await this.getCredentials<{ + region: string; + secretAccessKey: string; + accessKeyId: string; + sessionToken: string; + }>('aws'); const modelName = this.getNodeParameter('model', itemIndex) as string; const options = this.getNodeParameter('options', itemIndex, {}) as { temperature: number; maxTokensToSample: number; }; + // We set-up client manually to pass httpAgent and httpsAgent + const proxyAgent = getNodeProxyAgent(); + const clientConfig: BedrockRuntimeClientConfig = { + region: credentials.region, + credentials: { + secretAccessKey: credentials.secretAccessKey, + accessKeyId: credentials.accessKeyId, + ...(credentials.sessionToken && { sessionToken: credentials.sessionToken }), + }, + }; + + if (proxyAgent) { + clientConfig.requestHandler = new NodeHttpHandler({ + httpAgent: proxyAgent, + httpsAgent: proxyAgent, + }); + } + + // Pass the pre-configured client to avoid credential resolution proxy issues + const client = new BedrockRuntimeClient(clientConfig); + const model = new ChatBedrockConverse({ - region: credentials.region as string, + client, model: modelName, + region: credentials.region, temperature: options.temperature, maxTokens: options.maxTokensToSample, - clientConfig: { - httpAgent: getProxyAgent(), - }, - credentials: { - secretAccessKey: credentials.secretAccessKey as string, - accessKeyId: credentials.accessKeyId as string, - sessionToken: credentials.sessionToken as string, - }, callbacks: [new N8nLlmTracing(this)], onFailedAttempt: makeN8nLlmFailedAttemptHandler(this), }); diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/McpClientTool.node.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/McpClientTool.node.ts index 5004c577b68..8948957f13b 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/McpClientTool.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/McpClientTool.node.ts @@ -1,3 +1,4 @@ +import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; import { type IDataObject, type IExecuteFunctions, @@ -394,7 +395,7 @@ export class McpClientTool implements INodeType { this.logger.debug('McpClientTool: Successfully connected to MCP Server'); - if (!mcpTools || !mcpTools.length) { + if (!mcpTools?.length) { return setError( 'MCP Server returned no tools', 'Connected successfully to your MCP server but it returned an empty list of tools.', @@ -461,7 +462,9 @@ export class McpClientTool implements INodeType { name: tool.name, arguments: toolArguments, }; - const result = await client.callTool(params); + const result = await client.callTool(params, CallToolResultSchema, { + timeout: config.timeout, + }); returnData.push({ json: { response: result.content as IDataObject, diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/__test__/McpClientTool.node.test.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/__test__/McpClientTool.node.test.ts index 80d59c309b9..b97e2dbab84 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/__test__/McpClientTool.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/__test__/McpClientTool.node.test.ts @@ -1,8 +1,9 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; -import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; -import { mock } from 'jest-mock-extended'; +import { McpError, ErrorCode, CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; +import { mock, mockDeep } from 'jest-mock-extended'; import { + type IExecuteFunctions, NodeConnectionTypes, NodeOperationError, type ILoadOptionsFunctions, @@ -463,10 +464,14 @@ describe('McpClientTool', () => { ], ]); - expect(Client.prototype.callTool).toHaveBeenCalledWith({ - name: 'get_weather', - arguments: { location: 'Berlin' }, - }); + expect(Client.prototype.callTool).toHaveBeenCalledWith( + { + name: 'get_weather', + arguments: { location: 'Berlin' }, + }, + expect.anything(), + expect.anything(), + ); }); it('should not execute if tool name does not match', async () => { @@ -667,5 +672,53 @@ describe('McpClientTool', () => { expect(result[0]).toHaveLength(1); expect(result[0][0].json.response).toEqual([{ type: 'text', text: 'Weather is sunny' }]); }); + + it('should execute tool with timeout', async () => { + jest.spyOn(Client.prototype, 'connect').mockResolvedValue(); + jest.spyOn(Client.prototype, 'callTool').mockResolvedValue({ + content: [{ type: 'text', text: 'Weather is sunny' }], + }); + jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({ + tools: [ + { + name: 'get_weather', + description: 'Gets the weather', + inputSchema: { type: 'object', properties: { location: { type: 'string' } } }, + }, + ], + }); + const mockNode = mock({ typeVersion: 1.2, type: 'mcpClientTool' }); + const mockExecuteFunctions = mockDeep(); + mockExecuteFunctions.getNode.mockReturnValue(mockNode); + mockExecuteFunctions.getInputData.mockReturnValue([ + { + json: { + tool: 'get_weather', + location: 'Berlin', + }, + }, + ]); + mockExecuteFunctions.getNodeParameter.mockImplementation((key, _idx, defaultValue) => { + const params = { + include: 'all', + authentication: 'none', + serverTransport: 'httpStreamable', + endpointUrl: 'https://test.com/mcp', + 'options.timeout': 12345, + }; + return params[key as keyof typeof params] ?? defaultValue; + }); + + await new McpClientTool().execute.call(mockExecuteFunctions); + + expect(Client.prototype.callTool).toHaveBeenCalledWith( + { + name: 'get_weather', + arguments: { location: 'Berlin' }, + }, + CallToolResultSchema, + { timeout: 12345 }, + ); + }); }); }); diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index 22d47bbd4ef..2fe4e8861e6 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/n8n-nodes-langchain", - "version": "1.118.0", + "version": "1.119.0", "description": "", "main": "index.js", "scripts": { diff --git a/packages/@n8n/nodes-langchain/utils/httpProxyAgent.ts b/packages/@n8n/nodes-langchain/utils/httpProxyAgent.ts index bc894830cae..68342d8d26a 100644 --- a/packages/@n8n/nodes-langchain/utils/httpProxyAgent.ts +++ b/packages/@n8n/nodes-langchain/utils/httpProxyAgent.ts @@ -1,15 +1,28 @@ +import { HttpsProxyAgent } from 'https-proxy-agent'; import proxyFromEnv from 'proxy-from-env'; import { ProxyAgent } from 'undici'; +/** + * Resolves the proxy URL from environment variables for a given target URL. + * + * @param targetUrl - The target URL to check proxy configuration for (optional) + * @returns The proxy URL string or undefined if no proxy is configured + * + * @remarks + * There are cases where we don't know the target URL in advance (e.g. when we need to provide a proxy agent to ChatAwsBedrock). + * In such case we use a dummy URL. + * This will lead to `NO_PROXY` environment variable not being respected, but it is better than not having a proxy agent at all. + */ +function getProxyUrlFromEnv(targetUrl?: string): string { + return proxyFromEnv.getProxyForUrl(targetUrl ?? 'https://example.nonexistent/'); +} + /** * Returns a ProxyAgent or undefined based on the environment variables and target URL. * When target URL is not provided, NO_PROXY environment variable is not respected. */ export function getProxyAgent(targetUrl?: string) { - // There are cases where we don't know the target URL in advance (e.g. when we need to provide a proxy agent to ChatAwsBedrock) - // In such case we use a dummy URL. - // This will lead to `NO_PROXY` environment variable not being respected, but it is better than not having a proxy agent at all. - const proxyUrl = proxyFromEnv.getProxyForUrl(targetUrl ?? 'https://example.nonexistent/'); + const proxyUrl = getProxyUrlFromEnv(targetUrl); if (!proxyUrl) { return undefined; @@ -17,3 +30,20 @@ export function getProxyAgent(targetUrl?: string) { return new ProxyAgent(proxyUrl); } + +/** + * Returns a Node.js HTTP/HTTPS proxy agent for use with AWS SDK v3 clients. + * AWS SDK v3 requires Node.js http.Agent/https.Agent instances (not undici ProxyAgent). + * + * @param targetUrl - The target URL to check proxy configuration for + * @returns HttpsProxyAgent instance or undefined if no proxy is configured + */ +export function getNodeProxyAgent(targetUrl?: string) { + const proxyUrl = getProxyUrlFromEnv(targetUrl); + + if (!proxyUrl) { + return undefined; + } + + return new HttpsProxyAgent(proxyUrl); +} diff --git a/packages/@n8n/permissions/package.json b/packages/@n8n/permissions/package.json index 9424b3df471..291cbca68fc 100644 --- a/packages/@n8n/permissions/package.json +++ b/packages/@n8n/permissions/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/permissions", - "version": "0.41.0", + "version": "0.42.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index 9890040c0ad..f9e83b1ff4d 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -32,7 +32,7 @@ export const RESOURCES = { execution: ['delete', 'read', 'retry', 'list', 'get'] as const, workflowTags: ['update', 'list'] as const, role: ['manage'] as const, - mcp: ['manage'] as const, + mcp: ['manage', 'oauth'] as const, mcpApiKey: ['create', 'rotate'] as const, chatHub: ['manage', 'message'] as const, chatHubAgent: [...DEFAULT_OPERATIONS] as const, diff --git a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts index 22853a71d59..2f3720f3040 100644 --- a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts @@ -100,6 +100,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [ 'dataTable:writeRow', 'role:manage', 'mcp:manage', + 'mcp:oauth', 'mcpApiKey:create', 'mcpApiKey:rotate', 'chatHub:manage', @@ -130,6 +131,7 @@ export const GLOBAL_MEMBER_SCOPES: Scope[] = [ 'variable:list', 'variable:read', 'dataTable:list', + 'mcp:oauth', 'mcpApiKey:create', 'mcpApiKey:rotate', 'chatHub:message', diff --git a/packages/@n8n/task-runner/package.json b/packages/@n8n/task-runner/package.json index 4df86090ec3..d761fa05cae 100644 --- a/packages/@n8n/task-runner/package.json +++ b/packages/@n8n/task-runner/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/task-runner", - "version": "1.55.0", + "version": "1.56.0", "scripts": { "clean": "rimraf dist .turbo", "start": "node dist/start.js", diff --git a/packages/@n8n/task-runner/src/message-types.ts b/packages/@n8n/task-runner/src/message-types.ts index 40f7aeca77a..046c7495fdb 100644 --- a/packages/@n8n/task-runner/src/message-types.ts +++ b/packages/@n8n/task-runner/src/message-types.ts @@ -87,6 +87,12 @@ export namespace BrokerMessage { error: unknown; } + export interface RequestExpired { + type: 'broker:requestexpired'; + requestId: string; + reason: 'timeout'; + } + export interface TaskDataRequest { type: 'broker:taskdatarequest'; taskId: string; @@ -109,7 +115,14 @@ export namespace BrokerMessage { params: unknown[]; } - export type All = TaskReady | TaskDone | TaskError | TaskDataRequest | NodeTypesRequest | RPC; + export type All = + | TaskReady + | TaskDone + | TaskError + | RequestExpired + | TaskDataRequest + | NodeTypesRequest + | RPC; } } diff --git a/packages/cli/package.json b/packages/cli/package.json index 23aa86e0507..cb88e7d84e6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.119.0", + "version": "1.120.0", "description": "n8n Workflow Automation Tool", "main": "dist/index", "types": "dist/index.d.ts", @@ -20,17 +20,17 @@ "start": "run-script-os", "start:default": "cd bin && ./n8n", "start:windows": "cd bin && n8n", - "test": "N8N_LOG_LEVEL=silent DB_TYPE=sqlite jest", - "test:unit": "N8N_LOG_LEVEL=silent DB_TYPE=sqlite jest --config=jest.config.unit.js", - "test:integration": "N8N_LOG_LEVEL=silent DB_TYPE=sqlite jest --config=jest.config.integration.js", - "test:dev": "N8N_LOG_LEVEL=silent DB_TYPE=sqlite jest --watch", - "test:sqlite": "N8N_LOG_LEVEL=silent DB_TYPE=sqlite jest --config=jest.config.integration.js --no-coverage", + "test": "N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest", + "test:unit": "N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest --config=jest.config.unit.js", + "test:integration": "N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest --config=jest.config.integration.js", + "test:dev": "N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest --watch", + "test:sqlite": "N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest --config=jest.config.integration.js --no-coverage", "test:postgres": "N8N_LOG_LEVEL=silent DB_TYPE=postgresdb DB_POSTGRESDB_SCHEMA=alt_schema DB_TABLE_PREFIX=test_ jest --config=jest.config.integration.js --no-coverage", "test:mariadb": "N8N_LOG_LEVEL=silent DB_TYPE=mariadb DB_TABLE_PREFIX=test_ jest --config=jest.config.integration.js --no-coverage", "test:mysql": "N8N_LOG_LEVEL=silent DB_TYPE=mysqldb DB_TABLE_PREFIX=test_ jest --config=jest.config.integration.js --no-coverage --maxWorkers=1", - "test:win": "set N8N_LOG_LEVEL=silent&& set DB_TYPE=sqlite&& jest", - "test:dev:win": "set N8N_LOG_LEVEL=silent&& set DB_TYPE=sqlite&& jest --watch", - "test:sqlite:win": "set N8N_LOG_LEVEL=silent&& set DB_TYPE=sqlite&& jest --config=jest.config.integration.js", + "test:win": "set N8N_LOG_LEVEL=silent&& set DB_SQLITE_POOL_SIZE=4&& set DB_TYPE=sqlite&& jest", + "test:dev:win": "set N8N_LOG_LEVEL=silent&& set DB_SQLITE_POOL_SIZE=4&& set DB_TYPE=sqlite&& jest --watch", + "test:sqlite:win": "set N8N_LOG_LEVEL=silent&& set DB_SQLITE_POOL_SIZE=4&& set DB_TYPE=sqlite&& jest --config=jest.config.integration.js", "test:postgres:win": "set N8N_LOG_LEVEL=silent&& set DB_TYPE=postgresdb&& set DB_POSTGRESDB_SCHEMA=alt_schema&& set DB_TABLE_PREFIX=test_&& jest --config=jest.config.integration.js --no-coverage", "test:mariadb:win": "set N8N_LOG_LEVEL=silent&& set DB_TYPE=mariadb&& set DB_TABLE_PREFIX=test_&& jest --config=jest.config.integration.js --no-coverage", "test:mysql:win": "set N8N_LOG_LEVEL=silent&& set DB_TYPE=mysqldb&& set DB_TABLE_PREFIX=test_&& jest --config=jest.config.integration.js --no-coverage", diff --git a/packages/cli/src/auth/auth.service.ts b/packages/cli/src/auth/auth.service.ts index f122baf1167..77457a096ec 100644 --- a/packages/cli/src/auth/auth.service.ts +++ b/packages/cli/src/auth/auth.service.ts @@ -86,6 +86,7 @@ export class AuthService { // Skip browser ID check for type files '/types/nodes.json', '/types/credentials.json', + '/mcp-oauth/authorize/', ]; } diff --git a/packages/cli/src/commands/base-command.ts b/packages/cli/src/commands/base-command.ts index 6f2033c57f8..641e25f352f 100644 --- a/packages/cli/src/commands/base-command.ts +++ b/packages/cli/src/commands/base-command.ts @@ -35,7 +35,7 @@ import { CommunityPackagesConfig } from '@/modules/community-packages/community- import { NodeTypes } from '@/node-types'; import { PostHogClient } from '@/posthog'; import { ShutdownService } from '@/shutdown/shutdown.service'; -import { WorkflowHistoryManager } from '@/workflows/workflow-history.ee/workflow-history-manager.ee'; +import { WorkflowHistoryManager } from '@/workflows/workflow-history/workflow-history-manager'; export abstract class BaseCommand { readonly flags: F; diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 3d798111c9a..7e2880cd787 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -86,6 +86,7 @@ export const GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE = [ 'googleOAuth2Api', 'microsoftOAuth2Api', 'highLevelOAuth2Api', + 'mcpOAuth2Api', ]; export const ARTIFICIAL_TASK_DATA = { diff --git a/packages/cli/src/controller.registry.ts b/packages/cli/src/controller.registry.ts index 148f53056c6..ccfe721d326 100644 --- a/packages/cli/src/controller.registry.ts +++ b/packages/cli/src/controller.registry.ts @@ -4,7 +4,7 @@ import { GlobalConfig } from '@n8n/config'; import { type BooleanLicenseFeature } from '@n8n/constants'; import type { AuthenticatedRequest } from '@n8n/db'; import { ControllerRegistryMetadata } from '@n8n/decorators'; -import type { AccessScope, Controller, RateLimit } from '@n8n/decorators'; +import type { AccessScope, Controller, RateLimit, StaticRouterMetadata } from '@n8n/decorators'; import { Container, Service } from '@n8n/di'; import { Router } from 'express'; import type { Application, Request, Response, RequestHandler } from 'express'; @@ -19,7 +19,7 @@ import { AuthService } from '@/auth/auth.service'; import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.error'; import { License } from '@/license'; import { userHasScopes } from '@/permissions.ee/check-access'; -import { send } from '@/response-helper'; // TODO: move `ResponseHelper.send` to this file +import { send } from '@/response-helper'; @Service() export class ControllerRegistry { @@ -52,7 +52,23 @@ export class ControllerRegistry { (handlerName) => controller[handlerName].bind(controller) as RequestHandler, ); + const staticRouters = (controllerClass as any).routers as StaticRouterMetadata[] | undefined; + + if (staticRouters) { + for (const routerConfig of staticRouters) { + if (!routerConfig.router) { + throw new UnexpectedError( + `Router is undefined for path "${routerConfig.path}" in controller "${controllerClass.name}"`, + ); + } + const middlewares = this.buildMiddlewares(routerConfig, controllerMiddlewares); + router.use(routerConfig.path, ...middlewares, routerConfig.router); + } + } + + // Register regular routes for (const [handlerName, route] of metadata.routes) { + // Original handler logic for non-router routes const argTypes = Reflect.getMetadata( 'design:paramtypes', controller, @@ -63,7 +79,7 @@ export class ControllerRegistry { const args: unknown[] = [req, res]; for (let index = 0; index < route.args.length; index++) { const arg = route.args[index]; - if (!arg) continue; // Skip args without any decorators + if (!arg) continue; if (arg.type === 'param') args.push(req.params[arg.key]); else if (['body', 'query'].includes(arg.type)) { const paramType = argTypes[index] as ZodClass; @@ -79,34 +95,64 @@ export class ControllerRegistry { return await controller[handlerName](...args); }; - router[route.method]( - route.path, - ...(inProduction && route.rateLimit - ? [this.createRateLimitMiddleware(route.rateLimit)] - : []), + const middlewares = this.buildMiddlewares(route, controllerMiddlewares); + const finalHandler = route.usesTemplates + ? async (req: Request, res: Response) => { + await handler(req, res); + } + : send(handler); - ...(route.skipAuth - ? [] - : ([ - this.authService.createAuthMiddleware({ - allowSkipMFA: route.allowSkipMFA, - allowSkipPreviewAuth: route.allowSkipPreviewAuth, - }), - this.lastActiveAtService.middleware.bind(this.lastActiveAtService), - ] as RequestHandler[])), - ...(route.licenseFeature ? [this.createLicenseMiddleware(route.licenseFeature)] : []), - ...(route.accessScope ? [this.createScopedMiddleware(route.accessScope)] : []), - ...controllerMiddlewares, - ...route.middlewares, - route.usesTemplates - ? async (req, res) => { - // When using templates, intentionally drop the return value, - // since template rendering writes directly to the response. - await handler(req, res); - } - : send(handler), + router[route.method](route.path, ...middlewares, finalHandler); + } + } + + /** + * Builds middleware array based on route configuration. + * Used for both static routers and inline router definitions. + */ + private buildMiddlewares( + route: { + skipAuth?: boolean; + allowSkipMFA?: boolean; + allowSkipPreviewAuth?: boolean; + rateLimit?: boolean | RateLimit; + licenseFeature?: BooleanLicenseFeature; + accessScope?: AccessScope; + middlewares?: RequestHandler[]; + }, + controllerMiddlewares: RequestHandler[], + ): RequestHandler[] { + const middlewares: RequestHandler[] = []; + + if (inProduction && route.rateLimit) { + middlewares.push(this.createRateLimitMiddleware(route.rateLimit)); + } + + if (!route.skipAuth) { + middlewares.push( + this.authService.createAuthMiddleware({ + allowSkipMFA: route.allowSkipMFA ?? false, + allowSkipPreviewAuth: route.allowSkipPreviewAuth ?? false, + }), + this.lastActiveAtService.middleware.bind(this.lastActiveAtService) as RequestHandler, ); } + + if (route.licenseFeature) { + middlewares.push(this.createLicenseMiddleware(route.licenseFeature)); + } + + if (route.accessScope) { + middlewares.push(this.createScopedMiddleware(route.accessScope)); + } + + middlewares.push(...controllerMiddlewares); + + if (route.middlewares) { + middlewares.push(...route.middlewares); + } + + return middlewares; } private createRateLimitMiddleware(rateLimit: true | RateLimit): RequestHandler { diff --git a/packages/cli/src/controllers/auth.controller.ts b/packages/cli/src/controllers/auth.controller.ts index 9892bf0d106..617614c3d01 100644 --- a/packages/cli/src/controllers/auth.controller.ts +++ b/packages/cli/src/controllers/auth.controller.ts @@ -150,7 +150,9 @@ export class AuthController { throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); } - const users = await this.userRepository.findManyByIds([inviterId, inviteeId]); + const users = await this.userRepository.findManyByIds([inviterId, inviteeId], { + includeRole: true, + }); if (users.length !== 2) { this.logger.debug( diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index 275a49f2e43..4529fb25736 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -95,7 +95,6 @@ export class E2EController { [LICENSE_FEATURES.API_DISABLED]: false, [LICENSE_FEATURES.EXTERNAL_SECRETS]: false, [LICENSE_FEATURES.SHOW_NON_PROD_BANNER]: false, - [LICENSE_FEATURES.WORKFLOW_HISTORY]: false, [LICENSE_FEATURES.DEBUG_IN_EDITOR]: false, [LICENSE_FEATURES.BINARY_DATA_S3]: false, [LICENSE_FEATURES.MULTIPLE_MAIN_INSTANCES]: false, diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index 6de204395f6..b9ed1513952 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -16,7 +16,7 @@ export type UserLike = { email?: string; firstName?: string; lastName?: string; - role: { + role?: { slug: string; }; }; diff --git a/packages/cli/src/license.ts b/packages/cli/src/license.ts index 1bb5100fc7e..db7280f0b6c 100644 --- a/packages/cli/src/license.ts +++ b/packages/cli/src/license.ts @@ -2,6 +2,7 @@ import type { LicenseProvider } from '@n8n/backend-common'; import { Logger } from '@n8n/backend-common'; import { GlobalConfig } from '@n8n/config'; import { + DEFAULT_WORKFLOW_HISTORY_PRUNE_LIMIT, LICENSE_FEATURES, LICENSE_QUOTAS, Time, @@ -300,11 +301,6 @@ export class License implements LicenseProvider { return this.isLicensed(LICENSE_FEATURES.EXTERNAL_SECRETS); } - /** @deprecated Use `LicenseState.isWorkflowHistoryLicensed` instead. */ - isWorkflowHistoryLicensed() { - return this.isLicensed(LICENSE_FEATURES.WORKFLOW_HISTORY); - } - /** @deprecated Use `LicenseState.isAPIDisabled` instead. */ isAPIDisabled() { return this.isLicensed(LICENSE_FEATURES.API_DISABLED); @@ -403,7 +399,10 @@ export class License implements LicenseProvider { /** @deprecated Use `LicenseState` instead. */ getWorkflowHistoryPruneLimit() { - return this.getValue(LICENSE_QUOTAS.WORKFLOW_HISTORY_PRUNE_LIMIT) ?? UNLIMITED_LICENSE_QUOTA; + return ( + this.getValue(LICENSE_QUOTAS.WORKFLOW_HISTORY_PRUNE_LIMIT) ?? + DEFAULT_WORKFLOW_HISTORY_PRUNE_LIMIT + ); } /** @deprecated Use `LicenseState` instead. */ diff --git a/packages/cli/src/middlewares/list-query/__tests__/list-query.test.ts b/packages/cli/src/middlewares/list-query/__tests__/list-query.test.ts index cc99b738d40..6bc0ee1c082 100644 --- a/packages/cli/src/middlewares/list-query/__tests__/list-query.test.ts +++ b/packages/cli/src/middlewares/list-query/__tests__/list-query.test.ts @@ -38,20 +38,20 @@ describe('List query middleware', () => { }); test('should parse valid filter', async () => { - mockReq.query = { filter: '{ "name": "My Workflow" }' }; + mockReq.query = { filter: '{ "query": "My Workflow" }' }; await filterListQueryMiddleware(...args); - expect(mockReq.listQueryOptions).toEqual({ filter: { name: 'My Workflow' } }); + expect(mockReq.listQueryOptions).toEqual({ filter: { query: 'My Workflow' } }); expect(nextFn).toBeCalledTimes(1); }); test('should ignore invalid filter', async () => { - mockReq.query = { filter: '{ "name": "My Workflow", "foo": "bar" }' }; + mockReq.query = { filter: '{ "query": "My Workflow", "foo": "bar" }' }; await filterListQueryMiddleware(...args); - expect(mockReq.listQueryOptions).toEqual({ filter: { name: 'My Workflow' } }); + expect(mockReq.listQueryOptions).toEqual({ filter: { query: 'My Workflow' } }); expect(nextFn).toBeCalledTimes(1); }); @@ -64,7 +64,7 @@ describe('List query middleware', () => { }); test('should throw on valid filter with invalid type', async () => { - mockReq.query = { filter: '{ "name" : 123 }' }; + mockReq.query = { filter: '{ "query" : 123 }' }; await filterListQueryMiddleware(...args); @@ -257,27 +257,27 @@ describe('List query middleware', () => { describe('Combinations', () => { test('should combine filter with select', async () => { - mockReq.query = { filter: '{ "name": "My Workflow" }', select: '["name", "id"]' }; + mockReq.query = { filter: '{ "query": "My Workflow" }', select: '["name", "id"]' }; await filterListQueryMiddleware(...args); await selectListQueryMiddleware(...args); expect(mockReq.listQueryOptions).toEqual({ select: { name: true, id: true }, - filter: { name: 'My Workflow' }, + filter: { query: 'My Workflow' }, }); expect(nextFn).toBeCalledTimes(2); }); test('should combine filter with pagination options', async () => { - mockReq.query = { filter: '{ "name": "My Workflow" }', skip: '1', take: '2' }; + mockReq.query = { filter: '{ "query": "My Workflow" }', skip: '1', take: '2' }; await filterListQueryMiddleware(...args); await paginationListQueryMiddleware(...args); expect(mockReq.listQueryOptions).toEqual({ - filter: { name: 'My Workflow' }, + filter: { query: 'My Workflow' }, skip: 1, take: 2, }); diff --git a/packages/cli/src/middlewares/list-query/dtos/workflow.filter.dto.ts b/packages/cli/src/middlewares/list-query/dtos/workflow.filter.dto.ts index 46f80be7604..9366c56d574 100644 --- a/packages/cli/src/middlewares/list-query/dtos/workflow.filter.dto.ts +++ b/packages/cli/src/middlewares/list-query/dtos/workflow.filter.dto.ts @@ -7,7 +7,7 @@ export class WorkflowFilter extends BaseFilter { @IsString() @IsOptional() @Expose() - name?: string; + query?: string; @IsBoolean() @IsOptional() diff --git a/packages/cli/src/middlewares/list-query/dtos/workflow.select.dto.ts b/packages/cli/src/middlewares/list-query/dtos/workflow.select.dto.ts index 237a59f171e..e827e5903cb 100644 --- a/packages/cli/src/middlewares/list-query/dtos/workflow.select.dto.ts +++ b/packages/cli/src/middlewares/list-query/dtos/workflow.select.dto.ts @@ -14,6 +14,7 @@ export class WorkflowSelect extends BaseSelect { 'parentFolder', 'nodes', 'isArchived', + 'description', ]); } diff --git a/packages/cli/src/modules/insights/__tests__/insights.controller.test.ts b/packages/cli/src/modules/insights/__tests__/insights.controller.test.ts index 6c6f9200120..dcc7a116e6c 100644 --- a/packages/cli/src/modules/insights/__tests__/insights.controller.test.ts +++ b/packages/cli/src/modules/insights/__tests__/insights.controller.test.ts @@ -28,7 +28,7 @@ afterAll(async () => { describe('InsightsController', () => { const insightsByPeriodRepository = mockInstance(InsightsByPeriodRepository); let controller: InsightsController; - const sixDaysAgo = DateTime.now().minus({ days: 6 }).toJSDate(); + const sevenDaysAgo = DateTime.now().minus({ days: 7 }).toJSDate(); const today = DateTime.now().toJSDate(); const licenseState = mock(); @@ -62,7 +62,7 @@ describe('InsightsController', () => { const callArgs = insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates.mock.calls[0][0]; - expectDatesClose(callArgs.startDate, sixDaysAgo); + expectDatesClose(callArgs.startDate, sevenDaysAgo); expectDatesClose(callArgs.endDate, today); expect(response).toEqual({ @@ -102,7 +102,7 @@ describe('InsightsController', () => { const callArgs = insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates.mock.calls[0][0]; - expectDatesClose(callArgs.startDate, sixDaysAgo); + expectDatesClose(callArgs.startDate, sevenDaysAgo); expectDatesClose(callArgs.endDate, today); expect(response).toEqual({ @@ -146,7 +146,7 @@ describe('InsightsController', () => { const callArgs = insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates.mock.calls[0][0]; - expectDatesClose(callArgs.startDate, sixDaysAgo); + expectDatesClose(callArgs.startDate, sevenDaysAgo); expectDatesClose(callArgs.endDate, today); expect(response).toEqual({ @@ -399,7 +399,7 @@ describe('InsightsController', () => { }); const callArgs = insightsByPeriodRepository.getInsightsByWorkflow.mock.calls[0][0]; - expectDatesClose(callArgs.startDate, sixDaysAgo); + expectDatesClose(callArgs.startDate, sevenDaysAgo); expectDatesClose(callArgs.endDate, today); expect(response).toEqual({ count: 0, data: [] }); @@ -433,7 +433,7 @@ describe('InsightsController', () => { }); const callArgs = insightsByPeriodRepository.getInsightsByWorkflow.mock.calls[0][0]; - expectDatesClose(callArgs.startDate, sixDaysAgo); + expectDatesClose(callArgs.startDate, sevenDaysAgo); expectDatesClose(callArgs.endDate, today); expect(response).toEqual({ count: 3, data: mockRows }); @@ -671,7 +671,7 @@ describe('InsightsController', () => { }); const callArgs = insightsByPeriodRepository.getInsightsByTime.mock.calls[0][0]; - expectDatesClose(callArgs.startDate, sixDaysAgo); + expectDatesClose(callArgs.startDate, sevenDaysAgo); expectDatesClose(callArgs.endDate, today); expect(response).toEqual([]); @@ -694,7 +694,7 @@ describe('InsightsController', () => { }); const callArgs = insightsByPeriodRepository.getInsightsByTime.mock.calls[0][0]; - expectDatesClose(callArgs.startDate, sixDaysAgo); + expectDatesClose(callArgs.startDate, sevenDaysAgo); expectDatesClose(callArgs.endDate, today); expect(response).toEqual(expectedResponse); @@ -740,7 +740,7 @@ describe('InsightsController', () => { }); const callArgs = insightsByPeriodRepository.getInsightsByTime.mock.calls[0][0]; - expectDatesClose(callArgs.startDate, sixDaysAgo); + expectDatesClose(callArgs.startDate, sevenDaysAgo); expectDatesClose(callArgs.endDate, today); expect(response).toEqual(expectedResponse); @@ -887,7 +887,7 @@ describe('InsightsController', () => { }); const callArgs = insightsByPeriodRepository.getInsightsByTime.mock.calls[0][0]; - expectDatesClose(callArgs.startDate, sixDaysAgo); + expectDatesClose(callArgs.startDate, sevenDaysAgo); expectDatesClose(callArgs.endDate, today); expect(response).toEqual(expectedResponse); @@ -912,7 +912,7 @@ describe('InsightsController', () => { }); const callArgs = insightsByPeriodRepository.getInsightsByTime.mock.calls[0][0]; - expectDatesClose(callArgs.startDate, sixDaysAgo); + expectDatesClose(callArgs.startDate, sevenDaysAgo); expectDatesClose(callArgs.endDate, today); expect(response).toEqual(expectedResponse); diff --git a/packages/cli/src/modules/insights/__tests__/insights.service.integration.test.ts b/packages/cli/src/modules/insights/__tests__/insights.service.integration.test.ts index d43b908ff36..4a9be88cbf8 100644 --- a/packages/cli/src/modules/insights/__tests__/insights.service.integration.test.ts +++ b/packages/cli/src/modules/insights/__tests__/insights.service.integration.test.ts @@ -375,7 +375,7 @@ describe('InsightsService (Integration)', () => { type: 'success', value: 1, periodUnit: 'hour', - periodStart: now.minus({ days: 1 }), + periodStart: now.minus({ days: 2 }), }); // Out of date range insight (should not be included) @@ -553,7 +553,7 @@ describe('InsightsService (Integration)', () => { type: 'success', value: 1, periodUnit: 'hour', - periodStart: now.minus({ days: 14 }).startOf('day'), + periodStart: now.minus({ days: 14 }).endOf('day'), }); // Out of date range insight (should not be included) @@ -714,7 +714,7 @@ describe('InsightsService (Integration)', () => { type: workflow === workflow1 ? 'success' : 'failure', value: 1, periodUnit: 'hour', - periodStart: now.minus({ days: 14 }).startOf('day'), + periodStart: now.minus({ days: 14 }).endOf('day'), }); // Out of date range insight (should not be included) @@ -872,7 +872,7 @@ describe('InsightsService (Integration)', () => { type: workflow === workflow1 ? 'success' : 'failure', value: 1, periodUnit: 'hour', - periodStart: now.minus({ days: 14 }).startOf('day'), + periodStart: now.minus({ days: 14 }).endOf('day'), }); // Out of date range insight (should not be included) diff --git a/packages/cli/src/modules/insights/database/repositories/__tests__/insights-by-period-query.helper.test.ts b/packages/cli/src/modules/insights/database/repositories/__tests__/insights-by-period-query.helper.test.ts index 3d600ab6e48..601c90e5e07 100644 --- a/packages/cli/src/modules/insights/database/repositories/__tests__/insights-by-period-query.helper.test.ts +++ b/packages/cli/src/modules/insights/database/repositories/__tests__/insights-by-period-query.helper.test.ts @@ -1,76 +1,10 @@ import type { DatabaseConfig } from '@n8n/config'; import { DateTime } from 'luxon'; -import { getDateRangesCommonTableExpressionQuery } from '../insights-by-period-query.helper'; - -function expectLastXDaysDateRangeQuery(params: { - result: string; - dbType: DatabaseConfig['type']; - prevStartDateOffset: number; - startDateOffset: number; -}) { - const { result, dbType, prevStartDateOffset: prev, startDateOffset: start } = params; - - if (dbType === 'sqlite') { - expect(result).toContain(`datetime('now', '-${prev} days', 'start of day') AS prev_start_date`); - expect(result).toContain(`datetime('now', '-${start} days', 'start of day') AS start_date`); - expect(result).toContain("datetime('now') AS end_date"); - } else if (dbType === 'postgresdb') { - expect(result).toContain( - `DATE_TRUNC('day', NOW() - INTERVAL '${prev} days') AS prev_start_date`, - ); - expect(result).toContain(`DATE_TRUNC('day', NOW() - INTERVAL '${start} days') AS start_date`); - expect(result).toContain('NOW() AS end_date'); - } else { - expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${prev} DAY)) AS prev_start_date`); - expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${start} DAY)) AS start_date`); - expect(result).toContain('NOW() AS end_date'); - } -} - -function expectStartOfDayDateRangeQuery(params: { - result: string; - dbType: DatabaseConfig['type']; - prevStartDateOffset: number; - startDateOffset: number; - endDateOffset: number; -}) { - const { - result, - dbType, - prevStartDateOffset: prev, - startDateOffset: start, - endDateOffset: end, - } = params; - - if (dbType === 'sqlite') { - expect(result).toContain(`datetime('now', '-${prev} days', 'start of day') AS prev_start_date`); - expect(result).toContain(`datetime('now', '-${start} days', 'start of day') AS start_date`); - if (end !== 0) { - expect(result).toContain(`datetime('now', '-${end} days', 'start of day') AS end_date`); - } else { - expect(result).toContain("datetime('now', 'start of day') AS end_date"); - } - } else if (dbType === 'postgresdb') { - expect(result).toContain( - `DATE_TRUNC('day', NOW() - INTERVAL '${prev} days') AS prev_start_date`, - ); - expect(result).toContain(`DATE_TRUNC('day', NOW() - INTERVAL '${start} days') AS start_date`); - if (end !== 0) { - expect(result).toContain(`DATE_TRUNC('day', NOW() - INTERVAL '${end} days') AS end_date`); - } else { - expect(result).toContain("DATE_TRUNC('day', NOW()) AS end_date"); - } - } else { - expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${prev} DAY)) AS prev_start_date`); - expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${start} DAY)) AS start_date`); - if (end !== 0) { - expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${end} DAY)) AS end_date`); - } else { - expect(result).toContain('DATE(NOW()) AS end_date'); - } - } -} +import { + getDateRangesCommonTableExpressionQuery, + getDateRangesSelectQuery, +} from '../insights-by-period-query.helper'; describe('getDateRangesCommonTableExpressionQuery', () => { const now = DateTime.utc(2025, 10, 8, 8, 51, 27); @@ -92,195 +26,273 @@ describe('getDateRangesCommonTableExpressionQuery', () => { ])('%s', (dbType: DatabaseConfig['type']) => { describe('hour periodicity (1 day - startDate == endDate)', () => { test('last 24 hours (endDate is today)', () => { - const startDate = now.startOf('day').toJSDate(); + const startDate = now.minus({ days: 1 }).startOf('day').toJSDate(); const endDate = now.startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, + dbType, + }); - if (dbType === 'sqlite') { - expect(result).toContain("datetime('now', '-2 days')"); // prev_start_date - expect(result).toContain("datetime('now', '-1 days')"); // start_date - expect(result).toContain("datetime('now')"); // end_date - } else if (dbType === 'postgresdb') { - expect(result).toContain("NOW() - INTERVAL '2 days'"); // prev_start_date - expect(result).toContain("NOW() - INTERVAL '1 days'"); // start_date - expect(result).toContain('NOW()'); // end_date - } else { - expect(result).toContain('DATE_SUB(NOW(), INTERVAL 2 DAY)'); // prev_start_date - expect(result).toContain('DATE_SUB(NOW(), INTERVAL 1 DAY)'); // start_date - expect(result).toContain('NOW()'); // end_date - } + // endDate is today but different day from startDate, so dates stay as-is + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 2 }).startOf('day'), + startDateTime: now.minus({ days: 1 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); - test('yesterday (specific day)', () => { - const startDate = now.minus({ days: 1 }).startOf('day').toJSDate(); + test('day before yesterday (specific day)', () => { + const startDate = now.minus({ days: 2 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 2, - startDateOffset: 1, - endDateOffset: 0, // the end of the range is the start of the next day }); + + // Past range: end+1 day startOf('day') + // Duration = 2 days, so prev starts 2 days before start + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 4 }).startOf('day'), + startDateTime: now.minus({ days: 2 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); test('7 days ago (specific day)', () => { const startDate = now.minus({ days: 7 }).startOf('day').toJSDate(); - const endDate = now.minus({ days: 7 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 6 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 8, - startDateOffset: 7, - endDateOffset: 6, // the end of the range is the start of the next day }); + + // Past range: end+1 day startOf('day') + // Duration = 2 days, so prev starts 2 days before start + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 9 }).startOf('day'), + startDateTime: now.minus({ days: 7 }).startOf('day'), + endDateTime: now.minus({ days: 5 }).startOf('day'), + }); + expect(result).toBe(expected); }); test('14 days ago (specific day)', () => { const startDate = now.minus({ days: 14 }).startOf('day').toJSDate(); - const endDate = now.minus({ days: 14 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 13 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 15, - startDateOffset: 14, - endDateOffset: 13, // the end of the range is the start of the next day }); + + // Past range: end+1 day startOf('day') + // Duration = 2 days, so prev starts 2 days before start + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 16 }).startOf('day'), + startDateTime: now.minus({ days: 14 }).startOf('day'), + endDateTime: now.minus({ days: 12 }).startOf('day'), + }); + expect(result).toBe(expected); }); test('X days ago (specific day far in the past)', () => { // 109 days ago (2025-06-21) const startDate = now.minus({ days: 109 }).startOf('day').toJSDate(); - const endDate = now.minus({ days: 109 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 108 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 110, - startDateOffset: 109, - endDateOffset: 108, // the end of the range is the start of the next day }); + + // Past range: end+1 day startOf('day') + // Duration = 2 days, so prev starts 2 days before start + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 111 }).startOf('day'), + startDateTime: now.minus({ days: 109 }).startOf('day'), + endDateTime: now.minus({ days: 107 }).startOf('day'), + }); + expect(result).toBe(expected); }); }); describe('day periodicity (2-30 days)', () => { describe('last X days (endDate is today)', () => { test('last 7 days', () => { - const startDate = now.minus({ days: 6 }).startOf('day').toJSDate(); + const startDate = now.minus({ days: 7 }).startOf('day').toJSDate(); const endDate = now.startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectLastXDaysDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 13, // 6 + 7 - startDateOffset: 6, // today - 6 days ago = 7 days range }); + + // endDate is today but different day, dates stay as-is + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 14 }).startOf('day'), + startDateTime: now.minus({ days: 7 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); test('last 14 days', () => { - const startDate = now.minus({ days: 13 }).startOf('day').toJSDate(); + const startDate = now.minus({ days: 14 }).startOf('day').toJSDate(); const endDate = now.startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectLastXDaysDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 27, // 13 + 14 - startDateOffset: 13, // today - 13 days ago = 14 days range }); + + // endDate is today but different day, dates stay as-is + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 28 }).startOf('day'), + startDateTime: now.minus({ days: 14 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); test('last 30 days', () => { - const startDate = now.minus({ days: 29 }).startOf('day').toJSDate(); + const startDate = now.minus({ days: 30 }).startOf('day').toJSDate(); const endDate = now.startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectLastXDaysDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 59, // 29 + 30 - startDateOffset: 29, // today - 29 days ago = 30 days range }); + + // endDate is today but different day, dates stay as-is + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 60 }).startOf('day'), + startDateTime: now.minus({ days: 30 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); }); describe('specific historical range', () => { test('2 day range', () => { - const startDate = now.minus({ days: 2 }).startOf('day').toJSDate(); + const startDate = now.minus({ days: 3 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 4, // 2 + 2 - startDateOffset: 2, - endDateOffset: 0, // the end of the range is the start of the next day }); + + // Past range: end+1 day, duration = 3 days + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 6 }).startOf('day'), + startDateTime: now.minus({ days: 3 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); test('5 days range', () => { const startDate = now.minus({ days: 10 }).startOf('day').toJSDate(); - const endDate = now.minus({ days: 6 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 5 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 15, // 10 + 5 - startDateOffset: 10, - endDateOffset: 5, // the end of the range is the start of the next day }); + + // Past range: end+1 day, duration = 6 days + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 16 }).startOf('day'), + startDateTime: now.minus({ days: 10 }).startOf('day'), + endDateTime: now.minus({ days: 4 }).startOf('day'), + }); + expect(result).toBe(expected); }); test('7 days range', () => { - const startDate = now.minus({ days: 12 }).startOf('day').toJSDate(); - const endDate = now.minus({ days: 6 }).startOf('day').toJSDate(); + const startDate = now.minus({ days: 14 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 7 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 19, // 12 + 7 - startDateOffset: 12, - endDateOffset: 5, // the end of the range is the start of the next day }); + + // Past range: end+1 day, duration = 8 days + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 22 }).startOf('day'), + startDateTime: now.minus({ days: 14 }).startOf('day'), + endDateTime: now.minus({ days: 6 }).startOf('day'), + }); + expect(result).toBe(expected); }); test('14 days range', () => { - const startDate = now.minus({ days: 14 }).startOf('day').toJSDate(); + const startDate = now.minus({ days: 15 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 28, // 14 + 14 - startDateOffset: 14, - endDateOffset: 0, // the end of the range is the start of the next day }); + + // Past range: end+1 day, duration = 15 days + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 30 }).startOf('day'), + startDateTime: now.minus({ days: 15 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); test('30 days range', () => { - const startDate = now.minus({ days: 52 }).startOf('day').toJSDate(); + const startDate = now.minus({ days: 53 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 23 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 82, // 52 + 30 - startDateOffset: 52, - endDateOffset: 22, // the end of the range is the start of the next day }); + + // Past range: end+1 day, duration = 31 days + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 84 }).startOf('day'), + startDateTime: now.minus({ days: 53 }).startOf('day'), + endDateTime: now.minus({ days: 22 }).startOf('day'), + }); + expect(result).toBe(expected); }); }); }); @@ -288,137 +300,232 @@ describe('getDateRangesCommonTableExpressionQuery', () => { describe('week periodicity (31+ days)', () => { describe('last X days (endDate is today)', () => { test('last 90 days', () => { - const startDate = now.minus({ days: 89 }).startOf('day').toJSDate(); + const startDate = now.minus({ days: 90 }).startOf('day').toJSDate(); const endDate = now.startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectLastXDaysDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 179, // 89 + 90 - startDateOffset: 89, }); + + // endDate is today but different day, dates stay as-is + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 180 }).startOf('day'), + startDateTime: now.minus({ days: 90 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); test('last 6 months', () => { const startDate = now.minus({ months: 6 }).startOf('day').toJSDate(); const endDate = now.startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - const daysBack = Math.floor(now.diff(DateTime.fromJSDate(startDate), 'days').days); - const prevDaysBack = daysBack * 2 + 1; - expectLastXDaysDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: prevDaysBack, - startDateOffset: daysBack, }); + + const startDateTime = DateTime.fromJSDate(startDate).toUTC().startOf('day'); + const endDateTime = now.startOf('day'); + const duration = endDateTime.diff(startDateTime); + + // endDate is today but different day, dates stay as-is + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: startDateTime.minus(duration), + startDateTime, + endDateTime, + }); + expect(result).toBe(expected); }); test('last year', () => { const startDate = now.minus({ years: 1 }).startOf('day').toJSDate(); const endDate = now.startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - const daysBack = Math.floor(now.diff(DateTime.fromJSDate(startDate), 'days').days); - const prevDaysBack = daysBack * 2 + 1; - expectLastXDaysDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: prevDaysBack, - startDateOffset: daysBack, }); + + const startDateTime = DateTime.fromJSDate(startDate).toUTC().startOf('day'); + const endDateTime = now.startOf('day'); + const duration = endDateTime.diff(startDateTime); + + // endDate is today but different day, dates stay as-is + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: startDateTime.minus(duration), + startDateTime, + endDateTime, + }); + expect(result).toBe(expected); }); }); describe('specific historical range', () => { test('31 days range (specific historical range)', () => { - const startDate = now.minus({ days: 31 }).startOf('day').toJSDate(); + const startDate = now.minus({ days: 32 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 62, // 31 + 31 - startDateOffset: 31, - endDateOffset: 0, // the end of the range is the start of the next day }); + + // Past range: end+1 day, duration = 32 days + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 64 }).startOf('day'), + startDateTime: now.minus({ days: 32 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); test('90 days range (specific historical range)', () => { - const startDate = now.minus({ days: 97 }).startOf('day').toJSDate(); + const startDate = now.minus({ days: 98 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 8 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 187, // 97 + 90 - startDateOffset: 97, - endDateOffset: 7, // the end of the range is the start of the next day }); + + // Past range: end+1 day, duration = 91 days + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 189 }).startOf('day'), + startDateTime: now.minus({ days: 98 }).startOf('day'), + endDateTime: now.minus({ days: 7 }).startOf('day'), + }); + expect(result).toBe(expected); }); test('180 days range (specific historical range)', () => { - const startDate = now.minus({ days: 180 }).startOf('day').toJSDate(); + const startDate = now.minus({ days: 181 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 360, // 180 + 180 - startDateOffset: 180, - endDateOffset: 0, // the end of the range is the start of the next day }); + + // Past range: end+1 day, duration = 181 days + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 362 }).startOf('day'), + startDateTime: now.minus({ days: 181 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); test('360 days range (specific historical range)', () => { - const startDate = now.minus({ days: 360 }).startOf('day').toJSDate(); + const startDate = now.minus({ days: 361 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 720, // 360 + 360 - startDateOffset: 360, - endDateOffset: 0, // the end of the range is the start of the next day }); + + // Past range: end+1 day, duration = 361 days + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 722 }).startOf('day'), + startDateTime: now.minus({ days: 361 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); }); }); describe('edge cases', () => { test('handles date with time component correctly', () => { - // Date with time should be treated as start of day + // Oct 6 14:30 to Oct 7 18:45 + // Now is Oct 8 8:51:27, so Oct 7 18:45 is in the past const startDate = DateTime.utc(2025, 10, 6, 14, 30, 0).toJSDate(); const endDate = DateTime.utc(2025, 10, 7, 18, 45, 30).toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - // 2-day range - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 4, // 2 + 2 - startDateOffset: 2, - endDateOffset: 0, // the end of the range is the start of the next day }); + + // Past range, take full days + const startDateTime = DateTime.utc(2025, 10, 6, 14, 30, 0).startOf('day'); + const endDateTime = DateTime.utc(2025, 10, 7, 18, 45, 30).plus({ days: 1 }).startOf('day'); + const duration = endDateTime.diff(startDateTime); + + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: startDateTime.minus(duration), + startDateTime, + endDateTime, + }); + expect(result).toBe(expected); }); test('handles same day with different times correctly (hour periodicity)', () => { + // Oct 7 9:00 to Oct 7 17:00 (same day) + // Now is Oct 8 8:51:27 const startDate = DateTime.utc(2025, 10, 7, 9, 0, 0).toJSDate(); const endDate = DateTime.utc(2025, 10, 7, 17, 0, 0).toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 2, // 1 + 1 - startDateOffset: 1, - endDateOffset: 0, // the end of the range is the start of the next day }); + + // Past range, take full days + const startDateTime = DateTime.utc(2025, 10, 7, 9, 0, 0).startOf('day'); + const endDateTime = DateTime.utc(2025, 10, 7, 17, 0, 0).plus({ days: 1 }).startOf('day'); + const duration = endDateTime.diff(startDateTime); + + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: startDateTime.minus(duration), + startDateTime, + endDateTime, + }); + expect(result).toBe(expected); + }); + + test('handle current day as both start and end date', () => { + const startDate = now.toJSDate(); + const endDate = now.toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, + dbType, + }); + + // startDate and endDate are today, so start is startOf('day'), end is now + const startDateTime = now.startOf('day'); + const endDateTime = now; + const duration = endDateTime.diff(startDateTime); + + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: startDateTime.minus(duration), + startDateTime, + endDateTime, + }); + expect(result).toBe(expected); }); }); }); diff --git a/packages/cli/src/modules/insights/database/repositories/insights-by-period-query.helper.ts b/packages/cli/src/modules/insights/database/repositories/insights-by-period-query.helper.ts index aa612b45ff5..3b90c7d5894 100644 --- a/packages/cli/src/modules/insights/database/repositories/insights-by-period-query.helper.ts +++ b/packages/cli/src/modules/insights/database/repositories/insights-by-period-query.helper.ts @@ -2,132 +2,83 @@ import type { DatabaseConfig } from '@n8n/config'; import { sql } from '@n8n/db'; import { DateTime } from 'luxon'; -/** - * Generates database-specific SQL for a datetime value relative to now - * @param dbType - The database type - * @param daysFromToday - Number of days back from today (0 = now) - * @param useStartOfDay - Whether to truncate to start of day (00:00:00) - */ -const getDatetimeSql = ({ - dbType, - daysFromToday, - useStartOfDay = false, -}: { - dbType: DatabaseConfig['type']; - daysFromToday: number; - useStartOfDay?: boolean; -}): string => { - // Handle "now" case - if (daysFromToday === 0 && !useStartOfDay) { - return dbType === 'sqlite' ? "datetime('now')" : 'NOW()'; - } - - // SQLite - if (dbType === 'sqlite') { - if (daysFromToday === 0 && useStartOfDay) { - return "datetime('now', 'start of day')"; - } - if (useStartOfDay) { - return `datetime('now', '-${daysFromToday} days', 'start of day')`; - } - return `datetime('now', '-${daysFromToday} days')`; - } - - // PostgreSQL - if (dbType === 'postgresdb') { - if (daysFromToday === 0 && useStartOfDay) { - return "DATE_TRUNC('day', NOW())"; - } - if (useStartOfDay) { - return `DATE_TRUNC('day', NOW() - INTERVAL '${daysFromToday} days')`; - } - return `NOW() - INTERVAL '${daysFromToday} days'`; - } - - // MySQL/MariaDB - if (daysFromToday === 0 && useStartOfDay) { - return 'DATE(NOW())'; - } - if (useStartOfDay) { - return `DATE(DATE_SUB(NOW(), INTERVAL ${daysFromToday} DAY))`; - } - return `DATE_SUB(NOW(), INTERVAL ${daysFromToday} DAY)`; -}; - /** * Generates a SQL Common Table Expression (CTE) query that provides three date boundaries for insights queries * * Behavior: - * - If startDate and endDate are the same and today - * - returns the last 24 hours: prev_start_date (2 days ago), start_date (1 day ago), end_date (now). - * - Otherwise: - * - prev_start_date: start of the day before the range - * - start_date: start of the current range - * - end_date: "now" if endDate is today, else start of the day after endDate + * - If the end date is today and the start date is also today, start date is set to the start of the day to take today's data. + * - If the end date is in the past, both start and end dates are set to the start of their respective days, to take full days. * * The SQL CTE can be joined with the insights table for filtering/aggregation. * - * @param dbType - The database type ('sqlite', 'postgresdb', 'mysqldb', 'mariadb') * @param startDate - The start date of the range (inclusive) * @param endDate - The end date of the range (inclusive, or "now" if today) + * @param dbType - The database type (postgresdb, mysqldb, mariadb, or sqlite) * @returns SQL CTE query with `prev_start_date`, `start_date`, and `end_date` columns * - `prev_start_date`: The start of the previous period (used for comparison) * - `start_date`: The start of the current period (inclusive) * - `end_date`: The end of the current period (exclusive) */ export const getDateRangesCommonTableExpressionQuery = ({ - dbType, startDate, endDate, + dbType, }: { - dbType: DatabaseConfig['type']; startDate: Date; endDate: Date; + dbType: DatabaseConfig['type']; }) => { - const today = DateTime.now().startOf('day'); - const startDateStartOfDay = DateTime.fromJSDate(startDate).startOf('day'); - const endDateStartOfDay = DateTime.fromJSDate(endDate).startOf('day'); + let startDateTime = DateTime.fromJSDate(startDate).toUTC(); + let endDateTime = DateTime.fromJSDate(endDate).toUTC(); - const daysFromEndDateToToday = Math.floor(today.diff(endDateStartOfDay, 'days').days); - const daysDiff = Math.floor(endDateStartOfDay.diff(startDateStartOfDay, 'days').days); + const today = DateTime.now().toUTC(); + const isEndDateToday = endDateTime.hasSame(today, 'day'); - const isEndDateToday = daysFromEndDateToToday === 0; + // Past range, take full days + if (!isEndDateToday) { + startDateTime = startDateTime.startOf('day'); + endDateTime = endDateTime.plus({ days: 1 }).startOf('day'); + } - let prevStartDateSql: string; - let startDateSql: string; - let endDateSql: string; + // Today range, take all day data starting from the beginning of the day + if (isEndDateToday && startDateTime.hasSame(endDateTime, 'day')) { + startDateTime = startDateTime.startOf('day'); + } - if (daysDiff === 0 && isEndDateToday) { - // Last 24 hours - prevStartDateSql = getDatetimeSql({ dbType, daysFromToday: 2, useStartOfDay: false }); - startDateSql = getDatetimeSql({ dbType, daysFromToday: 1, useStartOfDay: false }); - endDateSql = getDatetimeSql({ dbType, daysFromToday: 0, useStartOfDay: false }); - } else { - const dateRangeInDays = daysDiff + 1; + const prevStartDateTime = startDateTime.minus(endDateTime.diff(startDateTime)); - const daysFromStartDateToToday = Math.floor(today.diff(startDateStartOfDay, 'days').days); - const prevStartDaysFromToday = daysFromStartDateToToday + dateRangeInDays; + return getDateRangesSelectQuery({ dbType, prevStartDateTime, startDateTime, endDateTime }); +}; - prevStartDateSql = getDatetimeSql({ - dbType, - daysFromToday: prevStartDaysFromToday, - useStartOfDay: true, - }); +export function getDateRangesSelectQuery({ + dbType, + prevStartDateTime, + startDateTime, + endDateTime, +}: { + dbType: DatabaseConfig['type']; + prevStartDateTime: DateTime; + startDateTime: DateTime; + endDateTime: DateTime; +}) { + const prevStartStr = prevStartDateTime.toSQL({ includeZone: false, includeOffset: false }); + const startStr = startDateTime.toSQL({ includeZone: false, includeOffset: false }); + const endStr = endDateTime.toSQL({ includeZone: false, includeOffset: false }); - startDateSql = getDatetimeSql({ - dbType, - daysFromToday: daysFromStartDateToToday, - useStartOfDay: true, - }); - - endDateSql = isEndDateToday - ? getDatetimeSql({ dbType, daysFromToday: 0, useStartOfDay: false }) - : getDatetimeSql({ dbType, daysFromToday: daysFromEndDateToToday - 1, useStartOfDay: true }); + // Database-specific timestamp casting + // PostgreSQL requires explicit CAST or :: syntax for timestamp comparisons + // SQLite and MySQL/MariaDB can work with string literals in comparisons + if (dbType === 'postgresdb') { + return sql`SELECT + CAST('${prevStartStr}' AS TIMESTAMP) AS prev_start_date, + CAST('${startStr}' AS TIMESTAMP) AS start_date, + CAST('${endStr}' AS TIMESTAMP) AS end_date + `; } return sql`SELECT - ${prevStartDateSql} AS prev_start_date, - ${startDateSql} AS start_date, - ${endDateSql} AS end_date + '${prevStartStr}' AS prev_start_date, + '${startStr}' AS start_date, + '${endStr}' AS end_date `; -}; +} diff --git a/packages/cli/src/modules/insights/insights.controller.ts b/packages/cli/src/modules/insights/insights.controller.ts index f239e630d37..53fec84d413 100644 --- a/packages/cli/src/modules/insights/insights.controller.ts +++ b/packages/cli/src/modules/insights/insights.controller.ts @@ -152,7 +152,7 @@ export class InsightsController { if (!query.startDate) { return { - startDate: DateTime.now().minus({ days: 6 }).toJSDate(), + startDate: DateTime.now().minus({ days: 7 }).toJSDate(), endDate: today, }; } diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-oauth-authorization-code.service.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-oauth-authorization-code.service.test.ts new file mode 100644 index 00000000000..3fb822a2f1f --- /dev/null +++ b/packages/cli/src/modules/mcp/__tests__/mcp-oauth-authorization-code.service.test.ts @@ -0,0 +1,230 @@ +import { mockInstance } from '@n8n/backend-test-utils'; +import { mock } from 'jest-mock-extended'; + +import type { AuthorizationCode } from '../database/entities/oauth-authorization-code.entity'; +import { AuthorizationCodeRepository } from '../database/repositories/oauth-authorization-code.repository'; +import { McpOAuthAuthorizationCodeService } from '../mcp-oauth-authorization-code.service'; + +let authorizationCodeRepository: jest.Mocked; +let service: McpOAuthAuthorizationCodeService; + +describe('McpOAuthAuthorizationCodeService', () => { + beforeAll(() => { + authorizationCodeRepository = mockInstance(AuthorizationCodeRepository); + service = new McpOAuthAuthorizationCodeService(authorizationCodeRepository); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('createAuthorizationCode', () => { + it('should generate and save authorization code with all parameters', async () => { + const clientId = 'client-123'; + const userId = 'user-456'; + const redirectUri = 'https://example.com/callback'; + const codeChallenge = 'challenge-abc'; + const state = 'state-xyz'; + + authorizationCodeRepository.insert.mockResolvedValue(mock()); + + const result = await service.createAuthorizationCode( + clientId, + userId, + redirectUri, + codeChallenge, + state, + ); + + expect(result).toHaveLength(64); // 32 bytes hex = 64 characters + expect(authorizationCodeRepository.insert).toHaveBeenCalledWith({ + code: result, + clientId, + userId, + redirectUri, + codeChallenge, + codeChallengeMethod: 'S256', + state, + expiresAt: expect.any(Number), + used: false, + }); + }); + + it('should handle null state', async () => { + authorizationCodeRepository.insert.mockResolvedValue(mock()); + + await service.createAuthorizationCode( + 'client-123', + 'user-456', + 'https://example.com', + 'challenge', + null, + ); + + expect(authorizationCodeRepository.insert).toHaveBeenCalledWith( + expect.objectContaining({ + state: null, + }), + ); + }); + }); + + describe('findAndValidateAuthorizationCode', () => { + it('should return auth record when valid', async () => { + const authRecord = mock({ + code: 'code-123', + clientId: 'client-123', + expiresAt: Date.now() + 10000, // Future expiry + used: false, + }); + + authorizationCodeRepository.findOne.mockResolvedValue(authRecord); + + const result = await service.findAndValidateAuthorizationCode('code-123', 'client-123'); + + expect(result).toEqual(authRecord); + expect(authorizationCodeRepository.findOne).toHaveBeenCalledWith({ + where: { + code: 'code-123', + clientId: 'client-123', + }, + }); + }); + + it('should throw error when authorization code not found', async () => { + authorizationCodeRepository.findOne.mockResolvedValue(null); + + await expect( + service.findAndValidateAuthorizationCode('invalid-code', 'client-123'), + ).rejects.toThrow('Invalid authorization code'); + }); + + it('should throw error and remove when authorization code expired', async () => { + const authRecord = mock({ + code: 'code-123', + clientId: 'client-123', + expiresAt: Date.now() - 1000, // Expired + }); + + authorizationCodeRepository.findOne.mockResolvedValue(authRecord); + authorizationCodeRepository.remove.mockResolvedValue(authRecord); + + await expect( + service.findAndValidateAuthorizationCode('code-123', 'client-123'), + ).rejects.toThrow('Authorization code expired'); + + expect(authorizationCodeRepository.remove).toHaveBeenCalledWith(authRecord); + }); + }); + + describe('validateAndConsumeAuthorizationCode', () => { + it('should mark code as used and return auth record', async () => { + const authRecord = mock({ + code: 'code-123', + clientId: 'client-123', + expiresAt: Date.now() + 10000, + used: false, + redirectUri: 'https://example.com/callback', + }); + + authorizationCodeRepository.findOne.mockResolvedValue(authRecord); + authorizationCodeRepository.update.mockResolvedValue({ affected: 1 } as any); + + const result = await service.validateAndConsumeAuthorizationCode( + 'code-123', + 'client-123', + 'https://example.com/callback', + ); + + expect(result).toEqual(authRecord); + expect(authRecord.used).toBe(true); + expect(authorizationCodeRepository.update).toHaveBeenCalledWith( + { code: 'code-123', used: false }, + { used: true }, + ); + }); + + it('should throw error when code already used (atomic update fails)', async () => { + const authRecord = mock({ + code: 'code-123', + clientId: 'client-123', + expiresAt: Date.now() + 10000, + used: false, + redirectUri: 'https://example.com/callback', + }); + + authorizationCodeRepository.findOne.mockResolvedValue(authRecord); + authorizationCodeRepository.update.mockResolvedValue({ affected: 0 } as any); + + await expect( + service.validateAndConsumeAuthorizationCode('code-123', 'client-123'), + ).rejects.toThrow('Authorization code already used'); + }); + + it('should throw error when redirect URI mismatch', async () => { + const authRecord = mock({ + code: 'code-123', + clientId: 'client-123', + expiresAt: Date.now() + 10000, + used: false, + redirectUri: 'https://example.com/callback', + }); + + authorizationCodeRepository.findOne.mockResolvedValue(authRecord); + + await expect( + service.validateAndConsumeAuthorizationCode( + 'code-123', + 'client-123', + 'https://evil.com/callback', + ), + ).rejects.toThrow('Redirect URI mismatch'); + }); + + it('should allow missing redirect URI parameter', async () => { + const authRecord = mock({ + code: 'code-123', + clientId: 'client-123', + expiresAt: Date.now() + 10000, + used: false, + redirectUri: 'https://example.com/callback', + }); + + authorizationCodeRepository.findOne.mockResolvedValue(authRecord); + authorizationCodeRepository.update.mockResolvedValue({ affected: 1 } as any); + + const result = await service.validateAndConsumeAuthorizationCode('code-123', 'client-123'); + + expect(result).toEqual(authRecord); + expect(authorizationCodeRepository.update).toHaveBeenCalledWith( + { code: 'code-123', used: false }, + { used: true }, + ); + }); + }); + + describe('getCodeChallenge', () => { + it('should return code challenge from valid auth record', async () => { + const authRecord = mock({ + code: 'code-123', + clientId: 'client-123', + expiresAt: Date.now() + 10000, + codeChallenge: 'challenge-abc', + }); + + authorizationCodeRepository.findOne.mockResolvedValue(authRecord); + + const result = await service.getCodeChallenge('code-123', 'client-123'); + + expect(result).toBe('challenge-abc'); + }); + + it('should throw error when code invalid', async () => { + authorizationCodeRepository.findOne.mockResolvedValue(null); + + await expect(service.getCodeChallenge('invalid-code', 'client-123')).rejects.toThrow( + 'Invalid authorization code', + ); + }); + }); +}); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-oauth-consent.service.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-oauth-consent.service.test.ts new file mode 100644 index 00000000000..d0d069949f2 --- /dev/null +++ b/packages/cli/src/modules/mcp/__tests__/mcp-oauth-consent.service.test.ts @@ -0,0 +1,246 @@ +import { mockInstance } from '@n8n/backend-test-utils'; +import { Logger } from '@n8n/backend-common'; +import type { OAuthClient } from '../database/entities/oauth-client.entity'; +import { mock } from 'jest-mock-extended'; + +import { McpOAuthAuthorizationCodeService } from '../mcp-oauth-authorization-code.service'; +import { McpOAuthConsentService } from '../mcp-oauth-consent.service'; +import { OAuthClientRepository } from '../database/repositories/oauth-client.repository'; +import { OAuthSessionService } from '../oauth-session.service'; +import { UserConsentRepository } from '../database/repositories/oauth-user-consent.repository'; + +let logger: jest.Mocked; +let oauthSessionService: jest.Mocked; +let oauthClientRepository: jest.Mocked; +let userConsentRepository: jest.Mocked; +let authorizationCodeService: jest.Mocked; +let service: McpOAuthConsentService; + +describe('McpOAuthConsentService', () => { + beforeAll(() => { + logger = mockInstance(Logger); + oauthSessionService = mockInstance(OAuthSessionService) as jest.Mocked; + oauthClientRepository = mockInstance( + OAuthClientRepository, + ) as jest.Mocked; + userConsentRepository = mockInstance( + UserConsentRepository, + ) as jest.Mocked; + authorizationCodeService = mockInstance(McpOAuthAuthorizationCodeService); + + service = new McpOAuthConsentService( + logger, + oauthSessionService, + oauthClientRepository, + userConsentRepository, + authorizationCodeService, + ); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getConsentDetails', () => { + it('should return client details from valid session token', async () => { + const sessionToken = 'valid-session-token'; + const sessionPayload = { + clientId: 'client-123', + redirectUri: 'https://example.com/callback', + codeChallenge: 'challenge', + state: 'state', + }; + const client = mock({ + id: 'client-123', + name: 'Test Client', + }); + + oauthSessionService.verifySession.mockReturnValue(sessionPayload); + oauthClientRepository.findOne.mockResolvedValue(client); + + const result = await service.getConsentDetails(sessionToken); + + expect(result).toEqual({ + clientName: 'Test Client', + clientId: 'client-123', + }); + expect(oauthSessionService.verifySession).toHaveBeenCalledWith(sessionToken); + expect(oauthClientRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'client-123' }, + }); + }); + + it('should return null when client not found', async () => { + const sessionToken = 'valid-session-token'; + const sessionPayload = { + clientId: 'nonexistent-client', + redirectUri: 'https://example.com/callback', + codeChallenge: 'challenge', + state: null, + }; + + oauthSessionService.verifySession.mockReturnValue(sessionPayload); + oauthClientRepository.findOne.mockResolvedValue(null); + + const result = await service.getConsentDetails(sessionToken); + + expect(result).toBeNull(); + }); + + it('should return null and log error when session verification fails', async () => { + const sessionToken = 'invalid-session-token'; + + oauthSessionService.verifySession.mockImplementation(() => { + throw new Error('Invalid session'); + }); + + const result = await service.getConsentDetails(sessionToken); + + expect(result).toBeNull(); + expect(logger.error).toHaveBeenCalledWith('Error getting consent details', { + error: expect.any(Error), + }); + }); + + it('should return client details', async () => { + const sessionToken = 'valid-session-token'; + const sessionPayload = { + clientId: 'client-123', + redirectUri: 'https://example.com/callback', + codeChallenge: 'challenge', + state: null, + }; + const client = mock({ + id: 'client-123', + name: 'Test Client', + }); + + oauthSessionService.verifySession.mockReturnValue(sessionPayload); + oauthClientRepository.findOne.mockResolvedValue(client); + + const result = await service.getConsentDetails(sessionToken); + + expect(result).toEqual({ + clientName: 'Test Client', + clientId: 'client-123', + }); + }); + }); + + describe('handleConsentDecision', () => { + it('should handle user denial', async () => { + const sessionToken = 'valid-session-token'; + const userId = 'user-123'; + const sessionPayload = { + clientId: 'client-123', + redirectUri: 'https://example.com/callback', + codeChallenge: 'challenge', + state: 'state-xyz', + }; + + oauthSessionService.verifySession.mockReturnValue(sessionPayload); + + const result = await service.handleConsentDecision(sessionToken, userId, false); + + expect(result.redirectUrl).toContain('error=access_denied'); + expect(result.redirectUrl).toContain( + 'error_description=User+denied+the+authorization+request', + ); + expect(result.redirectUrl).toContain('state=state-xyz'); + expect(logger.info).toHaveBeenCalledWith('Consent denied', { + clientId: 'client-123', + userId: 'user-123', + }); + expect(userConsentRepository.insert).not.toHaveBeenCalled(); + }); + + it('should handle user approval and generate authorization code', async () => { + const sessionToken = 'valid-session-token'; + const userId = 'user-123'; + const sessionPayload = { + clientId: 'client-123', + redirectUri: 'https://example.com/callback', + codeChallenge: 'challenge-abc', + state: 'state-xyz', + }; + const authCode = 'generated-auth-code'; + + oauthSessionService.verifySession.mockReturnValue(sessionPayload); + userConsentRepository.insert.mockResolvedValue(mock()); + authorizationCodeService.createAuthorizationCode.mockResolvedValue(authCode); + + const result = await service.handleConsentDecision(sessionToken, userId, true); + + expect(result.redirectUrl).toContain('code=generated-auth-code'); + expect(result.redirectUrl).toContain('state=state-xyz'); + expect(userConsentRepository.insert).toHaveBeenCalledWith({ + userId: 'user-123', + clientId: 'client-123', + grantedAt: expect.any(Number), + }); + expect(authorizationCodeService.createAuthorizationCode).toHaveBeenCalledWith( + 'client-123', + 'user-123', + 'https://example.com/callback', + 'challenge-abc', + 'state-xyz', + ); + expect(logger.info).toHaveBeenCalledWith('Consent approved', { + clientId: 'client-123', + userId: 'user-123', + }); + }); + + it('should handle approval without state parameter', async () => { + const sessionToken = 'valid-session-token'; + const userId = 'user-123'; + const sessionPayload = { + clientId: 'client-123', + redirectUri: 'https://example.com/callback', + codeChallenge: 'challenge-abc', + state: null, + }; + const authCode = 'generated-auth-code'; + + oauthSessionService.verifySession.mockReturnValue(sessionPayload); + userConsentRepository.insert.mockResolvedValue(mock()); + authorizationCodeService.createAuthorizationCode.mockResolvedValue(authCode); + + const result = await service.handleConsentDecision(sessionToken, userId, true); + + expect(result.redirectUrl).toContain('code=generated-auth-code'); + expect(result.redirectUrl).not.toContain('state='); + }); + + it('should throw error when session verification fails', async () => { + const sessionToken = 'invalid-session-token'; + const userId = 'user-123'; + + oauthSessionService.verifySession.mockImplementation(() => { + throw new Error('Invalid session'); + }); + + await expect(service.handleConsentDecision(sessionToken, userId, true)).rejects.toThrow( + 'Invalid or expired session', + ); + }); + + it('should handle denial without state parameter', async () => { + const sessionToken = 'valid-session-token'; + const userId = 'user-123'; + const sessionPayload = { + clientId: 'client-123', + redirectUri: 'https://example.com/callback', + codeChallenge: 'challenge', + state: null, + }; + + oauthSessionService.verifySession.mockReturnValue(sessionPayload); + + const result = await service.handleConsentDecision(sessionToken, userId, false); + + expect(result.redirectUrl).toContain('error=access_denied'); + expect(result.redirectUrl).not.toContain('state='); + }); + }); +}); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-oauth-service.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-oauth-service.test.ts new file mode 100644 index 00000000000..9a2b77127cf --- /dev/null +++ b/packages/cli/src/modules/mcp/__tests__/mcp-oauth-service.test.ts @@ -0,0 +1,551 @@ +import { Logger } from '@n8n/backend-common'; +import { mockInstance } from '@n8n/backend-test-utils'; +import type { Response } from 'express'; +import { mock } from 'jest-mock-extended'; + +import type { AuthorizationCode } from '../database/entities/oauth-authorization-code.entity'; +import type { OAuthClient } from '../database/entities/oauth-client.entity'; +import { OAuthClientRepository } from '../database/repositories/oauth-client.repository'; +import { UserConsentRepository } from '../database/repositories/oauth-user-consent.repository'; +import { McpOAuthAuthorizationCodeService } from '../mcp-oauth-authorization-code.service'; +import { McpOAuthService, SUPPORTED_SCOPES } from '../mcp-oauth-service'; +import { McpOAuthTokenService } from '../mcp-oauth-token.service'; +import { OAuthSessionService } from '../oauth-session.service'; + +let logger: jest.Mocked; +let oauthSessionService: jest.Mocked; +let oauthClientRepository: jest.Mocked; +let tokenService: jest.Mocked; +let authorizationCodeService: jest.Mocked; +let service: McpOAuthService; +let userConsentRepository: jest.Mocked; + +describe('McpOAuthService', () => { + beforeAll(() => { + logger = mockInstance(Logger); + oauthSessionService = mockInstance(OAuthSessionService); + oauthClientRepository = mockInstance(OAuthClientRepository); + tokenService = mockInstance(McpOAuthTokenService); + authorizationCodeService = mockInstance(McpOAuthAuthorizationCodeService); + userConsentRepository = mockInstance(UserConsentRepository); + + service = new McpOAuthService( + logger, + oauthSessionService, + oauthClientRepository, + tokenService, + authorizationCodeService, + userConsentRepository, + ); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('clientsStore', () => { + describe('getClient', () => { + it('should return client information when client exists', async () => { + const client = { + id: 'client-123', + name: 'Test Client', + redirectUris: ['https://example.com/callback'], + grantTypes: ['authorization_code', 'refresh_token'], + tokenEndpointAuthMethod: 'none', + clientSecret: null, + clientSecretExpiresAt: null, + } as OAuthClient; + + oauthClientRepository.findOneBy.mockResolvedValue(client); + + const result = await service.clientsStore.getClient('client-123'); + + expect(result).toEqual({ + client_id: 'client-123', + client_name: 'Test Client', + redirect_uris: ['https://example.com/callback'], + grant_types: ['authorization_code', 'refresh_token'], + token_endpoint_auth_method: 'none', + response_types: ['code'], + scope: SUPPORTED_SCOPES.join(' '), + }); + }); + + it('should include client secret when present', async () => { + const client = { + id: 'client-123', + name: 'Test Client', + redirectUris: ['https://example.com/callback'], + grantTypes: ['authorization_code'], + tokenEndpointAuthMethod: 'client_secret_post', + clientSecret: 'secret-value', + clientSecretExpiresAt: 1234567890, + } as OAuthClient; + + oauthClientRepository.findOneBy.mockResolvedValue(client); + + const result = await service.clientsStore.getClient('client-123'); + + expect(result).toMatchObject({ + client_secret: 'secret-value', + client_secret_expires_at: 1234567890, + }); + }); + + it('should return undefined when client not found', async () => { + oauthClientRepository.findOneBy.mockResolvedValue(null); + + const result = await service.clientsStore.getClient('nonexistent'); + + expect(result).toBeUndefined(); + }); + }); + + describe('registerClient', () => { + it('should save client with all required fields', async () => { + const clientInfo = { + client_id: 'new-client-123', + client_name: 'New Client', + redirect_uris: ['https://example.com/callback'], + grant_types: ['authorization_code', 'refresh_token'], + token_endpoint_auth_method: 'none', + response_types: ['code'], + scope: 'read write', + }; + + oauthClientRepository.insert.mockResolvedValue({} as any); + + const result = await service.clientsStore.registerClient!(clientInfo); + + expect(oauthClientRepository.insert).toHaveBeenCalledWith({ + id: 'new-client-123', + name: 'New Client', + redirectUris: ['https://example.com/callback'], + grantTypes: ['authorization_code', 'refresh_token'], + clientSecret: null, + clientSecretExpiresAt: null, + tokenEndpointAuthMethod: 'none', + }); + expect(result).toEqual(clientInfo); + }); + + it('should save client with client secret', async () => { + const clientInfo = { + client_id: 'new-client-123', + client_name: 'New Client', + redirect_uris: ['https://example.com/callback'], + grant_types: ['authorization_code'], + token_endpoint_auth_method: 'client_secret_post', + client_secret: 'secret-123', + client_secret_expires_at: 1234567890, + response_types: ['code'], + scope: 'read', + }; + + oauthClientRepository.insert.mockResolvedValue({} as any); + + await service.clientsStore.registerClient!(clientInfo); + + expect(oauthClientRepository.insert).toHaveBeenCalledWith({ + id: 'new-client-123', + name: 'New Client', + redirectUris: ['https://example.com/callback'], + grantTypes: ['authorization_code'], + clientSecret: 'secret-123', + clientSecretExpiresAt: 1234567890, + tokenEndpointAuthMethod: 'client_secret_post', + }); + }); + + it('should handle save errors gracefully', async () => { + const clientInfo = { + client_id: 'new-client-123', + client_name: 'New Client', + redirect_uris: ['https://example.com/callback'], + grant_types: ['authorization_code'], + token_endpoint_auth_method: 'none', + response_types: ['code'], + scope: 'read', + }; + + const error = new Error('Database error'); + oauthClientRepository.insert.mockRejectedValue(error); + + const result = await service.clientsStore.registerClient!(clientInfo); + + expect(logger.error).toHaveBeenCalledWith('Error registering OAuth client', { + error, + clientId: 'new-client-123', + }); + expect(result).toEqual(clientInfo); + }); + }); + }); + + describe('authorize', () => { + it('should create session and redirect to consent page', async () => { + const client = { + client_id: 'client-123', + client_name: 'Test Client', + redirect_uris: ['https://example.com/callback'], + grant_types: ['authorization_code'], + token_endpoint_auth_method: 'none', + response_types: ['code'], + scope: 'read write', + }; + + const params = { + redirectUri: 'https://example.com/callback', + codeChallenge: 'challenge-123', + state: 'state-xyz', + }; + + const res = mock(); + + await service.authorize(client, params, res); + + expect(oauthSessionService.createSession).toHaveBeenCalledWith(res, { + clientId: 'client-123', + redirectUri: 'https://example.com/callback', + codeChallenge: 'challenge-123', + state: 'state-xyz', + }); + expect(res.redirect).toHaveBeenCalledWith('/oauth/consent'); + }); + + it('should handle null state parameter', async () => { + const client = { + client_id: 'client-123', + client_name: 'Test Client', + redirect_uris: ['https://example.com/callback'], + grant_types: ['authorization_code'], + token_endpoint_auth_method: 'none', + response_types: ['code'], + scope: 'read', + }; + + const params = { + redirectUri: 'https://example.com/callback', + codeChallenge: 'challenge-123', + }; + + const res = mock(); + + await service.authorize(client, params, res); + + expect(oauthSessionService.createSession).toHaveBeenCalledWith(res, { + clientId: 'client-123', + redirectUri: 'https://example.com/callback', + codeChallenge: 'challenge-123', + state: null, + }); + }); + + it('should handle errors and clear session', async () => { + const client = { + client_id: 'client-123', + client_name: 'Test Client', + redirect_uris: ['https://example.com/callback'], + grant_types: ['authorization_code'], + token_endpoint_auth_method: 'none', + response_types: ['code'], + scope: 'read', + }; + + const params = { + redirectUri: 'https://example.com/callback', + codeChallenge: 'challenge-123', + }; + + const res = mock(); + res.status.mockReturnThis(); + res.json.mockReturnThis(); + + const error = new Error('Session creation failed'); + oauthSessionService.createSession.mockImplementation(() => { + throw error; + }); + + await service.authorize(client, params, res); + + expect(logger.error).toHaveBeenCalledWith('Error in authorize method', { + error, + clientId: 'client-123', + }); + expect(oauthSessionService.clearSession).toHaveBeenCalledWith(res); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + error: 'server_error', + error_description: 'Internal server error', + }); + }); + }); + + describe('challengeForAuthorizationCode', () => { + it('should return code challenge from authorization code service', async () => { + const client = { + client_id: 'client-123', + client_name: 'Test Client', + redirect_uris: ['https://example.com/callback'], + grant_types: ['authorization_code'], + token_endpoint_auth_method: 'none', + response_types: ['code'], + scope: 'read', + }; + + authorizationCodeService.getCodeChallenge.mockResolvedValue('challenge-123'); + + const result = await service.challengeForAuthorizationCode(client, 'auth-code-123'); + + expect(authorizationCodeService.getCodeChallenge).toHaveBeenCalledWith( + 'auth-code-123', + 'client-123', + ); + expect(result).toBe('challenge-123'); + }); + }); + + describe('exchangeAuthorizationCode', () => { + it('should validate code and return token pair', async () => { + const client = { + client_id: 'client-123', + client_name: 'Test Client', + redirect_uris: ['https://example.com/callback'], + grant_types: ['authorization_code'], + token_endpoint_auth_method: 'none', + response_types: ['code'], + scope: 'read', + }; + + const authRecord = { + userId: 'user-456', + clientId: 'client-123', + } as AuthorizationCode; + + authorizationCodeService.validateAndConsumeAuthorizationCode.mockResolvedValue(authRecord); + tokenService.generateTokenPair.mockReturnValue({ + accessToken: 'access-token-123', + refreshToken: 'refresh-token-456', + }); + tokenService.saveTokenPair.mockResolvedValue(); + + const result = await service.exchangeAuthorizationCode( + client, + 'auth-code-123', + 'verifier-123', + 'https://example.com/callback', + ); + + expect(authorizationCodeService.validateAndConsumeAuthorizationCode).toHaveBeenCalledWith( + 'auth-code-123', + 'client-123', + 'https://example.com/callback', + ); + expect(tokenService.generateTokenPair).toHaveBeenCalledWith('user-456', 'client-123'); + expect(tokenService.saveTokenPair).toHaveBeenCalledWith( + 'access-token-123', + 'refresh-token-456', + 'client-123', + 'user-456', + ); + expect(result).toEqual({ + access_token: 'access-token-123', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'refresh-token-456', + }); + }); + + it('should handle authorization code exchange without redirect URI', async () => { + const client = { + client_id: 'client-123', + client_name: 'Test Client', + redirect_uris: ['https://example.com/callback'], + grant_types: ['authorization_code'], + token_endpoint_auth_method: 'none', + response_types: ['code'], + scope: 'read', + }; + + const authRecord = { + userId: 'user-456', + clientId: 'client-123', + } as AuthorizationCode; + + authorizationCodeService.validateAndConsumeAuthorizationCode.mockResolvedValue(authRecord); + tokenService.generateTokenPair.mockReturnValue({ + accessToken: 'access-token-123', + refreshToken: 'refresh-token-456', + }); + + await service.exchangeAuthorizationCode(client, 'auth-code-123', 'verifier-123'); + + expect(authorizationCodeService.validateAndConsumeAuthorizationCode).toHaveBeenCalledWith( + 'auth-code-123', + 'client-123', + undefined, + ); + }); + }); + + describe('exchangeRefreshToken', () => { + it('should validate and rotate refresh token', async () => { + const client = { + client_id: 'client-123', + client_name: 'Test Client', + redirect_uris: ['https://example.com/callback'], + grant_types: ['refresh_token'], + token_endpoint_auth_method: 'none', + response_types: ['code'], + scope: 'read', + }; + + const newTokens = { + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token', + }; + + tokenService.validateAndRotateRefreshToken.mockResolvedValue(newTokens); + + const result = await service.exchangeRefreshToken(client, 'old-refresh-token', ['read']); + + expect(tokenService.validateAndRotateRefreshToken).toHaveBeenCalledWith( + 'old-refresh-token', + 'client-123', + ); + expect(result).toEqual(newTokens); + }); + }); + + describe('verifyAccessToken', () => { + it('should verify access token and return auth info', async () => { + const authInfo = { + token: 'access-token-123', + userId: 'user-123', + clientId: 'client-456', + scopes: ['read', 'write'], + }; + + tokenService.verifyAccessToken.mockResolvedValue(authInfo); + + const result = await service.verifyAccessToken('access-token-123'); + + expect(tokenService.verifyAccessToken).toHaveBeenCalledWith('access-token-123'); + expect(result).toEqual(authInfo); + }); + }); + + describe('revokeToken', () => { + it('should revoke access token when type hint is access_token', async () => { + const client = { + client_id: 'client-123', + client_name: 'Test Client', + redirect_uris: ['https://example.com/callback'], + grant_types: ['authorization_code'], + token_endpoint_auth_method: 'none', + response_types: ['code'], + scope: 'read', + }; + + tokenService.revokeAccessToken.mockResolvedValue(true); + + await service.revokeToken(client, { + token: 'token-123', + token_type_hint: 'access_token', + }); + + expect(tokenService.revokeAccessToken).toHaveBeenCalledWith('token-123', 'client-123'); + expect(tokenService.revokeRefreshToken).not.toHaveBeenCalled(); + }); + + it('should revoke refresh token when type hint is refresh_token', async () => { + const client = { + client_id: 'client-123', + client_name: 'Test Client', + redirect_uris: ['https://example.com/callback'], + grant_types: ['refresh_token'], + token_endpoint_auth_method: 'none', + response_types: ['code'], + scope: 'read', + }; + + tokenService.revokeRefreshToken.mockResolvedValue(true); + + await service.revokeToken(client, { + token: 'token-123', + token_type_hint: 'refresh_token', + }); + + expect(tokenService.revokeAccessToken).not.toHaveBeenCalled(); + expect(tokenService.revokeRefreshToken).toHaveBeenCalledWith('token-123', 'client-123'); + }); + + it('should try access token first when no type hint provided', async () => { + const client = { + client_id: 'client-123', + client_name: 'Test Client', + redirect_uris: ['https://example.com/callback'], + grant_types: ['authorization_code', 'refresh_token'], + token_endpoint_auth_method: 'none', + response_types: ['code'], + scope: 'read', + }; + + tokenService.revokeAccessToken.mockResolvedValue(true); + + await service.revokeToken(client, { + token: 'token-123', + }); + + expect(tokenService.revokeAccessToken).toHaveBeenCalledWith('token-123', 'client-123'); + expect(tokenService.revokeRefreshToken).not.toHaveBeenCalled(); + }); + + it('should try refresh token if access token revocation fails', async () => { + const client = { + client_id: 'client-123', + client_name: 'Test Client', + redirect_uris: ['https://example.com/callback'], + grant_types: ['authorization_code', 'refresh_token'], + token_endpoint_auth_method: 'none', + response_types: ['code'], + scope: 'read', + }; + + tokenService.revokeAccessToken.mockResolvedValue(false); + tokenService.revokeRefreshToken.mockResolvedValue(true); + + await service.revokeToken(client, { + token: 'token-123', + }); + + expect(tokenService.revokeAccessToken).toHaveBeenCalledWith('token-123', 'client-123'); + expect(tokenService.revokeRefreshToken).toHaveBeenCalledWith('token-123', 'client-123'); + }); + + it('should silently succeed when token not found', async () => { + const client = { + client_id: 'client-123', + client_name: 'Test Client', + redirect_uris: ['https://example.com/callback'], + grant_types: ['authorization_code', 'refresh_token'], + token_endpoint_auth_method: 'none', + response_types: ['code'], + scope: 'read', + }; + + tokenService.revokeAccessToken.mockResolvedValue(false); + tokenService.revokeRefreshToken.mockResolvedValue(false); + + await service.revokeToken(client, { + token: 'unknown-token', + }); + + expect(logger.debug).toHaveBeenCalledWith('Token revocation requested for unknown token', { + clientId: 'client-123', + }); + }); + }); +}); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-oauth-token.service.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-oauth-token.service.test.ts new file mode 100644 index 00000000000..66d62b71b48 --- /dev/null +++ b/packages/cli/src/modules/mcp/__tests__/mcp-oauth-token.service.test.ts @@ -0,0 +1,344 @@ +import { Logger } from '@n8n/backend-common'; +import { mockInstance } from '@n8n/backend-test-utils'; +import type { User } from '@n8n/db'; +import { UserRepository } from '@n8n/db'; +import { mock } from 'jest-mock-extended'; +import type { InstanceSettings } from 'n8n-core'; + +import { JwtService } from '@/services/jwt.service'; + +import type { AccessToken } from '../database/entities/oauth-access-token.entity'; +import type { RefreshToken } from '../database/entities/oauth-refresh-token.entity'; +import { AccessTokenRepository } from '../database/repositories/oauth-access-token.repository'; +import { RefreshTokenRepository } from '../database/repositories/oauth-refresh-token.repository'; +import { McpOAuthTokenService } from '../mcp-oauth-token.service'; + +const instanceSettings = mock({ encryptionKey: 'test-key' }); +const jwtService = new JwtService(instanceSettings, mock()); + +let logger: jest.Mocked; +let userRepository: jest.Mocked; +let accessTokenRepository: jest.Mocked; +let refreshTokenRepository: jest.Mocked; +let service: McpOAuthTokenService; +let mockTransactionManager: any; + +describe('McpOAuthTokenService', () => { + beforeAll(() => { + logger = mockInstance(Logger); + userRepository = mockInstance(UserRepository); + accessTokenRepository = mockInstance( + AccessTokenRepository, + ) as jest.Mocked; + refreshTokenRepository = mockInstance( + RefreshTokenRepository, + ) as jest.Mocked; + + mockTransactionManager = { + insert: jest.fn().mockResolvedValue(mock()), + remove: jest.fn().mockResolvedValue(mock()), + findOne: jest.fn(), + delete: jest.fn(), + }; + + const mockManager: any = { + transaction: jest.fn(async (cb: any) => await cb(mockTransactionManager)), + }; + + (accessTokenRepository as any).manager = mockManager; + (accessTokenRepository as any).target = 'AccessToken'; + (refreshTokenRepository as any).manager = mockManager; + (refreshTokenRepository as any).target = 'RefreshToken'; + + service = new McpOAuthTokenService( + logger, + jwtService, + userRepository, + accessTokenRepository, + refreshTokenRepository, + ); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('generateTokenPair', () => { + it('should generate JWT access token and opaque refresh token', () => { + const userId = 'user-123'; + const clientId = 'client-456'; + + const { accessToken, refreshToken } = service.generateTokenPair(userId, clientId); + + expect(accessToken).toMatch(/^[\w-]+\.[\w-]+\.[\w-]+$/); // JWT format + + const decoded = jwtService.decode(accessToken); + expect(decoded.sub).toBe(userId); + expect(decoded.aud).toBe('mcp-server-api'); + expect(decoded.client_id).toBe(clientId); + expect(decoded.meta.isOAuth).toBe(true); + expect(decoded.jti).toBeDefined(); + expect(decoded.iat).toBeDefined(); + expect(decoded.exp).toBeDefined(); + + expect(refreshToken).toHaveLength(64); // 32 bytes hex = 64 characters + expect(refreshToken).toMatch(/^[a-f0-9]{64}$/); + }); + + it('should generate different tokens on each call', () => { + const userId = 'user-123'; + const clientId = 'client-456'; + + const pair1 = service.generateTokenPair(userId, clientId); + const pair2 = service.generateTokenPair(userId, clientId); + + expect(pair1.accessToken).not.toBe(pair2.accessToken); + expect(pair1.refreshToken).not.toBe(pair2.refreshToken); + }); + }); + + describe('saveTokenPair', () => { + it('should save both tokens in a transaction', async () => { + const accessToken = 'jwt-access-token'; + const refreshToken = 'opaque-refresh-token'; + const clientId = 'client-123'; + const userId = 'user-456'; + + await service.saveTokenPair(accessToken, refreshToken, clientId, userId); + + const mockManager = accessTokenRepository.manager as any; + expect(mockManager.transaction).toHaveBeenCalled(); + expect(mockTransactionManager.insert).toHaveBeenCalledTimes(2); + + expect(mockTransactionManager.insert).toHaveBeenCalledWith('AccessToken', { + token: accessToken, + clientId, + userId, + }); + + expect(mockTransactionManager.insert).toHaveBeenCalledWith('RefreshToken', { + token: refreshToken, + clientId, + userId, + expiresAt: expect.any(Number), + }); + }); + }); + + describe('validateAndRotateRefreshToken', () => { + it('should rotate refresh token and return new token pair in a transaction', async () => { + const refreshToken = 'old-refresh-token'; + const clientId = 'client-123'; + const refreshTokenRecord = mock({ + token: refreshToken, + clientId, + userId: 'user-456', + expiresAt: Date.now() + 1000000, // Valid + }); + + mockTransactionManager.findOne.mockResolvedValue(refreshTokenRecord); + mockTransactionManager.delete.mockResolvedValue({ affected: 1 }); + + const result = await service.validateAndRotateRefreshToken(refreshToken, clientId); + + expect(result).toEqual({ + access_token: expect.stringMatching(/^[\w-]+\.[\w-]+\.[\w-]+$/), + token_type: 'Bearer', + expires_in: 3600, + refresh_token: expect.stringMatching(/^[a-f0-9]{64}$/), + }); + + // Verify transaction was used + const mockManager = refreshTokenRepository.manager as any; + expect(mockManager.transaction).toHaveBeenCalled(); + + // Verify all operations happened inside the transaction + expect(mockTransactionManager.findOne).toHaveBeenCalled(); + expect(mockTransactionManager.delete).toHaveBeenCalled(); + expect(mockTransactionManager.insert).toHaveBeenCalledTimes(2); + }); + + it('should throw error when refresh token not found', async () => { + mockTransactionManager.findOne.mockResolvedValue(null); + + await expect( + service.validateAndRotateRefreshToken('invalid-token', 'client-123'), + ).rejects.toThrow('Invalid refresh token'); + }); + + it('should throw error when refresh token expired (atomic delete fails)', async () => { + const refreshTokenRecord = mock({ + token: 'expired-token', + clientId: 'client-123', + userId: 'user-456', + expiresAt: Date.now() - 1000, // Expired + }); + + mockTransactionManager.findOne.mockResolvedValue(refreshTokenRecord); + mockTransactionManager.delete.mockResolvedValue({ affected: 0 }); // Atomic delete fails due to expiry + + await expect( + service.validateAndRotateRefreshToken('expired-token', 'client-123'), + ).rejects.toThrow('Invalid refresh token'); + }); + }); + + describe('verifyAccessToken', () => { + it('should verify valid access token and return auth info', async () => { + const userId = 'user-123'; + const clientId = 'client-456'; + const { accessToken } = service.generateTokenPair(userId, clientId); + + const accessTokenRecord = mock({ + token: accessToken, + clientId, + userId, + }); + + accessTokenRepository.findOne.mockResolvedValue(accessTokenRecord); + + const result = await service.verifyAccessToken(accessToken); + + expect(result).toEqual({ + token: accessToken, + clientId, + scopes: [], + extra: { + userId, + }, + }); + }); + + it('should throw error for invalid JWT signature', async () => { + const invalidToken = 'invalid.jwt.token'; + + await expect(service.verifyAccessToken(invalidToken)).rejects.toThrow( + 'Invalid access token: JWT verification failed', + ); + }); + + it('should throw error for wrong audience', async () => { + const wrongAudienceToken = jwtService.sign({ + sub: 'user-123', + aud: 'wrong-audience', // Not 'mcp-server-api' + client_id: 'client-456', + }); + + await expect(service.verifyAccessToken(wrongAudienceToken)).rejects.toThrow( + 'Invalid access token: JWT verification failed', + ); + }); + + it('should throw error when token not found in database', async () => { + const userId = 'user-123'; + const clientId = 'client-456'; + const { accessToken } = service.generateTokenPair(userId, clientId); + + accessTokenRepository.findOne.mockResolvedValue(null); + + await expect(service.verifyAccessToken(accessToken)).rejects.toThrow( + 'Invalid access token: not found in database', + ); + }); + }); + + describe('verifyOAuthAccessToken', () => { + it('should verify token and return user', async () => { + const userId = 'user-123'; + const clientId = 'client-456'; + const { accessToken } = service.generateTokenPair(userId, clientId); + + const accessTokenRecord = mock({ + token: accessToken, + clientId, + userId, + }); + + const user = mock({ id: userId }); + + accessTokenRepository.findOne.mockResolvedValue(accessTokenRecord); + userRepository.findOne.mockResolvedValue(user); + + const result = await service.verifyOAuthAccessToken(accessToken); + + expect(result).toEqual(user); + expect(userRepository.findOne).toHaveBeenCalledWith({ + where: { id: userId }, + relations: ['role'], + }); + }); + + it('should return null for invalid token', async () => { + const invalidToken = 'invalid.jwt.token'; + + const result = await service.verifyOAuthAccessToken(invalidToken); + + expect(result).toBeNull(); + }); + + it('should return null when user not found', async () => { + const userId = 'user-123'; + const clientId = 'client-456'; + const { accessToken } = service.generateTokenPair(userId, clientId); + + const accessTokenRecord = mock({ + token: accessToken, + clientId, + userId, + }); + + accessTokenRepository.findOne.mockResolvedValue(accessTokenRecord); + userRepository.findOne.mockResolvedValue(null); + + const result = await service.verifyOAuthAccessToken(accessToken); + + expect(result).toBeNull(); + }); + }); + + describe('revokeAccessToken', () => { + it('should delete access token', async () => { + const token = 'access-token-123'; + const clientId = 'client-456'; + + accessTokenRepository.delete.mockResolvedValue({ affected: 1 } as any); + + const result = await service.revokeAccessToken(token, clientId); + + expect(result).toBe(true); + expect(accessTokenRepository.delete).toHaveBeenCalledWith({ token, clientId }); + expect(logger.info).toHaveBeenCalledWith('Access token revoked', { clientId }); + }); + + it('should return false when token not found', async () => { + accessTokenRepository.delete.mockResolvedValue({ affected: 0 } as any); + + const result = await service.revokeAccessToken('nonexistent-token', 'client-456'); + + expect(result).toBe(false); + }); + }); + + describe('revokeRefreshToken', () => { + it('should delete refresh token', async () => { + const token = 'refresh-token-123'; + const clientId = 'client-456'; + + refreshTokenRepository.delete.mockResolvedValue({ affected: 1 } as any); + + const result = await service.revokeRefreshToken(token, clientId); + + expect(result).toBe(true); + expect(refreshTokenRepository.delete).toHaveBeenCalledWith({ token, clientId }); + expect(logger.info).toHaveBeenCalledWith('Refresh token revoked', { clientId }); + }); + + it('should return false when token not found', async () => { + refreshTokenRepository.delete.mockResolvedValue({ affected: 0 } as any); + + const result = await service.revokeRefreshToken('nonexistent-token', 'client-456'); + + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-oauth.helpers.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-oauth.helpers.test.ts new file mode 100644 index 00000000000..e74d5799d1f --- /dev/null +++ b/packages/cli/src/modules/mcp/__tests__/mcp-oauth.helpers.test.ts @@ -0,0 +1,151 @@ +import { McpOAuthHelpers } from '../mcp-oauth.helpers'; + +describe('McpOAuthHelpers', () => { + describe('buildSuccessRedirectUrl', () => { + it('should build redirect URL with authorization code', () => { + const redirectUri = 'https://example.com/callback'; + const code = 'auth-code-123'; + const state = null; + + const result = McpOAuthHelpers.buildSuccessRedirectUrl(redirectUri, code, state); + + expect(result).toBe('https://example.com/callback?code=auth-code-123'); + }); + + it('should include state parameter when provided', () => { + const redirectUri = 'https://example.com/callback'; + const code = 'auth-code-123'; + const state = 'state-xyz'; + + const result = McpOAuthHelpers.buildSuccessRedirectUrl(redirectUri, code, state); + + expect(result).toBe('https://example.com/callback?code=auth-code-123&state=state-xyz'); + }); + + it('should preserve existing query parameters', () => { + const redirectUri = 'https://example.com/callback?foo=bar'; + const code = 'auth-code-123'; + const state = 'state-xyz'; + + const result = McpOAuthHelpers.buildSuccessRedirectUrl(redirectUri, code, state); + + expect(result).toContain('foo=bar'); + expect(result).toContain('code=auth-code-123'); + expect(result).toContain('state=state-xyz'); + }); + + it('should handle redirect URI with port', () => { + const redirectUri = 'http://localhost:3000/callback'; + const code = 'auth-code-123'; + const state = null; + + const result = McpOAuthHelpers.buildSuccessRedirectUrl(redirectUri, code, state); + + expect(result).toBe('http://localhost:3000/callback?code=auth-code-123'); + }); + + it('should URL-encode special characters in code', () => { + const redirectUri = 'https://example.com/callback'; + const code = 'code+with/special=chars'; + const state = null; + + const result = McpOAuthHelpers.buildSuccessRedirectUrl(redirectUri, code, state); + + expect(result).toContain('code=code%2Bwith%2Fspecial%3Dchars'); + }); + }); + + describe('buildErrorRedirectUrl', () => { + it('should build redirect URL with error parameters', () => { + const redirectUri = 'https://example.com/callback'; + const error = 'access_denied'; + const errorDescription = 'User denied the authorization request'; + const state = null; + + const result = McpOAuthHelpers.buildErrorRedirectUrl( + redirectUri, + error, + errorDescription, + state, + ); + + expect(result).toContain('error=access_denied'); + expect(result).toContain('error_description=User+denied+the+authorization+request'); + }); + + it('should include state parameter when provided', () => { + const redirectUri = 'https://example.com/callback'; + const error = 'invalid_request'; + const errorDescription = 'Missing required parameter'; + const state = 'state-xyz'; + + const result = McpOAuthHelpers.buildErrorRedirectUrl( + redirectUri, + error, + errorDescription, + state, + ); + + expect(result).toContain('error=invalid_request'); + expect(result).toContain('state=state-xyz'); + }); + + it('should preserve existing query parameters', () => { + const redirectUri = 'https://example.com/callback?foo=bar'; + const error = 'server_error'; + const errorDescription = 'Internal server error'; + const state = 'state-xyz'; + + const result = McpOAuthHelpers.buildErrorRedirectUrl( + redirectUri, + error, + errorDescription, + state, + ); + + expect(result).toContain('foo=bar'); + expect(result).toContain('error=server_error'); + expect(result).toContain('state=state-xyz'); + }); + + it('should handle common OAuth error codes', () => { + const testCases = [ + { error: 'access_denied', description: 'User denied' }, + { error: 'invalid_request', description: 'Bad request' }, + { error: 'unauthorized_client', description: 'Client not authorized' }, + { error: 'invalid_scope', description: 'Invalid scope' }, + { error: 'server_error', description: 'Server error' }, + { error: 'temporarily_unavailable', description: 'Service unavailable' }, + ]; + + testCases.forEach(({ error, description }) => { + const result = McpOAuthHelpers.buildErrorRedirectUrl( + 'https://example.com/callback', + error, + description, + null, + ); + + expect(result).toContain(`error=${error}`); + expect(result).toContain('error_description='); + }); + }); + + it('should URL-encode special characters in error description', () => { + const redirectUri = 'https://example.com/callback'; + const error = 'access_denied'; + const errorDescription = 'User said "no thanks!"'; + const state = null; + + const result = McpOAuthHelpers.buildErrorRedirectUrl( + redirectUri, + error, + errorDescription, + state, + ); + + expect(result).toContain('error_description='); + expect(result).not.toContain('"'); // Should be encoded + }); + }); +}); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-server-api-key.service.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-server-api-key.service.test.ts deleted file mode 100644 index fa76ad3e2f1..00000000000 --- a/packages/cli/src/modules/mcp/__tests__/mcp-server-api-key.service.test.ts +++ /dev/null @@ -1,423 +0,0 @@ -import { mockInstance } from '@n8n/backend-test-utils'; -import type { User } from '@n8n/db'; -import { ApiKeyRepository, UserRepository } from '@n8n/db'; -import { randomUUID } from 'crypto'; -import type { Request, Response, NextFunction } from 'express'; -import { mock, mockDeep } from 'jest-mock-extended'; -import type { InstanceSettings } from 'n8n-core'; - -import { JwtService } from '@/services/jwt.service'; -import { Telemetry } from '@/telemetry'; - -import { McpServerApiKeyService } from '../mcp-api-key.service'; - -const mockReqWith = (authHeader: string | undefined) => { - const req = mockDeep(); - req.header.mockImplementation((name: string) => { - if (name === 'authorization') return authHeader; - return undefined; - }); - return req; -}; - -const instanceSettings = mock({ encryptionKey: 'test-key' }); -const jwtService = new JwtService(instanceSettings, mock()); - -let userRepository: jest.Mocked; -let apiKeyRepository: jest.Mocked; -let telemetry: jest.Mocked; -let mcpServerApiKeyService: McpServerApiKeyService; - -describe('McpServerApiKeyService', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - beforeAll(() => { - userRepository = mockInstance(UserRepository); - apiKeyRepository = mockInstance(ApiKeyRepository); - telemetry = mockInstance(Telemetry); - mcpServerApiKeyService = new McpServerApiKeyService( - apiKeyRepository, - jwtService, - userRepository, - telemetry, - ); - }); - - describe('getAuthMiddleware', () => { - it('should return 401 if authorization header is missing', async () => { - // Arrange - const req = mockReqWith(undefined); - const res = mockDeep(); - res.status.mockReturnThis(); - res.send.mockReturnThis(); - const next = jest.fn() as NextFunction; - - const middleware = mcpServerApiKeyService.getAuthMiddleware(); - - // Act - await middleware(req, res, next); - - // Assert - expect(res.status).toHaveBeenCalledWith(401); - expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' }); - expect(next).not.toHaveBeenCalled(); - }); - - it('should throw error if authorization header does not start with Bearer', async () => { - // Arrange - const req = mockReqWith('Basic sometoken'); - const res = mockDeep(); - res.status.mockReturnThis(); - res.send.mockReturnThis(); - const next = jest.fn() as NextFunction; - - const middleware = mcpServerApiKeyService.getAuthMiddleware(); - - // Act & Assert - await expect(middleware(req, res, next)).rejects.toThrow( - 'Invalid authorization header format', - ); - expect(next).not.toHaveBeenCalled(); - }); - - it('should throw error if authorization header has invalid Bearer format', async () => { - // Arrange - const req = mockReqWith('Bearer'); - const res = mockDeep(); - res.status.mockReturnThis(); - res.send.mockReturnThis(); - const next = jest.fn() as NextFunction; - - const middleware = mcpServerApiKeyService.getAuthMiddleware(); - - // Act & Assert - await expect(middleware(req, res, next)).rejects.toThrow( - 'Invalid authorization header format', - ); - expect(next).not.toHaveBeenCalled(); - }); - - it('should return 401 if API key is not found in database', async () => { - // Arrange - const apiKey = jwtService.sign({ - sub: randomUUID(), - iss: 'n8n', - aud: 'mcp-server-api', - jti: randomUUID(), - }); - - userRepository.findOne.mockResolvedValue(null); - - const req = mockReqWith(`Bearer ${apiKey}`); - const res = mockDeep(); - res.status.mockReturnThis(); - res.send.mockReturnThis(); - const next = jest.fn() as NextFunction; - - const middleware = mcpServerApiKeyService.getAuthMiddleware(); - - // Act - await middleware(req, res, next); - - // Assert - expect(res.status).toHaveBeenCalledWith(401); - expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' }); - expect(next).not.toHaveBeenCalled(); - }); - - it('should return 401 if JWT verification fails (invalid signature)', async () => { - // Arrange - const userId = randomUUID(); - const mockUser = mockDeep(); - mockUser.id = userId; - - const wrongJwtService = new JwtService( - mock({ encryptionKey: 'wrong-key' }), - mock(), - ); - - const apiKey = wrongJwtService.sign({ - sub: userId, - iss: 'n8n', - aud: 'mcp-server-api', - jti: randomUUID(), - }); - - userRepository.findOne.mockResolvedValue(mockUser); - - const req = mockReqWith(`Bearer ${apiKey}`); - const res = mockDeep(); - res.status.mockReturnThis(); - res.send.mockReturnThis(); - const next = jest.fn() as NextFunction; - - const middleware = mcpServerApiKeyService.getAuthMiddleware(); - - // Act - await middleware(req, res, next); - - // Assert - expect(res.status).toHaveBeenCalledWith(401); - expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' }); - expect(next).not.toHaveBeenCalled(); - }); - - it('should authenticate successfully with valid API key', async () => { - // Arrange - const userId = randomUUID(); - const mockUser = mockDeep(); - mockUser.id = userId; - - const apiKey = jwtService.sign({ - sub: userId, - iss: 'n8n', - aud: 'mcp-server-api', - jti: randomUUID(), - }); - - userRepository.findOne.mockResolvedValue(mockUser); - - const req = mockReqWith(`Bearer ${apiKey}`); - const res = mockDeep(); - res.status.mockReturnThis(); - res.send.mockReturnThis(); - const next = jest.fn() as NextFunction; - - const middleware = mcpServerApiKeyService.getAuthMiddleware(); - - // Act - await middleware(req, res, next); - - // Assert - expect(next).toHaveBeenCalled(); - expect(res.status).not.toHaveBeenCalled(); - expect(res.send).not.toHaveBeenCalled(); - // @ts-ignore - expect(req.user).toBeDefined(); - // @ts-ignore - expect(req.user.id).toBe(userId); - }); - - it('should attach user with role information to request', async () => { - // Arrange - const userId = randomUUID(); - const mockUser = mockDeep(); - mockUser.id = userId; - - const apiKey = jwtService.sign({ - sub: userId, - iss: 'n8n', - aud: 'mcp-server-api', - jti: randomUUID(), - }); - - userRepository.findOne.mockResolvedValue(mockUser); - - const req = mockReqWith(`Bearer ${apiKey}`); - const res = mockDeep(); - res.status.mockReturnThis(); - res.send.mockReturnThis(); - const next = jest.fn() as NextFunction; - - const middleware = mcpServerApiKeyService.getAuthMiddleware(); - - // Act - await middleware(req, res, next); - - // Assert - expect(next).toHaveBeenCalled(); - // @ts-ignore - expect(req.user).toBeDefined(); - // @ts-ignore - expect(req.user.role).toBeDefined(); - }); - - it('should handle Bearer token with exact case matching', async () => { - // Arrange - const userId = randomUUID(); - const mockUser = mockDeep(); - mockUser.id = userId; - - const apiKey = jwtService.sign({ - sub: userId, - iss: 'n8n', - aud: 'mcp-server-api', - jti: randomUUID(), - }); - - userRepository.findOne.mockResolvedValue(mockUser); - - const req = mockReqWith(`Bearer ${apiKey}`); - const res = mockDeep(); - res.status.mockReturnThis(); - res.send.mockReturnThis(); - const next = jest.fn() as NextFunction; - - const middleware = mcpServerApiKeyService.getAuthMiddleware(); - - // Act - await middleware(req, res, next); - - // Assert - expect(next).toHaveBeenCalled(); - expect(res.status).not.toHaveBeenCalled(); - }); - - it('should throw error with non-standard Bearer casing', async () => { - // Arrange - const userId = randomUUID(); - const mockUser = mockDeep(); - mockUser.id = userId; - - const apiKey = jwtService.sign({ - sub: userId, - iss: 'n8n', - aud: 'mcp-server-api', - jti: randomUUID(), - }); - - userRepository.findOne.mockResolvedValue(mockUser); - - const req = mockReqWith(`BEARER ${apiKey}`); - const res = mockDeep(); - res.status.mockReturnThis(); - res.send.mockReturnThis(); - const next = jest.fn() as NextFunction; - - const middleware = mcpServerApiKeyService.getAuthMiddleware(); - - // Act & Assert - await expect(middleware(req, res, next)).rejects.toThrow( - 'Invalid authorization header format', - ); - expect(next).not.toHaveBeenCalled(); - }); - - it('should return 401 if user is not found for valid JWT', async () => { - // Arrange - const apiKey = jwtService.sign({ - sub: randomUUID(), - iss: 'n8n', - aud: 'mcp-server-api', - jti: randomUUID(), - }); - - userRepository.findOne.mockResolvedValue(null); - - const req = mockReqWith(`Bearer ${apiKey}`); - const res = mockDeep(); - res.status.mockReturnThis(); - res.send.mockReturnThis(); - const next = jest.fn() as NextFunction; - - const middleware = mcpServerApiKeyService.getAuthMiddleware(); - - // Act - await middleware(req, res, next); - - // Assert - expect(res.status).toHaveBeenCalledWith(401); - expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' }); - expect(next).not.toHaveBeenCalled(); - }); - - it('should return 401 for malformed JWT', async () => { - // Arrange - userRepository.findOne.mockResolvedValue(null); - - const req = mockReqWith('Bearer malformed.jwt.token'); - const res = mockDeep(); - res.status.mockReturnThis(); - res.send.mockReturnThis(); - const next = jest.fn() as NextFunction; - - const middleware = mcpServerApiKeyService.getAuthMiddleware(); - - // Act - await middleware(req, res, next); - - // Assert - expect(res.status).toHaveBeenCalledWith(401); - expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' }); - expect(next).not.toHaveBeenCalled(); - }); - - it('should handle Bearer token with extra whitespace', async () => { - // Arrange - const userId = randomUUID(); - const mockUser = mockDeep(); - mockUser.id = userId; - - const apiKey = jwtService.sign({ - sub: userId, - iss: 'n8n', - aud: 'mcp-server-api', - jti: randomUUID(), - }); - - userRepository.findOne.mockResolvedValue(mockUser); - - const req = mockReqWith(`Bearer ${apiKey}`); - const res = mockDeep(); - res.status.mockReturnThis(); - res.send.mockReturnThis(); - const next = jest.fn() as NextFunction; - - const middleware = mcpServerApiKeyService.getAuthMiddleware(); - - // Act - await middleware(req, res, next); - - // Assert - expect(next).toHaveBeenCalled(); - expect(res.status).not.toHaveBeenCalled(); - }); - - it('should return 401 if API key exists but user is deleted', async () => { - // Arrange - const apiKey = jwtService.sign({ - sub: randomUUID(), - iss: 'n8n', - aud: 'mcp-server-api', - jti: randomUUID(), - }); - - userRepository.findOne.mockResolvedValue(null); - - const req = mockReqWith(`Bearer ${apiKey}`); - const res = mockDeep(); - res.status.mockReturnThis(); - res.send.mockReturnThis(); - const next = jest.fn() as NextFunction; - - const middleware = mcpServerApiKeyService.getAuthMiddleware(); - - // Act - await middleware(req, res, next); - - // Assert - expect(res.status).toHaveBeenCalledWith(401); - expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' }); - expect(next).not.toHaveBeenCalled(); - }); - - it('should throw error with empty Bearer token', async () => { - // Arrange - const req = mockReqWith('Bearer '); - const res = mockDeep(); - res.status.mockReturnThis(); - res.send.mockReturnThis(); - const next = jest.fn() as NextFunction; - - const middleware = mcpServerApiKeyService.getAuthMiddleware(); - - // Act & Assert - await expect(middleware(req, res, next)).rejects.toThrow( - 'Invalid authorization header format', - ); - expect(next).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-server-middleware.service.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-server-middleware.service.test.ts new file mode 100644 index 00000000000..230e6cf97b4 --- /dev/null +++ b/packages/cli/src/modules/mcp/__tests__/mcp-server-middleware.service.test.ts @@ -0,0 +1,313 @@ +import { mockInstance } from '@n8n/backend-test-utils'; +import type { User } from '@n8n/db'; +import type { Request, Response, NextFunction } from 'express'; +import { mock, mockDeep } from 'jest-mock-extended'; +import type { InstanceSettings } from 'n8n-core'; + +import { JwtService } from '@/services/jwt.service'; +import { Telemetry } from '@/telemetry'; + +import { McpServerApiKeyService } from '../mcp-api-key.service'; +import { McpOAuthTokenService } from '../mcp-oauth-token.service'; +import { McpServerMiddlewareService } from '../mcp-server-middleware.service'; + +const mockReqWith = (authHeader: string | undefined, body?: any) => { + const req = mockDeep(); + req.header.mockImplementation((name: string) => { + if (name === 'authorization') return authHeader; + return undefined; + }); + req.body = body || {}; + return req; +}; + +const instanceSettings = mock({ encryptionKey: 'test-key' }); +const jwtService = new JwtService(instanceSettings, mock()); + +let mcpServerApiKeyService: jest.Mocked; +let oauthTokenService: jest.Mocked; +let telemetry: jest.Mocked; +let service: McpServerMiddlewareService; + +describe('McpServerMiddlewareService', () => { + beforeAll(() => { + mcpServerApiKeyService = mockInstance( + McpServerApiKeyService, + ) as jest.Mocked; + oauthTokenService = mockInstance(McpOAuthTokenService) as jest.Mocked; + telemetry = mockInstance(Telemetry); + + service = new McpServerMiddlewareService( + mcpServerApiKeyService, + oauthTokenService, + jwtService, + telemetry, + ); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getUserForToken', () => { + it('should return user for valid OAuth token (meta.isOAuth = true)', async () => { + const user = mock({ id: 'user-123' }); + const oauthToken = jwtService.sign({ + sub: 'user-123', + aud: 'mcp-server-api', + meta: { isOAuth: true }, + }); + + oauthTokenService.verifyOAuthAccessToken.mockResolvedValue(user); + + const result = await service.getUserForToken(oauthToken); + + expect(result).toEqual(user); + expect(oauthTokenService.verifyOAuthAccessToken).toHaveBeenCalledWith(oauthToken); + expect(mcpServerApiKeyService.verifyApiKey).not.toHaveBeenCalled(); + }); + + it('should return user for valid API key (no meta.isOAuth)', async () => { + const user = mock({ id: 'user-123' }); + const apiKeyToken = jwtService.sign({ + sub: 'user-123', + aud: 'mcp-server-api', + }); + + mcpServerApiKeyService.verifyApiKey.mockResolvedValue(user); + + const result = await service.getUserForToken(apiKeyToken); + + expect(result).toEqual(user); + expect(mcpServerApiKeyService.verifyApiKey).toHaveBeenCalledWith(apiKeyToken); + expect(oauthTokenService.verifyOAuthAccessToken).not.toHaveBeenCalled(); + }); + + it('should return user for valid API key (meta.isOAuth = false)', async () => { + const user = mock({ id: 'user-123' }); + const apiKeyToken = jwtService.sign({ + sub: 'user-123', + aud: 'mcp-server-api', + meta: { isOAuth: false }, + }); + + mcpServerApiKeyService.verifyApiKey.mockResolvedValue(user); + + const result = await service.getUserForToken(apiKeyToken); + + expect(result).toEqual(user); + expect(mcpServerApiKeyService.verifyApiKey).toHaveBeenCalledWith(apiKeyToken); + expect(oauthTokenService.verifyOAuthAccessToken).not.toHaveBeenCalled(); + }); + + it('should return null for invalid JWT format', async () => { + const invalidToken = 'not-a-jwt-token'; + + mcpServerApiKeyService.verifyApiKey.mockResolvedValue(null); + const result = await service.getUserForToken(invalidToken); + + expect(result).toBeNull(); + expect(oauthTokenService.verifyOAuthAccessToken).not.toHaveBeenCalled(); + }); + + it('should return null when OAuth token verification fails', async () => { + const oauthToken = jwtService.sign({ + sub: 'user-123', + aud: 'mcp-server-api', + meta: { isOAuth: true }, + }); + + oauthTokenService.verifyOAuthAccessToken.mockResolvedValue(null); + + const result = await service.getUserForToken(oauthToken); + + expect(result).toBeNull(); + }); + + it('should return null when API key verification fails', async () => { + const apiKeyToken = jwtService.sign({ + sub: 'user-123', + aud: 'mcp-server-api', + }); + + mcpServerApiKeyService.verifyApiKey.mockResolvedValue(null); + + const result = await service.getUserForToken(apiKeyToken); + + expect(result).toBeNull(); + }); + }); + + describe('getAuthMiddleware', () => { + it('should return 401 with WWW-Authenticate header when authorization header is missing', async () => { + const req = mockReqWith(undefined); + const res = mockDeep(); + res.status.mockReturnThis(); + res.send.mockReturnThis(); + res.header.mockReturnThis(); + const next = jest.fn() as NextFunction; + + const middleware = service.getAuthMiddleware(); + + await middleware(req, res, next); + + expect(res.header).toHaveBeenCalledWith('WWW-Authenticate', 'Bearer realm="n8n MCP Server"'); + expect(res.status).toHaveBeenCalledWith(401); + expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' }); + expect(next).not.toHaveBeenCalled(); + expect(telemetry.track).toHaveBeenCalledWith('User connected to MCP server', { + mcp_connection_status: 'error', + error: 'Unauthorized', + client_name: undefined, + client_version: undefined, + }); + }); + + it('should throw error when authorization header does not start with Bearer', async () => { + const req = mockReqWith('Basic sometoken'); + const res = mockDeep(); + res.status.mockReturnThis(); + const next = jest.fn() as NextFunction; + + const middleware = service.getAuthMiddleware(); + + await expect(middleware(req, res, next)).rejects.toThrow( + 'Invalid authorization header format', + ); + expect(next).not.toHaveBeenCalled(); + }); + + it('should throw error when Bearer token is malformed', async () => { + const req = mockReqWith('Bearer'); + const res = mockDeep(); + res.status.mockReturnThis(); + const next = jest.fn() as NextFunction; + + const middleware = service.getAuthMiddleware(); + + await expect(middleware(req, res, next)).rejects.toThrow( + 'Invalid authorization header format', + ); + expect(next).not.toHaveBeenCalled(); + }); + + it('should authenticate with valid OAuth token and call next', async () => { + const user = mock({ id: 'user-123' }); + const oauthToken = jwtService.sign({ + sub: 'user-123', + aud: 'mcp-server-api', + meta: { isOAuth: true }, + }); + + const req = mockReqWith(`Bearer ${oauthToken}`); + const res = mockDeep(); + const next = jest.fn() as NextFunction; + + oauthTokenService.verifyOAuthAccessToken.mockResolvedValue(user); + + const middleware = service.getAuthMiddleware(); + + await middleware(req, res, next); + + expect((req as any).user).toEqual(user); + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('should authenticate with valid API key and call next', async () => { + const user = mock({ id: 'user-123' }); + const apiKeyToken = jwtService.sign({ + sub: 'user-123', + aud: 'mcp-server-api', + }); + + const req = mockReqWith(`Bearer ${apiKeyToken}`); + const res = mockDeep(); + const next = jest.fn() as NextFunction; + + mcpServerApiKeyService.verifyApiKey.mockResolvedValue(user); + + const middleware = service.getAuthMiddleware(); + + await middleware(req, res, next); + + expect((req as any).user).toEqual(user); + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('should return 401 with WWW-Authenticate header when token validation fails', async () => { + const invalidToken = jwtService.sign({ + sub: 'user-123', + aud: 'mcp-server-api', + meta: { isOAuth: true }, + }); + + const req = mockReqWith(`Bearer ${invalidToken}`); + const res = mockDeep(); + res.status.mockReturnThis(); + res.send.mockReturnThis(); + res.header.mockReturnThis(); + const next = jest.fn() as NextFunction; + + oauthTokenService.verifyOAuthAccessToken.mockResolvedValue(null); + + const middleware = service.getAuthMiddleware(); + + await middleware(req, res, next); + + expect(res.header).toHaveBeenCalledWith('WWW-Authenticate', 'Bearer realm="n8n MCP Server"'); + expect(res.status).toHaveBeenCalledWith(401); + expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should track telemetry with client info from request body', async () => { + const req = mockReqWith(undefined, { + params: { + clientInfo: { + name: 'test-client', + version: '1.0.0', + }, + }, + }); + const res = mockDeep(); + res.status.mockReturnThis(); + res.send.mockReturnThis(); + res.header.mockReturnThis(); + const next = jest.fn() as NextFunction; + + const middleware = service.getAuthMiddleware(); + + await middleware(req, res, next); + + expect(res.header).toHaveBeenCalledWith('WWW-Authenticate', 'Bearer realm="n8n MCP Server"'); + expect(telemetry.track).toHaveBeenCalledWith('User connected to MCP server', { + mcp_connection_status: 'error', + error: 'Unauthorized', + client_name: 'test-client', + client_version: '1.0.0', + }); + }); + + it('should handle invalid token format gracefully with WWW-Authenticate header', async () => { + const req = mockReqWith('Bearer invalid-token-format'); + const res = mockDeep(); + res.status.mockReturnThis(); + res.send.mockReturnThis(); + res.header.mockReturnThis(); + const next = jest.fn() as NextFunction; + + mcpServerApiKeyService.verifyApiKey.mockResolvedValue(null); + + const middleware = service.getAuthMiddleware(); + + await middleware(req, res, next); + + expect(res.header).toHaveBeenCalledWith('WWW-Authenticate', 'Bearer realm="n8n MCP Server"'); + expect(res.status).toHaveBeenCalledWith(401); + expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' }); + expect(next).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp.auth.consent.controller.api.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp.auth.consent.controller.api.test.ts new file mode 100644 index 00000000000..f3f0ced81e7 --- /dev/null +++ b/packages/cli/src/modules/mcp/__tests__/mcp.auth.consent.controller.api.test.ts @@ -0,0 +1,433 @@ +import { testDb } from '@n8n/backend-test-utils'; +import type { User } from '@n8n/db'; +import { Container } from '@n8n/di'; + +import { JwtService } from '@/services/jwt.service'; +import { createOwner, createMember } from '@test-integration/db/users'; +import { setupTestServer } from '@test-integration/utils'; + +import type { OAuthClient } from '../database/entities/oauth-client.entity'; +import { OAuthClientRepository } from '../database/repositories/oauth-client.repository'; +import type { OAuthSessionPayload } from '../oauth-session.service'; + +const testServer = setupTestServer({ endpointGroups: ['mcp'], modules: ['mcp'] }); + +let owner: User; +let member: User; +let jwtService: JwtService; + +const createSessionToken = (payload: OAuthSessionPayload): string => { + return jwtService.sign(payload, { expiresIn: '10m' }); +}; +let oauthClientRepository: OAuthClientRepository; + +beforeAll(async () => { + owner = await createOwner(); + member = await createMember(); + jwtService = Container.get(JwtService); + oauthClientRepository = Container.get(OAuthClientRepository); +}); + +afterEach(async () => { + await testDb.truncate(['OAuthClient', 'AuthorizationCode', 'UserConsent']); +}); + +describe('GET /rest/consent/details', () => { + test('should return consent details for valid session', async () => { + const client = await oauthClientRepository.save({ + id: 'test-client-id', + name: 'Test OAuth Client', + redirectUris: ['https://example.com/callback'], + grantTypes: ['authorization_code'], + tokenEndpointAuthMethod: 'none', + }); + + const sessionPayload = { + clientId: client.id, + redirectUri: 'https://example.com/callback', + codeChallenge: 'test-challenge', + state: 'test-state', + }; + + const sessionToken = createSessionToken(sessionPayload); + + const response = await testServer + .authAgentFor(owner) + .get('/consent/details') + .set('Cookie', `n8n-oauth-session=${sessionToken}`); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toEqual({ + clientName: 'Test OAuth Client', + clientId: 'test-client-id', + }); + }); + + test('should return 400 when session cookie is missing', async () => { + const response = await testServer.authAgentFor(owner).get('/consent/details'); + + expect(response.statusCode).toBeGreaterThanOrEqual(400); + expect(response.body).toEqual({ + status: 'error', + message: 'Invalid or expired authorization session', + }); + }); + + test('should return 400 when session token is invalid', async () => { + const response = await testServer + .authAgentFor(owner) + .get('/consent/details') + .set('Cookie', 'n8n-oauth-session=invalid-token'); + + expect(response.statusCode).toBeGreaterThanOrEqual(400); + expect(response.body).toEqual({ + status: 'error', + message: 'Invalid or expired authorization session', + }); + }); + + test('should return 400 when client does not exist', async () => { + const sessionPayload = { + clientId: 'non-existent-client', + redirectUri: 'https://example.com/callback', + codeChallenge: 'test-challenge', + state: 'test-state', + }; + + const sessionToken = createSessionToken(sessionPayload); + + const response = await testServer + .authAgentFor(owner) + .get('/consent/details') + .set('Cookie', `n8n-oauth-session=${sessionToken}`); + + expect(response.statusCode).toBeGreaterThanOrEqual(400); + expect(response.body).toEqual({ + status: 'error', + message: 'Invalid or expired authorization session', + }); + }); + + test('should clear session cookie on invalid token', async () => { + const response = await testServer + .authAgentFor(owner) + .get('/consent/details') + .set('Cookie', 'n8n-oauth-session=invalid-token'); + + expect(response.statusCode).toBeGreaterThanOrEqual(400); + const setCookieHeader = response.headers['set-cookie']; + expect(setCookieHeader).toBeDefined(); + expect(setCookieHeader[0]).toContain('n8n-oauth-session='); + expect(setCookieHeader[0]).toMatch(/Max-Age=0|Expires=Thu, 01 Jan 1970/); + }); + + test('should require authentication', async () => { + const response = await testServer.authlessAgent.get('/consent/details'); + + expect(response.statusCode).toBe(401); + }); + + test('should work for different users', async () => { + const client = await oauthClientRepository.save({ + id: 'test-client-id-2', + name: 'Test Client 2', + redirectUris: ['https://example.com/callback'], + grantTypes: ['authorization_code'], + tokenEndpointAuthMethod: 'none', + }); + + const sessionPayload = { + clientId: client.id, + redirectUri: 'https://example.com/callback', + codeChallenge: 'test-challenge', + state: 'test-state', + }; + + const sessionToken = createSessionToken(sessionPayload); + + const ownerResponse = await testServer + .authAgentFor(owner) + .get('/consent/details') + .set('Cookie', `n8n-oauth-session=${sessionToken}`); + + expect(ownerResponse.statusCode).toBe(200); + expect(ownerResponse.body.data.clientName).toBe('Test Client 2'); + + const memberResponse = await testServer + .authAgentFor(member) + .get('/consent/details') + .set('Cookie', `n8n-oauth-session=${sessionToken}`); + + expect(memberResponse.statusCode).toBe(200); + expect(memberResponse.body.data.clientName).toBe('Test Client 2'); + }); +}); + +describe('POST /rest/consent/approve', () => { + let client: OAuthClient; + let sessionToken: string; + + beforeEach(async () => { + client = await oauthClientRepository.save({ + id: `test-client-${Date.now()}`, + name: 'Test OAuth Client', + redirectUris: ['https://example.com/callback'], + grantTypes: ['authorization_code'], + tokenEndpointAuthMethod: 'none', + }); + + const sessionPayload = { + clientId: client.id, + redirectUri: 'https://example.com/callback', + codeChallenge: 'test-challenge-string-that-is-long-enough', + state: 'test-state', + }; + + sessionToken = createSessionToken(sessionPayload); + }); + + test('should handle consent approval and return redirect URL with authorization code', async () => { + const response = await testServer + .authAgentFor(owner) + .post('/consent/approve') + .set('Cookie', `n8n-oauth-session=${sessionToken}`) + .send({ approved: true }); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toEqual({ + status: 'success', + redirectUrl: expect.stringContaining('https://example.com/callback?code='), + }); + + const redirectUrl = new URL(response.body.data.redirectUrl); + expect(redirectUrl.searchParams.get('code')).toBeTruthy(); + expect(redirectUrl.searchParams.get('code')?.length).toBeGreaterThan(32); + expect(redirectUrl.searchParams.get('state')).toBe('test-state'); + }); + + test('should handle consent denial and return error redirect URL', async () => { + const response = await testServer + .authAgentFor(owner) + .post('/consent/approve') + .set('Cookie', `n8n-oauth-session=${sessionToken}`) + .send({ approved: false }); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toEqual({ + status: 'success', + redirectUrl: expect.stringContaining('https://example.com/callback?error=access_denied'), + }); + + const redirectUrl = new URL(response.body.data.redirectUrl); + expect(redirectUrl.searchParams.get('error')).toBe('access_denied'); + expect(redirectUrl.searchParams.get('error_description')).toBeTruthy(); + expect(redirectUrl.searchParams.get('state')).toBe('test-state'); + }); + + test('should clear session cookie after processing consent', async () => { + const response = await testServer + .authAgentFor(owner) + .post('/consent/approve') + .set('Cookie', `n8n-oauth-session=${sessionToken}`) + .send({ approved: true }); + + expect(response.statusCode).toBe(200); + + const setCookieHeader = response.headers['set-cookie']; + expect(setCookieHeader).toBeDefined(); + expect(setCookieHeader[0]).toContain('n8n-oauth-session='); + expect(setCookieHeader[0]).toMatch(/Max-Age=0|Expires=Thu, 01 Jan 1970/); + }); + + test('should return 400 when approved field is missing', async () => { + const response = await testServer + .authAgentFor(owner) + .post('/consent/approve') + .set('Cookie', `n8n-oauth-session=${sessionToken}`) + .send({}); + + expect(response.statusCode).toBeGreaterThanOrEqual(400); + expect(response.body).toMatchObject({ + code: 'invalid_type', + expected: 'boolean', + path: ['approved'], + }); + }); + + test('should return 400 when approved field is not boolean', async () => { + const response = await testServer + .authAgentFor(owner) + .post('/consent/approve') + .set('Cookie', `n8n-oauth-session=${sessionToken}`) + .send({ approved: 'yes' }); + + expect(response.statusCode).toBeGreaterThanOrEqual(400); + expect(response.body).toMatchObject({ + code: 'invalid_type', + expected: 'boolean', + received: 'string', + path: ['approved'], + }); + }); + + test('should return 400 when session cookie is missing', async () => { + const response = await testServer + .authAgentFor(owner) + .post('/consent/approve') + .send({ approved: true }); + + expect(response.statusCode).toBeGreaterThanOrEqual(400); + expect(response.body).toEqual({ + status: 'error', + message: 'Invalid or expired authorization session', + }); + }); + + test('should return 400 when session token is invalid', async () => { + const response = await testServer + .authAgentFor(owner) + .post('/consent/approve') + .set('Cookie', 'n8n-oauth-session=invalid-token') + .send({ approved: true }); + + expect(response.statusCode).toBeGreaterThanOrEqual(400); + expect(response.body.status).toBe('error'); + }); + + test('should clear session cookie even on error', async () => { + const response = await testServer + .authAgentFor(owner) + .post('/consent/approve') + .set('Cookie', 'n8n-oauth-session=invalid-token') + .send({ approved: true }); + + const setCookieHeader = response.headers['set-cookie']; + expect(setCookieHeader).toBeDefined(); + expect(setCookieHeader[0]).toContain('n8n-oauth-session='); + expect(setCookieHeader[0]).toMatch(/Max-Age=0|Expires=Thu, 01 Jan 1970/); + }); + + test('should require authentication', async () => { + const response = await testServer.authlessAgent + .post('/consent/approve') + .set('Cookie', `n8n-oauth-session=${sessionToken}`) + .send({ approved: true }); + + expect(response.statusCode).toBe(401); + }); + + test('should create user consent record on approval', async () => { + const response = await testServer + .authAgentFor(owner) + .post('/consent/approve') + .set('Cookie', `n8n-oauth-session=${sessionToken}`) + .send({ approved: true }); + + expect(response.statusCode).toBe(200); + + const { UserConsentRepository } = await import( + '../database/repositories/oauth-user-consent.repository' + ); + const userConsentRepository = Container.get(UserConsentRepository); + const consent = await userConsentRepository.findOne({ + where: { userId: owner.id, clientId: client.id }, + }); + + expect(consent).toBeDefined(); + expect(consent?.userId).toBe(owner.id); + expect(consent?.clientId).toBe(client.id); + expect(consent?.grantedAt).toBeDefined(); + }); + + test('should not create user consent record on denial', async () => { + const response = await testServer + .authAgentFor(owner) + .post('/consent/approve') + .set('Cookie', `n8n-oauth-session=${sessionToken}`) + .send({ approved: false }); + + expect(response.statusCode).toBe(200); + + const { UserConsentRepository } = await import( + '../database/repositories/oauth-user-consent.repository' + ); + const userConsentRepository = Container.get(UserConsentRepository); + const consent = await userConsentRepository.findOne({ + where: { userId: owner.id, clientId: client.id }, + }); + + expect(consent).toBeNull(); + }); + + test('should handle consent from different users', async () => { + const ownerResponse = await testServer + .authAgentFor(owner) + .post('/consent/approve') + .set('Cookie', `n8n-oauth-session=${sessionToken}`) + .send({ approved: true }); + + expect(ownerResponse.statusCode).toBe(200); + expect(ownerResponse.body.data.redirectUrl).toContain('code='); + + const newSessionToken = createSessionToken({ + clientId: client.id, + redirectUri: 'https://example.com/callback', + codeChallenge: 'test-challenge-string-that-is-long-enough', + state: 'test-state-2', + }); + + const memberResponse = await testServer + .authAgentFor(member) + .post('/consent/approve') + .set('Cookie', `n8n-oauth-session=${newSessionToken}`) + .send({ approved: false }); + + expect(memberResponse.statusCode).toBe(200); + expect(memberResponse.body.data.redirectUrl).toContain('error=access_denied'); + }); +}); + +describe('Consent Flow - End-to-End', () => { + test('should complete full consent flow from details to approval', async () => { + const client = await oauthClientRepository.save({ + id: 'e2e-test-client', + name: 'End-to-End Test Client', + redirectUris: ['https://example.com/callback'], + grantTypes: ['authorization_code'], + tokenEndpointAuthMethod: 'none', + }); + + const sessionPayload = { + clientId: client.id, + redirectUri: 'https://example.com/callback', + codeChallenge: 'e2e-test-challenge-string-that-is-long-enough', + state: 'e2e-state', + }; + + const sessionToken = createSessionToken(sessionPayload); + + const detailsResponse = await testServer + .authAgentFor(owner) + .get('/consent/details') + .set('Cookie', `n8n-oauth-session=${sessionToken}`); + + expect(detailsResponse.statusCode).toBe(200); + expect(detailsResponse.body.data.clientName).toBe('End-to-End Test Client'); + + const approvalResponse = await testServer + .authAgentFor(owner) + .post('/consent/approve') + .set('Cookie', `n8n-oauth-session=${sessionToken}`) + .send({ approved: true }); + + expect(approvalResponse.statusCode).toBe(200); + expect(approvalResponse.body.data.status).toBe('success'); + expect(approvalResponse.body.data.redirectUrl).toContain('code='); + expect(approvalResponse.body.data.redirectUrl).toContain('state=e2e-state'); + + const setCookieHeader = approvalResponse.headers['set-cookie']; + expect(setCookieHeader).toBeDefined(); + expect(setCookieHeader[0]).toContain('n8n-oauth-session='); + expect(setCookieHeader[0]).toMatch(/Max-Age=0|Expires=Thu, 01 Jan 1970/); + }); +}); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp.controller.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp.controller.test.ts index dfa15c15b5b..0a60f3bbccd 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp.controller.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp.controller.test.ts @@ -1,19 +1,20 @@ import { Logger } from '@n8n/backend-common'; import { type AuthenticatedRequest } from '@n8n/db'; import { Container } from '@n8n/di'; +import type { Request } from 'express'; import { mock, mockDeep } from 'jest-mock-extended'; // eslint-disable-next-line import-x/order -import { McpServerApiKeyService } from '../mcp-api-key.service'; +import { McpServerMiddlewareService } from '../mcp-server-middleware.service'; const mockAuthMiddleware = jest.fn().mockImplementation(async (_req, _res, next) => { next(); }); -const mcpServerApiKeyService = mockDeep(); -mcpServerApiKeyService.getAuthMiddleware.mockReturnValue(mockAuthMiddleware); +const mcpServerMiddlewareService = mockDeep(); +mcpServerMiddlewareService.getAuthMiddleware.mockReturnValue(mockAuthMiddleware); // We need to mock the service before importing the controller as it's used in the middleware -Container.set(McpServerApiKeyService, mcpServerApiKeyService); +Container.set(McpServerMiddlewareService, mcpServerMiddlewareService); import { McpController, type FlushableResponse } from '../mcp.controller'; import { McpService } from '../mcp.service'; @@ -72,4 +73,17 @@ describe('McpController', () => { await controller.build(createReq(), res); expect(mcpService.getServer as unknown as jest.Mock).toHaveBeenCalled(); }); + + test('HEAD /http returns 401 with WWW-Authenticate header for auth scheme discovery', async () => { + const req = {} as Request; + const res = createRes(); + res.header = jest.fn().mockReturnThis(); + res.end = jest.fn().mockReturnThis(); + + await controller.discoverAuthSchemeHead(req, res); + + expect(res.header).toHaveBeenCalledWith('WWW-Authenticate', 'Bearer realm="n8n MCP Server"'); + expect(res.status).toHaveBeenCalledWith(401); + expect(res.end).toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp.oauth.controller.api.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp.oauth.controller.api.test.ts new file mode 100644 index 00000000000..e9e7aa8ed42 --- /dev/null +++ b/packages/cli/src/modules/mcp/__tests__/mcp.oauth.controller.api.test.ts @@ -0,0 +1,335 @@ +import { testDb } from '@n8n/backend-test-utils'; +import type { User } from '@n8n/db'; + +import { createOwner } from '@test-integration/db/users'; +import { setupTestServer } from '@test-integration/utils'; + +import { SUPPORTED_SCOPES } from '../mcp-oauth-service'; + +const testServer = setupTestServer({ modules: ['mcp'], endpointGroups: ['mcp'] }); + +let owner: User; + +beforeAll(async () => { + owner = await createOwner(); +}); + +afterEach(async () => { + await testDb.truncate(['OAuthClient', 'AuthorizationCode', 'AccessToken', 'RefreshToken']); +}); + +describe('GET /.well-known/oauth-authorization-server', () => { + test('should return OAuth authorization server metadata', async () => { + const response = await testServer.restlessAgent.get('/.well-known/oauth-authorization-server'); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ + issuer: expect.any(String), + authorization_endpoint: expect.stringContaining('/mcp-oauth/authorize'), + token_endpoint: expect.stringContaining('/mcp-oauth/token'), + registration_endpoint: expect.stringContaining('/mcp-oauth/register'), + revocation_endpoint: expect.stringContaining('/mcp-oauth/revoke'), + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + token_endpoint_auth_methods_supported: ['none', 'client_secret_post', 'client_secret_basic'], + code_challenge_methods_supported: ['S256'], + scopes_supported: SUPPORTED_SCOPES, + }); + }); + + test('should return metadata with correct base URL', async () => { + const response = await testServer.restlessAgent.get('/.well-known/oauth-authorization-server'); + + expect(response.statusCode).toBe(200); + + const { + issuer, + authorization_endpoint, + token_endpoint, + registration_endpoint, + revocation_endpoint, + } = response.body; + + expect(issuer).toMatch(/^https?:\/\//); + expect(authorization_endpoint).toBe(`${issuer}/mcp-oauth/authorize`); + expect(token_endpoint).toBe(`${issuer}/mcp-oauth/token`); + expect(registration_endpoint).toBe(`${issuer}/mcp-oauth/register`); + expect(revocation_endpoint).toBe(`${issuer}/mcp-oauth/revoke`); + }); + + test('should include all required OAuth 2.1 fields', async () => { + const response = await testServer.restlessAgent.get('/.well-known/oauth-authorization-server'); + + expect(response.statusCode).toBe(200); + + const metadata = response.body; + + expect(metadata.issuer).toBeDefined(); + expect(metadata.authorization_endpoint).toBeDefined(); + expect(metadata.token_endpoint).toBeDefined(); + expect(metadata.response_types_supported).toBeDefined(); + expect(metadata.grant_types_supported).toBeDefined(); + expect(metadata.code_challenge_methods_supported).toContain('S256'); + }); + + test('should be accessible without authentication', async () => { + const response = await testServer.restlessAgent.get('/.well-known/oauth-authorization-server'); + + expect(response.statusCode).toBe(200); + }); +}); + +describe('GET /.well-known/oauth-protected-resource/mcp-server/http', () => { + test('should return protected resource metadata', async () => { + const response = await testServer.restlessAgent.get( + '/.well-known/oauth-protected-resource/mcp-server/http', + ); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ + resource: expect.stringContaining('/mcp-server/http'), + bearer_methods_supported: ['header'], + authorization_servers: [expect.any(String)], + scopes_supported: SUPPORTED_SCOPES, + }); + }); + + test('should return metadata with correct resource URL', async () => { + const response = await testServer.restlessAgent.get( + '/.well-known/oauth-protected-resource/mcp-server/http', + ); + + expect(response.statusCode).toBe(200); + + const { resource, authorization_servers } = response.body; + + expect(resource).toMatch(/^https?:\/\//); + expect(resource).toContain('/mcp-server/http'); + expect(authorization_servers).toHaveLength(1); + expect(authorization_servers[0]).toMatch(/^https?:\/\//); + }); + + test('should indicate Bearer token authentication via header', async () => { + const response = await testServer.restlessAgent.get( + '/.well-known/oauth-protected-resource/mcp-server/http', + ); + + expect(response.statusCode).toBe(200); + expect(response.body.bearer_methods_supported).toEqual(['header']); + }); + + test('should list supported scopes', async () => { + const response = await testServer.restlessAgent.get( + '/.well-known/oauth-protected-resource/mcp-server/http', + ); + + expect(response.statusCode).toBe(200); + expect(response.body.scopes_supported).toEqual(SUPPORTED_SCOPES); + expect(response.body.scopes_supported.length).toBeGreaterThan(0); + }); + + test('should be accessible without authentication', async () => { + const response = await testServer.restlessAgent.get( + '/.well-known/oauth-protected-resource/mcp-server/http', + ); + + expect(response.statusCode).toBe(200); + }); +}); + +describe('POST /mcp-oauth/register', () => { + test('should register a new OAuth client with dynamic registration', async () => { + const clientData = { + client_name: 'Test MCP Client', + redirect_uris: ['https://example.com/callback'], + grant_types: ['authorization_code', 'refresh_token'], + token_endpoint_auth_method: 'none', + }; + + const response = await testServer.restlessAgent.post('/mcp-oauth/register').send(clientData); + + expect(response.statusCode).toBe(201); + expect(response.body.client_id).toBeDefined(); + expect(response.body.client_name).toBe('Test MCP Client'); + expect(response.body.redirect_uris).toEqual(['https://example.com/callback']); + expect(response.body.grant_types).toEqual(['authorization_code', 'refresh_token']); + expect(response.body.token_endpoint_auth_method).toBe('none'); + }); + + test('should generate unique client IDs for each registration', async () => { + const clientData = { + client_name: 'Test Client 1', + redirect_uris: ['https://example.com/callback'], + grant_types: ['authorization_code'], + token_endpoint_auth_method: 'none', + }; + + const response1 = await testServer.restlessAgent.post('/mcp-oauth/register').send(clientData); + const response2 = await testServer.restlessAgent + .post('/mcp-oauth/register') + .send({ ...clientData, client_name: 'Test Client 2' }); + + expect(response1.statusCode).toBe(201); + expect(response2.statusCode).toBe(201); + expect(response1.body.client_id).toBeDefined(); + expect(response2.body.client_id).toBeDefined(); + expect(response1.body.client_id).not.toBe(response2.body.client_id); + }); + + test('should accept client registration without authentication', async () => { + const clientData = { + client_name: 'Public Client', + redirect_uris: ['https://example.com/callback'], + grant_types: ['authorization_code'], + token_endpoint_auth_method: 'none', + }; + + const response = await testServer.restlessAgent.post('/mcp-oauth/register').send(clientData); + + expect(response.statusCode).toBe(201); + }); + + test('should validate required fields in client registration', async () => { + const response = await testServer.restlessAgent.post('/mcp-oauth/register').send({}); + + expect(response.statusCode).toBeGreaterThanOrEqual(400); + }); +}); + +describe('GET /mcp-oauth/authorize', () => { + test('should require authentication for authorization endpoint', async () => { + const response = await testServer.restlessAgent.get('/mcp-oauth/authorize').query({ + client_id: 'test-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'challenge', + code_challenge_method: 'S256', + }); + + expect([302, 400, 401, 403]).toContain(response.statusCode); + }); + + test('should accept valid authorization request parameters', async () => { + const registerResponse = await testServer.restlessAgent.post('/mcp-oauth/register').send({ + client_name: 'Test Client', + redirect_uris: ['https://example.com/callback'], + grant_types: ['authorization_code'], + token_endpoint_auth_method: 'none', + }); + + const clientId = registerResponse.body.client_id; + + const response = await testServer.authAgentFor(owner).get('/mcp-oauth/authorize').query({ + client_id: clientId, + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'test-challenge-string-must-be-long-enough-for-validation', + code_challenge_method: 'S256', + state: 'random-state', + }); + + expect(response.statusCode).toBeGreaterThanOrEqual(200); + }); +}); + +describe('POST /mcp-oauth/token', () => { + test('should be accessible without authentication', async () => { + const response = await testServer.restlessAgent.post('/mcp-oauth/token').send({ + grant_type: 'authorization_code', + code: 'invalid-code', + client_id: 'test-client', + }); + + expect(response.statusCode).not.toBe(401); + }); + + test('should return error for invalid authorization code', async () => { + const response = await testServer.restlessAgent.post('/mcp-oauth/token').send({ + grant_type: 'authorization_code', + code: 'invalid-authorization-code', + client_id: 'test-client', + redirect_uri: 'https://example.com/callback', + code_verifier: 'test-verifier', + }); + + expect(response.statusCode).toBeGreaterThanOrEqual(400); + expect(response.body.error).toBeDefined(); + }); + + test('should validate grant_type parameter', async () => { + const response = await testServer.restlessAgent.post('/mcp-oauth/token').send({ + grant_type: 'invalid_grant_type', + code: 'test-code', + client_id: 'test-client', + }); + + expect(response.statusCode).toBeGreaterThanOrEqual(400); + expect(response.body.error).toBeDefined(); + }); +}); + +describe('POST /mcp-oauth/revoke', () => { + test('should be accessible without authentication', async () => { + const response = await testServer.restlessAgent.post('/mcp-oauth/revoke').send({ + token: 'test-token', + client_id: 'test-client', + }); + + expect(response.statusCode).not.toBe(401); + }); + + test('should accept token revocation request', async () => { + const response = await testServer.restlessAgent.post('/mcp-oauth/revoke').send({ + token: 'some-token-to-revoke', + client_id: 'test-client', + token_type_hint: 'access_token', + }); + + expect([200, 204, 400]).toContain(response.statusCode); + }); + + test('should handle revocation without token_type_hint', async () => { + const response = await testServer.restlessAgent.post('/mcp-oauth/revoke').send({ + token: 'some-token', + client_id: 'test-client', + }); + + expect(response.statusCode).toBeGreaterThanOrEqual(200); + }); +}); + +describe('OAuth Discovery - Cross-validation', () => { + test('should have consistent URLs between authorization server and protected resource metadata', async () => { + const authServerResponse = await testServer.restlessAgent.get( + '/.well-known/oauth-authorization-server', + ); + const protectedResourceResponse = await testServer.restlessAgent.get( + '/.well-known/oauth-protected-resource/mcp-server/http', + ); + + expect(authServerResponse.statusCode).toBe(200); + expect(protectedResourceResponse.statusCode).toBe(200); + + const authServer = authServerResponse.body; + const protectedResource = protectedResourceResponse.body; + + expect(protectedResource.authorization_servers).toContain(authServer.issuer); + }); + + test('should have consistent scopes between authorization server and protected resource', async () => { + const authServerResponse = await testServer.restlessAgent.get( + '/.well-known/oauth-authorization-server', + ); + const protectedResourceResponse = await testServer.restlessAgent.get( + '/.well-known/oauth-protected-resource/mcp-server/http', + ); + + expect(authServerResponse.statusCode).toBe(200); + expect(protectedResourceResponse.statusCode).toBe(200); + + const authServerScopes = authServerResponse.body.scopes_supported; + const protectedResourceScopes = protectedResourceResponse.body.scopes_supported; + + expect(authServerScopes).toEqual(protectedResourceScopes); + }); +}); diff --git a/packages/cli/src/modules/mcp/__tests__/search-workflows.tool.test.ts b/packages/cli/src/modules/mcp/__tests__/search-workflows.tool.test.ts index 8632b6c0de1..8a2cfb29f11 100644 --- a/packages/cli/src/modules/mcp/__tests__/search-workflows.tool.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/search-workflows.tool.test.ts @@ -97,18 +97,16 @@ describe('search-workflows MCP tool', () => { }); await searchWorkflows(user, workflowService as unknown as WorkflowService, { limit: 500, - active: true, - name: 'foo', + query: 'foo', projectId: 'proj-1', }); const [_userArg, optionsArg] = (workflowService.getMany as jest.Mock).mock.calls[0]; - expect(optionsArg.take).toBe(200); // clamped to MAX_RESULTS + expect(optionsArg.take).toBe(200); expect(optionsArg.filter).toMatchObject({ isArchived: false, availableInMCP: true, - active: true, - name: 'foo', + query: 'foo', projectId: 'proj-1', }); }); diff --git a/packages/cli/src/modules/mcp/database/entities/oauth-access-token.entity.ts b/packages/cli/src/modules/mcp/database/entities/oauth-access-token.entity.ts new file mode 100644 index 00000000000..e518eb3dba5 --- /dev/null +++ b/packages/cli/src/modules/mcp/database/entities/oauth-access-token.entity.ts @@ -0,0 +1,28 @@ +import { User } from '@n8n/db'; +import { Column, Entity, Index, ManyToOne } from '@n8n/typeorm'; + +import { OAuthClient } from './oauth-client.entity'; + +@Entity('oauth_access_tokens') +export class AccessToken { + @Column({ type: 'varchar', primary: true }) + token: string; + + @ManyToOne( + () => OAuthClient, + (client) => client.accessTokens, + { onDelete: 'CASCADE' }, + ) + client: OAuthClient; + + @Index() + @Column({ type: String }) + clientId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + user: User; + + @Index() + @Column({ type: String }) + userId: string; +} diff --git a/packages/cli/src/modules/mcp/database/entities/oauth-authorization-code.entity.ts b/packages/cli/src/modules/mcp/database/entities/oauth-authorization-code.entity.ts new file mode 100644 index 00000000000..7626716d031 --- /dev/null +++ b/packages/cli/src/modules/mcp/database/entities/oauth-authorization-code.entity.ts @@ -0,0 +1,47 @@ +import { User, WithTimestamps } from '@n8n/db'; +import { Column, Entity, Index, ManyToOne } from '@n8n/typeorm'; + +import { OAuthClient } from './oauth-client.entity'; + +@Entity('oauth_authorization_codes') +export class AuthorizationCode extends WithTimestamps { + @Column({ type: 'varchar', primary: true }) + code: string; + + @ManyToOne( + () => OAuthClient, + (client) => client.authorizationCodes, + { onDelete: 'CASCADE' }, + ) + client: OAuthClient; + + @Index() + @Column({ type: String }) + clientId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + user: User; + + @Index() + @Column({ type: String }) + userId: string; + + @Column({ type: String }) + redirectUri: string; + + @Column({ type: String }) + codeChallenge: string; + + @Column({ type: String }) + codeChallengeMethod: string; + + @Column({ type: String, nullable: true }) + state: string | null; + + @Index() + @Column({ type: 'int' }) + expiresAt: number; + + @Column({ type: Boolean, default: false }) + used: boolean; +} diff --git a/packages/cli/src/modules/mcp/database/entities/oauth-client.entity.ts b/packages/cli/src/modules/mcp/database/entities/oauth-client.entity.ts new file mode 100644 index 00000000000..47aa98d2101 --- /dev/null +++ b/packages/cli/src/modules/mcp/database/entities/oauth-client.entity.ts @@ -0,0 +1,43 @@ +import { JsonColumn, WithTimestamps } from '@n8n/db'; +import { Column, Entity, OneToMany } from '@n8n/typeorm'; + +import type { AccessToken } from './oauth-access-token.entity'; +import type { AuthorizationCode } from './oauth-authorization-code.entity'; +import type { RefreshToken } from './oauth-refresh-token.entity'; +import type { UserConsent } from './oauth-user-consent.entity'; + +@Entity('oauth_clients') +export class OAuthClient extends WithTimestamps { + @Column({ type: 'varchar', primary: true }) + id: string; + + @Column({ type: String }) + name: string; + + @JsonColumn() + redirectUris: string[]; + + @JsonColumn() + grantTypes: string[]; + + @Column({ type: String, default: 'none' }) + tokenEndpointAuthMethod: string; + + @OneToMany('AuthorizationCode', 'client') + authorizationCodes: AuthorizationCode[]; + + @OneToMany('AccessToken', 'client') + accessTokens: AccessToken[]; + + @OneToMany('RefreshToken', 'client') + refreshTokens: RefreshToken[]; + + @OneToMany('UserConsent', 'client') + userConsents: UserConsent[]; + + @Column({ type: String, nullable: true }) + clientSecret: string | null; + + @Column({ type: 'int', nullable: true }) + clientSecretExpiresAt: number | null; +} diff --git a/packages/cli/src/modules/mcp/database/entities/oauth-refresh-token.entity.ts b/packages/cli/src/modules/mcp/database/entities/oauth-refresh-token.entity.ts new file mode 100644 index 00000000000..7c19e5b4274 --- /dev/null +++ b/packages/cli/src/modules/mcp/database/entities/oauth-refresh-token.entity.ts @@ -0,0 +1,32 @@ +import { User, WithTimestamps } from '@n8n/db'; +import { Column, Entity, Index, ManyToOne } from '@n8n/typeorm'; + +import { OAuthClient } from './oauth-client.entity'; + +@Entity('oauth_refresh_tokens') +export class RefreshToken extends WithTimestamps { + @Column({ type: 'varchar', primary: true }) + token: string; + + @ManyToOne( + () => OAuthClient, + (client) => client.refreshTokens, + { onDelete: 'CASCADE' }, + ) + client: OAuthClient; + + @Index() + @Column({ type: String }) + clientId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + user: User; + + @Index() + @Column({ type: String }) + userId: string; + + @Index() + @Column({ type: 'int' }) + expiresAt: number; +} diff --git a/packages/cli/src/modules/mcp/database/entities/oauth-user-consent.entity.ts b/packages/cli/src/modules/mcp/database/entities/oauth-user-consent.entity.ts new file mode 100644 index 00000000000..27e0cfd1204 --- /dev/null +++ b/packages/cli/src/modules/mcp/database/entities/oauth-user-consent.entity.ts @@ -0,0 +1,32 @@ +import { User } from '@n8n/db'; +import { Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn, Unique } from '@n8n/typeorm'; + +import { OAuthClient } from './oauth-client.entity'; + +@Entity('oauth_user_consents') +@Unique(['userId', 'clientId']) +export class UserConsent { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + user: User; + + @Index() + @Column({ type: String }) + userId: string; + + @ManyToOne( + () => OAuthClient, + (client) => client.userConsents, + { onDelete: 'CASCADE' }, + ) + client: OAuthClient; + + @Index() + @Column({ type: String }) + clientId: string; + + @Column({ type: 'bigint' }) + grantedAt: number; +} diff --git a/packages/cli/src/modules/mcp/database/repositories/oauth-access-token.repository.ts b/packages/cli/src/modules/mcp/database/repositories/oauth-access-token.repository.ts new file mode 100644 index 00000000000..d2f6aafe1b0 --- /dev/null +++ b/packages/cli/src/modules/mcp/database/repositories/oauth-access-token.repository.ts @@ -0,0 +1,11 @@ +import { Service } from '@n8n/di'; +import { DataSource, Repository } from '@n8n/typeorm'; + +import { AccessToken } from '../entities/oauth-access-token.entity'; + +@Service() +export class AccessTokenRepository extends Repository { + constructor(dataSource: DataSource) { + super(AccessToken, dataSource.manager); + } +} diff --git a/packages/cli/src/modules/mcp/database/repositories/oauth-authorization-code.repository.ts b/packages/cli/src/modules/mcp/database/repositories/oauth-authorization-code.repository.ts new file mode 100644 index 00000000000..09ef581ce7e --- /dev/null +++ b/packages/cli/src/modules/mcp/database/repositories/oauth-authorization-code.repository.ts @@ -0,0 +1,11 @@ +import { Service } from '@n8n/di'; +import { DataSource, Repository } from '@n8n/typeorm'; + +import { AuthorizationCode } from '../entities/oauth-authorization-code.entity'; + +@Service() +export class AuthorizationCodeRepository extends Repository { + constructor(dataSource: DataSource) { + super(AuthorizationCode, dataSource.manager); + } +} diff --git a/packages/cli/src/modules/mcp/database/repositories/oauth-client.repository.ts b/packages/cli/src/modules/mcp/database/repositories/oauth-client.repository.ts new file mode 100644 index 00000000000..e34d5a56d6a --- /dev/null +++ b/packages/cli/src/modules/mcp/database/repositories/oauth-client.repository.ts @@ -0,0 +1,11 @@ +import { Service } from '@n8n/di'; +import { DataSource, Repository } from '@n8n/typeorm'; + +import { OAuthClient } from '../entities/oauth-client.entity'; + +@Service() +export class OAuthClientRepository extends Repository { + constructor(dataSource: DataSource) { + super(OAuthClient, dataSource.manager); + } +} diff --git a/packages/cli/src/modules/mcp/database/repositories/oauth-refresh-token.repository.ts b/packages/cli/src/modules/mcp/database/repositories/oauth-refresh-token.repository.ts new file mode 100644 index 00000000000..fafa530341e --- /dev/null +++ b/packages/cli/src/modules/mcp/database/repositories/oauth-refresh-token.repository.ts @@ -0,0 +1,11 @@ +import { Service } from '@n8n/di'; +import { DataSource, Repository } from '@n8n/typeorm'; + +import { RefreshToken } from '../entities/oauth-refresh-token.entity'; + +@Service() +export class RefreshTokenRepository extends Repository { + constructor(dataSource: DataSource) { + super(RefreshToken, dataSource.manager); + } +} diff --git a/packages/cli/src/modules/mcp/database/repositories/oauth-user-consent.repository.ts b/packages/cli/src/modules/mcp/database/repositories/oauth-user-consent.repository.ts new file mode 100644 index 00000000000..a4d13c377cb --- /dev/null +++ b/packages/cli/src/modules/mcp/database/repositories/oauth-user-consent.repository.ts @@ -0,0 +1,22 @@ +import { Service } from '@n8n/di'; +import { DataSource, Repository } from '@n8n/typeorm'; + +import { UserConsent } from '../entities/oauth-user-consent.entity'; + +@Service() +export class UserConsentRepository extends Repository { + constructor(dataSource: DataSource) { + super(UserConsent, dataSource.manager); + } + + /** + * Find all consents for a user with client information + */ + async findByUserWithClient(userId: string): Promise { + return await this.find({ + where: { userId }, + relations: ['client'], + order: { grantedAt: 'DESC' }, + }); + } +} diff --git a/packages/cli/src/modules/mcp/dto/approve-consent-request.dto.ts b/packages/cli/src/modules/mcp/dto/approve-consent-request.dto.ts new file mode 100644 index 00000000000..afc0de92987 --- /dev/null +++ b/packages/cli/src/modules/mcp/dto/approve-consent-request.dto.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class ApproveConsentRequestDto extends Z.class({ + approved: z.boolean(), +}) {} diff --git a/packages/cli/src/modules/mcp/mcp-api-key.service.ts b/packages/cli/src/modules/mcp/mcp-api-key.service.ts index 27387c5b7fa..55719b2ea55 100644 --- a/packages/cli/src/modules/mcp/mcp-api-key.service.ts +++ b/packages/cli/src/modules/mcp/mcp-api-key.service.ts @@ -1,16 +1,12 @@ -import { ApiKey, ApiKeyRepository, AuthenticatedRequest, User, UserRepository } from '@n8n/db'; +import { ApiKey, ApiKeyRepository, User, UserRepository } from '@n8n/db'; import { Service } from '@n8n/di'; import { EntityManager } from '@n8n/typeorm'; import { randomUUID } from 'crypto'; -import { NextFunction, Response, Request } from 'express'; import { ApiKeyAudience } from 'n8n-workflow'; -import { USER_CONNECTED_TO_MCP_EVENT, UNAUTHORIZED_ERROR_MESSAGE } from './mcp.constants'; -import { getClientInfo } from './mcp.utils'; - -import { AuthError } from '@/errors/response-errors/auth.error'; import { JwtService } from '@/services/jwt.service'; -import { Telemetry } from '@/telemetry'; + +import { AccessTokenRepository } from './database/repositories/oauth-access-token.repository'; const API_KEY_AUDIENCE: ApiKeyAudience = 'mcp-server-api'; const API_KEY_ISSUER = 'n8n'; @@ -28,7 +24,7 @@ export class McpServerApiKeyService { private readonly apiKeyRepository: ApiKeyRepository, private readonly jwtService: JwtService, private readonly userRepository: UserRepository, - private readonly telemetry: Telemetry, + private readonly accessTokenRepository: AccessTokenRepository, ) {} async createMcpServerApiKey(user: User, trx?: EntityManager) { @@ -69,7 +65,7 @@ export class McpServerApiKeyService { return apiKey; } - private async getUserForApiKey(apiKey: string) { + async getUserForApiKey(apiKey: string) { return await this.userRepository.findOne({ where: { apiKeys: { @@ -81,6 +77,38 @@ export class McpServerApiKeyService { }); } + async verifyApiKey(apiKey: string): Promise { + try { + this.jwtService.verify(apiKey, { + issuer: API_KEY_ISSUER, + audience: API_KEY_AUDIENCE, + }); + + return await this.getUserForApiKey(apiKey); + } catch (error) { + return null; + } + } + + async getUserForAccessToken(token: string) { + const accessToken = await this.accessTokenRepository.findOne({ + where: { + token, + }, + }); + + if (!accessToken) { + return null; + } + + return await this.userRepository.findOne({ + where: { + id: accessToken.userId, + }, + relations: ['role'], + }); + } + async deleteAllMcpApiKeysForUser(user: User, trx?: EntityManager) { const manager = trx ?? this.apiKeyRepository.manager; @@ -103,71 +131,6 @@ export class McpServerApiKeyService { return redactedPart + visiblePart; } - private extractAPIKeyFromHeader(headerValue: string) { - if (!headerValue.startsWith('Bearer')) { - throw new AuthError('Invalid authorization header format'); - } - const apiKeyMatch = headerValue.match(/^Bearer\s+(.+)$/i); - if (apiKeyMatch) { - return apiKeyMatch[1]; - } - throw new AuthError('Invalid authorization header format'); - } - - getAuthMiddleware() { - return async (req: Request, res: Response, next: NextFunction) => { - const authorizationHeader = req.header('authorization'); - - if (!authorizationHeader) { - this.responseWithUnauthorized(res, req); - return; - } - - const apiKey = this.extractAPIKeyFromHeader(authorizationHeader); - - if (!apiKey) { - this.responseWithUnauthorized(res, req); - return; - } - - const user = await this.getUserForApiKey(apiKey); - - if (!user) { - this.responseWithUnauthorized(res, req); - return; - } - - try { - this.jwtService.verify(apiKey, { - issuer: API_KEY_ISSUER, - audience: API_KEY_AUDIENCE, - }); - } catch (e) { - this.responseWithUnauthorized(res, req); - return; - } - - (req as AuthenticatedRequest).user = user; - - next(); - }; - } - - private responseWithUnauthorized(res: Response, req: Request) { - this.trackUnauthorizedEvent(req); - res.status(401).send({ message: UNAUTHORIZED_ERROR_MESSAGE }); - } - - private trackUnauthorizedEvent(req: Request) { - const clientInfo = getClientInfo(req); - this.telemetry.track(USER_CONNECTED_TO_MCP_EVENT, { - mcp_connection_status: 'error', - error: UNAUTHORIZED_ERROR_MESSAGE, - client_name: clientInfo?.name, - client_version: clientInfo?.version, - }); - } - async getOrCreateApiKey(user: User) { const apiKey = await this.apiKeyRepository.findOne({ where: { diff --git a/packages/cli/src/modules/mcp/mcp-oauth-authorization-code.service.ts b/packages/cli/src/modules/mcp/mcp-oauth-authorization-code.service.ts new file mode 100644 index 00000000000..1869bf4073b --- /dev/null +++ b/packages/cli/src/modules/mcp/mcp-oauth-authorization-code.service.ts @@ -0,0 +1,110 @@ +import { Time } from '@n8n/constants'; +import { Service } from '@n8n/di'; +import { randomBytes } from 'node:crypto'; + +import type { AuthorizationCode } from './database/entities/oauth-authorization-code.entity'; +import { AuthorizationCodeRepository } from './database/repositories/oauth-authorization-code.repository'; + +/** + * Handles OAuth 2.1 authorization code lifecycle for MCP server + * Generates, validates, and consumes authorization codes with PKCE support + */ +@Service() +export class McpOAuthAuthorizationCodeService { + private readonly AUTHORIZATION_CODE_EXPIRY_MS = 10 * Time.minutes.toMilliseconds; + + constructor(private readonly authorizationCodeRepository: AuthorizationCodeRepository) {} + + /** + * Generate and save authorization code + * Returns the generated code string + */ + async createAuthorizationCode( + clientId: string, + userId: string, + redirectUri: string, + codeChallenge: string, + state: string | null, + ): Promise { + const code = randomBytes(32).toString('hex'); + + await this.authorizationCodeRepository.insert({ + code, + clientId, + userId, + redirectUri, + codeChallenge, + codeChallengeMethod: 'S256', + state, + expiresAt: Date.now() + this.AUTHORIZATION_CODE_EXPIRY_MS, + used: false, + }); + + return code; + } + + /** + * Find and validate authorization code (without consuming) + * Returns the auth record if valid, throws if invalid/expired + */ + async findAndValidateAuthorizationCode( + authorizationCode: string, + clientId: string, + ): Promise { + const authRecord = await this.authorizationCodeRepository.findOne({ + where: { + code: authorizationCode, + clientId, + }, + }); + + if (!authRecord) { + throw new Error('Invalid authorization code'); + } + + if (authRecord.expiresAt < Date.now()) { + await this.authorizationCodeRepository.remove(authRecord); + throw new Error('Authorization code expired'); + } + + return authRecord; + } + + /** + * Validate and consume authorization code + * Returns the auth record if valid, throws if invalid/expired/used + */ + async validateAndConsumeAuthorizationCode( + authorizationCode: string, + clientId: string, + redirectUri?: string, + ): Promise { + const authRecord = await this.findAndValidateAuthorizationCode(authorizationCode, clientId); + + if (redirectUri && authRecord.redirectUri !== redirectUri) { + throw new Error('Redirect URI mismatch'); + } + + const result = await this.authorizationCodeRepository.update( + { code: authorizationCode, used: false }, + { used: true }, + ); + + const numAffected = result.affected ?? 0; + if (numAffected < 1) { + throw new Error('Authorization code already used'); + } + + authRecord.used = true; + return authRecord; + } + + /** + * Get PKCE code challenge for authorization code + * Used by MCP SDK for PKCE verification + */ + async getCodeChallenge(authorizationCode: string, clientId: string): Promise { + const authRecord = await this.findAndValidateAuthorizationCode(authorizationCode, clientId); + return authRecord.codeChallenge; + } +} diff --git a/packages/cli/src/modules/mcp/mcp-oauth-consent.service.ts b/packages/cli/src/modules/mcp/mcp-oauth-consent.service.ts new file mode 100644 index 00000000000..5d35509819e --- /dev/null +++ b/packages/cli/src/modules/mcp/mcp-oauth-consent.service.ts @@ -0,0 +1,113 @@ +import { Logger } from '@n8n/backend-common'; +import { Service } from '@n8n/di'; +import { UserError } from 'n8n-workflow'; + +import { OAuthClientRepository } from './database/repositories/oauth-client.repository'; +import { UserConsentRepository } from './database/repositories/oauth-user-consent.repository'; +import { McpOAuthAuthorizationCodeService } from './mcp-oauth-authorization-code.service'; +import { McpOAuthHelpers } from './mcp-oauth.helpers'; +import { OAuthSessionService, type OAuthSessionPayload } from './oauth-session.service'; + +/** + * Manages OAuth consent flow for MCP server + * Handles user authorization decisions and generates authorization codes + */ +@Service() +export class McpOAuthConsentService { + constructor( + private readonly logger: Logger, + private readonly oauthSessionService: OAuthSessionService, + private readonly oauthClientRepository: OAuthClientRepository, + private readonly userConsentRepository: UserConsentRepository, + private readonly authorizationCodeService: McpOAuthAuthorizationCodeService, + ) {} + + /** + * Get consent details from session cookie + * Verifies JWT session token and returns client information + */ + async getConsentDetails(sessionToken: string): Promise<{ + clientName: string; + clientId: string; + } | null> { + try { + const sessionPayload = this.oauthSessionService.verifySession(sessionToken); + + const client = await this.oauthClientRepository.findOne({ + where: { id: sessionPayload.clientId }, + }); + + if (!client) { + return null; + } + + return { + clientName: client.name, + clientId: client.id, + }; + } catch (error) { + this.logger.error('Error getting consent details', { error }); + return null; + } + } + + /** + * Handle consent approval/denial + * Uses JWT session token instead of database lookup + */ + async handleConsentDecision( + sessionToken: string, + userId: string, + approved: boolean, + ): Promise<{ redirectUrl: string }> { + let sessionPayload: OAuthSessionPayload; + try { + sessionPayload = this.oauthSessionService.verifySession(sessionToken); + } catch (error) { + throw new UserError('Invalid or expired session'); + } + + if (!approved) { + const redirectUrl = McpOAuthHelpers.buildErrorRedirectUrl( + sessionPayload.redirectUri, + 'access_denied', + 'User denied the authorization request', + sessionPayload.state, + ); + + this.logger.info('Consent denied', { + clientId: sessionPayload.clientId, + userId, + }); + + return { redirectUrl }; + } + + await this.userConsentRepository.insert({ + userId, + clientId: sessionPayload.clientId, + grantedAt: Date.now(), + }); + + const code = await this.authorizationCodeService.createAuthorizationCode( + sessionPayload.clientId, + userId, + sessionPayload.redirectUri, + sessionPayload.codeChallenge, + sessionPayload.state, + ); + + const successRedirectUrl = McpOAuthHelpers.buildSuccessRedirectUrl( + sessionPayload.redirectUri, + code, + sessionPayload.state, + ); + + this.logger.info('Consent approved', { + clientId: sessionPayload.clientId, + userId, + }); + + return { redirectUrl: successRedirectUrl }; + } +} diff --git a/packages/cli/src/modules/mcp/mcp-oauth-service.ts b/packages/cli/src/modules/mcp/mcp-oauth-service.ts new file mode 100644 index 00000000000..e84127eb8f9 --- /dev/null +++ b/packages/cli/src/modules/mcp/mcp-oauth-service.ts @@ -0,0 +1,232 @@ +import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients'; +import type { + AuthorizationParams, + OAuthServerProvider, +} from '@modelcontextprotocol/sdk/server/auth/provider'; +import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types'; +import type { + OAuthClientInformationFull, + OAuthTokens, + OAuthTokenRevocationRequest, +} from '@modelcontextprotocol/sdk/shared/auth'; +import { Logger } from '@n8n/backend-common'; +import { Service } from '@n8n/di'; +import type { Response } from 'express'; + +import { OAuthClient } from './database/entities/oauth-client.entity'; +import { OAuthClientRepository } from './database/repositories/oauth-client.repository'; +import { UserConsentRepository } from './database/repositories/oauth-user-consent.repository'; +import { McpOAuthAuthorizationCodeService } from './mcp-oauth-authorization-code.service'; +import { McpOAuthTokenService } from './mcp-oauth-token.service'; +import { OAuthSessionService } from './oauth-session.service'; + +export const SUPPORTED_SCOPES = ['tool:listWorkflows', 'tool:getWorkflowDetails']; + +/** + * OAuth 2.1 server implementation for MCP + * Implements MCP SDK OAuthServerProvider interface for client registration, authorization, and token management + */ +@Service() +export class McpOAuthService implements OAuthServerProvider { + constructor( + private readonly logger: Logger, + private readonly oauthSessionService: OAuthSessionService, + private readonly oauthClientRepository: OAuthClientRepository, + private readonly tokenService: McpOAuthTokenService, + private readonly authorizationCodeService: McpOAuthAuthorizationCodeService, + private readonly userConsentRepository: UserConsentRepository, + ) {} + + get clientsStore(): OAuthRegisteredClientsStore { + return { + getClient: async (clientId: string): Promise => { + const client = await this.oauthClientRepository.findOneBy({ id: clientId }); + if (!client) { + return undefined; + } + + return { + client_id: client.id, + client_name: client.name, + redirect_uris: client.redirectUris, + grant_types: client.grantTypes, + token_endpoint_auth_method: client.tokenEndpointAuthMethod, + ...(client.clientSecret && { client_secret: client.clientSecret }), + ...(client.clientSecretExpiresAt && { + client_secret_expires_at: client.clientSecretExpiresAt, + }), + response_types: ['code'], + scope: SUPPORTED_SCOPES.join(' '), + }; + }, + registerClient: async ( + client: OAuthClientInformationFull, + ): Promise => { + try { + await this.oauthClientRepository.insert({ + id: client.client_id, + name: client.client_name, + redirectUris: client.redirect_uris, + grantTypes: client.grant_types, + clientSecret: client.client_secret ?? null, + clientSecretExpiresAt: client.client_secret_expires_at ?? null, + tokenEndpointAuthMethod: client.token_endpoint_auth_method ?? 'none', + }); + } catch (error) { + this.logger.error('Error registering OAuth client', { + error, + clientId: client.client_id, + }); + } + + return client; + }, + }; + } + + async authorize( + client: OAuthClientInformationFull, + params: AuthorizationParams, + res: Response, + ): Promise { + this.logger.debug('Starting OAuth authorization', { clientId: client.client_id }); + + try { + this.oauthSessionService.createSession(res, { + clientId: client.client_id, + redirectUri: params.redirectUri, + codeChallenge: params.codeChallenge, + state: params.state ?? null, + }); + + res.redirect('/oauth/consent'); + } catch (error) { + this.logger.error('Error in authorize method', { error, clientId: client.client_id }); + this.oauthSessionService.clearSession(res); + res.status(500).json({ error: 'server_error', error_description: 'Internal server error' }); + } + } + + async challengeForAuthorizationCode( + client: OAuthClientInformationFull, + authorizationCode: string, + ): Promise { + return await this.authorizationCodeService.getCodeChallenge( + authorizationCode, + client.client_id, + ); + } + + async exchangeAuthorizationCode( + client: OAuthClientInformationFull, + authorizationCode: string, + _codeVerifier?: string, + redirectUri?: string, + ): Promise { + const authRecord = await this.authorizationCodeService.validateAndConsumeAuthorizationCode( + authorizationCode, + client.client_id, + redirectUri, + ); + + const { accessToken, refreshToken } = this.tokenService.generateTokenPair( + authRecord.userId, + client.client_id, + ); + + await this.tokenService.saveTokenPair( + accessToken, + refreshToken, + client.client_id, + authRecord.userId, + ); + + this.logger.info('Authorization code exchanged for tokens', { + clientId: client.client_id, + userId: authRecord.userId, + }); + + return { + access_token: accessToken, + token_type: 'Bearer', + expires_in: 3600, + refresh_token: refreshToken, + }; + } + + async exchangeRefreshToken( + client: OAuthClientInformationFull, + refreshToken: string, + _scopes?: string[], + ): Promise { + return await this.tokenService.validateAndRotateRefreshToken(refreshToken, client.client_id); + } + + async verifyAccessToken(token: string): Promise { + return await this.tokenService.verifyAccessToken(token); + } + + async revokeToken( + client: OAuthClientInformationFull, + request: OAuthTokenRevocationRequest, + ): Promise { + const { token, token_type_hint } = request; + + if (!token_type_hint || token_type_hint === 'access_token') { + const revoked = await this.tokenService.revokeAccessToken(token, client.client_id); + if (revoked) { + return; + } + } + + if (!token_type_hint || token_type_hint === 'refresh_token') { + const revoked = await this.tokenService.revokeRefreshToken(token, client.client_id); + if (revoked) { + return; + } + } + + this.logger.debug('Token revocation requested for unknown token', { + clientId: client.client_id, + }); + } + + /** + * Get all OAuth clients for a specific user (excluding sensitive data) + */ + async getAllClients( + userId: string, + ): Promise>> { + // Get all consents for the user with client information + const userConsents = await this.userConsentRepository.findByUserWithClient(userId); + + // Extract and sanitize the client information + return userConsents.map((consent) => { + const { clientSecret, clientSecretExpiresAt, ...sanitizedClient } = consent.client; + return sanitizedClient; + }); + } + + /** + * Delete an OAuth client and all related data + */ + async deleteClient(clientId: string): Promise { + // First check if the client exists + const client = await this.oauthClientRepository.findOne({ + where: { id: clientId }, + }); + + if (!client) { + throw new Error(`OAuth client with ID ${clientId} not found`); + } + + this.logger.info('Deleting OAuth client and related data', { clientId }); + + await this.oauthClientRepository.delete({ id: clientId }); + + this.logger.info('OAuth client deleted successfully', { + clientId, + clientName: client.name, + }); + } +} diff --git a/packages/cli/src/modules/mcp/mcp-oauth-token.service.ts b/packages/cli/src/modules/mcp/mcp-oauth-token.service.ts new file mode 100644 index 00000000000..560e8256b33 --- /dev/null +++ b/packages/cli/src/modules/mcp/mcp-oauth-token.service.ts @@ -0,0 +1,215 @@ +import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types'; +import { OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth'; +import { Logger } from '@n8n/backend-common'; +import { Time } from '@n8n/constants'; +import { User, UserRepository, withTransaction } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { MoreThanOrEqual } from '@n8n/typeorm'; +import { randomBytes, randomUUID } from 'node:crypto'; + +import { JwtService } from '@/services/jwt.service'; + +import { AccessToken } from './database/entities/oauth-access-token.entity'; +import { RefreshToken } from './database/entities/oauth-refresh-token.entity'; +import { AccessTokenRepository } from './database/repositories/oauth-access-token.repository'; +import { RefreshTokenRepository } from './database/repositories/oauth-refresh-token.repository'; + +/** + * Manages OAuth 2.1 token lifecycle for MCP server + * Generates, validates, rotates, and revokes access and refresh tokens + */ +@Service() +export class McpOAuthTokenService { + private readonly MCP_AUDIENCE = 'mcp-server-api'; + private readonly ACCESS_TOKEN_EXPIRY_SECONDS = 1 * Time.hours.toSeconds; + private readonly REFRESH_TOKEN_EXPIRY_MS = 30 * Time.days.toMilliseconds; + + constructor( + private readonly logger: Logger, + private readonly jwtService: JwtService, + private readonly userRepository: UserRepository, + private readonly accessTokenRepository: AccessTokenRepository, + private readonly refreshTokenRepository: RefreshTokenRepository, + ) {} + + generateTokenPair( + userId: string, + clientId: string, + ): { accessToken: string; refreshToken: string } { + const accessToken = this.jwtService.sign({ + sub: userId, + aud: this.MCP_AUDIENCE, + client_id: clientId, + jti: randomUUID(), + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + this.ACCESS_TOKEN_EXPIRY_SECONDS, + meta: { + isOAuth: true, + }, + }); + + const refreshToken = randomBytes(32).toString('hex'); + + return { accessToken, refreshToken }; + } + + async saveTokenPair( + accessToken: string, + refreshToken: string, + clientId: string, + userId: string, + ): Promise { + await this.accessTokenRepository.manager.transaction(async (transactionManager) => { + await transactionManager.insert(this.accessTokenRepository.target, { + token: accessToken, + clientId, + userId, + }); + + await transactionManager.insert(this.refreshTokenRepository.target, { + token: refreshToken, + clientId, + userId, + expiresAt: Date.now() + this.REFRESH_TOKEN_EXPIRY_MS, + }); + }); + } + + async validateAndRotateRefreshToken( + refreshToken: string, + clientId: string, + ): Promise { + return await withTransaction(this.refreshTokenRepository.manager, undefined, async (trx) => { + const now = Date.now(); + + const refreshTokenRecord = await trx.findOne(RefreshToken, { + where: { + token: refreshToken, + clientId, + }, + }); + + if (!refreshTokenRecord) { + throw new Error('Invalid refresh token'); + } + + const result = await trx.delete(RefreshToken, { + token: refreshToken, + clientId, + expiresAt: MoreThanOrEqual(now), + }); + + const numAffected = result.affected ?? 0; + if (numAffected < 1) { + throw new Error('Invalid refresh token'); + } + + const { accessToken, refreshToken: newRefreshToken } = this.generateTokenPair( + refreshTokenRecord.userId, + clientId, + ); + + await trx.insert(AccessToken, { + token: accessToken, + clientId, + userId: refreshTokenRecord.userId, + }); + + await trx.insert(RefreshToken, { + token: newRefreshToken, + clientId, + userId: refreshTokenRecord.userId, + expiresAt: now + this.REFRESH_TOKEN_EXPIRY_MS, + }); + + this.logger.info('Refresh token rotated and new access token issued', { + clientId, + userId: refreshTokenRecord.userId, + }); + + return { + access_token: accessToken, + token_type: 'Bearer', + expires_in: this.ACCESS_TOKEN_EXPIRY_SECONDS, + refresh_token: newRefreshToken, + }; + }); + } + + async verifyAccessToken(token: string): Promise { + let decoded; + + try { + decoded = this.jwtService.verify(token, { audience: this.MCP_AUDIENCE }); + } catch (error) { + throw new Error('Invalid access token: JWT verification failed'); + } + + const accessTokenRecord = await this.accessTokenRepository.findOne({ + where: { token }, + }); + + if (!accessTokenRecord) { + throw new Error('Invalid access token: not found in database'); + } + + return { + token, + clientId: decoded.client_id, + scopes: [], + extra: { + userId: decoded.sub, + }, + }; + } + + async verifyOAuthAccessToken(token: string): Promise { + try { + const authInfo = await this.verifyAccessToken(token); + + const userId = authInfo.extra?.userId as string; + if (!userId) { + return null; + } + + const user = await this.userRepository.findOne({ + where: { id: userId }, + relations: ['role'], + }); + + return user; + } catch (error) { + return null; + } + } + + async revokeAccessToken(token: string, clientId: string): Promise { + const result = await this.accessTokenRepository.delete({ + token, + clientId, + }); + + const revoked = (result.affected ?? 0) > 0; + + if (revoked) { + this.logger.info('Access token revoked', { clientId }); + } + + return revoked; + } + + async revokeRefreshToken(token: string, clientId: string): Promise { + const result = await this.refreshTokenRepository.delete({ + token, + clientId, + }); + + const revoked = (result.affected ?? 0) > 0; + + if (revoked) { + this.logger.info('Refresh token revoked', { clientId }); + } + + return revoked; + } +} diff --git a/packages/cli/src/modules/mcp/mcp-oauth.helpers.ts b/packages/cli/src/modules/mcp/mcp-oauth.helpers.ts new file mode 100644 index 00000000000..0f025431f98 --- /dev/null +++ b/packages/cli/src/modules/mcp/mcp-oauth.helpers.ts @@ -0,0 +1,36 @@ +/** + * Static utility functions for OAuth URL building + */ +export class McpOAuthHelpers { + /** + * Build success redirect URL with authorization code + * Used when user approves consent + */ + static buildSuccessRedirectUrl(redirectUri: string, code: string, state: string | null): string { + const targetUrl = new URL(redirectUri); + targetUrl.searchParams.set('code', code); + if (state) { + targetUrl.searchParams.set('state', state); + } + return targetUrl.toString(); + } + + /** + * Build error redirect URL + * Used when user denies consent or errors occur + */ + static buildErrorRedirectUrl( + redirectUri: string, + error: string, + errorDescription: string, + state: string | null, + ): string { + const targetUrl = new URL(redirectUri); + targetUrl.searchParams.set('error', error); + targetUrl.searchParams.set('error_description', errorDescription); + if (state) { + targetUrl.searchParams.set('state', state); + } + return targetUrl.toString(); + } +} diff --git a/packages/cli/src/modules/mcp/mcp-server-middleware.service.ts b/packages/cli/src/modules/mcp/mcp-server-middleware.service.ts new file mode 100644 index 00000000000..944d8f07bc7 --- /dev/null +++ b/packages/cli/src/modules/mcp/mcp-server-middleware.service.ts @@ -0,0 +1,109 @@ +import { AuthenticatedRequest, User } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { NextFunction, Response, Request } from 'express'; + +import { AuthError } from '@/errors/response-errors/auth.error'; +import { JwtService } from '@/services/jwt.service'; +import { Telemetry } from '@/telemetry'; + +import { McpServerApiKeyService } from './mcp-api-key.service'; +import { McpOAuthTokenService } from './mcp-oauth-token.service'; +import { USER_CONNECTED_TO_MCP_EVENT, UNAUTHORIZED_ERROR_MESSAGE } from './mcp.constants'; +import { getClientInfo } from './mcp.utils'; + +/** + * MCP Server Middleware Service + * Centralizes authentication for MCP server endpoints + * Supports both API key and OAuth token validation + */ +@Service() +export class McpServerMiddlewareService { + constructor( + private readonly mcpServerApiKeyService: McpServerApiKeyService, + private readonly mcpAuthTokenService: McpOAuthTokenService, + private readonly jwtService: JwtService, + private readonly telemetry: Telemetry, + ) {} + + /** + * Get user for a given token (API key or OAuth access token) + * Uses JWT metadata to determine token type and route to correct validation + */ + async getUserForToken(token: string): Promise { + let decoded: { meta?: { isOAuth?: boolean } }; + try { + decoded = this.jwtService.decode<{ meta?: { isOAuth?: boolean } }>(token); + } catch (error) { + return null; + } + + if (decoded?.meta?.isOAuth === true) { + return await this.mcpAuthTokenService.verifyOAuthAccessToken(token); + } + + return await this.mcpServerApiKeyService.verifyApiKey(token); + } + + /** + * Express middleware for MCP server authentication + * Validates Bearer token (OAuth or API key) and attaches user to request + */ + getAuthMiddleware() { + return async (req: Request, res: Response, next: NextFunction) => { + const authorizationHeader = req.header('authorization'); + + if (!authorizationHeader) { + this.responseWithUnauthorized(res, req); + return; + } + + const token = this.extractBearerToken(authorizationHeader); + + if (!token) { + this.responseWithUnauthorized(res, req); + return; + } + + const user = await this.getUserForToken(token); + + if (!user) { + this.responseWithUnauthorized(res, req); + return; + } + + (req as AuthenticatedRequest).user = user; + + next(); + }; + } + + private extractBearerToken(headerValue: string): string | null { + if (!headerValue.startsWith('Bearer')) { + throw new AuthError('Invalid authorization header format'); + } + + const tokenMatch = headerValue.match(/^Bearer\s+(.+)$/i); + if (tokenMatch) { + return tokenMatch[1]; + } + + throw new AuthError('Invalid authorization header format'); + } + + private responseWithUnauthorized(res: Response, req: Request) { + this.trackUnauthorizedEvent(req); + // RFC 6750 Section 3: Include WWW-Authenticate header for 401 responses + res.header('WWW-Authenticate', 'Bearer realm="n8n MCP Server"'); + res.status(401).send({ message: UNAUTHORIZED_ERROR_MESSAGE }); + } + + private trackUnauthorizedEvent(req: Request) { + const clientInfo = getClientInfo(req); + this.telemetry.track(USER_CONNECTED_TO_MCP_EVENT, { + mcp_connection_status: 'error', + error: UNAUTHORIZED_ERROR_MESSAGE, + client_name: clientInfo?.name, + client_version: clientInfo?.version, + }); + } +} diff --git a/packages/cli/src/modules/mcp/mcp.auth.consent.controller.ts b/packages/cli/src/modules/mcp/mcp.auth.consent.controller.ts new file mode 100644 index 00000000000..e27f080e229 --- /dev/null +++ b/packages/cli/src/modules/mcp/mcp.auth.consent.controller.ts @@ -0,0 +1,106 @@ +import { Logger } from '@n8n/backend-common'; +import type { AuthenticatedRequest } from '@n8n/db'; +import { Body, Get, Post, RestController } from '@n8n/decorators'; +import type { Response } from 'express'; + +import { ApproveConsentRequestDto } from './dto/approve-consent-request.dto'; +import { McpOAuthConsentService } from './mcp-oauth-consent.service'; +import { OAuthSessionService } from './oauth-session.service'; + +@RestController('/consent') +export class McpConsentController { + constructor( + private readonly logger: Logger, + private readonly consentService: McpOAuthConsentService, + private readonly oauthSessionService: OAuthSessionService, + ) {} + + @Get('/details', { usesTemplates: true }) + async getConsentDetails(req: AuthenticatedRequest, res: Response) { + try { + const sessionToken = this.getAndValidateSessionToken(req, res); + if (!sessionToken) return; + + const consentDetails = await this.consentService.getConsentDetails(sessionToken); + + if (!consentDetails) { + this.sendInvalidSessionError(res, true); + return; + } + + res.json({ + data: { + clientName: consentDetails.clientName, + clientId: consentDetails.clientId, + }, + }); + } catch (error) { + this.logger.error('Failed to get consent details', { error }); + this.oauthSessionService.clearSession(res); + this.sendErrorResponse(res, 500, 'Failed to load authorization details'); + } + } + + @Post('/approve', { usesTemplates: true }) + async approveConsent( + req: AuthenticatedRequest, + res: Response, + @Body payload: ApproveConsentRequestDto, + ) { + try { + const sessionToken = this.getAndValidateSessionToken(req, res); + if (!sessionToken) return; + + const result = await this.consentService.handleConsentDecision( + sessionToken, + req.user.id, + payload.approved, + ); + + this.oauthSessionService.clearSession(res); + + res.json({ + data: { + status: 'success', + redirectUrl: result.redirectUrl, + }, + }); + } catch (error) { + this.logger.error('Failed to process consent', { error }); + this.oauthSessionService.clearSession(res); + const message = error instanceof Error ? error.message : 'Failed to process authorization'; + this.sendErrorResponse(res, 500, message); + } + } + + private sendErrorResponse(res: Response, statusCode: number, message: string): void { + res.status(statusCode).json({ + status: 'error', + message, + }); + } + + private sendInvalidSessionError(res: Response, clearCookie = false): void { + if (clearCookie) { + this.oauthSessionService.clearSession(res); + } + this.sendErrorResponse(res, 400, 'Invalid or expired authorization session'); + } + + private getAndValidateSessionToken(req: AuthenticatedRequest, res: Response): string | null { + const sessionToken = this.oauthSessionService.getSessionToken(req.cookies); + if (!sessionToken) { + this.sendInvalidSessionError(res); + return null; + } + + try { + this.oauthSessionService.verifySession(sessionToken); + return sessionToken; + } catch (error) { + this.logger.debug('Invalid session token', { error }); + this.sendInvalidSessionError(res, true); + return null; + } + } +} diff --git a/packages/cli/src/modules/mcp/mcp.controller.ts b/packages/cli/src/modules/mcp/mcp.controller.ts index f881fa300f7..6aa325d0243 100644 --- a/packages/cli/src/modules/mcp/mcp.controller.ts +++ b/packages/cli/src/modules/mcp/mcp.controller.ts @@ -1,11 +1,13 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { AuthenticatedRequest } from '@n8n/db'; -import { Post, RootLevelController } from '@n8n/decorators'; +import { Head, Post, RootLevelController } from '@n8n/decorators'; import { Container } from '@n8n/di'; -import type { Response } from 'express'; +import type { Request, Response } from 'express'; import { ErrorReporter } from 'n8n-core'; -import { McpServerApiKeyService } from './mcp-api-key.service'; +import { Telemetry } from '@/telemetry'; + +import { McpServerMiddlewareService } from './mcp-server-middleware.service'; import { USER_CONNECTED_TO_MCP_EVENT, MCP_ACCESS_DISABLED_ERROR_MESSAGE, @@ -17,11 +19,9 @@ import { isJSONRPCRequest } from './mcp.typeguards'; import type { UserConnectedToMCPEventPayload } from './mcp.types'; import { getClientInfo } from './mcp.utils'; -import { Telemetry } from '@/telemetry'; - export type FlushableResponse = Response & { flush: () => void }; -const getAuthMiddleware = () => Container.get(McpServerApiKeyService).getAuthMiddleware(); +const getAuthMiddleware = () => Container.get(McpServerMiddlewareService).getAuthMiddleware(); @RootLevelController('/mcp-server') export class McpController { @@ -32,6 +32,40 @@ export class McpController { private readonly telemetry: Telemetry, ) {} + // Add CORS headers helper + private setCorsHeaders(res: Response) { + // Allow requests from Claude AI playground and other MCP clients + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With'); + res.header('Access-Control-Allow-Credentials', 'true'); + res.header('Access-Control-Max-Age', '86400'); // 24 hours + } + + // // Handle OPTIONS preflight requests + // @Option('/http', { + // skipAuth: true, + // }) + // async handlePreflight(req: AuthenticatedRequest, res: Response) { + // this.setCorsHeaders(res); + // res.status(204).send(); + // } + + /** + * HEAD endpoint for authentication scheme discovery + * Per RFC 6750 Section 3, returns 401 with WWW-Authenticate header + * This allows MCP clients to probe the endpoint and discover Bearer token authentication + */ + @Head('/http', { + skipAuth: true, + usesTemplates: true, + }) + async discoverAuthSchemeHead(_req: Request, res: Response) { + this.setCorsHeaders(res); + res.header('WWW-Authenticate', 'Bearer realm="n8n MCP Server"'); + res.status(401).end(); + } + @Post('/http', { rateLimit: { limit: 100 }, middlewares: [getAuthMiddleware()], @@ -39,6 +73,9 @@ export class McpController { usesTemplates: true, }) async build(req: AuthenticatedRequest, res: FlushableResponse) { + // Set CORS headers for all responses + this.setCorsHeaders(res); + const body = req.body; const isInitializationRequest = isJSONRPCRequest(body) ? body.method === 'initialize' : false; const clientInfo = getClientInfo(req); @@ -51,6 +88,7 @@ export class McpController { // Deny if MCP access is disabled const enabled = await this.mcpSettingsService.getEnabled(); + if (!enabled) { if (isInitializationRequest) { this.trackConnectionEvent({ diff --git a/packages/cli/src/modules/mcp/mcp.module.ts b/packages/cli/src/modules/mcp/mcp.module.ts index a7e19112572..a0bb1dc7602 100644 --- a/packages/cli/src/modules/mcp/mcp.module.ts +++ b/packages/cli/src/modules/mcp/mcp.module.ts @@ -12,6 +12,9 @@ export class McpModule implements ModuleInterface { async init() { await import('./mcp.controller'); await import('./mcp.settings.controller'); + await import('./mcp.oauth.controller'); + await import('./mcp.auth.consent.controller'); + await import('./mcp.oauth-clients.controller'); // Initialize event relay to handle workflow deactivation const { McpEventRelay } = await import('./mcp.event-relay'); @@ -29,6 +32,18 @@ export class McpModule implements ModuleInterface { return { mcpAccessEnabled }; } + async entities() { + const { OAuthClient } = await import('./database/entities/oauth-client.entity'); + const { AuthorizationCode } = await import( + './database/entities/oauth-authorization-code.entity' + ); + const { AccessToken } = await import('./database/entities/oauth-access-token.entity'); + const { RefreshToken } = await import('./database/entities/oauth-refresh-token.entity'); + const { UserConsent } = await import('./database/entities/oauth-user-consent.entity'); + + return [OAuthClient, AuthorizationCode, AccessToken, RefreshToken, UserConsent] as never; + } + @OnShutdown() async shutdown() {} } diff --git a/packages/cli/src/modules/mcp/mcp.oauth-clients.controller.ts b/packages/cli/src/modules/mcp/mcp.oauth-clients.controller.ts new file mode 100644 index 00000000000..453f8eabddd --- /dev/null +++ b/packages/cli/src/modules/mcp/mcp.oauth-clients.controller.ts @@ -0,0 +1,93 @@ +import { + DeleteOAuthClientResponseDto, + ListOAuthClientsResponseDto, + OAuthClientResponseDto, +} from '@n8n/api-types'; +import { Logger } from '@n8n/backend-common'; +import { AuthenticatedRequest } from '@n8n/db'; +import { Delete, Get, GlobalScope, Param, RestController } from '@n8n/decorators'; +import type { Response } from 'express'; + +import { NotFoundError } from '@/errors/response-errors/not-found.error'; + +import { McpOAuthService } from './mcp-oauth-service'; + +@RestController('/mcp/oauth-clients') +export class McpOAuthClientsController { + constructor( + private readonly mcpOAuthService: McpOAuthService, + private readonly logger: Logger, + ) {} + + /** + * Get all OAuth clients for the current user + */ + @GlobalScope('mcp:oauth') + @Get('/') + async getAllClients( + req: AuthenticatedRequest, + _res: Response, + ): Promise { + this.logger.debug('Fetching all OAuth clients for user', { userId: req.user.id }); + + const clients = await this.mcpOAuthService.getAllClients(req.user.id); + + this.logger.debug(`Found ${clients.length} OAuth clients`); + + const clientDtos: OAuthClientResponseDto[] = clients.map((client) => ({ + id: client.id, + name: client.name, + redirectUris: client.redirectUris, + grantTypes: client.grantTypes, + tokenEndpointAuthMethod: client.tokenEndpointAuthMethod, + createdAt: client.createdAt.toISOString(), + updatedAt: client.updatedAt.toISOString(), + })); + + return { + data: clientDtos, + count: clients.length, + }; + } + + /** + * Delete an OAuth client by ID + * This will cascade delete all related tokens, authorization codes, and user consents + */ + @GlobalScope('mcp:oauth') + @Delete('/:clientId') + async deleteClient( + req: AuthenticatedRequest, + _res: Response, + @Param('clientId') clientId: string, + ): Promise { + this.logger.info('Deleting OAuth client', { + clientId, + userId: req.user.id, + userEmail: req.user.email, + }); + + try { + await this.mcpOAuthService.deleteClient(clientId); + + this.logger.info('OAuth client deleted successfully', { + clientId, + userId: req.user.id, + }); + + return { + success: true, + message: `OAuth client ${clientId} has been deleted successfully`, + }; + } catch (error) { + if (error instanceof Error && error.message.includes('not found')) { + this.logger.warn('Attempted to delete non-existent OAuth client', { + clientId, + userId: req.user.id, + }); + throw new NotFoundError(`OAuth client with ID ${clientId} not found`); + } + throw error; + } + } +} diff --git a/packages/cli/src/modules/mcp/mcp.oauth.controller.ts b/packages/cli/src/modules/mcp/mcp.oauth.controller.ts new file mode 100644 index 00000000000..b078b5b64c8 --- /dev/null +++ b/packages/cli/src/modules/mcp/mcp.oauth.controller.ts @@ -0,0 +1,101 @@ +import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize.js'; +import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register.js'; +import { revocationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/revoke.js'; +import { tokenHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/token.js'; +import { Get, Options, RootLevelController, StaticRouterMetadata } from '@n8n/decorators'; +import { Container } from '@n8n/di'; +import type { Response, Request, Router } from 'express'; + +import { UrlService } from '@/services/url.service'; + +import { McpOAuthService, SUPPORTED_SCOPES } from './mcp-oauth-service'; + +const mcpOAuthService = Container.get(McpOAuthService); + +@RootLevelController('/') +export class McpOAuthController { + constructor(private readonly urlService: UrlService) {} + + // Add CORS headers for OAuth discovery endpoints + private setCorsHeaders(res: Response) { + // Allow requests from any origin for OAuth discovery + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Methods', 'GET, OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Content-Type'); + } + + static routers: StaticRouterMetadata[] = [ + { + path: '/mcp-oauth/register', + router: clientRegistrationHandler({ clientsStore: mcpOAuthService.clientsStore }) as Router, + skipAuth: true, + }, + { + path: '/mcp-oauth/authorize', + router: authorizationHandler({ provider: mcpOAuthService }) as Router, + skipAuth: true, + }, + { + path: '/mcp-oauth/token', + router: tokenHandler({ provider: mcpOAuthService }) as Router, + skipAuth: true, + }, + { + path: '/mcp-oauth/revoke', + router: revocationHandler({ provider: mcpOAuthService }) as Router, + skipAuth: true, + }, + ]; + + @Options('/.well-known/oauth-authorization-server', { skipAuth: true, usesTemplates: true }) + metadataOptions(_req: Request, res: Response) { + this.setCorsHeaders(res); + res.status(204).end(); + } + + @Get('/.well-known/oauth-authorization-server', { skipAuth: true, usesTemplates: true }) + metadata(_req: Request, res: Response) { + this.setCorsHeaders(res); + + const baseUrl = this.urlService.getInstanceBaseUrl(); + const metadata = { + issuer: baseUrl, + authorization_endpoint: `${baseUrl}/mcp-oauth/authorize`, + token_endpoint: `${baseUrl}/mcp-oauth/token`, + registration_endpoint: `${baseUrl}/mcp-oauth/register`, + revocation_endpoint: `${baseUrl}/mcp-oauth/revoke`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + token_endpoint_auth_methods_supported: ['none', 'client_secret_post', 'client_secret_basic'], + code_challenge_methods_supported: ['S256'], + scopes_supported: SUPPORTED_SCOPES, + }; + + res.json(metadata); + } + + @Options('/.well-known/oauth-protected-resource/mcp-server/http', { + skipAuth: true, + usesTemplates: true, + }) + protectedResourceMetadataOptions(_req: Request, res: Response) { + this.setCorsHeaders(res); + res.status(204).end(); + } + + @Get('/.well-known/oauth-protected-resource/mcp-server/http', { + skipAuth: true, + usesTemplates: true, + }) + protectedResourceMetadata(_req: Request, res: Response) { + this.setCorsHeaders(res); + + const baseUrl = this.urlService.getInstanceBaseUrl(); + res.json({ + resource: `${baseUrl}/mcp-server/http`, + bearer_methods_supported: ['header'], + authorization_servers: [baseUrl], + scopes_supported: SUPPORTED_SCOPES, + }); + } +} diff --git a/packages/cli/src/modules/mcp/mcp.types.ts b/packages/cli/src/modules/mcp/mcp.types.ts index 281ef4f7535..c16f6279002 100644 --- a/packages/cli/src/modules/mcp/mcp.types.ts +++ b/packages/cli/src/modules/mcp/mcp.types.ts @@ -16,8 +16,7 @@ export type ToolDefinition = { // Shared MCP tool types export type SearchWorkflowsParams = { limit?: number; - active?: boolean; - name?: string; + query?: string; projectId?: string; }; diff --git a/packages/cli/src/modules/mcp/oauth-session.service.ts b/packages/cli/src/modules/mcp/oauth-session.service.ts new file mode 100644 index 00000000000..11809fa44c2 --- /dev/null +++ b/packages/cli/src/modules/mcp/oauth-session.service.ts @@ -0,0 +1,61 @@ +import { Time } from '@n8n/constants'; +import { Service } from '@n8n/di'; +import { Response } from 'express'; + +import { JwtService } from '@/services/jwt.service'; + +export interface OAuthSessionPayload { + clientId: string; + redirectUri: string; + codeChallenge: string; + state: string | null; +} + +const COOKIE_NAME = 'n8n-oauth-session'; +const SESSION_EXPIRY_MS = 10 * Time.minutes.toMilliseconds; // 10 minutes + +/** + * Manages OAuth authorization session state using JWT-based cookies + * Stores temporary session data during the authorization flow + */ +@Service() +export class OAuthSessionService { + constructor(private readonly jwtService: JwtService) {} + + /** + * Create OAuth session token and set it as a cookie + */ + createSession(res: Response, payload: OAuthSessionPayload): void { + const sessionToken = this.jwtService.sign(payload, { + expiresIn: '10m', + }); + + res.cookie(COOKIE_NAME, sessionToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: SESSION_EXPIRY_MS, + }); + } + + /** + * Verify and decode OAuth session token + */ + verifySession(sessionToken: string): OAuthSessionPayload { + return this.jwtService.verify(sessionToken); + } + + /** + * Clear OAuth session cookie + */ + clearSession(res: Response): void { + res.clearCookie(COOKIE_NAME); + } + + /** + * Extract session token from request cookies + */ + getSessionToken(cookies: Record): string | undefined { + return cookies[COOKIE_NAME]; + } +} diff --git a/packages/cli/src/modules/mcp/tools/search-workflows.tool.ts b/packages/cli/src/modules/mcp/tools/search-workflows.tool.ts index de9bca88abc..d64cc168e9d 100644 --- a/packages/cli/src/modules/mcp/tools/search-workflows.tool.ts +++ b/packages/cli/src/modules/mcp/tools/search-workflows.tool.ts @@ -26,8 +26,7 @@ const inputSchema = { .max(MAX_RESULTS) .optional() .describe(`Limit the number of results (max ${MAX_RESULTS})`), - active: z.boolean().optional().describe('Filter by active status'), - name: z.string().optional().describe('Filter by name'), + query: z.string().optional().describe('Filter by name or description'), projectId: z.string().optional(), } satisfies z.ZodRawShape; @@ -37,6 +36,7 @@ const outputSchema = { z.object({ id: z.string(), name: z.string().nullable(), + description: z.string().nullable(), active: z.boolean().nullable(), createdAt: z.string().nullable(), updatedAt: z.string().nullable(), @@ -65,8 +65,8 @@ export const createSearchWorkflowsTool = ( inputSchema, outputSchema, }, - handler: async ({ limit = MAX_RESULTS, active, name, projectId }) => { - const parameters = { limit, active, name, projectId }; + handler: async ({ limit = MAX_RESULTS, query, projectId }) => { + const parameters = { limit, query, projectId }; const telemetryPayload: UserCalledMCPToolEventPayload = { user_id: user.id, tool_name: 'search_workflows', @@ -76,8 +76,7 @@ export const createSearchWorkflowsTool = ( try { const payload: SearchWorkflowsResult = await searchWorkflows(user, workflowService, { limit, - active, - name, + query, projectId, }); @@ -116,7 +115,7 @@ export const createSearchWorkflowsTool = ( export async function searchWorkflows( user: User, workflowService: WorkflowService, - { limit = MAX_RESULTS, active, name, projectId }: SearchWorkflowsParams, + { limit = MAX_RESULTS, query, projectId }: SearchWorkflowsParams, ): Promise { const safeLimit = Math.min(Math.max(1, limit), MAX_RESULTS); @@ -125,13 +124,14 @@ export async function searchWorkflows( filter: { isArchived: false, availableInMCP: true, - ...(active !== undefined ? { active } : {}), - ...(name ? { name } : {}), + active: true, + ...(query ? { query } : {}), ...(projectId ? { projectId } : {}), }, select: { id: true, name: true, + description: true, active: true, createdAt: true, updatedAt: true, @@ -149,9 +149,10 @@ export async function searchWorkflows( ); const formattedWorkflows: SearchWorkflowsItem[] = (workflows as WorkflowEntity[]).map( - ({ id, name, active, createdAt, updatedAt, triggerCount, nodes }) => ({ + ({ id, name, description, active, createdAt, updatedAt, triggerCount, nodes }) => ({ id, name, + description, active, createdAt: createdAt.toISOString(), updatedAt: updatedAt.toISOString(), diff --git a/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts index 9bd20ba2564..ef126b29d36 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts @@ -14,7 +14,7 @@ import { EventService } from '@/events/event.service'; import { ExternalHooks } from '@/external-hooks'; import { addNodeIds, replaceInvalidCredentials } from '@/workflow-helpers'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; -import { WorkflowHistoryService } from '@/workflows/workflow-history.ee/workflow-history.service.ee'; +import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service'; import { WorkflowService } from '@/workflows/workflow.service'; import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee'; diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 40754ce2ff1..a5fc76eaca8 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -59,7 +59,7 @@ import '@/events/events.controller'; import '@/executions/executions.controller'; import '@/license/license.controller'; import '@/evaluation.ee/test-runs.controller.ee'; -import '@/workflows/workflow-history.ee/workflow-history.controller.ee'; +import '@/workflows/workflow-history/workflow-history.controller'; import '@/workflows/workflows.controller'; import '@/webhooks/webhooks.controller'; diff --git a/packages/cli/src/services/__tests__/frontend.service.test.ts b/packages/cli/src/services/__tests__/frontend.service.test.ts index 89cffe20f19..c6268670438 100644 --- a/packages/cli/src/services/__tests__/frontend.service.test.ts +++ b/packages/cli/src/services/__tests__/frontend.service.test.ts @@ -1,10 +1,11 @@ -import { N8N_VERSION } from '@/constants'; import type { LicenseState, Logger, ModuleRegistry } from '@n8n/backend-common'; import type { GlobalConfig, SecurityConfig } from '@n8n/config'; import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import type { BinaryDataConfig, InstanceSettings } from 'n8n-core'; +import { N8N_VERSION } from '@/constants'; + import type { CredentialTypes } from '@/credential-types'; import type { CredentialsOverwrites } from '@/credentials-overwrites'; import type { License } from '@/license'; @@ -16,6 +17,12 @@ import { FrontendService, type PublicFrontendSettings } from '@/services/fronten import type { UrlService } from '@/services/url.service'; import type { UserManagementMailer } from '@/user-management/email'; +// Mock the workflow history helper functions to avoid DI container issues in tests +jest.mock('@/workflows/workflow-history/workflow-history-helper', () => ({ + getWorkflowHistoryLicensePruneTime: jest.fn(() => 24), + getWorkflowHistoryPruneTime: jest.fn(() => 24), +})); + describe('FrontendService', () => { let originalEnv: NodeJS.ProcessEnv; const globalConfig = mock({ @@ -45,7 +52,7 @@ describe('FrontendService', () => { license: { tenantId: 1 }, mfa: { enabled: false }, deployment: { type: 'default' }, - workflowHistory: { enabled: false }, + workflowHistory: { pruneTime: 24 }, path: '', sso: { ldap: { loginEnabled: false }, @@ -99,7 +106,6 @@ describe('FrontendService', () => { isExternalSecretsEnabled: jest.fn().mockReturnValue(false), isLicensed: jest.fn().mockReturnValue(false), isDebugInEditorLicensed: jest.fn().mockReturnValue(false), - isWorkflowHistoryLicensed: jest.fn().mockReturnValue(false), isWorkerViewLicensed: jest.fn().mockReturnValue(false), isAdvancedPermissionsLicensed: jest.fn().mockReturnValue(false), isApiKeyScopesEnabled: jest.fn().mockReturnValue(false), diff --git a/packages/cli/src/services/__tests__/import.service.test.ts b/packages/cli/src/services/__tests__/import.service.test.ts index 649fbfe1a9c..9678076110a 100644 --- a/packages/cli/src/services/__tests__/import.service.test.ts +++ b/packages/cli/src/services/__tests__/import.service.test.ts @@ -681,18 +681,6 @@ describe('ImportService', () => { ); }); - it('should throw error when migration IDs do not match', async () => { - const migrationsContent = '{"id":"001","timestamp":"1000","name":"TestMigration"}'; - const dbMigrations = [{ id: '002', timestamp: '1000', name: 'TestMigration' }]; - - jest.mocked(readFile).mockResolvedValue(migrationsContent); - jest.mocked(mockDataSource.query).mockResolvedValue(dbMigrations); - - await expect(importService.validateMigrations('/test/input')).rejects.toThrow( - 'Migration ID mismatch. Import data: TestMigration (id: 001) does not match target database TestMigration (id: 002). Cannot import data from different migration states.', - ); - }); - it('should pass validation when migrations match exactly', async () => { const migrationsContent = '{"id":"1","timestamp":"1000","name":"TestMigration"}'; const dbMigrations = [{ id: '1', timestamp: '1000', name: 'TestMigration' }]; @@ -703,18 +691,6 @@ describe('ImportService', () => { await expect(importService.validateMigrations('/test/input')).resolves.not.toThrow(); }); - it('should throw error when migration IDs have different formats', async () => { - const migrationsContent = '{"id":"001","timestamp":"1000","name":"TestMigration"}'; - const dbMigrations = [{ id: '1', timestamp: '1000', name: 'TestMigration' }]; - - jest.mocked(readFile).mockResolvedValue(migrationsContent); - jest.mocked(mockDataSource.query).mockResolvedValue(dbMigrations); - - await expect(importService.validateMigrations('/test/input')).rejects.toThrow( - 'Migration ID mismatch. Import data: TestMigration (id: 001) does not match target database TestMigration (id: 1). Cannot import data from different migration states.', - ); - }); - it('should handle multiple migrations and find the latest one', async () => { const migrationsContent = '{"id":"2","timestamp":"2000","name":"LatestMigration"}'; const dbMigrations = [ diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index d4985934f91..f1e04f98c4f 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -35,7 +35,7 @@ import { UserManagementMailer } from '@/user-management/email'; import { getWorkflowHistoryLicensePruneTime, getWorkflowHistoryPruneTime, -} from '@/workflows/workflow-history.ee/workflow-history-helper.ee'; +} from '@/workflows/workflow-history/workflow-history-helper'; export type PublicEnterpriseSettings = Pick< IEnterpriseSettings, @@ -272,7 +272,6 @@ export class FrontendService { showNonProdBanner: false, debugInEditor: false, binaryDataS3: false, - workflowHistory: false, workerView: false, advancedPermissions: false, apiKeyScopes: false, @@ -312,8 +311,8 @@ export class FrontendService { credits: 0, }, workflowHistory: { - pruneTime: -1, - licensePruneTime: -1, + pruneTime: getWorkflowHistoryPruneTime(), + licensePruneTime: getWorkflowHistoryLicensePruneTime(), }, pruning: { isEnabled: this.globalConfig.executions.pruneData, @@ -407,8 +406,6 @@ export class FrontendService { showNonProdBanner: this.license.isLicensed(LICENSE_FEATURES.SHOW_NON_PROD_BANNER), debugInEditor: this.license.isDebugInEditorLicensed(), binaryDataS3: isS3Available && isS3Selected && isS3Licensed, - workflowHistory: - this.license.isWorkflowHistoryLicensed() && this.globalConfig.workflowHistory.enabled, workerView: this.license.isWorkerViewLicensed(), advancedPermissions: this.license.isAdvancedPermissionsLicensed(), apiKeyScopes: this.license.isApiKeyScopesEnabled(), @@ -440,13 +437,6 @@ export class FrontendService { this.settings.variables.limit = this.license.getVariablesLimit(); } - if (this.globalConfig.workflowHistory.enabled && this.license.isWorkflowHistoryLicensed()) { - Object.assign(this.settings.workflowHistory, { - pruneTime: getWorkflowHistoryPruneTime(), - licensePruneTime: getWorkflowHistoryLicensePruneTime(), - }); - } - if (this.communityPackagesService) { this.settings.missingPackages = this.communityPackagesService.hasMissingPackages; } diff --git a/packages/cli/src/services/import.service.ts b/packages/cli/src/services/import.service.ts index a24da08fcbd..8f337763a6d 100644 --- a/packages/cli/src/services/import.service.ts +++ b/packages/cli/src/services/import.service.ts @@ -566,8 +566,6 @@ export class ImportService { const dbTimestamp = parseInt(String(latestDbMigration.timestamp || '0')); const importName = latestImportMigration.name; const dbName = latestDbMigration.name; - const importId = latestImportMigration.id; - const dbId = latestDbMigration.id; // Check timestamp match if (importTimestamp !== dbTimestamp) { @@ -583,13 +581,6 @@ export class ImportService { ); } - // Check ID match (if both have IDs) - if (importId && dbId && importId !== dbId) { - throw new Error( - `Migration ID mismatch. Import data: ${String(importName)} (id: ${String(importId)}) does not match target database ${String(dbName)} (id: ${String(dbId)}). Cannot import data from different migration states.`, - ); - } - this.logger.info( '✅ Migration validation passed - import data matches target database migration state', ); diff --git a/packages/cli/src/services/jwt.service.ts b/packages/cli/src/services/jwt.service.ts index 14e78a05b71..ffc64bb5d93 100644 --- a/packages/cli/src/services/jwt.service.ts +++ b/packages/cli/src/services/jwt.service.ts @@ -27,8 +27,8 @@ export class JwtService { return jwt.sign(payload, this.jwtSecret, options); } - decode(token: string) { - return jwt.decode(token) as JwtPayload; + decode(token: string) { + return jwt.decode(token) as T; } verify(token: string, options: jwt.VerifyOptions = {}) { diff --git a/packages/cli/src/sso.ee/saml/__tests__/saml-helpers.test.ts b/packages/cli/src/sso.ee/saml/__tests__/saml-helpers.test.ts index ff21f14a964..9ada785fb9c 100644 --- a/packages/cli/src/sso.ee/saml/__tests__/saml-helpers.test.ts +++ b/packages/cli/src/sso.ee/saml/__tests__/saml-helpers.test.ts @@ -133,7 +133,6 @@ describe('sso/saml/samlHelpers', () => { firstName: 'test', lastName: 'test', userPrincipalName: 'test', - projectRoles: ['projectRole1', 'projectRole2'], instanceRole: 'instanceRole', }, }, @@ -161,10 +160,89 @@ describe('sso/saml/samlHelpers', () => { firstName: 'test', lastName: 'test', userPrincipalName: 'test', - n8nProjectRoles: ['projectRole1', 'projectRole2'], }, missingAttributes: [], }); }); }); + + test('returns the attributes from the flow result with project roles', () => { + const flowResult = { + extract: { + attributes: { + email: 'test@test.com', + firstName: 'test', + lastName: 'test', + userPrincipalName: 'test', + projectRoles: ['projectRole1', 'projectRole2'], + }, + }, + } as any; + const attributeMapping = { + email: 'email', + instanceRole: 'instanceRole', + firstName: 'firstName', + lastName: 'lastName', + userPrincipalName: 'userPrincipalName', + }; + const jitClaimNames = { + instanceRole: 'instanceRole', + projectRoles: 'projectRoles', + }; + const result = helpers.getMappedSamlAttributesFromFlowResult( + flowResult, + attributeMapping, + jitClaimNames, + ); + expect(result).toEqual({ + attributes: { + email: 'test@test.com', + firstName: 'test', + lastName: 'test', + userPrincipalName: 'test', + n8nProjectRoles: ['projectRole1', 'projectRole2'], + }, + missingAttributes: [], + }); + }); + + test('maps single projectRoles string to array', () => { + const flowResult = { + extract: { + attributes: { + email: 'test@test.com', + firstName: 'test', + lastName: 'test', + userPrincipalName: 'test', + projectRoles: 'projectRole1', + }, + }, + } as any; + const attributeMapping = { + email: 'email', + instanceRole: 'instanceRole', + firstName: 'firstName', + lastName: 'lastName', + userPrincipalName: 'userPrincipalName', + }; + const jitClaimNames = { + instanceRole: 'instanceRole', + projectRoles: 'projectRoles', + }; + const result = helpers.getMappedSamlAttributesFromFlowResult( + flowResult, + attributeMapping, + jitClaimNames, + ); + expect(result).toEqual({ + attributes: { + email: 'test@test.com', + firstName: 'test', + lastName: 'test', + userPrincipalName: 'test', + n8nProjectRoles: ['projectRole1'], + }, + missingAttributes: [], + }); + }); }); diff --git a/packages/cli/src/sso.ee/saml/__tests__/saml.service.ee.test.ts b/packages/cli/src/sso.ee/saml/__tests__/saml.service.ee.test.ts index 4e94331b713..784a631d9e1 100644 --- a/packages/cli/src/sso.ee/saml/__tests__/saml.service.ee.test.ts +++ b/packages/cli/src/sso.ee/saml/__tests__/saml.service.ee.test.ts @@ -2,7 +2,7 @@ import type { SamlPreferences } from '@n8n/api-types'; import { mockInstance, mockLogger } from '@n8n/backend-test-utils'; import type { GlobalConfig } from '@n8n/config'; import { SettingsRepository } from '@n8n/db'; -import type { UserRepository, Settings } from '@n8n/db'; +import type { UserRepository, Settings, User } from '@n8n/db'; import { Container } from '@n8n/di'; import axios from 'axios'; import type express from 'express'; @@ -251,6 +251,46 @@ describe('SamlService', () => { 'SAML Authentication failed. Invalid SAML response (missing attributes: http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/firstname, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/lastname, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn).', ); }); + + test('returns the attributes when they are present', async () => { + jest + .spyOn(samlService, 'getIdentityProviderInstance') + .mockReturnValue(mock()); + const serviceProviderInstance = mock(); + serviceProviderInstance.parseLoginResponse.mockResolvedValue({ + samlContent: '', + extract: {}, + }); + jest + .spyOn(samlService, 'getServiceProviderInstance') + .mockReturnValue(serviceProviderInstance); + + jest.spyOn(samlHelpers, 'getMappedSamlAttributesFromFlowResult').mockReturnValue({ + attributes: { + email: 'test@test.com', + firstName: 'test', + lastName: 'test', + userPrincipalName: 'test', + n8nInstanceRole: 'global:admin', + n8nProjectRoles: ['projectRole1', 'projectRole2'], + }, + missingAttributes: [], + }); + + const attributes = await samlService.getAttributesFromLoginResponse( + mock(), + 'post', + ); + + expect(attributes).toEqual({ + email: 'test@test.com', + firstName: 'test', + lastName: 'test', + userPrincipalName: 'test', + n8nInstanceRole: 'global:admin', + n8nProjectRoles: ['projectRole1', 'projectRole2'], + }); + }); }); describe('init', () => { @@ -320,10 +360,7 @@ describe('SamlService', () => { }); }); - // TODO: add tests for getAttributesFromLoginResponse - describe('handleSamlLogin', () => { - // TODO: add test cases for remaining logic (so far only for onboarding user) it('throws error for invalid email', async () => { jest.spyOn(samlService, 'getAttributesFromLoginResponse').mockResolvedValue({ email: 'invalid', @@ -363,6 +400,75 @@ describe('SamlService', () => { }); }); + it('logs in user that has not completed onboarding', async () => { + const samlAttributes = { + email: 'foo@bar.com', + firstName: '', + lastName: '', + userPrincipalName: 'foo@bar.com', + }; + const mockUser = { + id: '123', + email: samlAttributes.email, + authIdentities: [], + } as any; + jest.spyOn(samlService, 'getAttributesFromLoginResponse').mockResolvedValue(samlAttributes); + jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser); + jest.spyOn(samlHelpers, 'updateUserFromSamlAttributes').mockResolvedValue(mockUser); + + const loginResult = await samlService.handleSamlLogin(mock(), 'post'); + + expect(loginResult).toEqual({ + authenticatedUser: mockUser, + attributes: samlAttributes, + onboardingRequired: true, + }); + }); + + it('does not log in the user if sso just-in-time provisioning is disabled', async () => { + const samlAttributes = { + email: 'foo@bar.com', + firstName: '', + lastName: '', + userPrincipalName: 'foo@bar.com', + }; + + jest.spyOn(samlService, 'getAttributesFromLoginResponse').mockResolvedValue(samlAttributes); + jest.spyOn(userRepository, 'findOne').mockResolvedValue(null); + jest.spyOn(ssoHelpers, 'isSsoJustInTimeProvisioningEnabled').mockReturnValue(false); + + const loginResult = await samlService.handleSamlLogin(mock(), 'post'); + + expect(loginResult).toEqual({ + authenticatedUser: undefined, + attributes: samlAttributes, + onboardingRequired: false, + }); + }); + + it('logs in the user if just-in-time provisioning is enabled', async () => { + const samlAttributes = { + email: 'foo@bar.com', + firstName: '', + lastName: '', + userPrincipalName: 'foo@bar.com', + }; + const mockUser = mock(); + + jest.spyOn(samlService, 'getAttributesFromLoginResponse').mockResolvedValue(samlAttributes); + jest.spyOn(userRepository, 'findOne').mockResolvedValue(null); + jest.spyOn(samlHelpers, 'createUserFromSamlAttributes').mockResolvedValue(mockUser); + jest.spyOn(ssoHelpers, 'isSsoJustInTimeProvisioningEnabled').mockReturnValue(true); + + const loginResult = await samlService.handleSamlLogin(mock(), 'post'); + + expect(loginResult).toEqual({ + authenticatedUser: mockUser, + attributes: samlAttributes, + onboardingRequired: true, + }); + }); + it('provisions instance and project role for onboarded user', async () => { const samlAttributes = { email: 'foo@bar.com', diff --git a/packages/cli/src/task-runners/errors/task-request-timeout.error.ts b/packages/cli/src/task-runners/errors/task-request-timeout.error.ts new file mode 100644 index 00000000000..a7e7c4ba9d2 --- /dev/null +++ b/packages/cli/src/task-runners/errors/task-request-timeout.error.ts @@ -0,0 +1,22 @@ +import { OperationalError } from 'n8n-workflow'; + +export class TaskRequestTimeoutError extends OperationalError { + description: string; + + constructor({ timeout, isSelfHosted }: { timeout: number; isSelfHosted: boolean }) { + super(`Task request timed out after ${timeout} ${timeout === 1 ? 'second' : 'seconds'}`); + + const description = [ + 'Your Code node task was not matched to a runner within the timeout period. This indicates that the task runner is currently down, or not ready, or at capacity, so it cannot service your task.', + 'If you are repeatedly executing Code nodes with long-running tasks across your instance, please space them apart to give the runner time to catch up. If this does not describe your use case, please open a GitHub issue or reach out to support.', + ]; + + if (isSelfHosted) { + description.push( + 'If needed, you can increase the timeout using the N8N_RUNNERS_TASK_REQUEST_TIMEOUT environment variable.', + ); + } + + this.description = description.join('

'); + } +} diff --git a/packages/cli/src/task-runners/task-broker/__tests__/task-broker.service.test.ts b/packages/cli/src/task-runners/task-broker/__tests__/task-broker.service.test.ts index 3b1ba7b10e5..1adfb1be6d0 100644 --- a/packages/cli/src/task-runners/task-broker/__tests__/task-broker.service.test.ts +++ b/packages/cli/src/task-runners/task-broker/__tests__/task-broker.service.test.ts @@ -930,4 +930,142 @@ describe('TaskBroker', () => { jest.useRealTimers(); }); }); + + describe('request timeout', () => { + it('should time out request and send `broker:requestexpired` message', async () => { + jest.useFakeTimers(); + + const requesterId = 'requester1'; + const requesterCallback = jest.fn(); + + taskBroker.registerRequester(requesterId, requesterCallback); + + const request: TaskRequest = { + requestId: 'request1', + requesterId, + taskType: 'taskType1', + timeout: taskBroker['createRequestTimeout']('request1'), + }; + + taskBroker.taskRequested(request); + + expect(taskBroker.getPendingTaskRequests()).toHaveLength(1); + + jest.advanceTimersByTime(60 * 1000); + + await Promise.resolve(); + + expect(taskBroker.getPendingTaskRequests()).toHaveLength(0); + expect(requesterCallback).toHaveBeenCalledWith({ + type: 'broker:requestexpired', + requestId: 'request1', + reason: 'timeout', + }); + + jest.useRealTimers(); + }); + + it('should clear timeout on request matched', async () => { + jest.useFakeTimers(); + + const requesterId = 'requester1'; + const requesterCallback = jest.fn(); + + taskBroker.registerRequester(requesterId, requesterCallback); + + const offer: TaskOffer = { + offerId: 'offer1', + runnerId: 'runner1', + taskType: 'taskType1', + validFor: 1000, + validUntil: createValidUntil(1000), + }; + + taskBroker.setPendingTaskOffers([offer]); + + const request: TaskRequest = { + requestId: 'request1', + requesterId, + taskType: 'taskType1', + }; + + jest.spyOn(taskBroker, 'acceptOffer').mockImplementation(async (_offer, request) => { + clearTimeout(request.timeout); + const requests = taskBroker.getPendingTaskRequests(); + const filtered = requests.filter((r) => r.requestId !== request.requestId); + taskBroker.setPendingTaskRequests(filtered); + }); + + taskBroker.taskRequested(request); + + await Promise.resolve(); + await Promise.resolve(); + + expect(taskBroker.getPendingTaskRequests()).toHaveLength(0); + + jest.advanceTimersByTime(65 * 1000); + + await Promise.resolve(); + + expect(requesterCallback).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'broker:requestexpired' }), + ); + + jest.useRealTimers(); + }); + + it('should reset timeout on request deferred', async () => { + jest.useFakeTimers(); + + const requesterId = 'requester1'; + const requesterCallback = jest.fn(); + + taskBroker.registerRequester(requesterId, requesterCallback); + + const offer: TaskOffer = { + offerId: 'offer1', + runnerId: 'runner1', + taskType: 'taskType1', + validFor: 1000, + validUntil: createValidUntil(1000), + }; + + const request: TaskRequest = { + requestId: 'request1', + requesterId, + taskType: 'taskType1', + }; + + taskBroker.setPendingTaskOffers([offer]); + taskBroker.setPendingTaskRequests([request]); + + const handleTimeoutSpy = jest.spyOn(taskBroker as any, 'handleRequestTimeout'); + + jest.spyOn(taskBroker, 'acceptOffer').mockImplementation(async (_offer, request) => { + clearTimeout(request.timeout); + request.timeout = taskBroker['createRequestTimeout'](request.requestId); + taskBroker.setPendingTaskRequests([request]); + }); + + taskBroker.settleTasks(); + + expect(taskBroker.getPendingTaskRequests()).toHaveLength(1); + const deferredRequest = taskBroker.getPendingTaskRequests()[0]; + expect(deferredRequest.timeout).toBeDefined(); + + jest.advanceTimersByTime(60 * 1000); + + await Promise.resolve(); + + expect(handleTimeoutSpy).toHaveBeenCalledWith('request1'); + expect(requesterCallback).toHaveBeenCalledWith({ + type: 'broker:requestexpired', + requestId: 'request1', + reason: 'timeout', + }); + + handleTimeoutSpy.mockRestore(); + jest.useRealTimers(); + }); + }); }); diff --git a/packages/cli/src/task-runners/task-broker/task-broker.service.ts b/packages/cli/src/task-runners/task-broker/task-broker.service.ts index 042d4fb8f1a..0372a7f29e5 100644 --- a/packages/cli/src/task-runners/task-broker/task-broker.service.ts +++ b/packages/cli/src/task-runners/task-broker/task-broker.service.ts @@ -46,7 +46,7 @@ export interface TaskRequest { requestId: string; requesterId: string; taskType: string; - + timeout?: NodeJS.Timeout; acceptInProgress?: boolean; } @@ -97,6 +97,28 @@ export class TaskBroker { } } + private createRequestTimeout(requestId: string): NodeJS.Timeout { + return setTimeout(() => { + this.handleRequestTimeout(requestId); + }, this.taskRunnersConfig.taskRequestTimeout * Time.seconds.toMilliseconds); + } + + private handleRequestTimeout(requestId: string) { + const requestIndex = this.pendingTaskRequests.findIndex((r) => r.requestId === requestId); + if (requestIndex === -1) return; + + const request = this.pendingTaskRequests[requestIndex]; + this.pendingTaskRequests.splice(requestIndex, 1); + + clearTimeout(request.timeout); + + void this.requesters.get(request.requesterId)?.({ + type: 'broker:requestexpired', + requestId: request.requestId, + reason: 'timeout', + }); + } + expireTasks() { const now = process.hrtime.bigint(); for (let i = this.pendingTaskOffers.length - 1; i >= 0; i--) { @@ -306,6 +328,7 @@ export class TaskBroker { taskType: message.taskType, requestId: message.requestId, requesterId, + timeout: this.createRequestTimeout(message.requestId), }); break; case 'requester:taskdataresponse': @@ -533,6 +556,8 @@ export class TaskBroker { } if (e instanceof TaskDeferredError) { this.logger.debug(`Task (${taskId}) deferred until runner is ready`); + clearTimeout(request.timeout); + request.timeout = this.createRequestTimeout(request.requestId); this.pendingTaskRequests.push(request); // will settle on receiving task offer from runner return; } @@ -543,6 +568,8 @@ export class TaskBroker { throw e; } + clearTimeout(request.timeout); + const task: Task = { id: taskId, taskType: offer.taskType, diff --git a/packages/cli/src/task-runners/task-managers/__tests__/task-manager.test.ts b/packages/cli/src/task-runners/task-managers/__tests__/task-manager.test.ts index c1600dd8ebb..c90ea5867a2 100644 --- a/packages/cli/src/task-runners/task-managers/__tests__/task-manager.test.ts +++ b/packages/cli/src/task-runners/task-managers/__tests__/task-manager.test.ts @@ -1,6 +1,8 @@ +import type { GlobalConfig, TaskRunnersConfig } from '@n8n/config'; import { mock } from 'jest-mock-extended'; import get from 'lodash/get'; import set from 'lodash/set'; +import type { ErrorReporter } from 'n8n-core'; import type { EventService } from '@/events/event.service'; import type { NodeTypes } from '@/node-types'; @@ -19,9 +21,18 @@ describe('TaskRequester', () => { let instance: TestTaskRequester; const mockNodeTypes = mock(); const mockEventService = mock(); + const mockTaskRunnersConfig = mock(); + const mockGlobalConfig = mock(); + const mockErrorReporter = mock(); beforeEach(() => { - instance = new TestTaskRequester(mockNodeTypes, mockEventService); + instance = new TestTaskRequester( + mockNodeTypes, + mockEventService, + mockTaskRunnersConfig, + mockGlobalConfig, + mockErrorReporter, + ); }); describe('handleRpc', () => { diff --git a/packages/cli/src/task-runners/task-managers/local-task-requester.ts b/packages/cli/src/task-runners/task-managers/local-task-requester.ts index 821906cb768..c9b666bcec9 100644 --- a/packages/cli/src/task-runners/task-managers/local-task-requester.ts +++ b/packages/cli/src/task-runners/task-managers/local-task-requester.ts @@ -1,5 +1,7 @@ +import { GlobalConfig, TaskRunnersConfig } from '@n8n/config'; import { Container, Service } from '@n8n/di'; import type { RequesterMessage } from '@n8n/task-runner'; +import { ErrorReporter } from 'n8n-core'; import { EventService } from '@/events/event.service'; import { NodeTypes } from '@/node-types'; @@ -14,8 +16,14 @@ export class LocalTaskRequester extends TaskRequester { id = 'local-task-requester'; - constructor(nodeTypes: NodeTypes, eventService: EventService) { - super(nodeTypes, eventService); + constructor( + nodeTypes: NodeTypes, + eventService: EventService, + taskRunnersConfig: TaskRunnersConfig, + globalConfig: GlobalConfig, + errorReporter: ErrorReporter, + ) { + super(nodeTypes, eventService, taskRunnersConfig, globalConfig, errorReporter); this.registerRequester(); } diff --git a/packages/cli/src/task-runners/task-managers/task-requester.ts b/packages/cli/src/task-runners/task-managers/task-requester.ts index e9952ce57c5..630c460a448 100644 --- a/packages/cli/src/task-runners/task-managers/task-requester.ts +++ b/packages/cli/src/task-runners/task-managers/task-requester.ts @@ -1,7 +1,8 @@ +import { GlobalConfig, TaskRunnersConfig } from '@n8n/config'; import { Service } from '@n8n/di'; import type { TaskResultData, RequesterMessage, BrokerMessage, TaskData } from '@n8n/task-runner'; import { AVAILABLE_RPC_METHODS } from '@n8n/task-runner'; -import { isSerializedBuffer, toBuffer } from 'n8n-core'; +import { isSerializedBuffer, toBuffer, ErrorReporter } from 'n8n-core'; import { createResultOk, createResultError } from 'n8n-workflow'; import type { EnvProviderState, @@ -22,12 +23,13 @@ import { nanoid } from 'nanoid'; import { EventService } from '@/events/event.service'; import { NodeTypes } from '@/node-types'; +import { TaskRequestTimeoutError } from '@/task-runners/errors/task-request-timeout.error'; import { DataRequestResponseBuilder } from './data-request-response-builder'; import { DataRequestResponseStripper } from './data-request-response-stripper'; export type RequestAccept = (jobId: string) => void; -export type RequestReject = (reason: string) => void; +export type RequestReject = (reason: string | Error) => void; export type TaskAccept = (data: TaskResultData) => void; export type TaskReject = (error: unknown) => void; @@ -64,6 +66,9 @@ export abstract class TaskRequester { constructor( private readonly nodeTypes: NodeTypes, private readonly eventService: EventService, + private readonly taskRunnersConfig: TaskRunnersConfig, + private readonly globalConfig: GlobalConfig, + private readonly errorReporter: ErrorReporter, ) {} async startTask( @@ -250,6 +255,9 @@ export abstract class TaskRequester { case 'broker:taskerror': this.taskError(message.taskId, message.error); break; + case 'broker:requestexpired': + this.requestExpired(message.requestId); + break; case 'broker:taskdatarequest': this.sendTaskData(message.taskId, message.requestId, message.requestParams); break; @@ -276,6 +284,30 @@ export abstract class TaskRequester { this.requestAcceptRejects.delete(requestId); } + requestExpired(requestId: string) { + const acceptReject = this.requestAcceptRejects.get(requestId); + if (!acceptReject) return; + + const error = new TaskRequestTimeoutError({ + timeout: this.taskRunnersConfig.taskRequestTimeout, + isSelfHosted: this.globalConfig.deployment.type !== 'cloud', + }); + + this.errorReporter.error('Task request timed out', { + extra: { + requestId, + timeout: this.taskRunnersConfig.taskRequestTimeout, + deploymentType: this.globalConfig.deployment.type, + }, + tags: { + issue: 'task-runners-timeouts', + }, + }); + + acceptReject.reject(error); + this.requestAcceptRejects.delete(requestId); + } + rejectTask(jobId: string, reason: string) { this.sendMessage({ type: 'requester:taskcancel', diff --git a/packages/cli/src/utils/__tests__/validate-database-type.test.ts b/packages/cli/src/utils/__tests__/validate-database-type.test.ts index 863080aa19f..eb954c6b3b4 100644 --- a/packages/cli/src/utils/__tests__/validate-database-type.test.ts +++ b/packages/cli/src/utils/__tests__/validate-database-type.test.ts @@ -1,14 +1,47 @@ -import { validateDbTypeForExportEntities } from '../validate-database-type'; +import { + supportedTypesForExport, + supportedTypesForImport, + validateDbTypeForExportEntities, + validateDbTypeForImportEntities, +} from '../validate-database-type'; describe('validateDbTypeForExportEntities', () => { it('should throw an error if the database type is not supported', () => { expect(() => validateDbTypeForExportEntities('invalid')).toThrow( - 'Unsupported database type: invalid. Supported types: sqlite, postgres', + 'Unsupported database type: invalid. Supported types: ' + supportedTypesForExport.join(', '), ); }); it('should not throw an error if the database type is supported', () => { expect(() => validateDbTypeForExportEntities('sqlite')).not.toThrow(); expect(() => validateDbTypeForExportEntities('postgres')).not.toThrow(); + expect(() => validateDbTypeForExportEntities('mysql')).not.toThrow(); + expect(() => validateDbTypeForExportEntities('mariadb')).not.toThrow(); + expect(() => validateDbTypeForExportEntities('mysqldb')).not.toThrow(); + expect(() => validateDbTypeForExportEntities('sqlite-pooled')).not.toThrow(); + expect(() => validateDbTypeForExportEntities('sqlite-memory')).not.toThrow(); + expect(() => validateDbTypeForExportEntities('postgresql')).not.toThrow(); + }); +}); + +describe('validateDbTypeForImportEntities', () => { + it('should throw an error if the database type is not supported', () => { + expect(() => validateDbTypeForImportEntities('invalid')).toThrow( + 'Unsupported database type: invalid. Supported types: ' + supportedTypesForImport.join(', '), + ); + }); + + it('should throw an error for MySQL/MariaDB (not supported for imports)', () => { + expect(() => validateDbTypeForImportEntities('mysql')).toThrow(); + expect(() => validateDbTypeForImportEntities('mariadb')).toThrow(); + expect(() => validateDbTypeForImportEntities('mysqldb')).toThrow(); + }); + + it('should not throw an error if the database type is supported', () => { + expect(() => validateDbTypeForImportEntities('sqlite')).not.toThrow(); + expect(() => validateDbTypeForImportEntities('postgres')).not.toThrow(); + expect(() => validateDbTypeForImportEntities('sqlite-pooled')).not.toThrow(); + expect(() => validateDbTypeForImportEntities('sqlite-memory')).not.toThrow(); + expect(() => validateDbTypeForImportEntities('postgresql')).not.toThrow(); }); }); diff --git a/packages/cli/src/utils/validate-database-type.ts b/packages/cli/src/utils/validate-database-type.ts index 10e78988795..fdc96ed2abd 100644 --- a/packages/cli/src/utils/validate-database-type.ts +++ b/packages/cli/src/utils/validate-database-type.ts @@ -1,11 +1,34 @@ +export const supportedTypesForExport = [ + 'sqlite', + 'sqlite-pooled', + 'sqlite-memory', + 'postgres', + 'postgresql', + 'mysql', + 'mariadb', + 'mysqldb', +]; + +export const supportedTypesForImport = [ + 'sqlite', + 'sqlite-pooled', + 'sqlite-memory', + 'postgres', + 'postgresql', +]; + export function validateDbTypeForExportEntities(dbType: string) { - if ( - !['sqlite', 'sqlite-pooled', 'sqlite-memory', 'postgres', 'postgresql'].includes( - dbType.toLowerCase(), - ) - ) { - throw new Error(`Unsupported database type: ${dbType}. Supported types: sqlite, postgres`); + if (!supportedTypesForExport.includes(dbType.toLowerCase())) { + throw new Error( + `Unsupported database type: ${dbType}. Supported types: ${supportedTypesForExport.join(', ')}`, + ); } } -export const validateDbTypeForImportEntities = validateDbTypeForExportEntities; +export function validateDbTypeForImportEntities(dbType: string) { + if (!supportedTypesForImport.includes(dbType.toLowerCase())) { + throw new Error( + `Unsupported database type: ${dbType}. Supported types: ${supportedTypesForImport.join(', ')}`, + ); + } +} diff --git a/packages/cli/src/workflows/workflow-history.ee/__tests__/workflow-history-helper.ee.test.ts b/packages/cli/src/workflows/workflow-history/__tests__/workflow-history-helper.test.ts similarity index 97% rename from packages/cli/src/workflows/workflow-history.ee/__tests__/workflow-history-helper.ee.test.ts rename to packages/cli/src/workflows/workflow-history/__tests__/workflow-history-helper.test.ts index 88434638375..ef67b69cc77 100644 --- a/packages/cli/src/workflows/workflow-history.ee/__tests__/workflow-history-helper.ee.test.ts +++ b/packages/cli/src/workflows/workflow-history/__tests__/workflow-history-helper.test.ts @@ -3,7 +3,7 @@ import { GlobalConfig } from '@n8n/config'; import { Container } from '@n8n/di'; import { License } from '@/license'; -import { getWorkflowHistoryPruneTime } from '@/workflows/workflow-history.ee/workflow-history-helper.ee'; +import { getWorkflowHistoryPruneTime } from '@/workflows/workflow-history/workflow-history-helper'; let licensePruneTime = -1; const globalConfig = Container.get(GlobalConfig); diff --git a/packages/cli/src/workflows/workflow-history.ee/__tests__/workflow-history.service.ee.test.ts b/packages/cli/src/workflows/workflow-history/__tests__/workflow-history.service.test.ts similarity index 66% rename from packages/cli/src/workflows/workflow-history.ee/__tests__/workflow-history.service.ee.test.ts rename to packages/cli/src/workflows/workflow-history/__tests__/workflow-history.service.test.ts index 8be8f8c758e..7985f969ca6 100644 --- a/packages/cli/src/workflows/workflow-history.ee/__tests__/workflow-history.service.ee.test.ts +++ b/packages/cli/src/workflows/workflow-history/__tests__/workflow-history.service.test.ts @@ -3,7 +3,7 @@ import { User, WorkflowHistoryRepository } from '@n8n/db'; import { mockClear } from 'jest-mock-extended'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; -import { WorkflowHistoryService } from '@/workflows/workflow-history.ee/workflow-history.service.ee'; +import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service'; import { getWorkflow } from '@test-integration/workflow'; const workflowHistoryRepository = mockInstance(WorkflowHistoryRepository); @@ -21,13 +21,6 @@ const testUser = Object.assign(new User(), { firstName: 'John', lastName: 'Doe', }); -let isWorkflowHistoryEnabled = true; - -jest.mock('@/workflows/workflow-history.ee/workflow-history-helper.ee', () => { - return { - isWorkflowHistoryEnabled: jest.fn(() => isWorkflowHistoryEnabled), - }; -}); describe('WorkflowHistoryService', () => { beforeEach(() => { @@ -35,9 +28,8 @@ describe('WorkflowHistoryService', () => { }); describe('saveVersion', () => { - it('should save a new version when workflow history is enabled and nodes and connections are present', async () => { + it('should save a new version when nodes and connections are present', async () => { // Arrange - isWorkflowHistoryEnabled = true; const workflow = getWorkflow({ addNodeWithoutCreds: true }); const workflowId = '123'; workflow.connections = {}; @@ -57,41 +49,25 @@ describe('WorkflowHistoryService', () => { }); }); - it('should not save a new version when workflow history is disabled', async () => { + it('should throw an error when nodes or connections are missing', async () => { // Arrange - isWorkflowHistoryEnabled = false; - const workflow = getWorkflow({ addNodeWithoutCreds: true }); - const workflowId = '123'; - workflow.connections = {}; - workflow.id = workflowId; - workflow.versionId = '456'; - - // Act - await workflowHistoryService.saveVersion(testUser, workflow, workflowId); - - // Assert - expect(workflowHistoryRepository.insert).not.toHaveBeenCalled(); - }); - - it('should not save a new version when nodes or connections are missing', async () => { - // Arrange - isWorkflowHistoryEnabled = true; const workflow = getWorkflow({ addNodeWithoutCreds: true }); const workflowId = '123'; workflow.id = workflowId; workflow.versionId = '456'; // Nodes are set but connections is empty - // Act - await workflowHistoryService.saveVersion(testUser, workflow, workflowId); - - // Assert + // Act & Assert + await expect( + workflowHistoryService.saveVersion(testUser, workflow, workflowId), + ).rejects.toThrow( + 'Cannot save workflow history: nodes and connections are required for workflow 123', + ); expect(workflowHistoryRepository.insert).not.toHaveBeenCalled(); }); it('should log an error when failed to save workflow history version', async () => { // Arrange - isWorkflowHistoryEnabled = true; const workflow = getWorkflow({ addNodeWithoutCreds: true }); const workflowId = '123'; workflow.connections = {}; diff --git a/packages/cli/src/workflows/workflow-history.ee/workflow-history-helper.ee.ts b/packages/cli/src/workflows/workflow-history/workflow-history-helper.ts similarity index 74% rename from packages/cli/src/workflows/workflow-history.ee/workflow-history-helper.ee.ts rename to packages/cli/src/workflows/workflow-history/workflow-history-helper.ts index 3d9010fa32d..831f7895411 100644 --- a/packages/cli/src/workflows/workflow-history.ee/workflow-history-helper.ee.ts +++ b/packages/cli/src/workflows/workflow-history/workflow-history-helper.ts @@ -3,15 +3,6 @@ import { Container } from '@n8n/di'; import { License } from '@/license'; -export function isWorkflowHistoryLicensed() { - const license = Container.get(License); - return license.isWorkflowHistoryLicensed(); -} - -export function isWorkflowHistoryEnabled() { - return isWorkflowHistoryLicensed() && Container.get(GlobalConfig).workflowHistory.enabled; -} - export function getWorkflowHistoryLicensePruneTime() { return Container.get(License).getWorkflowHistoryPruneLimit(); } diff --git a/packages/cli/src/workflows/workflow-history.ee/workflow-history-manager.ee.ts b/packages/cli/src/workflows/workflow-history/workflow-history-manager.ts similarity index 76% rename from packages/cli/src/workflows/workflow-history.ee/workflow-history-manager.ee.ts rename to packages/cli/src/workflows/workflow-history/workflow-history-manager.ts index a9581904193..85e2e9669f2 100644 --- a/packages/cli/src/workflows/workflow-history.ee/workflow-history-manager.ee.ts +++ b/packages/cli/src/workflows/workflow-history/workflow-history-manager.ts @@ -3,10 +3,7 @@ import { WorkflowHistoryRepository } from '@n8n/db'; import { Service } from '@n8n/di'; import { DateTime } from 'luxon'; -import { - getWorkflowHistoryPruneTime, - isWorkflowHistoryEnabled, -} from './workflow-history-helper.ee'; +import { getWorkflowHistoryPruneTime } from './workflow-history-helper'; @Service() export class WorkflowHistoryManager { @@ -30,17 +27,13 @@ export class WorkflowHistoryManager { } async prune() { - if (!isWorkflowHistoryEnabled()) { - return; - } - const pruneHours = getWorkflowHistoryPruneTime(); - // No prune time set + // No prune time set (infinite retention) if (pruneHours === -1) { return; } const pruneDateTime = DateTime.now().minus({ hours: pruneHours }).toJSDate(); - await this.workflowHistoryRepo.deleteEarlierThan(pruneDateTime); + await this.workflowHistoryRepo.deleteEarlierThanExceptCurrent(pruneDateTime); } } diff --git a/packages/cli/src/workflows/workflow-history.ee/workflow-history.controller.ee.ts b/packages/cli/src/workflows/workflow-history/workflow-history.controller.ts similarity index 69% rename from packages/cli/src/workflows/workflow-history.ee/workflow-history.controller.ee.ts rename to packages/cli/src/workflows/workflow-history/workflow-history.controller.ts index a52c0adb772..9887ef95de5 100644 --- a/packages/cli/src/workflows/workflow-history.ee/workflow-history.controller.ee.ts +++ b/packages/cli/src/workflows/workflow-history/workflow-history.controller.ts @@ -1,14 +1,12 @@ import { PaginationDto } from '@n8n/api-types'; -import { RestController, Get, Middleware, Query } from '@n8n/decorators'; -import { Request, Response, NextFunction } from 'express'; +import { RestController, Get, Query } from '@n8n/decorators'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { SharedWorkflowNotFoundError } from '@/errors/shared-workflow-not-found.error'; import { WorkflowHistoryVersionNotFoundError } from '@/errors/workflow-history-version-not-found.error'; import { WorkflowHistoryRequest } from '@/requests'; -import { isWorkflowHistoryEnabled, isWorkflowHistoryLicensed } from './workflow-history-helper.ee'; -import { WorkflowHistoryService } from './workflow-history.service.ee'; +import { WorkflowHistoryService } from './workflow-history.service'; const DEFAULT_TAKE = 20; @@ -16,26 +14,6 @@ const DEFAULT_TAKE = 20; export class WorkflowHistoryController { constructor(private readonly historyService: WorkflowHistoryService) {} - @Middleware() - workflowHistoryLicense(_req: Request, res: Response, next: NextFunction) { - if (!isWorkflowHistoryLicensed()) { - res.status(403); - res.send('Workflow History license data not found'); - return; - } - next(); - } - - @Middleware() - workflowHistoryEnabled(_req: Request, res: Response, next: NextFunction) { - if (!isWorkflowHistoryEnabled()) { - res.status(403); - res.send('Workflow History is disabled'); - return; - } - next(); - } - @Get('/workflow/:workflowId') async getList(req: WorkflowHistoryRequest.GetList, _res: Response, @Query query: PaginationDto) { try { diff --git a/packages/cli/src/workflows/workflow-history.ee/workflow-history.service.ee.ts b/packages/cli/src/workflows/workflow-history/workflow-history.service.ts similarity index 70% rename from packages/cli/src/workflows/workflow-history.ee/workflow-history.service.ee.ts rename to packages/cli/src/workflows/workflow-history/workflow-history.service.ts index 2c59bac1836..59b8692b355 100644 --- a/packages/cli/src/workflows/workflow-history.ee/workflow-history.service.ee.ts +++ b/packages/cli/src/workflows/workflow-history/workflow-history.service.ts @@ -3,12 +3,11 @@ import type { User, WorkflowHistory } from '@n8n/db'; import { WorkflowHistoryRepository } from '@n8n/db'; import { Service } from '@n8n/di'; import type { IWorkflowBase } from 'n8n-workflow'; -import { ensureError } from 'n8n-workflow'; +import { ensureError, UnexpectedError } from 'n8n-workflow'; import { SharedWorkflowNotFoundError } from '@/errors/shared-workflow-not-found.error'; import { WorkflowHistoryVersionNotFoundError } from '@/errors/workflow-history-version-not-found.error'; -import { isWorkflowHistoryEnabled } from './workflow-history-helper.ee'; import { WorkflowFinderService } from '../workflow-finder.service'; @Service() @@ -66,24 +65,25 @@ export class WorkflowHistoryService { } async saveVersion(user: User, workflow: IWorkflowBase, workflowId: string) { - // On some update scenarios, `nodes` and `connections` are missing, such as when - // changing workflow settings or renaming. In these cases, we don't want to save - // a new version - if (isWorkflowHistoryEnabled() && workflow.nodes && workflow.connections) { - try { - await this.workflowHistoryRepository.insert({ - authors: user.firstName + ' ' + user.lastName, - connections: workflow.connections, - nodes: workflow.nodes, - versionId: workflow.versionId, - workflowId, - }); - } catch (e) { - const error = ensureError(e); - this.logger.error(`Failed to save workflow history version for workflow ${workflowId}`, { - error, - }); - } + if (!workflow.nodes || !workflow.connections) { + throw new UnexpectedError( + `Cannot save workflow history: nodes and connections are required for workflow ${workflowId}`, + ); + } + + try { + await this.workflowHistoryRepository.insert({ + authors: user.firstName + ' ' + user.lastName, + connections: workflow.connections, + nodes: workflow.nodes, + versionId: workflow.versionId, + workflowId, + }); + } catch (e) { + const error = ensureError(e); + this.logger.error(`Failed to save workflow history version for workflow ${workflowId}`, { + error, + }); } } } diff --git a/packages/cli/src/workflows/workflow.request.ts b/packages/cli/src/workflows/workflow.request.ts index 57598068cb5..d5fb12a7949 100644 --- a/packages/cli/src/workflows/workflow.request.ts +++ b/packages/cli/src/workflows/workflow.request.ts @@ -16,6 +16,7 @@ export declare namespace WorkflowRequest { type CreateUpdatePayload = Partial<{ id: string; // deleted if sent name: string; + description: string | null; nodes: INode[]; connections: IConnections; settings: IWorkflowSettings; diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index 15215a4bbfe..b82d9a69399 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -39,7 +39,7 @@ import { TagService } from '@/services/tag.service'; import * as WorkflowHelpers from '@/workflow-helpers'; import { WorkflowFinderService } from './workflow-finder.service'; -import { WorkflowHistoryService } from './workflow-history.ee/workflow-history.service.ee'; +import { WorkflowHistoryService } from './workflow-history/workflow-history.service'; import { WorkflowSharingService } from './workflow-sharing.service'; @Service() @@ -233,6 +233,9 @@ export class WorkflowService { if (Object.keys(omit(workflowUpdateData, ['id', 'versionId', 'active'])).length > 0) { // Update the workflow's version when changing properties such as // `name`, `pinData`, `nodes`, `connections`, `settings` or `tags` + // This is necessary for collaboration to work properly - even when only name or settings + // change, we need to update the version to detect conflicts when multiple users are editing. + workflowUpdateData.versionId = uuid(); this.logger.debug( `Updating versionId for workflow ${workflowId} for user ${user.id} after saving`, @@ -243,6 +246,14 @@ export class WorkflowService { ); } + const versionChanged = + workflowUpdateData.versionId && workflowUpdateData.versionId !== workflow.versionId; + if (versionChanged) { + // To save a version, we need both nodes and connections + workflowUpdateData.nodes = workflowUpdateData.nodes ?? workflow.nodes; + workflowUpdateData.connections = workflowUpdateData.connections ?? workflow.connections; + } + // check credentials for old format await WorkflowHelpers.replaceInvalidCredentials(workflowUpdateData); @@ -297,6 +308,7 @@ export class WorkflowService { 'staticData', 'pinData', 'versionId', + 'description', ]); if (parentFolderId) { @@ -322,7 +334,7 @@ export class WorkflowService { await this.workflowTagMappingRepository.overwriteTaggings(workflowId, tagIds); } - if (workflowUpdateData.versionId !== workflow.versionId) { + if (versionChanged) { await this.workflowHistoryService.saveVersion(user, workflowUpdateData, workflowId); } diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index 306fe12de8a..aa0d36210bf 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -35,7 +35,7 @@ import { v4 as uuid } from 'uuid'; import { WorkflowExecutionService } from './workflow-execution.service'; import { WorkflowFinderService } from './workflow-finder.service'; -import { WorkflowHistoryService } from './workflow-history.ee/workflow-history.service.ee'; +import { WorkflowHistoryService } from './workflow-history/workflow-history.service'; import { WorkflowRequest } from './workflow.request'; import { WorkflowService } from './workflow.service'; import { EnterpriseWorkflowService } from './workflow.service.ee'; diff --git a/packages/cli/test/integration/auth.api.test.ts b/packages/cli/test/integration/auth.api.test.ts index 3412daf080d..b4f0aa87e50 100644 --- a/packages/cli/test/integration/auth.api.test.ts +++ b/packages/cli/test/integration/auth.api.test.ts @@ -12,6 +12,8 @@ import { LOGGED_OUT_RESPONSE_BODY } from './shared/constants'; import { createUser, createUserShell } from './shared/db/users'; import type { SuperAgentTest } from './shared/types'; import * as utils from './shared/utils/'; +import { EventService } from '@/events/event.service'; +import type { RelayEventMap } from '@/events/maps/relay.event-map'; let owner: User; let authOwnerAgent: SuperAgentTest; @@ -392,6 +394,36 @@ describe('GET /resolve-signup-token', () => { expect(response.statusCode).toBe(400); } }); + + test('should send roles for user-invite-email-click event', async () => { + const memberShell = await createUserShell(GLOBAL_MEMBER_ROLE); + + const eventService = Container.get(EventService); + const emitSpy = jest.spyOn(eventService, 'emit'); + + await authOwnerAgent + .get('/resolve-signup-token') + .query({ inviterId: owner.id }) + .query({ inviteeId: memberShell.id }) + .expect(200); + + // Check all emitted events + let foundEvent = false; + for (const [eventName, payload] of emitSpy.mock.calls) { + if (eventName === 'user-invite-email-click') { + foundEvent = true; + expect(payload).toBeDefined(); + const { invitee, inviter } = payload as RelayEventMap['user-invite-email-click']; + expect(invitee.role).toBeDefined(); + expect(invitee.role?.slug).toBe('global:member'); + expect(inviter.role).toBeDefined(); + expect(inviter.role?.slug).toBe('global:owner'); + } + } + + expect(foundEvent).toBe(true); + emitSpy.mockRestore(); + }); }); describe('POST /logout', () => { diff --git a/packages/cli/test/integration/executions/legacy-recovery.test.ts b/packages/cli/test/integration/executions/legacy-recovery.test.ts index 41e19b38f06..c3be521332f 100644 --- a/packages/cli/test/integration/executions/legacy-recovery.test.ts +++ b/packages/cli/test/integration/executions/legacy-recovery.test.ts @@ -4,6 +4,7 @@ import { testDb } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import { DbConnection, ExecutionRepository, WorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; +import { v4 as uuid } from 'uuid'; const globalConfig = Container.get(GlobalConfig); @@ -37,6 +38,7 @@ if (globalConfig.database.isLegacySqlite) { nodes: [], connections: {}, settings: {}, + versionId: uuid(), createdAt: new Date(), updatedAt: new Date(), }); diff --git a/packages/cli/test/integration/executions/pre-execution-checks/credentials-permission-checker.test.ts b/packages/cli/test/integration/executions/pre-execution-checks/credentials-permission-checker.test.ts index 2a6e89361dd..8a1b7211af0 100644 --- a/packages/cli/test/integration/executions/pre-execution-checks/credentials-permission-checker.test.ts +++ b/packages/cli/test/integration/executions/pre-execution-checks/credentials-permission-checker.test.ts @@ -35,6 +35,7 @@ const createWorkflow = async (nodes: INode[], workflowOwner?: User): Promise { expect(sharedWorkflow?.role).toEqual('workflow:owner'); }); - test('should create workflow history version when licensed', async () => { - license.enable('feat:workflowHistory'); + test('should always create workflow history version', async () => { const payload = { name: 'testing', nodes: [ @@ -915,44 +914,6 @@ describe('POST /workflows', () => { expect(historyVersion!.nodes).toEqual(payload.nodes); }); - test('should not create workflow history version when not licensed', async () => { - license.disable('feat:workflowHistory'); - const payload = { - name: 'testing', - nodes: [ - { - id: 'uuid-1234', - parameters: {}, - name: 'Start', - type: 'n8n-nodes-base.start', - typeVersion: 1, - position: [240, 300], - }, - ], - connections: {}, - staticData: null, - settings: { - saveExecutionProgress: true, - saveManualExecutions: true, - saveDataErrorExecution: 'all', - saveDataSuccessExecution: 'all', - executionTimeout: 3600, - timezone: 'America/New_York', - }, - }; - - const response = await authMemberAgent.post('/workflows').send(payload); - - expect(response.statusCode).toBe(200); - - const { id } = response.body; - - expect(id).toBeDefined(); - expect( - await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), - ).toBe(0); - }); - test('should not add a starting node if the payload has no starting nodes', async () => { const response = await authMemberAgent.post('/workflows').send({ name: 'testing', @@ -1111,8 +1072,7 @@ describe('PUT /workflows/:id', () => { ); }); - test('should create workflow history version when licensed', async () => { - license.enable('feat:workflowHistory'); + test('should always create workflow history version', async () => { const workflow = await createWorkflow({}, member); const payload = { name: 'name updated', @@ -1166,53 +1126,6 @@ describe('PUT /workflows/:id', () => { expect(historyVersion!.nodes).toEqual(payload.nodes); }); - test('should not create workflow history when not licensed', async () => { - license.disable('feat:workflowHistory'); - const workflow = await createWorkflow({}, member); - const payload = { - name: 'name updated', - nodes: [ - { - id: 'uuid-1234', - parameters: {}, - name: 'Start', - type: 'n8n-nodes-base.start', - typeVersion: 1, - position: [240, 300], - }, - { - id: 'uuid-1234', - parameters: {}, - name: 'Cron', - type: 'n8n-nodes-base.cron', - typeVersion: 1, - position: [400, 300], - }, - ], - connections: {}, - staticData: '{"id":1}', - settings: { - saveExecutionProgress: false, - saveManualExecutions: false, - saveDataErrorExecution: 'all', - saveDataSuccessExecution: 'all', - executionTimeout: 3600, - timezone: 'America/New_York', - }, - }; - - const response = await authMemberAgent.put(`/workflows/${workflow.id}`).send(payload); - - const { id } = response.body; - - expect(response.statusCode).toBe(200); - - expect(id).toBe(workflow.id); - expect( - await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), - ).toBe(0); - }); - test('should update non-owned workflow if owner', async () => { const workflow = await createWorkflow({}, member); diff --git a/packages/cli/test/integration/security-audit/credentials-risk-reporter.test.ts b/packages/cli/test/integration/security-audit/credentials-risk-reporter.test.ts index 3a532ac203d..e48418133cd 100644 --- a/packages/cli/test/integration/security-audit/credentials-risk-reporter.test.ts +++ b/packages/cli/test/integration/security-audit/credentials-risk-reporter.test.ts @@ -51,6 +51,7 @@ test('should report credentials not in any use', async () => { active: false, connections: {}, nodeTypes: {}, + versionId: uuid(), nodes: [ { id: uuid(), @@ -98,6 +99,7 @@ test('should report credentials not in active use', async () => { active: false, connections: {}, nodeTypes: {}, + versionId: uuid(), nodes: [ { id: uuid(), @@ -142,6 +144,7 @@ test('should report credential in not recently executed workflow', async () => { active: false, connections: {}, nodeTypes: {}, + versionId: uuid(), nodes: [ { id: uuid(), @@ -211,6 +214,7 @@ test('should not report credentials in recently executed workflow', async () => active: true, connections: {}, nodeTypes: {}, + versionId: uuid(), nodes: [ { id: uuid(), diff --git a/packages/cli/test/integration/security-audit/database-risk-reporter.test.ts b/packages/cli/test/integration/security-audit/database-risk-reporter.test.ts index f8a6460599f..de199f7363b 100644 --- a/packages/cli/test/integration/security-audit/database-risk-reporter.test.ts +++ b/packages/cli/test/integration/security-audit/database-risk-reporter.test.ts @@ -41,6 +41,7 @@ test('should report expressions in queries', async () => { active: false, connections: {}, nodeTypes: {}, + versionId: uuid(), nodes: [ { id: nodeId, @@ -94,6 +95,7 @@ test('should report expressions in query params', async () => { active: false, connections: {}, nodeTypes: {}, + versionId: uuid(), nodes: [ { id: nodeId, @@ -149,6 +151,7 @@ test('should report unused query params', async () => { active: false, connections: {}, nodeTypes: {}, + versionId: uuid(), nodes: [ { id: nodeId, diff --git a/packages/cli/test/integration/security-audit/filesystem-risk-reporter.test.ts b/packages/cli/test/integration/security-audit/filesystem-risk-reporter.test.ts index 07002310b91..476e422cb5f 100644 --- a/packages/cli/test/integration/security-audit/filesystem-risk-reporter.test.ts +++ b/packages/cli/test/integration/security-audit/filesystem-risk-reporter.test.ts @@ -38,6 +38,7 @@ test('should report filesystem interaction nodes', async () => { name: 'My Test Workflow', active: false, connections: {}, + versionId: uuid(), nodes: [ { id: nodeId, diff --git a/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts b/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts index 93d407dccc5..5569243d404 100644 --- a/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts +++ b/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts @@ -45,6 +45,7 @@ test('should report webhook lacking authentication', async () => { active: true, nodeTypes: {}, connections: {}, + versionId: uuid(), nodes: [ { parameters: { @@ -88,6 +89,7 @@ test('should not report webhooks having basic or header auth', async () => { active: true, nodeTypes: {}, connections: {}, + versionId: uuid(), nodes: [ { parameters: { @@ -132,6 +134,7 @@ test('should not report webhooks validated by direct children', async () => { name: 'My Test Workflow', active: true, nodeTypes: {}, + versionId: uuid(), nodes: [ { parameters: { diff --git a/packages/cli/test/integration/security-audit/nodes-risk-reporter.test.ts b/packages/cli/test/integration/security-audit/nodes-risk-reporter.test.ts index 0a16470ae61..371bc0ba7b8 100644 --- a/packages/cli/test/integration/security-audit/nodes-risk-reporter.test.ts +++ b/packages/cli/test/integration/security-audit/nodes-risk-reporter.test.ts @@ -49,6 +49,7 @@ test('should report risky official nodes', async () => { name: 'My Test Workflow', active: false, connections: {}, + versionId: uuid(), nodes: [ { id: nodeId, diff --git a/packages/cli/test/integration/security-audit/utils.ts b/packages/cli/test/integration/security-audit/utils.ts index 0bb8ce74e0d..e1f7cebf311 100644 --- a/packages/cli/test/integration/security-audit/utils.ts +++ b/packages/cli/test/integration/security-audit/utils.ts @@ -43,6 +43,7 @@ export async function saveManualTriggerWorkflow() { active: false, connections: {}, nodeTypes: {}, + versionId: uuid(), nodes: [ { id: uuid(), diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index 6490ab6df1e..9b4e1d495be 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -48,7 +48,7 @@ type EndpointGroup = | 'third-party-licenses' | 'mcp'; -type ModuleName = 'insights' | 'external-secrets' | 'community-packages' | 'data-table'; +type ModuleName = 'insights' | 'external-secrets' | 'community-packages' | 'data-table' | 'mcp'; export interface SetupProps { endpointGroups?: EndpointGroup[]; diff --git a/packages/cli/test/integration/shared/utils/test-server.ts b/packages/cli/test/integration/shared/utils/test-server.ts index d69397dbdf9..defcef38ac7 100644 --- a/packages/cli/test/integration/shared/utils/test-server.ts +++ b/packages/cli/test/integration/shared/utils/test-server.ts @@ -260,7 +260,7 @@ export const setupTestServer = ({ break; case 'workflowHistory': - await import('@/workflows/workflow-history.ee/workflow-history.controller.ee'); + await import('@/workflows/workflow-history/workflow-history.controller'); break; case 'binaryData': diff --git a/packages/cli/test/integration/workflow-history-manager.test.ts b/packages/cli/test/integration/workflow-history-manager.test.ts index 26f23b3848e..8005bb6ca0b 100644 --- a/packages/cli/test/integration/workflow-history-manager.test.ts +++ b/packages/cli/test/integration/workflow-history-manager.test.ts @@ -1,12 +1,12 @@ import { createWorkflow, testDb, mockInstance } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; -import { WorkflowHistoryRepository } from '@n8n/db'; +import { WorkflowHistoryRepository, WorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import { In } from '@n8n/typeorm'; import { DateTime } from 'luxon'; import { License } from '@/license'; -import { WorkflowHistoryManager } from '@/workflows/workflow-history.ee/workflow-history-manager.ee'; +import { WorkflowHistoryManager } from '@/workflows/workflow-history/workflow-history-manager'; import { createManyWorkflowHistoryItems } from './shared/db/workflow-history'; @@ -27,10 +27,8 @@ describe('Workflow History Manager', () => { await testDb.truncate(['WorkflowEntity']); jest.clearAllMocks(); - globalConfig.workflowHistory.enabled = true; globalConfig.workflowHistory.pruneTime = -1; - license.isWorkflowHistoryLicensed.mockReturnValue(true); license.getWorkflowHistoryPruneLimit.mockReturnValue(-1); }); @@ -57,18 +55,6 @@ describe('Workflow History Manager', () => { pruneSpy.mockRestore(); }); - test('should not prune when not licensed', async () => { - license.isWorkflowHistoryLicensed.mockReturnValue(false); - await createWorkflowHistory(); - await pruneAndAssertCount(); - }); - - test('should not prune when licensed but disabled', async () => { - globalConfig.workflowHistory.enabled = false; - await createWorkflowHistory(); - await pruneAndAssertCount(); - }); - test('should not prune when both prune times are -1 (infinite)', async () => { await createWorkflowHistory(); await pruneAndAssertCount(); @@ -103,6 +89,47 @@ describe('Workflow History Manager', () => { ).toBe(0); }); + test('should not prune current versions', async () => { + globalConfig.workflowHistory.pruneTime = 24; + + const activeWorkflow = await createWorkflow({ active: true }); + const inactiveWorkflow = await createWorkflow({ active: false }); + + // Create old history versions for the active workflow + const activeWorkflowVersions = await createManyWorkflowHistoryItems( + activeWorkflow.id, + 5, + DateTime.now().minus({ days: 2 }).toJSDate(), + ); + + // Create old history versions for the inactive workflow + const inactiveWorkflowVersions = await createManyWorkflowHistoryItems( + inactiveWorkflow.id, + 5, + DateTime.now().minus({ days: 2 }).toJSDate(), + ); + + // Set the current version for each workflow + activeWorkflow.versionId = activeWorkflowVersions[0].versionId; + inactiveWorkflow.versionId = inactiveWorkflowVersions[0].versionId; + + const workflowRepo = Container.get(WorkflowRepository); + await workflowRepo.save([activeWorkflow, inactiveWorkflow]); + + await manager.prune(); + + // Both workflows' current versions should still exist even though they are old + expect(await repo.count({ where: { versionId: activeWorkflow.versionId } })).toBe(1); + expect(await repo.count({ where: { versionId: inactiveWorkflow.versionId } })).toBe(1); + + // Other old versions should be deleted + const otherVersionIds = [ + ...activeWorkflowVersions.slice(1).map((i) => i.versionId), + ...inactiveWorkflowVersions.slice(1).map((i) => i.versionId), + ]; + expect(await repo.count({ where: { versionId: In(otherVersionIds) } })).toBe(0); + }); + const createWorkflowHistory = async (ageInDays = 2) => { const workflow = await createWorkflow(); const time = DateTime.now().minus({ days: ageInDays }).toJSDate(); @@ -112,7 +139,7 @@ describe('Workflow History Manager', () => { const pruneAndAssertCount = async (finalCount = 10, initialCount = 10) => { expect(await repo.count()).toBe(initialCount); - const deleteSpy = jest.spyOn(repo, 'delete'); + const deleteSpy = jest.spyOn(repo, 'deleteEarlierThanExceptCurrent'); await manager.prune(); if (initialCount === finalCount) { diff --git a/packages/cli/test/integration/workflow-history.api.test.ts b/packages/cli/test/integration/workflow-history.api.test.ts index 7e30d608404..cca1ac11f21 100644 --- a/packages/cli/test/integration/workflow-history.api.test.ts +++ b/packages/cli/test/integration/workflow-history.api.test.ts @@ -13,7 +13,6 @@ let authMemberAgent: SuperAgentTest; const testServer = utils.setupTestServer({ endpointGroups: ['workflowHistory'], - enabledFeatures: ['feat:workflowHistory'], }); beforeAll(async () => { @@ -28,13 +27,6 @@ afterEach(async () => { }); describe('GET /workflow-history/:workflowId', () => { - test('should not work when license is not available', async () => { - testServer.license.disable('feat:workflowHistory'); - const resp = await authOwnerAgent.get('/workflow-history/workflow/badid'); - expect(resp.status).toBe(403); - expect(resp.text).toBe('Workflow History license data not found'); - }); - test('should not return anything on an invalid workflow ID', async () => { await createWorkflow(undefined, owner); const resp = await authOwnerAgent.get('/workflow-history/workflow/badid'); @@ -159,13 +151,6 @@ describe('GET /workflow-history/:workflowId', () => { }); describe('GET /workflow-history/workflow/:workflowId/version/:versionId', () => { - test('should not work when license is not available', async () => { - testServer.license.disable('feat:workflowHistory'); - const resp = await authOwnerAgent.get('/workflow-history/workflow/badid/version/badid'); - expect(resp.status).toBe(403); - expect(resp.text).toBe('Workflow History license data not found'); - }); - test('should not return anything on an invalid workflow ID', async () => { const workflow = await createWorkflow(undefined, owner); const version = await createWorkflowHistoryItem(workflow.id); diff --git a/packages/cli/test/integration/workflows/workflow.service.test.ts b/packages/cli/test/integration/workflows/workflow.service.test.ts index f71019dfa10..27cf019ac30 100644 --- a/packages/cli/test/integration/workflows/workflow.service.test.ts +++ b/packages/cli/test/integration/workflows/workflow.service.test.ts @@ -1,5 +1,5 @@ import { createWorkflow, testDb, mockInstance } from '@n8n/backend-test-utils'; -import { SharedWorkflowRepository, WorkflowRepository } from '@n8n/db'; +import { SharedWorkflowRepository, type WorkflowEntity, WorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; @@ -7,12 +7,14 @@ import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import { Telemetry } from '@/telemetry'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; +import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service'; import { WorkflowService } from '@/workflows/workflow.service'; import { createOwner } from '../shared/db/users'; let workflowService: WorkflowService; const activeWorkflowManager = mockInstance(ActiveWorkflowManager); +const workflowHistoryService = mockInstance(WorkflowHistoryService); mockInstance(MessageEventBus); mockInstance(Telemetry); @@ -27,7 +29,7 @@ beforeAll(async () => { mock(), mock(), mock(), - mock(), + workflowHistoryService, mock(), activeWorkflowManager, mock(), @@ -82,4 +84,80 @@ describe('update()', () => { expect(addSpy).not.toHaveBeenCalled(); }); + + test('should fetch missing connections from DB when updating nodes', async () => { + const owner = await createOwner(); + const workflow = await createWorkflow({}, owner); + + const updateData: Partial = { + nodes: [ + { + id: 'new-node', + name: 'New Node', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [250, 300], + parameters: {}, + }, + ], + versionId: workflow.versionId, + }; + + const updatedWorkflow = await workflowService.update( + owner, + updateData as WorkflowEntity, + workflow.id, + ); + + expect(updatedWorkflow.nodes).toHaveLength(1); + expect(updatedWorkflow.nodes[0].name).toBe('New Node'); + expect(updatedWorkflow.versionId).not.toBe(workflow.versionId); + }); + + test('should not save workflow history version when updating only active status', async () => { + const owner = await createOwner(); + const workflow = await createWorkflow({ active: false }, owner); + + const saveVersionSpy = jest.spyOn(workflowHistoryService, 'saveVersion'); + + const updateData: Partial = { + active: true, + versionId: workflow.versionId, + }; + + await workflowService.update(owner, updateData as WorkflowEntity, workflow.id); + + expect(saveVersionSpy).not.toHaveBeenCalled(); + }); + + test('should save workflow history version with backfilled data when versionId changes', async () => { + const owner = await createOwner(); + const workflow = await createWorkflow({ active: false }, owner); + + const saveVersionSpy = jest.spyOn(workflowHistoryService, 'saveVersion'); + + const newVersionId = 'new-version-id-123'; + const updateData: Partial = { + active: true, + versionId: newVersionId, + }; + + await workflowService.update( + owner, + updateData as WorkflowEntity, + workflow.id, + undefined, + undefined, + true, // forceSave + ); + + expect(saveVersionSpy).toHaveBeenCalledTimes(1); + const [user, workflowData, workflowId] = saveVersionSpy.mock.calls[0]; + expect(user).toBe(owner); + expect(workflowId).toBe(workflow.id); + // Verify that nodes and connections were backfilled from the DB + expect(workflowData.nodes).toEqual(workflow.nodes); + expect(workflowData.connections).toEqual(workflow.connections); + expect(workflowData.versionId).toBe(newVersionId); + }); }); diff --git a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts index f59509e48dd..037a91aa496 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts @@ -704,8 +704,7 @@ describe('POST /workflows', () => { expect(response.statusCode).toBe(200); }); - test('Should create workflow history version when licensed', async () => { - license.enable('feat:workflowHistory'); + test('Should always create workflow history version', async () => { const payload = { name: 'testing', nodes: [ @@ -752,47 +751,6 @@ describe('POST /workflows', () => { expect(historyVersion!.connections).toEqual(payload.connections); expect(historyVersion!.nodes).toEqual(payload.nodes); }); - - test('Should not create workflow history version when not licensed', async () => { - license.disable('feat:workflowHistory'); - const payload = { - name: 'testing', - nodes: [ - { - id: 'uuid-1234', - parameters: {}, - name: 'Start', - type: 'n8n-nodes-base.start', - typeVersion: 1, - position: [240, 300], - }, - ], - connections: {}, - staticData: null, - settings: { - saveExecutionProgress: true, - saveManualExecutions: true, - saveDataErrorExecution: 'all', - saveDataSuccessExecution: 'all', - executionTimeout: 3600, - timezone: 'America/New_York', - }, - active: false, - }; - - const response = await authOwnerAgent.post('/workflows').send(payload); - - expect(response.statusCode).toBe(200); - - const { - data: { id }, - } = response.body; - - expect(id).toBeDefined(); - expect( - await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), - ).toBe(0); - }); }); describe('PATCH /workflows/:workflowId', () => { @@ -1274,8 +1232,7 @@ describe('PATCH /workflows/:workflowId', () => { }); describe('workflow history', () => { - test('Should create workflow history version when licensed', async () => { - license.enable('feat:workflowHistory'); + test('Should always create workflow history version', async () => { const workflow = await createWorkflow({}, owner); const payload = { name: 'name updated', @@ -1331,61 +1288,10 @@ describe('PATCH /workflows/:workflowId', () => { expect(historyVersion!.connections).toEqual(payload.connections); expect(historyVersion!.nodes).toEqual(payload.nodes); }); - - test('Should not create workflow history version when not licensed', async () => { - license.disable('feat:workflowHistory'); - const workflow = await createWorkflow({}, owner); - const payload = { - name: 'name updated', - versionId: workflow.versionId, - nodes: [ - { - id: 'uuid-1234', - parameters: {}, - name: 'Start', - type: 'n8n-nodes-base.start', - typeVersion: 1, - position: [240, 300], - }, - { - id: 'uuid-1234', - parameters: {}, - name: 'Cron', - type: 'n8n-nodes-base.cron', - typeVersion: 1, - position: [400, 300], - }, - ], - connections: {}, - staticData: '{"id":1}', - settings: { - saveExecutionProgress: false, - saveManualExecutions: false, - saveDataErrorExecution: 'all', - saveDataSuccessExecution: 'all', - executionTimeout: 3600, - timezone: 'America/New_York', - }, - }; - - const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); - - const { - data: { id }, - } = response.body; - - expect(response.statusCode).toBe(200); - - expect(id).toBe(workflow.id); - expect( - await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), - ).toBe(0); - }); }); describe('activate workflow', () => { test('should activate workflow without changing version ID', async () => { - license.disable('feat:workflowHistory'); const workflow = await createWorkflow({}, owner); const payload = { versionId: workflow.versionId, @@ -1407,7 +1313,6 @@ describe('PATCH /workflows/:workflowId', () => { }); test('should deactivate workflow without changing version ID', async () => { - license.disable('feat:workflowHistory'); const workflow = await createWorkflow({ active: true }, owner); const payload = { versionId: workflow.versionId, diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index 514a943ee5d..7df66459373 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -49,7 +49,6 @@ const testServer = utils.setupTestServer({ 'quota:maxTeamProjects': -1, }, }); -const license = testServer.license; const { objectContaining, arrayContaining, any } = expect; @@ -177,8 +176,7 @@ describe('POST /workflows', () => { expect(name).toBe('testing with context'); }); - test('should create workflow history version when licensed', async () => { - license.enable('feat:workflowHistory'); + test('should always create workflow history version', async () => { const payload = { name: 'testing', nodes: [ @@ -226,47 +224,6 @@ describe('POST /workflows', () => { expect(historyVersion!.nodes).toEqual(payload.nodes); }); - test('should not create workflow history version when not licensed', async () => { - license.disable('feat:workflowHistory'); - const payload = { - name: 'testing', - nodes: [ - { - id: 'uuid-1234', - parameters: {}, - name: 'Start', - type: 'n8n-nodes-base.start', - typeVersion: 1, - position: [240, 300], - }, - ], - connections: {}, - staticData: null, - settings: { - saveExecutionProgress: true, - saveManualExecutions: true, - saveDataErrorExecution: 'all', - saveDataSuccessExecution: 'all', - executionTimeout: 3600, - timezone: 'America/New_York', - }, - active: false, - }; - - const response = await authOwnerAgent.post('/workflows').send(payload); - - expect(response.statusCode).toBe(200); - - const { - data: { id }, - } = response.body; - - expect(id).toBeDefined(); - expect( - await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), - ).toBe(0); - }); - test('create workflow in personal project by default', async () => { // // ARRANGE @@ -829,18 +786,19 @@ describe('GET /workflows', () => { }); describe('filter', () => { - test('should filter workflows by field: name', async () => { - await createWorkflow({ name: 'First' }, owner); - await createWorkflow({ name: 'Second' }, owner); + test('should filter workflows by field: query', async () => { + await createWorkflow({ name: 'First', description: 'A workflow' }, owner); + await createWorkflow({ name: 'Second', description: 'Also a workflow' }, owner); + await createWorkflow({ name: 'Third', description: 'My first workflow' }, owner); const response = await authOwnerAgent .get('/workflows') - .query('filter={"name":"First"}') + .query('filter={"query":"first"}') .expect(200); expect(response.body).toEqual({ - count: 1, - data: [objectContaining({ name: 'First' })], + count: 2, + data: [objectContaining({ name: 'First' }), objectContaining({ name: 'Third' })], }); }); @@ -1461,7 +1419,7 @@ describe('GET /workflows', () => { const response = await authOwnerAgent .get('/workflows') .query('take=2&skip=1') - .query('filter={"name":"Special"}') + .query('filter={"query":"Special"}') .expect(200); expect(response.body.data).toHaveLength(2); @@ -1797,7 +1755,7 @@ describe('GET /workflows?includeFolders=true', () => { }); describe('filter', () => { - test('should filter workflows and folders by field: name', async () => { + test('should filter workflows and folders by field: query', async () => { const workflow1 = await createWorkflow({ name: 'First' }, owner); await createWorkflow({ name: 'Second' }, owner); @@ -1806,7 +1764,7 @@ describe('GET /workflows?includeFolders=true', () => { const folder1 = await createFolder(ownerProject, { name: 'First' }); const response = await authOwnerAgent .get('/workflows') - .query('filter={"name":"First"}&includeFolders=true') + .query('filter={"query":"First"}&includeFolders=true') .expect(200); expect(response.body).toEqual({ @@ -1919,7 +1877,7 @@ describe('GET /workflows?includeFolders=true', () => { expect(response2.body.data).toHaveLength(0); }); - test('should filter workflows by parentFolderId and its descendants when filtering by name', async () => { + test('should filter workflows by parentFolderId and its descendants when filtering by query', async () => { const pp = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(owner.id); await createFolder(pp, { @@ -1966,7 +1924,7 @@ describe('GET /workflows?includeFolders=true', () => { const filter2Response = await authOwnerAgent .get('/workflows') .query( - `filter={ "projectId": "${pp.id}", "parentFolderId": "${rootFolder2.id}", "name": "key" }&includeFolders=true`, + `filter={ "projectId": "${pp.id}", "parentFolderId": "${rootFolder2.id}", "query": "key" }&includeFolders=true`, ); expect(filter2Response.body.count).toBe(4); @@ -2258,7 +2216,7 @@ describe('GET /workflows?includeFolders=true', () => { const response = await authOwnerAgent .get('/workflows') .query('take=2&skip=1') - .query('filter={"name":"Special"}&includeFolders=true') + .query('filter={"query":"Special"}&includeFolders=true') .expect(200); expect(response.body.data).toHaveLength(2); @@ -2290,8 +2248,7 @@ describe('GET /workflows?includeFolders=true', () => { }); describe('PATCH /workflows/:workflowId', () => { - test('should create workflow history version when licensed', async () => { - license.enable('feat:workflowHistory'); + test('should always create workflow history version', async () => { const workflow = await createWorkflow({}, owner); const payload = { name: 'name updated', @@ -2368,60 +2325,7 @@ describe('PATCH /workflows/:workflowId', () => { expect(versionCounter).toBe(workflow.versionCounter + 1); }); - test('should not create workflow history version when not licensed', async () => { - license.disable('feat:workflowHistory'); - const workflow = await createWorkflow({}, owner); - const payload = { - name: 'name updated', - versionId: workflow.versionId, - nodes: [ - { - id: 'uuid-1234', - parameters: {}, - name: 'Start', - type: 'n8n-nodes-base.start', - typeVersion: 1, - position: [240, 300], - }, - { - id: 'uuid-1234', - parameters: {}, - name: 'Cron', - type: 'n8n-nodes-base.cron', - typeVersion: 1, - position: [400, 300], - }, - ], - connections: {}, - staticData: '{"id":1}', - settings: { - saveExecutionProgress: false, - saveManualExecutions: false, - saveDataErrorExecution: 'all', - saveDataSuccessExecution: 'all', - executionTimeout: 3600, - timezone: 'America/New_York', - }, - }; - - const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); - - console.log(response.body); - - const { - data: { id }, - } = response.body; - - expect(response.statusCode).toBe(200); - - expect(id).toBe(workflow.id); - expect( - await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), - ).toBe(0); - }); - test('should activate workflow without changing version ID', async () => { - license.disable('feat:workflowHistory'); const workflow = await createWorkflow({}, owner); const payload = { versionId: workflow.versionId, @@ -2443,7 +2347,6 @@ describe('PATCH /workflows/:workflowId', () => { }); test('should deactivate workflow without changing version ID', async () => { - license.disable('feat:workflowHistory'); const workflow = await createWorkflow({ active: true }, owner); const payload = { versionId: workflow.versionId, diff --git a/packages/core/package.json b/packages/core/package.json index 90355abd609..35bf99a42f0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "1.118.0", + "version": "1.119.0", "description": "Core functionality of n8n", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/core/src/nodes-loader/__tests__/directory-loader.test.ts b/packages/core/src/nodes-loader/__tests__/directory-loader.test.ts index e9a3b5efc6a..65e8afc998b 100644 --- a/packages/core/src/nodes-loader/__tests__/directory-loader.test.ts +++ b/packages/core/src/nodes-loader/__tests__/directory-loader.test.ts @@ -106,6 +106,58 @@ describe('DirectoryLoader', () => { expect(mockFs.readFileSync).not.toHaveBeenCalled(); }); + + it('should load custom nodes when specified with CUSTOM prefix in includeNodes', async () => { + const loader = new CustomDirectoryLoader(directory, [], ['CUSTOM.node1', 'CUSTOM.node2']); + + await loader.loadAll(); + + expect(loader.nodeTypes).toEqual({ + node1: { sourcePath: 'dist/Node1/Node1.node.js', type: mockNode1 }, + node2: { sourcePath: 'dist/Node2/Node2.node.js', type: mockNode2 }, + }); + expect(Object.keys(loader.nodeTypes)).toHaveLength(2); + }); + + it('should load only specified custom nodes when includeNodes contains mixed packages', async () => { + const loader = new CustomDirectoryLoader( + directory, + [], + ['n8n-nodes-base.aggregate', 'CUSTOM.node1'], + ); + + await loader.loadAll(); + + expect(loader.nodeTypes).toEqual({ + node1: { sourcePath: 'dist/Node1/Node1.node.js', type: mockNode1 }, + }); + expect(Object.keys(loader.nodeTypes)).toHaveLength(1); + // node2 should not be loaded + }); + + it('should not load any custom nodes when only built-in nodes are in includeNodes', async () => { + const loader = new CustomDirectoryLoader( + directory, + [], + ['n8n-nodes-base.aggregate', 'n8n-nodes-base.httpRequest'], + ); + + await loader.loadAll(); + + expect(loader.nodeTypes).toEqual({}); + expect(loader.types.nodes).toEqual([]); + }); + + it('should exclude custom nodes when specified with CUSTOM prefix in excludeNodes', async () => { + const loader = new CustomDirectoryLoader(directory, ['CUSTOM.node1'], []); + + await loader.loadAll(); + + expect(loader.nodeTypes).toEqual({ + node2: { sourcePath: 'dist/Node2/Node2.node.js', type: mockNode2 }, + }); + expect(Object.keys(loader.nodeTypes)).toHaveLength(1); + }); }); describe('PackageDirectoryLoader', () => { diff --git a/packages/core/src/nodes-loader/custom-directory-loader.ts b/packages/core/src/nodes-loader/custom-directory-loader.ts index 8e844406083..615d8f61cf0 100644 --- a/packages/core/src/nodes-loader/custom-directory-loader.ts +++ b/packages/core/src/nodes-loader/custom-directory-loader.ts @@ -9,6 +9,13 @@ import { DirectoryLoader } from './directory-loader'; export class CustomDirectoryLoader extends DirectoryLoader { packageName = 'CUSTOM'; + constructor(directory: string, excludeNodes: string[] = [], includeNodes: string[] = []) { + super(directory, excludeNodes, includeNodes); + + this.excludeNodes = this.extractNodeTypes(excludeNodes, this.packageName); + this.includeNodes = this.extractNodeTypes(includeNodes, this.packageName); + } + override async loadAll() { const nodes = await glob('**/*.node.js', { cwd: this.directory, diff --git a/packages/core/src/nodes-loader/directory-loader.ts b/packages/core/src/nodes-loader/directory-loader.ts index 82345cd427b..08c2ed8a72a 100644 --- a/packages/core/src/nodes-loader/directory-loader.ts +++ b/packages/core/src/nodes-loader/directory-loader.ts @@ -115,6 +115,13 @@ export abstract class DirectoryLoader { return path.resolve(this.directory, file); } + protected extractNodeTypes(fullNodeTypes: string[], packageName: string): string[] { + return fullNodeTypes + .map((fullNodeType) => fullNodeType.split('.')) + .filter(([pkg]) => pkg === packageName) + .map(([_, nodeType]) => nodeType); + } + private loadClass(sourcePath: string) { const filePath = this.resolvePath(sourcePath); const [className] = path.parse(sourcePath).name.split('.'); diff --git a/packages/core/src/nodes-loader/package-directory-loader.ts b/packages/core/src/nodes-loader/package-directory-loader.ts index bdc64813dd7..32fe5e19ebc 100644 --- a/packages/core/src/nodes-loader/package-directory-loader.ts +++ b/packages/core/src/nodes-loader/package-directory-loader.ts @@ -19,15 +19,9 @@ export class PackageDirectoryLoader extends DirectoryLoader { this.packageJson = this.readJSONSync('package.json'); this.packageName = this.packageJson.name; - this.excludeNodes = this.extractNodeTypes(excludeNodes); - this.includeNodes = this.extractNodeTypes(includeNodes); - } - private extractNodeTypes(fullNodeTypes: string[]) { - return fullNodeTypes - .map((fullNodeType) => fullNodeType.split('.')) - .filter(([packageName]) => packageName === this.packageName) - .map(([_, nodeType]) => nodeType); + this.excludeNodes = this.extractNodeTypes(excludeNodes, this.packageName); + this.includeNodes = this.extractNodeTypes(includeNodes, this.packageName); } override async loadAll() { diff --git a/packages/frontend/@n8n/chat/package.json b/packages/frontend/@n8n/chat/package.json index 92249d1f0f1..968abc4c8d7 100644 --- a/packages/frontend/@n8n/chat/package.json +++ b/packages/frontend/@n8n/chat/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/chat", - "version": "0.64.0", + "version": "0.65.0", "scripts": { "dev": "pnpm run storybook", "build": "pnpm build:vite && pnpm build:bundle", diff --git a/packages/frontend/@n8n/design-system/package.json b/packages/frontend/@n8n/design-system/package.json index f44ff4867c9..41b6022eb91 100644 --- a/packages/frontend/@n8n/design-system/package.json +++ b/packages/frontend/@n8n/design-system/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@n8n/design-system", - "version": "1.106.0", + "version": "1.107.0", "main": "src/index.ts", "import": "src/index.ts", "scripts": { diff --git a/packages/frontend/@n8n/design-system/src/__tests__/setup.ts b/packages/frontend/@n8n/design-system/src/__tests__/setup.ts index 20c4db3cb10..fbaea37dfd5 100644 --- a/packages/frontend/@n8n/design-system/src/__tests__/setup.ts +++ b/packages/frontend/@n8n/design-system/src/__tests__/setup.ts @@ -1,6 +1,7 @@ import '@testing-library/jest-dom'; import { configure } from '@testing-library/vue'; import { config } from '@vue/test-utils'; +import { beforeAll } from 'vitest'; import { N8nPlugin } from '@n8n/design-system/plugin'; @@ -20,3 +21,62 @@ window.ResizeObserver = vi.mock('is-emoji-supported', () => ({ isEmojiSupported: () => true, })); + +/** + * Fixes missing pointer APIs and defaultPrevented issues for jsdom + user-event + */ +beforeAll(() => { + // Patch missing pointer APIs + const elementProto = HTMLElement.prototype as HTMLElement & { + hasPointerCapture?: (pointerId: number) => boolean; + setPointerCapture?: (pointerId: number) => void; + releasePointerCapture?: (pointerId: number) => void; + }; + + if (!elementProto.hasPointerCapture) { + Object.defineProperties(elementProto, { + hasPointerCapture: { + value: (_: number) => false, + writable: true, + }, + setPointerCapture: { + value: (_: number) => {}, + writable: true, + }, + releasePointerCapture: { + value: (_: number) => {}, + writable: true, + }, + }); + } +}); + +// Preserve originals +const OriginalMouseEvent = window.MouseEvent; +const OriginalPointerEvent = window.PointerEvent || window.MouseEvent; + +// Patched MouseEvent +class PatchedMouseEvent extends OriginalMouseEvent { + constructor(type: string, eventInit?: MouseEventInit) { + super(type, eventInit); + Object.defineProperty(this, 'defaultPrevented', { + get: () => false, + }); + } +} + +// Patched PointerEvent +class PatchedPointerEvent extends OriginalPointerEvent { + constructor(type: string, eventInit?: PointerEventInit) { + super(type, eventInit); + Object.defineProperty(this, 'defaultPrevented', { + get: () => false, + }); + } +} + +beforeEach(() => { + vi.stubGlobal('MouseEvent', PatchedMouseEvent); + vi.stubGlobal('PointerEvent', PatchedPointerEvent); +}); +afterEach(() => vi.unstubAllGlobals()); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/anthropic.svg b/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/anthropic.svg new file mode 100644 index 00000000000..135a8b9f33f --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/anthropic.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/lovable.svg b/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/lovable.svg new file mode 100644 index 00000000000..25f9bde660d --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/lovable.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts index e4bb757904b..de7623cd59f 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts @@ -1,9 +1,11 @@ +import Anthropic from './custom/anthropic.svg'; import Binary from './custom/binary.svg'; import BoltFilled from './custom/bolt-filled.svg'; import Continue from './custom/continue.svg'; import EmptyOutput from './custom/empty-output.svg'; import FilledSquare from './custom/filled-square.svg'; import GripLinesVertical from './custom/grip-lines-vertical.svg'; +import Lovable from './custom/lovable.svg'; import Mcp from './custom/mcp.svg'; import NodeDirty from './custom/node-dirty.svg'; import NodeEllipsis from './custom/node-ellipsis.svg'; @@ -152,6 +154,7 @@ import IconLucideMilestone from '~icons/lucide/milestone'; import IconLucideMinimize2 from '~icons/lucide/minimize-2'; import IconLucideMousePointer from '~icons/lucide/mouse-pointer'; import IconLucideNetwork from '~icons/lucide/network'; +import IconLucideNotebookPen from '~icons/lucide/notebook-pen'; import IconLucidePackageOpen from '~icons/lucide/package-open'; import IconLucidePalette from '~icons/lucide/palette'; import IconLucidePanelLeft from '~icons/lucide/panel-left'; @@ -430,6 +433,7 @@ export const deprecatedIconSet = { export const updatedIconSet = { // custom icons // NOTE: ensure to replace any colors with "currentColor" in SVG + anthropic: Anthropic, 'bolt-filled': BoltFilled, 'filled-square': FilledSquare, 'grip-lines-vertical': GripLinesVertical, @@ -464,6 +468,7 @@ export const updatedIconSet = { 'node-success': NodeSuccess, 'node-trash': NodeTrash, mcp: Mcp, + lovable: Lovable, // lucide 'align-right': IconLucideAlignRight, @@ -585,6 +590,7 @@ export const updatedIconSet = { milestone: IconLucideMilestone, 'mouse-pointer': IconLucideMousePointer, network: IconLucideNetwork, + 'notebook-pen': IconLucideNotebookPen, 'package-open': IconLucidePackageOpen, palette: IconLucidePalette, 'panel-left': IconLucidePanelLeft, diff --git a/packages/frontend/@n8n/design-system/src/index.ts b/packages/frontend/@n8n/design-system/src/index.ts index d96d74532f1..60ab054b4fb 100644 --- a/packages/frontend/@n8n/design-system/src/index.ts +++ b/packages/frontend/@n8n/design-system/src/index.ts @@ -5,4 +5,7 @@ export * from './plugin'; export * from './types'; export * from './utils'; export * from './directives'; +export { default as N8nSelect2 } from './v2/components/Select/Select.vue'; +export { default as N8nSelect2Item } from './v2/components/Select/SelectItem.vue'; +export type * from './v2/components/Select/Select.types'; export { locale }; diff --git a/packages/frontend/@n8n/design-system/src/v2/components/Select/Select.stories.ts b/packages/frontend/@n8n/design-system/src/v2/components/Select/Select.stories.ts new file mode 100644 index 00000000000..417b4b72b4d --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/v2/components/Select/Select.stories.ts @@ -0,0 +1,298 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import type { Meta, StoryObj } from '@storybook/vue3-vite'; +import { ref, computed } from 'vue'; + +import N8nIcon from '@n8n/design-system/components/N8nIcon/Icon.vue'; + +import type { SelectItem } from './Select.types'; +import Select from './Select.vue'; + +type GenericMeta = Omit, 'component'> & { + component: Record; +}; + +const meta = { + title: 'Design system v3/Select', + component: Select, + parameters: { + docs: { + source: { type: 'dynamic' }, + }, + }, +} satisfies GenericMeta>; +export default meta; + +type Story = StoryObj; + +export const Items = { + // @ts-expect-error generic typed components https://github.com/storybookjs/storybook/issues/24238 + render: (args) => ({ + components: { Select }, + setup() { + const value = ref(args.modelValue); + return { args, value }; + }, + template: ` +
+ +

Disabled

+ +

small (default)

+ +
+ `, + }), + args: { + items: [ + { type: 'label', label: 'Fruits' }, + { label: 'Apple', value: 'apple' }, + { label: 'Banana', value: 'banana' }, + { label: 'Orange', value: 'orange' }, + { label: 'Grapes', value: 'grapes' }, + { label: 'Mango', value: 'mango' }, + { label: 'Pineapple', value: 'pineapple' }, + { label: 'Strawberry', value: 'strawberry' }, + { label: 'Blueberry', value: 'blueberry' }, + { label: 'Watermelon', value: 'watermelon' }, + { label: 'Papaya', value: 'papaya' }, + { label: 'Cherry', value: 'cherry' }, + { label: 'Peach', value: 'peach' }, + { label: 'Pear', value: 'pear' }, + { label: 'Plum', value: 'plum' }, + { label: 'Kiwi', value: 'kiwi' }, + { label: 'Lemon', value: 'lemon' }, + { label: 'Lime', value: 'lime' }, + { label: 'Coconut', value: 'coconut' }, + { type: 'separator' }, + { type: 'label', label: 'More Fruits' }, + { label: 'Pomegranate', value: 'pomegranate' }, + { label: 'Guava', value: 'guava' }, + { label: 'Dragon Fruit', value: 'dragon_fruit' }, + { label: 'Lychee', value: 'lychee' }, + { label: 'Fig', value: 'fig' }, + { label: 'Apricot', value: 'apricot' }, + { label: 'Raspberry', value: 'raspberry' }, + { label: 'Blackberry', value: 'blackberry' }, + { label: 'Cantaloupe', value: 'cantaloupe' }, + { label: 'Passion Fruit', value: 'passion_fruit' }, + { label: 'Cranberry', value: 'cranberry' }, + { label: 'Tangerine', value: 'tangerine' }, + ], + modelValue: undefined, + }, +} satisfies Story; + +export const WithIcons = { + // @ts-expect-error generic typed components https://github.com/storybookjs/storybook/issues/24238 + render: (args) => ({ + components: { Select }, + setup() { + const value = ref(args.modelValue); + const icon = computed( + // @ts-expect-error TS2322 + () => (args.items as SelectItem[])?.find((item) => item.value === value.value)?.icon, + ); + return { args, value, icon }; + }, + template: ` +
+ + + + + +
+ `, + }), + args: { + items: [ + { + value: 'system', + label: 'System Default', + icon: 'settings', + disabled: true, + }, + { + value: 'light', + label: 'Light', + icon: 'wrench', + class: 'custom-class', + }, + { + value: 'dark', + label: 'Dark', + icon: 'filled-square', + class: ['custom-class2', 'custom-class3'], + }, + { + value: 'dark2', + label: 'Dark2', + icon: 'filled-square', + class: { ['custom-class4']: true }, + }, + ] satisfies SelectItem[], + modelValue: undefined, + }, +} satisfies Story; + +export const Variants = { + // @ts-expect-error generic typed components https://github.com/storybookjs/storybook/issues/24238 + render: (args) => ({ + components: { Select }, + setup() { + const value = ref(args.modelValue); + return { args, value }; + }, + template: ` +
+

Default

+ +
+ `, + }), + args: { + items: ['Option 1', 'Option 2', 'Option 3'], + modelValue: undefined, + }, +} satisfies Story; + +export const Sizes = { + // @ts-expect-error generic typed components https://github.com/storybookjs/storybook/issues/24238 + render: (args) => ({ + components: { Select }, + setup() { + const value = ref(args.modelValue); + const icon = computed( + // @ts-expect-error TS2322 + () => (args.items as SelectItem[])?.find((item) => item.value === value.value)?.icon, + ); + return { args, value, icon }; + }, + template: ` +
+

xsmall

+ +

medium

+ diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue b/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue index 1ed73bb670f..85b2a29b037 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue +++ b/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue @@ -509,20 +509,27 @@ const tags = computed( @click="onClick" >
{ return sourceControlStore.preferences.branchReadOnly; }); +// Show MCP action if: +// - MCP module is active +// - Instance-level access is enabled +// - Workflow is eligible for MCP access const isMcpAvailable = computed(() => { - return settingsStore.isModuleActive('mcp') && isEligibleForMcpAccess(props.workflow); + return ( + settingsStore.isModuleActive('mcp') && + settingsStore.moduleSettings.mcp?.mcpAccessEnabled && + isEligibleForMcpAccess(props.workflow) + ); }); const availableActions = computed(() => { diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.test.ts b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.test.ts index e1fb8aadbcf..acc74d5ba14 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.test.ts +++ b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.test.ts @@ -137,7 +137,7 @@ describe('WorkflowSettingsVue', () => { expect(searchWorkflowsSpy).toHaveBeenCalledTimes(1); expect(searchWorkflowsSpy).toHaveBeenCalledWith( expect.objectContaining({ - name: undefined, + query: undefined, }), ); }); diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue index e05f1d09043..5816376535b 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue +++ b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue @@ -84,7 +84,9 @@ const defaultValues = ref({ availableInMCP: false, }); -const isMCPEnabled = computed(() => settingsStore.isModuleActive('mcp')); +const isMCPEnabled = computed( + () => settingsStore.isModuleActive('mcp') && settingsStore.moduleSettings.mcp?.mcpAccessEnabled, +); const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly); const workflowName = computed(() => workflowsStore.workflowName); const workflowId = computed(() => workflowsStore.workflowId); @@ -99,6 +101,17 @@ const workflowOwnerName = computed(() => { }); const workflowPermissions = computed(() => getResourcePermissions(workflow.value?.scopes).workflow); +const mcpToggleDisabled = computed(() => { + return readOnlyEnv.value || !workflowPermissions.value.update || !isEligibleForMcp.value; +}); + +const mcpToggleTooltip = computed(() => { + if (!isEligibleForMcp.value) { + return i18n.baseText('mcp.workflowNotEligable.description'); + } + return i18n.baseText('workflowSettings.availableInMCP.tooltip'); +}); + const isEligibleForMcp = computed(() => { if (!workflow?.value) return false; return isEligibleForMcpAccess(workflow.value); @@ -279,7 +292,7 @@ const loadTimezones = async () => { const loadWorkflows = async (searchTerm?: string) => { const workflowsData = (await workflowsStore.searchWorkflows({ - name: searchTerm, + query: searchTerm, })) as IWorkflowShortResponse[]; workflowsData.sort((a, b) => { if (a.name.toLowerCase() < b.name.toLowerCase()) { @@ -857,11 +870,7 @@ onBeforeUnmount(() => { {{ i18n.baseText('workflowSettings.availableInMCP') }} @@ -869,13 +878,13 @@ onBeforeUnmount(() => {
- + ({ })); import { useCanvasOperations } from '@/app/composables/useCanvasOperations'; +import { GRID_SIZE, PUSH_NODES_OFFSET } from '@/app/utils/nodeViewUtils'; vi.mock('n8n-workflow', async (importOriginal) => { // eslint-disable-next-line @typescript-eslint/consistent-type-imports @@ -1072,6 +1073,41 @@ describe('useCanvasOperations', () => { }), ); }); + + it('should respect positionOffset', async () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = useNodeTypesStore(); + const nodeTypeName = 'type'; + const nodes: AddedNode[] = [ + { name: 'Node 1', type: nodeTypeName }, + { name: 'Node 2', type: nodeTypeName, positionOffset: [2 * GRID_SIZE, GRID_SIZE] }, + ]; + + workflowsStore.workflowObject = createTestWorkflowObject(workflowsStore.workflow); + + nodeTypesStore.nodeTypes = { + [nodeTypeName]: { 1: mockNodeTypeDescription({ name: nodeTypeName }) }, + }; + + const { addNodes } = useCanvasOperations(); + await addNodes(nodes, { position: [32, 32] }); + + expect(workflowsStore.addNode).toHaveBeenCalledTimes(2); + expect(workflowsStore.addNode.mock.calls[0][0]).toMatchObject({ + name: nodes[0].name, + type: nodeTypeName, + typeVersion: 1, + position: [32, 32], + parameters: {}, + }); + expect(workflowsStore.addNode.mock.calls[1][0]).toMatchObject({ + name: nodes[1].name, + type: nodeTypeName, + typeVersion: 1, + position: [32 + PUSH_NODES_OFFSET + 2 * GRID_SIZE, 32 + GRID_SIZE], + parameters: {}, + }); + }); }); describe('revertAddNode', () => { diff --git a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts index 1e49a2ab63d..7154ccaf43e 100644 --- a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts +++ b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts @@ -716,10 +716,15 @@ export function useCanvasOperations() { } for (const [index, nodeAddData] of nodesWithTypeVersion.entries()) { - const { isAutoAdd, openDetail: openNDV, actionName, ...node } = nodeAddData; - const position = node.position ?? insertPosition; - const nodeTypeDescription = requireNodeTypeDescription(node.type, node.typeVersion); + const { isAutoAdd, openDetail: openNDV, actionName, positionOffset, ...node } = nodeAddData; + const rawPosition = node.position ?? insertPosition; + const position: XYPosition | undefined = + rawPosition && positionOffset + ? [rawPosition[0] + positionOffset[0], rawPosition[1] + positionOffset[1]] + : rawPosition; + + const nodeTypeDescription = requireNodeTypeDescription(node.type, node.typeVersion); try { const newNode = addNode( { diff --git a/packages/frontend/editor-ui/src/app/composables/useDataSchema.test.ts b/packages/frontend/editor-ui/src/app/composables/useDataSchema.test.ts index 5c7c33ef050..902c24bf12b 100644 --- a/packages/frontend/editor-ui/src/app/composables/useDataSchema.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/useDataSchema.test.ts @@ -365,6 +365,185 @@ describe('useDataSchema', () => { ); expect(pathData).toEqual([new Date('2022-11-22T00:00:00.000Z')]); }); + + describe('with collapseArrays=true', () => { + it('should collapse simple arrays to first item only', () => { + const input = ['John', 'Jane', 'Joe']; + const schema = getSchema(input, '', false, true); + expect(schema).toEqual({ + type: 'array', + value: [{ type: 'string', value: 'John', key: '0', path: '[0]' }], + path: '', + }); + }); + + it('should collapse nested arrays recursively', () => { + const input = [ + { name: 'John', age: 22, hobbies: ['surfing', 'traveling', 'reading'] }, + { name: 'Joe', age: 33, hobbies: ['skateboarding', 'gaming', 'coding'] }, + { name: 'Jane', age: 28, hobbies: ['cooking', 'photography'] }, + ]; + const schema = getSchema(input, '', false, true); + expect(schema).toEqual({ + type: 'array', + value: [ + { + type: 'object', + key: '0', + value: [ + { type: 'string', key: 'name', value: 'Jane', path: '[0].name' }, + { type: 'number', key: 'age', value: '28', path: '[0].age' }, + { + type: 'array', + key: 'hobbies', + value: [{ type: 'string', key: '0', value: 'cooking', path: '[0].hobbies[0]' }], + path: '[0].hobbies', + }, + ], + path: '[0]', + }, + ], + path: '', + }); + }); + + it('should collapse nested arrays of objects with different keys recursively', () => { + const input = [ + { + name: 'John', + age: 22, + createdAt: 193939, + }, + { name: 'Joe', age: 33, hobbies: ['skateboarding', 'gaming', 'coding'], test: true }, + { name: 'Jane', age: 28, hobbies: ['cooking', 'photography'], updatedAt: 199994 }, + ]; + const schema = getSchema(input, '', false, true); + expect(schema).toEqual({ + type: 'array', + value: [ + { + type: 'object', + key: '0', + value: [ + { type: 'string', key: 'name', value: 'Jane', path: '[0].name' }, + { type: 'number', key: 'age', value: '28', path: '[0].age' }, + { type: 'number', key: 'createdAt', value: '193939', path: '[0].createdAt' }, + { + type: 'array', + key: 'hobbies', + value: [{ type: 'string', key: '0', value: 'cooking', path: '[0].hobbies[0]' }], + path: '[0].hobbies', + }, + { type: 'boolean', key: 'test', value: 'true', path: '[0].test' }, + { type: 'number', key: 'updatedAt', value: '199994', path: '[0].updatedAt' }, + ], + path: '[0]', + }, + ], + path: '', + }); + }); + + it('should handle empty arrays', () => { + const input: unknown[] = []; + const schema = getSchema(input, '', false, true); + expect(schema).toEqual({ + type: 'array', + value: [], + path: '', + }); + }); + + it('should collapse deeply nested arrays', () => { + const input = [ + { + dates: [ + [new Date('2022-11-22T00:00:00.000Z'), new Date('2022-11-23T00:00:00.000Z')], + [new Date('2022-12-22T00:00:00.000Z'), new Date('2022-12-23T00:00:00.000Z')], + ], + }, + { + dates: [[new Date('2023-01-01T00:00:00.000Z'), new Date('2023-01-02T00:00:00.000Z')]], + }, + ]; + const schema = getSchema(input, '', false, true); + expect(schema).toEqual({ + type: 'array', + value: [ + { + type: 'object', + key: '0', + value: [ + { + type: 'array', + key: 'dates', + value: [ + { + type: 'array', + key: '0', + value: [ + { + type: 'string', + key: '0', + value: '2023-01-01T00:00:00.000Z', + path: '[0].dates[0][0]', + }, + ], + path: '[0].dates[0]', + }, + ], + path: '[0].dates', + }, + ], + path: '[0]', + }, + ], + path: '', + }); + }); + + it('should not affect objects, only arrays', () => { + const input = { + person1: { name: 'John', age: 22 }, + person2: { name: 'Jane', age: 28 }, + person3: { name: 'Joe', age: 33 }, + }; + const schema = getSchema(input, '', false, true); + expect(schema).toEqual({ + type: 'object', + value: [ + { + type: 'object', + key: 'person1', + value: [ + { type: 'string', key: 'name', value: 'John', path: '.person1.name' }, + { type: 'number', key: 'age', value: '22', path: '.person1.age' }, + ], + path: '.person1', + }, + { + type: 'object', + key: 'person2', + value: [ + { type: 'string', key: 'name', value: 'Jane', path: '.person2.name' }, + { type: 'number', key: 'age', value: '28', path: '.person2.age' }, + ], + path: '.person2', + }, + { + type: 'object', + key: 'person3', + value: [ + { type: 'string', key: 'name', value: 'Joe', path: '.person3.name' }, + { type: 'number', key: 'age', value: '33', path: '.person3.age' }, + ], + path: '.person3', + }, + ], + path: '', + }); + }); + }); }); describe('filterSchema', () => { diff --git a/packages/frontend/editor-ui/src/app/composables/useDataSchema.ts b/packages/frontend/editor-ui/src/app/composables/useDataSchema.ts index b85cba534bf..5489078c0c6 100644 --- a/packages/frontend/editor-ui/src/app/composables/useDataSchema.ts +++ b/packages/frontend/editor-ui/src/app/composables/useDataSchema.ts @@ -23,6 +23,7 @@ export function useDataSchema() { input: Optional, path = '', excludeValues = false, + collapseArrays = false, ): Schema { let schema: Schema = { type: 'undefined', value: 'undefined', path }; switch (typeof input) { @@ -34,10 +35,24 @@ export function useDataSchema() { } else if (Array.isArray(input)) { schema = { type: 'array', - value: input.map((item, index) => ({ - key: index.toString(), - ...getSchema(item, `${path}[${index}]`, excludeValues), - })), + value: + collapseArrays && input.length > 0 + ? [ + { + key: '0', + ...getSchema( + // If array contains objects, merge all their keys into one + input.every((item) => isObj(item)) ? merge({}, ...input) : input[0], + `${path}[0]`, + excludeValues, + collapseArrays, + ), + }, + ] + : input.map((item, index) => ({ + key: index.toString(), + ...getSchema(item, `${path}[${index}]`, excludeValues, collapseArrays), + })), path, }; } else if (isObj(input)) { @@ -45,7 +60,7 @@ export function useDataSchema() { type: 'object', value: Object.entries(input).map(([k, v]) => ({ key: k, - ...getSchema(v, generatePath(path, [k]), excludeValues), + ...getSchema(v, generatePath(path, [k]), excludeValues, collapseArrays), })), path, }; @@ -65,10 +80,14 @@ export function useDataSchema() { return schema; } - function getSchemaForExecutionData(data: IDataObject[], excludeValues = false) { + function getSchemaForExecutionData( + data: IDataObject[], + excludeValues = false, + collapseArrays = false, + ) { const [head, ...tail] = data; - return getSchema(merge({}, head, ...tail, head), undefined, excludeValues); + return getSchema(merge({}, head, ...tail, head), undefined, excludeValues, collapseArrays); } function getSchemaForJsonSchema(schema: JSONSchema7 | JSONSchema7Definition, path = ''): Schema { diff --git a/packages/frontend/editor-ui/src/app/composables/useDebugInfo.test.ts b/packages/frontend/editor-ui/src/app/composables/useDebugInfo.test.ts index ae256c5b2d6..57ca5faa159 100644 --- a/packages/frontend/editor-ui/src/app/composables/useDebugInfo.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/useDebugInfo.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { useDebugInfo } from './useDebugInfo'; import type { RootStoreState } from '@n8n/stores/useRootStore'; import type { useSettingsStore as useSettingsStoreType } from '@/app/stores/settings.store'; -import type { RecursivePartial } from '@/type-utils'; +import type { RecursivePartial } from '@/app/types/utils'; vi.mock('@n8n/stores/useRootStore', () => ({ useRootStore: (): Partial => ({ diff --git a/packages/frontend/editor-ui/src/app/composables/useWorkflowHelpers.ts b/packages/frontend/editor-ui/src/app/composables/useWorkflowHelpers.ts index af25784fedc..14193d756b0 100644 --- a/packages/frontend/editor-ui/src/app/composables/useWorkflowHelpers.ts +++ b/packages/frontend/editor-ui/src/app/composables/useWorkflowHelpers.ts @@ -951,6 +951,7 @@ export function useWorkflowHelpers() { workflowsStore.addWorkflow(workflowData); workflowState.setActive(workflowData.active || false); workflowsStore.setIsArchived(workflowData.isArchived); + workflowsStore.setDescription(workflowData.description); workflowState.setWorkflowId(workflowData.id); workflowState.setWorkflowName({ newName: workflowData.name, diff --git a/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.test.ts b/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.test.ts index 093c29dce8f..c9e7fbb08ac 100644 --- a/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.test.ts @@ -1,7 +1,7 @@ import { useUIStore } from '@/app/stores/ui.store'; import { MODAL_CANCEL, MODAL_CONFIRM, PLACEHOLDER_EMPTY_WORKFLOW_ID, VIEWS } from '@/app/constants'; import { useWorkflowSaving } from './useWorkflowSaving'; -import router from '@/router'; +import router from '@/app/router'; import { createTestingPinia } from '@pinia/testing'; import { setActivePinia } from 'pinia'; import { useNpsSurveyStore } from '@/app/stores/npsSurvey.store'; diff --git a/packages/frontend/editor-ui/src/app/constants/enterprise.ts b/packages/frontend/editor-ui/src/app/constants/enterprise.ts index b3f951ddc06..7a5fb98b24a 100644 --- a/packages/frontend/editor-ui/src/app/constants/enterprise.ts +++ b/packages/frontend/editor-ui/src/app/constants/enterprise.ts @@ -16,7 +16,6 @@ export const EnterpriseEditionFeature: Record< ExternalSecrets: 'externalSecrets', AuditLogs: 'auditLogs', DebugInEditor: 'debugInEditor', - WorkflowHistory: 'workflowHistory', WorkerView: 'workerView', AdvancedPermissions: 'advancedPermissions', ApiKeyScopes: 'apiKeyScopes', diff --git a/packages/frontend/editor-ui/src/app/constants/limits.ts b/packages/frontend/editor-ui/src/app/constants/limits.ts index e00b3a65656..caebcec653c 100644 --- a/packages/frontend/editor-ui/src/app/constants/limits.ts +++ b/packages/frontend/editor-ui/src/app/constants/limits.ts @@ -3,4 +3,5 @@ export const MAX_EXPECTED_REQUEST_SIZE = 2048; // Expected maximum workflow requ export const MAX_PINNED_DATA_SIZE = 1024 * 1024 * 12; // 12 MB; Workflow pinned data size limit in bytes export const MAX_DISPLAY_DATA_SIZE = 1024 * 1024; // 1 MB export const MAX_DISPLAY_DATA_SIZE_SCHEMA_VIEW = 1024 * 1024 * 4; // 4 MB +export const MAX_DISPLAY_DATA_SIZE_LOGS_VIEW = 1024 * 512; // 512 KB export const MAX_DISPLAY_ITEMS_AUTO_ALL = 250; diff --git a/packages/frontend/editor-ui/src/app/constants/navigation.ts b/packages/frontend/editor-ui/src/app/constants/navigation.ts index f412a5a150d..3963e99b9c8 100644 --- a/packages/frontend/editor-ui/src/app/constants/navigation.ts +++ b/packages/frontend/editor-ui/src/app/constants/navigation.ts @@ -62,6 +62,7 @@ export const enum VIEWS { ENTITY_NOT_FOUND = 'EntityNotFound', ENTITY_UNAUTHORIZED = 'EntityUnAuthorized', PRE_BUILT_AGENT_TEMPLATES = 'PreBuiltAgentTemplates', + OAUTH_CONSENT = 'OAuthConsent', } export const EDITABLE_CANVAS_VIEWS = [VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.EXECUTION_DEBUG]; diff --git a/packages/frontend/editor-ui/src/app/constants/workflowSuggestions.ts b/packages/frontend/editor-ui/src/app/constants/workflowSuggestions.ts index 56aea83767b..e805c5a129d 100644 --- a/packages/frontend/editor-ui/src/app/constants/workflowSuggestions.ts +++ b/packages/frontend/editor-ui/src/app/constants/workflowSuggestions.ts @@ -6,51 +6,51 @@ export interface WorkflowSuggestion { export const WORKFLOW_SUGGESTIONS: WorkflowSuggestion[] = [ { - id: 'invoice-pipeline', - summary: 'Invoice processing pipeline', + id: 'multi-agent-research', + summary: 'Multi-agent research workflow', prompt: - 'Create an invoice parsing workflow using n8n forms. Extract key information (vendor, date, amount, line items) using AI, validate the data, and store structured information in Airtable. Generate a weekly spending report every Sunday at 6 PM using AI analysis and send via email.', - }, - { - id: 'ai-news-digest', - summary: 'Daily AI news digest', - prompt: - 'Create a workflow that fetches the latest AI news every morning at 8 AM. It should aggregate news from multiple sources, use LLM to summarize the top 5 stories, generate a relevant image using AI, and send everything as a structured Telegram message with article links. I should be able to chat about the news with the LLM so at least 40 last messages should be stored.', - }, - { - id: 'rag-assistant', - summary: 'RAG knowledge assistant', - prompt: - 'Build a pipeline that accepts PDF, CSV, or JSON files through an n8n form. Chunk documents into 1000-token segments, generate embeddings, and store in a vector database. Use the filename as the document key and add metadata including upload date and file type. Include a chatbot that can answer questions based on a knowledge base.', + 'Create a multi-agent AI workflow using GPT-4.1-mini where several agents work together to research a topic, fact-check the findings, and write a report that\'s sent as an HTML email. One agent should gather recent, credible information about the topic. Another agent should verify the facts and only mark something as "verified" if it appears in at least two independent sources. A third agent should combine the verified information into a clear, well-written report under 1,000 words. A final agent should edit and format the report to make it look clean and professional in the body of the email. Use Gmail to send the report.', }, { id: 'email-summary', summary: 'Summarize emails with AI', prompt: - 'Build a workflow that retrieves the last 50 emails from multiple email accounts. Merge all emails, perform AI analysis to identify action items, priorities, and sentiment. Generate a brief summary and send to Slack with categorized insights and recommended actions.', + 'Create an automation that runs on Monday mornings. It reads my Gmail inbox from the weekend, analyzes them with GPT-4.1-mini to find action items and priorities, and emails me a structured email using Gmail.', }, { - id: 'youtube-auto-chapters', - summary: 'YouTube video chapters', + id: 'ai-news-digest', + summary: 'Daily AI news digest', prompt: - "I want to build an n8n workflow that automatically creates YouTube chapter timestamps by analyzing the video captions. When I trigger it manually, it should take a video ID as input, fetch the existing video metadata and captions from YouTube, use an AI language model like Google Gemini to parse the transcript into chapters with timestamps, and then update the video's description with these chapters appended. The goal is to save time and improve SEO by automating the whole process.", + 'Build an automation that runs every night 8pm. Use the NewsAPI "/everything" endpoint to search for AI-related news from the day. Pick the top 5 articles and use OpenAI GPT-4.1-mini to summarize each in two sentences. Generate an image using OpenAI based on the top article\'s summary. Send a structured Telegram message.', }, { - id: 'pizza-delivery', - summary: 'Pizza delivery chatbot', + id: 'daily-weather-report', + summary: 'Daily weather report', prompt: - "I need an n8n workflow that creates a chatbot for my pizza delivery service. The bot should be able to answer customer questions about our pizza menu, take their orders accurately by capturing pizza type, quantity, and customer details, and also provide real-time updates when customers ask about their order status. It should use OpenAI's gpt-4.1-mini to handle conversations and integrate with HTTP APIs to get product info and manage orders. The workflow must maintain conversation context so the chatbot feels natural and can process multiple user queries sequentially.", + 'Create an automation that checks the weather for my location every morning at 5 a.m using OpenWeather. Send me a short weather report by email using Gmail. Use OpenAI GPT-4.1-mini to write a short, fun formatted email body by adding personality when describing the weather and how the day might feel. Include all details relevant to decide on my plans and clothes for the day.', + }, + { + id: 'invoice-pipeline', + summary: 'Invoice processing pipeline', + prompt: + 'Create an invoice processing workflow using an n8n Form. When a user submits an invoice file (PDF or image) with their email address, use OpenAI GPT-4.1-mini to extract invoice data. Then, validate the date format is correct, the currency is valid, and the total amount is greater than zero. If validation fails, email the user a clear error message that explains which check failed from my Gmail. If the data passes validation, store the structured result in a datatable plus email the user. Every Monday morning, generate a weekly spending report using GPT-4.1-mini based on stored invoices and send a clean email using Gmail.', + }, + { + id: 'rag-assistant', + summary: 'RAG knowledge assistant', + prompt: + 'Build an automation that creates a document-to-chat RAG pipeline. The workflow starts with an n8n Form where a user uploads one or more files (PDF, CSV, or JSON). Each upload should trigger a process that reads the file, splits it into chunks, and generates embeddings using OpenAI GPT-4.1-mini model, saved in one Pinecone table. Add a second part of the workflow for querying: use a Chat Message Trigger to act as a chatbot interface. When a user sends a question, retrieve the top 5 most relevant chunks from Pinecone, pass them into GPT-4.1-mini as context, and have it answer naturally using only the retrieved information. If a question can\'t be answered confidently, the bot should respond with: "I couldn\'t find that in the uploaded documents." Log each chat interaction in a Data Table with the user query, matched file(s), and timestamp. Send a daily summary email through Gmail showing total questions asked, top files referenced, and any failed lookups.', }, { id: 'lead-qualification', summary: 'Lead qualification and call scheduling', prompt: - 'Create a form with fields for email, company, and role. Build an automation that processes form submissions, enrich with company data from their website, uses AI to qualify the lead, sends data to Google Sheets. For high-score leads it should also schedule a 15-min call in a free slot in my calendar and send a confirmation email to both me and the lead.', + 'Create an n8n form with a lead generation form I can embed on my website homepage. Build an automation that processes form submissions, uses AI to qualify the lead, sends data to an n8n data table. For high-score leads, it should also email them to offer to schedule a 15-min call in a free slot in my calendar.', }, { - id: 'multi-agent-research', - summary: 'Multi-agent research workflow', + id: 'youtube-auto-chapters', + summary: 'YouTube video chapters', prompt: - 'Create a multi-agent AI workflow where different AI agents collaborate to research a topic, fact-check information, and compile comprehensive reports.', + "Build an n8n workflow that automatically generates YouTube chapter timestamps from video captions. Use the n8n chat trigger for me to enter the URL of the YouTube video. Use the YouTube Get a video node to get the video title, description, and existing metadata. Use the YouTube Captions API to download the transcript for the given video ID. Send the transcript to AI agent using Anthropic's Claude model. Prompt the model to identify topic shifts and return structured output in timestamp - chapter format. Append the generated chapter list to the existing video description. Use the YouTube Update a video node to update the video description. Respond back with the updates using the respond to chat node.", }, ]; diff --git a/packages/frontend/editor-ui/src/app/styles/_animations.scss b/packages/frontend/editor-ui/src/app/css/_animations.scss similarity index 100% rename from packages/frontend/editor-ui/src/app/styles/_animations.scss rename to packages/frontend/editor-ui/src/app/css/_animations.scss diff --git a/packages/frontend/editor-ui/src/n8n-theme.scss b/packages/frontend/editor-ui/src/app/css/_global.scss similarity index 97% rename from packages/frontend/editor-ui/src/n8n-theme.scss rename to packages/frontend/editor-ui/src/app/css/_global.scss index da4fe7f6a84..dc40b596b60 100644 --- a/packages/frontend/editor-ui/src/n8n-theme.scss +++ b/packages/frontend/editor-ui/src/app/css/_global.scss @@ -1,9 +1,5 @@ -@use '@n8n/design-system/css/mixins' as ds-mixins; -@use '@n8n/chat/css'; -@use '@/app/styles'; - :root { - // Using native css variable enables us to use this value in JS + --navbar--height: 64px; --header--height: 65; --content-container--width: 1280px; --banner--height: 48px; diff --git a/packages/frontend/editor-ui/src/n8n-theme-variables.scss b/packages/frontend/editor-ui/src/app/css/_variables.scss similarity index 98% rename from packages/frontend/editor-ui/src/n8n-theme-variables.scss rename to packages/frontend/editor-ui/src/app/css/_variables.scss index fdf1b885603..257dae2d240 100644 --- a/packages/frontend/editor-ui/src/n8n-theme-variables.scss +++ b/packages/frontend/editor-ui/src/app/css/_variables.scss @@ -92,3 +92,5 @@ $version-card-release-date-text-color: var(--color--foreground--shade-2); // supplemental node types $supplemental-node-types: ai_chain ai_document ai_embedding ai_languageModel ai_memory ai_outputParser ai_tool ai_retriever ai_textSplitter ai_vectorRetriever ai_vectorStore; + +$ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1); diff --git a/packages/frontend/editor-ui/src/app/styles/index.scss b/packages/frontend/editor-ui/src/app/css/index.scss similarity index 100% rename from packages/frontend/editor-ui/src/app/styles/index.scss rename to packages/frontend/editor-ui/src/app/css/index.scss diff --git a/packages/frontend/editor-ui/src/app/styles/plugins/_codemirror.scss b/packages/frontend/editor-ui/src/app/css/plugins/_codemirror.scss similarity index 100% rename from packages/frontend/editor-ui/src/app/styles/plugins/_codemirror.scss rename to packages/frontend/editor-ui/src/app/css/plugins/_codemirror.scss diff --git a/packages/frontend/editor-ui/src/app/styles/plugins/_vueflow.scss b/packages/frontend/editor-ui/src/app/css/plugins/_vueflow.scss similarity index 100% rename from packages/frontend/editor-ui/src/app/styles/plugins/_vueflow.scss rename to packages/frontend/editor-ui/src/app/css/plugins/_vueflow.scss diff --git a/packages/frontend/editor-ui/src/app/styles/plugins/index.scss b/packages/frontend/editor-ui/src/app/css/plugins/index.scss similarity index 100% rename from packages/frontend/editor-ui/src/app/styles/plugins/index.scss rename to packages/frontend/editor-ui/src/app/css/plugins/index.scss diff --git a/packages/frontend/editor-ui/src/dev/i18nHmr.ts b/packages/frontend/editor-ui/src/app/dev/i18nHmr.ts similarity index 100% rename from packages/frontend/editor-ui/src/dev/i18nHmr.ts rename to packages/frontend/editor-ui/src/app/dev/i18nHmr.ts diff --git a/packages/frontend/editor-ui/src/init.test.ts b/packages/frontend/editor-ui/src/app/init.test.ts similarity index 99% rename from packages/frontend/editor-ui/src/init.test.ts rename to packages/frontend/editor-ui/src/app/init.test.ts index 0fb71a9a517..432d0c20223 100644 --- a/packages/frontend/editor-ui/src/init.test.ts +++ b/packages/frontend/editor-ui/src/app/init.test.ts @@ -1,6 +1,6 @@ import { mockedStore, SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils'; import { EnterpriseEditionFeature } from '@/app/constants'; -import { initializeAuthenticatedFeatures, initializeCore, state } from '@/init'; +import { initializeAuthenticatedFeatures, initializeCore, state } from '@/app/init'; import { UserManagementAuthenticationMethod } from '@/Interface'; import { useCloudPlanStore } from '@/app/stores/cloudPlan.store'; import { useNodeTypesStore } from '@/app/stores/nodeTypes.store'; diff --git a/packages/frontend/editor-ui/src/init.ts b/packages/frontend/editor-ui/src/app/init.ts similarity index 99% rename from packages/frontend/editor-ui/src/init.ts rename to packages/frontend/editor-ui/src/app/init.ts index dffee71c64e..0108e24f444 100644 --- a/packages/frontend/editor-ui/src/init.ts +++ b/packages/frontend/editor-ui/src/app/init.ts @@ -1,3 +1,4 @@ +import '@/app/polyfills'; import SourceControlInitializationErrorMessage from '@/features/integrations/sourceControl.ee/components/SourceControlInitializationErrorMessage.vue'; import { useExternalHooks } from '@/app/composables/useExternalHooks'; import { useTelemetry } from '@/app/composables/useTelemetry'; diff --git a/packages/frontend/editor-ui/src/polyfills.ts b/packages/frontend/editor-ui/src/app/polyfills.ts similarity index 100% rename from packages/frontend/editor-ui/src/polyfills.ts rename to packages/frontend/editor-ui/src/app/polyfills.ts diff --git a/packages/frontend/editor-ui/src/router.test.ts b/packages/frontend/editor-ui/src/app/router.test.ts similarity index 92% rename from packages/frontend/editor-ui/src/router.test.ts rename to packages/frontend/editor-ui/src/app/router.test.ts index 5813c9247cd..15729bc51ff 100644 --- a/packages/frontend/editor-ui/src/router.test.ts +++ b/packages/frontend/editor-ui/src/app/router.test.ts @@ -1,6 +1,6 @@ import { createPinia, setActivePinia } from 'pinia'; import { createComponentRenderer } from '@/__tests__/render'; -import router from '@/router'; +import router from '@/app/router'; import { SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT, VIEWS } from '@/app/constants'; import { setupServer } from '@/__tests__/server'; import { useSettingsStore } from '@/app/stores/settings.store'; @@ -8,7 +8,7 @@ import { usePostHog } from '@/app/stores/posthog.store'; import { useRBACStore } from '@/app/stores/rbac.store'; import type { Scope } from '@n8n/permissions'; import type { RouteRecordName } from 'vue-router'; -import * as init from '@/init'; +import * as init from '@/app/init'; const App = { template: '
', @@ -61,11 +61,7 @@ describe('router', () => { 10000, ); - test.each([ - ['/workflow/R9JFXwkUCL1jZBuw/debug/29021', VIEWS.WORKFLOWS], - ['/workflow/8IFYawZ9dKqJu8sT/history', VIEWS.WORKFLOWS], - ['/workflow/8IFYawZ9dKqJu8sT/history/6513ed960252b846f3792f0c', VIEWS.WORKFLOWS], - ])( + test.each([['/workflow/R9JFXwkUCL1jZBuw/debug/29021', VIEWS.WORKFLOWS]])( 'should redirect %s to %s if user does not have permissions', async (path, name) => { await router.push(path); @@ -75,17 +71,12 @@ describe('router', () => { 10000, ); - test.each([ - ['/workflow/R9JFXwkUCL1jZBuw/debug/29021', VIEWS.EXECUTION_DEBUG], - ['/workflow/8IFYawZ9dKqJu8sT/history', VIEWS.WORKFLOW_HISTORY], - ['/workflow/8IFYawZ9dKqJu8sT/history/6513ed960252b846f3792f0c', VIEWS.WORKFLOW_HISTORY], - ])( + test.each([['/workflow/R9JFXwkUCL1jZBuw/debug/29021', VIEWS.EXECUTION_DEBUG]])( 'should resolve %s to %s if user has permissions', async (path, name) => { const settingsStore = useSettingsStore(); settingsStore.settings.enterprise.debugInEditor = true; - settingsStore.settings.enterprise.workflowHistory = true; await router.push(path); expect(initializeAuthenticatedFeaturesSpy).toHaveBeenCalled(); @@ -94,6 +85,19 @@ describe('router', () => { 10000, ); + test.each([ + ['/workflow/8IFYawZ9dKqJu8sT/history', VIEWS.WORKFLOW_HISTORY], + ['/workflow/8IFYawZ9dKqJu8sT/history/6513ed960252b846f3792f0c', VIEWS.WORKFLOW_HISTORY], + ])( + 'should resolve %s to %s (available to all users)', + async (path, name) => { + await router.push(path); + expect(initializeAuthenticatedFeaturesSpy).toHaveBeenCalled(); + expect(router.currentRoute.value.name).toBe(name); + }, + 10000, + ); + test.each<[string, RouteRecordName, Scope[]]>([ ['/settings/users', VIEWS.WORKFLOWS, []], ['/settings/users', VIEWS.USERS_SETTINGS, ['user:create', 'user:update']], diff --git a/packages/frontend/editor-ui/src/router.ts b/packages/frontend/editor-ui/src/app/router.ts similarity index 98% rename from packages/frontend/editor-ui/src/router.ts rename to packages/frontend/editor-ui/src/app/router.ts index 5ea543507e2..0a5b24b71f7 100644 --- a/packages/frontend/editor-ui/src/router.ts +++ b/packages/frontend/editor-ui/src/app/router.ts @@ -20,20 +20,21 @@ import { import { useTelemetry } from '@/app/composables/useTelemetry'; import { middleware } from '@/app/utils/rbac/middleware'; import type { RouterMiddleware } from '@/app/types/router'; -import { initializeAuthenticatedFeatures, initializeCore } from '@/init'; +import { initializeAuthenticatedFeatures, initializeCore } from '@/app/init'; import { tryToParseNumber } from '@/app/utils/typesUtils'; import { projectsRoutes } from '@/features/collaboration/projects/projects.routes'; import { MfaRequiredError } from '@n8n/rest-api-client'; import { useCalloutHelpers } from '@/app/composables/useCalloutHelpers'; import { useRecentResources } from '@/features/shared/commandBar/composables/useRecentResources'; import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag'; -import { usePostHog } from './app/stores/posthog.store'; +import { usePostHog } from '@/app/stores/posthog.store'; const ChangePasswordView = async () => await import('@/features/core/auth/views/ChangePasswordView.vue'); const ErrorView = async () => await import('@/app/views/ErrorView.vue'); const EntityNotFound = async () => await import('@/app/views/EntityNotFound.vue'); const EntityUnAuthorised = async () => await import('@/app/views/EntityUnAuthorised.vue'); +const OAuthConsentView = async () => await import('@/app/views/OAuthConsentView.vue'); const ForgotMyPasswordView = async () => await import('@/features/core/auth/views/ForgotMyPasswordView.vue'); const MainHeader = async () => await import('@/app/components/MainHeader/MainHeader.vue'); @@ -342,12 +343,7 @@ export const routes: RouteRecordRaw[] = [ sidebar: MainSidebar, }, meta: { - middleware: ['authenticated', 'enterprise'], - middlewareOptions: { - enterprise: { - feature: [EnterpriseEditionFeature.WorkflowHistory], - }, - }, + middleware: ['authenticated'], }, }, { @@ -472,6 +468,16 @@ export const routes: RouteRecordRaw[] = [ middleware: ['authenticated'], }, }, + { + path: '/oauth/consent', + name: VIEWS.OAUTH_CONSENT, + components: { + default: OAuthConsentView, + }, + meta: { + middleware: ['authenticated'], + }, + }, { path: '/setup', name: VIEWS.SETUP, diff --git a/packages/frontend/editor-ui/src/app/stores/consent.store.ts b/packages/frontend/editor-ui/src/app/stores/consent.store.ts new file mode 100644 index 00000000000..f83fb4761c0 --- /dev/null +++ b/packages/frontend/editor-ui/src/app/stores/consent.store.ts @@ -0,0 +1,60 @@ +import { STORES } from '@n8n/stores'; +import { defineStore } from 'pinia'; +import { useRootStore } from '@n8n/stores/useRootStore'; + +import * as consentApi from '@n8n/rest-api-client/api/consent'; +import { ref } from 'vue'; +import type { ConsentDetails } from '@n8n/rest-api-client/api/consent'; + +export const useConsentStore = defineStore(STORES.CONSENT, () => { + const consentDetails = ref(null); + const isLoading = ref(false); + const error = ref(null); + + const rootStore = useRootStore(); + + const fetchConsentDetails = async () => { + isLoading.value = true; + error.value = null; + + try { + consentDetails.value = await consentApi.getConsentDetails(rootStore.restApiContext); + return consentDetails.value; + } catch (err) { + error.value = err instanceof Error ? err.message : 'Failed to load consent details'; + throw err; + } finally { + isLoading.value = false; + } + }; + + const approveConsent = async (approved: boolean) => { + isLoading.value = true; + error.value = null; + + try { + const response = await consentApi.approveConsent(rootStore.restApiContext, approved); + return response; + } catch (err) { + error.value = err instanceof Error ? err.message : 'Failed to process consent'; + throw err; + } finally { + isLoading.value = false; + } + }; + + const resetState = () => { + consentDetails.value = null; + isLoading.value = false; + error.value = null; + }; + + return { + fetchConsentDetails, + approveConsent, + resetState, + consentDetails, + isLoading, + error, + }; +}); diff --git a/packages/frontend/editor-ui/src/app/stores/workflows.store.ts b/packages/frontend/editor-ui/src/app/stores/workflows.store.ts index ff7fb49391e..c38a5403995 100644 --- a/packages/frontend/editor-ui/src/app/stores/workflows.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/workflows.store.ts @@ -96,6 +96,7 @@ import { getResourcePermissions } from '@n8n/permissions'; const defaults: Omit & { settings: NonNullable } = { name: '', + description: '', active: false, isArchived: false, createdAt: -1, @@ -609,7 +610,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { pageSize = DEFAULT_WORKFLOW_PAGE_SIZE, sortBy?: string, filters: { - name?: string; + query?: string; tags?: string[]; active?: boolean; isArchived?: boolean; @@ -652,20 +653,20 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { async function searchWorkflows({ projectId, - name, + query, nodeTypes, tags, select, }: { projectId?: string; - name?: string; + query?: string; nodeTypes?: string[]; tags?: string[]; select?: string[]; }): Promise { const filter = { projectId, - name, + query, nodeTypes, tags, }; @@ -885,6 +886,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { workflow.value.isArchived = isArchived; } + function setDescription(description: string | undefined | null) { + workflow.value.description = description; + } + async function getDuplicateCurrentWorkflowName(currentWorkflowName: string): Promise { if ( currentWorkflowName && @@ -1611,6 +1616,47 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { return updated; } + async function saveWorkflowDescription( + id: string, + description: string | null, + ): Promise { + let currentVersionId = ''; + const isCurrentWorkflow = id === workflow.value.id; + + if (isCurrentWorkflow) { + currentVersionId = workflow.value.versionId; + } else { + const cached = workflowsById.value[id]; + if (cached?.versionId) { + currentVersionId = cached.versionId; + } else { + const fetched = await fetchWorkflow(id); + currentVersionId = fetched.versionId; + } + } + + const updated = await updateWorkflow(id, { + versionId: currentVersionId, + description, + }); + + // Update local store state + if (isCurrentWorkflow) { + setDescription(updated.description ?? ''); + if (updated.versionId !== currentVersionId) { + setWorkflowVersionId(updated.versionId); + } + } else if (workflowsById.value[id]) { + workflowsById.value[id] = { + ...workflowsById.value[id], + description: updated.description, + versionId: updated.versionId, + }; + } + + return updated; + } + async function runWorkflow(startRunData: IStartRunData): Promise { if (startRunData.workflowData.settings === null) { startRunData.workflowData.settings = undefined; @@ -1859,6 +1905,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { setWorkflowInactive, fetchActiveWorkflows, setIsArchived, + setDescription, getDuplicateCurrentWorkflowName, setWorkflowExecutionRunData, setWorkflowPinData, @@ -1887,6 +1934,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { createNewWorkflow, updateWorkflow, updateWorkflowSetting, + saveWorkflowDescription, runWorkflow, removeTestWebhook, fetchExecutionDataById, diff --git a/packages/frontend/editor-ui/src/app/styles/_global.scss b/packages/frontend/editor-ui/src/app/styles/_global.scss deleted file mode 100644 index 233a458c967..00000000000 --- a/packages/frontend/editor-ui/src/app/styles/_global.scss +++ /dev/null @@ -1,3 +0,0 @@ -:root { - --navbar--height: 64px; -} diff --git a/packages/frontend/editor-ui/src/app/styles/_variables.scss b/packages/frontend/editor-ui/src/app/styles/_variables.scss deleted file mode 100644 index 4676ec0b250..00000000000 --- a/packages/frontend/editor-ui/src/app/styles/_variables.scss +++ /dev/null @@ -1 +0,0 @@ -$ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1); diff --git a/packages/frontend/editor-ui/src/type-utils.d.ts b/packages/frontend/editor-ui/src/app/types/utils.ts similarity index 100% rename from packages/frontend/editor-ui/src/type-utils.d.ts rename to packages/frontend/editor-ui/src/app/types/utils.ts diff --git a/packages/frontend/editor-ui/src/app/views/OAuthConsentView.vue b/packages/frontend/editor-ui/src/app/views/OAuthConsentView.vue new file mode 100644 index 00000000000..2e918d8b169 --- /dev/null +++ b/packages/frontend/editor-ui/src/app/views/OAuthConsentView.vue @@ -0,0 +1,264 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/app/views/SettingsView.test.ts b/packages/frontend/editor-ui/src/app/views/SettingsView.test.ts index 9067a4097c8..34f5b6391ba 100644 --- a/packages/frontend/editor-ui/src/app/views/SettingsView.test.ts +++ b/packages/frontend/editor-ui/src/app/views/SettingsView.test.ts @@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event'; import { createComponentRenderer } from '@/__tests__/render'; import SettingsView from '@/app/views/SettingsView.vue'; import { VIEWS } from '@/app/constants'; -import { routes as originalRoutes } from '@/router'; +import { routes as originalRoutes } from '@/app/router'; const component = { template: '
' }; const settingsRoute = originalRoutes.find((route) => route.name === VIEWS.SETTINGS); diff --git a/packages/frontend/editor-ui/src/app/views/WorkflowsView.test.ts b/packages/frontend/editor-ui/src/app/views/WorkflowsView.test.ts index 0e776e4f39b..6f68c4a0113 100644 --- a/packages/frontend/editor-ui/src/app/views/WorkflowsView.test.ts +++ b/packages/frontend/editor-ui/src/app/views/WorkflowsView.test.ts @@ -289,7 +289,7 @@ describe('WorkflowsView', () => { expect.any(Number), expect.any(String), expect.objectContaining({ - name: 'one', + query: 'one', isArchived: false, }), expect.any(Boolean), diff --git a/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue b/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue index 038f41f0a5c..917d2d9dbdb 100644 --- a/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue +++ b/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue @@ -255,6 +255,10 @@ const teamProjectsEnabled = computed(() => { return projectsStore.isTeamProjectFeatureEnabled; }); +const mcpEnabled = computed(() => { + return settingsStore.isModuleActive('mcp') && settingsStore.moduleSettings.mcp?.mcpAccessEnabled; +}); + const showFolders = computed(() => { return foldersEnabled.value && !projectPages.isOverviewSubPage && !projectPages.isSharedSubPage; }); @@ -348,6 +352,7 @@ const workflowListResources = computed(() => { resourceType: 'workflow', id: resource.id, name: resource.name, + description: resource.description, active: resource.active ?? false, isArchived: resource.isArchived, updatedAt: resource.updatedAt.toString(), @@ -672,7 +677,7 @@ const fetchWorkflows = async () => { pageSize.value, currentSort.value, { - name: filters.value.search || undefined, + query: filters.value.search || undefined, active: activeFilter, isArchived: archivedFilter, tags: tags.length ? tags : undefined, @@ -2110,7 +2115,7 @@ const onNameSubmit = async (name: string) => { :show-ownership-badge="showCardsBadge" :are-folders-enabled="settingsStore.isFoldersFeatureEnabled" :are-tags-enabled="settingsStore.areTagsEnabled" - :is-mcp-enabled="settingsStore.isModuleActive('mcp')" + :is-mcp-enabled="mcpEnabled" @click:tag="onClickTag" @workflow:deleted="refreshWorkflows" @workflow:archived="refreshWorkflows" diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/composables/useAIAssistantHelpers.ts b/packages/frontend/editor-ui/src/features/ai/assistant/composables/useAIAssistantHelpers.ts index 534108c62d9..07fd7bb83cf 100644 --- a/packages/frontend/editor-ui/src/features/ai/assistant/composables/useAIAssistantHelpers.ts +++ b/packages/frontend/editor-ui/src/features/ai/assistant/composables/useAIAssistantHelpers.ts @@ -195,6 +195,7 @@ export const useAIAssistantHelpers = () => { const schema = getSchemaForExecutionData( executionDataToJson(getInputDataWithPinned(node)), excludeValues, + true, // collapseArrays: true for AI assistant to avoid verbose array expansion ); schemas.push({ nodeName: node.name, diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts index 29b01ae47ce..d9792e28e49 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts @@ -45,9 +45,11 @@ import type { import { retry } from '@n8n/utils/retry'; import { isMatchedAgent } from './chat.utils'; import { createAiMessageFromStreamingState, flattenModel } from './chat.utils'; +import { useTelemetry } from '@/app/composables/useTelemetry'; export const useChatStore = defineStore(CHAT_STORE, () => { const rootStore = useRootStore(); + const telemetry = useTelemetry(); const agents = ref(); const sessions = ref(); const currentEditingAgent = ref(null); @@ -470,6 +472,12 @@ export const useChatStore = defineStore(CHAT_STORE, () => { onStreamDone, onStreamError, ); + + telemetry.track('User sent chat hub message', { + ...flattenModel(model), + is_custom: model.provider === 'custom-agent', + chat_session_id: sessionId, + }); } function editMessage( @@ -638,6 +646,8 @@ export const useChatStore = defineStore(CHAT_STORE, () => { await fetchAgents(credentials); + telemetry.track('User created agent', { ...flattenModel(payload) }); + return agentModel; } diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatSidebarContent.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatSidebarContent.vue index 8d6847fb654..747642b3e0c 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatSidebarContent.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatSidebarContent.vue @@ -14,6 +14,7 @@ import Logo from '@n8n/design-system/components/N8nLogo'; import { computed, onMounted, ref } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import ChatSessionMenuItem from './ChatSessionMenuItem.vue'; +import { useTelemetry } from '@/app/composables/useTelemetry'; defineProps<{ isMobileDevice: boolean }>(); @@ -24,6 +25,7 @@ const toast = useToast(); const message = useMessage(); const sidebar = useChatHubSidebarState(); const settingsStore = useSettingsStore(); +const telemetry = useTelemetry(); const renamingSessionId = ref(); @@ -77,6 +79,11 @@ async function handleDeleteSession(sessionId: string) { } } +function handleNewChatClick() { + telemetry.track('User clicked new chat button', {}); + sidebar.toggleOpen(false); +} + onMounted(() => { void chatStore.fetchSessions(); }); @@ -113,7 +120,7 @@ onMounted(() => { label="New Chat" icon="square-pen" :active="route.name === CHAT_VIEW" - @click="sidebar.toggleOpen(false)" + @click="handleNewChatClick" /> (null); const uiStore = useUIStore(); const credentialsStore = useCredentialsStore(); +const telemetry = useTelemetry(); const credentialsName = computed(() => selectedAgent @@ -179,11 +182,25 @@ function onSelect(id: string) { return; } + telemetry.track('User selected model or agent', { + ...flattenModel(selected.model), + is_custom: selected.model.provider === 'custom-agent', + }); + emit('change', selected); } function handleCreateNewCredential(provider: ChatHubLLMProvider) { - uiStore.openNewCredential(PROVIDER_CREDENTIAL_TYPE_MAP[provider]); + const credentialType = PROVIDER_CREDENTIAL_TYPE_MAP[provider]; + + telemetry.track('User opened Credential modal', { + credential_type: credentialType, + source: 'chat', + new_credential: true, + workflow_id: null, + }); + + uiStore.openNewCredential(credentialType); } onClickOutside( diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.ts index ec47da3beb1..5d45fa56df1 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.ts @@ -7,7 +7,6 @@ import { useRootStore } from '@n8n/stores/useRootStore'; import { useUsersStore } from '@/features/settings/users/users.store'; import { useWorkflowsStore } from '@/app/stores/workflows.store'; import { useMCPStore } from './mcp.store'; -import { MCP_WORKFLOWS } from './SettingsMCPView.test.constants'; vi.mock('@/app/composables/useDocumentTitle', () => ({ useDocumentTitle: () => ({ @@ -85,54 +84,4 @@ describe('SettingsMCPView', () => { expect(toggle).toHaveClass('is-disabled'); }); - - test('shows an empty state when no workflows are available', async () => { - mcpStore.mcpAccessEnabled = true; - - const { findByTestId } = renderComponent({ pinia }); - await waitAllPromises(); - - expect(await findByTestId('empty-workflow-list-box')).toBeInTheDocument(); - }); - - test('shows workflows table when there are available workflows', async () => { - mcpStore.fetchWorkflowsAvailableForMCP.mockResolvedValue(MCP_WORKFLOWS); - mcpStore.mcpAccessEnabled = true; - - const { getByTestId } = renderComponent({ pinia }); - await waitAllPromises(); - - expect(getByTestId('mcp-workflow-list')).toBeInTheDocument(); - expect(getByTestId('mcp-workflow-table')).toBeInTheDocument(); - - // Should render both workflow info correctly - const rows = getByTestId('mcp-workflow-table').querySelectorAll('table tbody tr'); - - expect(rows).toHaveLength(MCP_WORKFLOWS.length); - - rows.forEach((row, index) => { - const workflow = MCP_WORKFLOWS[index]; - - expect(row.querySelector('[data-test-id=mcp-workflow-name]')).toHaveTextContent( - workflow.name, - ); - - if (workflow.parentFolder) { - expect(row.querySelector('[data-test-id=mcp-workflow-folder-link]')).toBeInTheDocument(); - expect(row.querySelector('[data-test-id=mcp-workflow-folder-name]')).toHaveTextContent( - workflow.parentFolder.name, - ); - } else { - expect(row.querySelector('[data-test-id=mcp-workflow-no-folder]')).toBeInTheDocument(); - } - - const projectNameCell = row.querySelector('[data-test-id=mcp-workflow-project-name]'); - - if (workflow.homeProject?.type === 'personal') { - expect(projectNameCell).toHaveTextContent('Personal'); - } else { - expect(projectNameCell).toHaveTextContent(workflow.homeProject?.name || ''); - } - }); - }); }); diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue index 05fa519f21c..4ebcb5438e8 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue @@ -1,34 +1,21 @@ @@ -381,85 +199,5 @@ onMounted(async () => { display: flex; flex-direction: column; gap: var(--spacing--lg); - - :global(.table-pagination) { - display: none; - } -} - -.headingContainer { - margin-bottom: var(--spacing--xs); -} - -.mainToggleContainer { - display: flex; - align-items: center; - padding: var(--spacing--sm); - justify-content: space-between; - flex-shrink: 0; - - border-radius: var(--radius); - border: var(--border); -} - -.mainToggleInfo { - display: flex; - flex-direction: column; - justify-content: center; - align-items: flex-start; -} - -.mainTooggle { - display: flex; - justify-content: flex-end; - align-items: center; - flex-shrink: 0; -} - -.header { - display: flex; - justify-content: space-between; - align-items: center; -} - -.workflow-table { - tr:last-child { - border-bottom: none !important; - } -} - -.table-link { - color: var(--color--text); - - :global(.n8n-text) { - display: flex; - align-items: center; - gap: var(--spacing--3xs); - - .link-icon { - display: none; - } - - &:hover { - .link-icon { - display: inline-flex; - } - } - } - - &.project-link { - :global(.n8n-text) { - gap: 0; - } - .link-icon { - margin-left: var(--spacing--3xs); - } - } -} - -.folder-cell { - display: flex; - align-items: center; - gap: var(--spacing--4xs); } diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/ConnectionParameter.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/ConnectionParameter.vue index 171a2399ea9..ba955389826 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/ConnectionParameter.vue +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/ConnectionParameter.vue @@ -27,7 +27,7 @@ const props = withDefaults(defineProps(), {
diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/MCPConnectionInstructions.test.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/MCPConnectionInstructions.test.ts new file mode 100644 index 00000000000..35a50d2390d --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/MCPConnectionInstructions.test.ts @@ -0,0 +1,150 @@ +import { createComponentRenderer } from '@/__tests__/render'; +import MCPConnectionInstructions from './MCPConnectionInstructions.vue'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import userEvent from '@testing-library/user-event'; +import type { ApiKey, OAuthClientResponseDto } from '@n8n/api-types'; +import { MCP_DOCS_PAGE_URL } from '@/features/ai/mcpAccess/mcp.constants'; + +const renderComponent = createComponentRenderer(MCPConnectionInstructions); + +let pinia: ReturnType; + +vi.mock( + '@/features/ai/mcpAccess/components/connectionInstructions/OAuthConnectionInstructions.vue', + () => ({ + default: { + name: 'OAuthConnectionInstructions', + template: '
OAuth Instructions
', + props: ['serverUrl', 'clients', 'clientsLoading'], + }, + }), +); + +vi.mock( + '@/features/ai/mcpAccess/components/connectionInstructions/AccessTokenConnectionInstructions.vue', + () => ({ + default: { + name: 'AccessTokenConnectionInstructions', + template: '
Token Instructions
', + props: ['serverUrl', 'apiKey', 'loadingApiKey'], + }, + }), +); + +describe('MCPConnectionInstructions', () => { + const mockApiKey: ApiKey = { + id: '123', + label: 'Test Key', + apiKey: 'test-api-key', + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + expiresAt: null, + scopes: [], + }; + + const mockOAuthClients: OAuthClientResponseDto[] = [ + { + id: '1', + name: 'Test Client', + redirectUris: ['http://localhost/callback'], + grantTypes: ['authorization_code'], + tokenEndpointAuthMethod: 'client_secret_basic', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ]; + + const defaultProps = { + baseUrl: 'http://localhost:5678', + apiKey: mockApiKey, + loadingApiKey: false, + oAuthClients: mockOAuthClients, + loadingOAuthClients: false, + }; + + beforeEach(() => { + pinia = createPinia(); + setActivePinia(pinia); + }); + + it('should render default configuration correctly', () => { + const { container, getByText, getByTestId } = renderComponent({ + pinia, + props: defaultProps, + }); + + // Main heading + expect(getByText('How to connect')).toBeInTheDocument(); + + // Both tabs + const tabs = container.querySelectorAll('.n8n-tabs .tab'); + expect(tabs).toHaveLength(2); + expect(tabs[0]).toHaveTextContent('oAuth'); + expect(tabs[1]).toHaveTextContent('Access Token'); + // OAuth tab should be active by default + expect(getByTestId('oauth-instructions')).toBeInTheDocument(); + expect(getByTestId('oauth-instructions')).toBeVisible(); + + // Documentation link + const docsText = getByTestId('mcp-connection-instructions-docs-text'); + expect(docsText).toBeInTheDocument(); + const docsLink = docsText.querySelector('a'); + expect(docsLink).toBeInTheDocument(); + expect(docsLink).toHaveAttribute('href', MCP_DOCS_PAGE_URL); + expect(docsLink).toHaveAttribute('target', '_blank'); + }); + + it('should switch to API Key tab when clicked', async () => { + const { container, getByTestId, queryByTestId } = renderComponent({ + pinia, + props: defaultProps, + }); + + // Find and click the API Key tab + const tabs = container.querySelectorAll('.n8n-tabs .tab'); + const oauthTab = tabs[0]; + const apiKeyTab = tabs[1]; + expect(apiKeyTab).toHaveTextContent('Access Token'); + + await userEvent.click(apiKeyTab); + + // OAuth instructions should be hidden + expect(getByTestId('token-instructions')).toBeInTheDocument(); + expect(queryByTestId('oauth-instructions')).not.toBeVisible(); + expect(oauthTab).not.toHaveClass('activeTab'); + + // Token instructions should be visible + expect(getByTestId('token-instructions')).toBeInTheDocument(); + expect(getByTestId('token-instructions')).toBeVisible(); + expect(apiKeyTab).toHaveClass('activeTab'); + }); + + it('should switch back to OAuth tab when clicked', async () => { + const { container, getByTestId, queryByTestId } = renderComponent({ + pinia, + props: defaultProps, + }); + + // First switch to API Key tab + const tabs = container.querySelectorAll('.n8n-tabs .tab'); + const oauthTab = tabs[0]; + const apiKeyTab = tabs[1]; + await userEvent.click(apiKeyTab); + + // Verify API Key tab is active + expect(queryByTestId('oauth-instructions')).not.toBeVisible(); + expect(oauthTab).not.toHaveClass('activeTab'); + expect(getByTestId('token-instructions')).toBeVisible(); + expect(apiKeyTab).toHaveClass('activeTab'); + + // Switch back to OAuth tab + await userEvent.click(oauthTab); + + // OAuth instructions should be visible again + expect(getByTestId('oauth-instructions')).toBeVisible(); + expect(oauthTab).toHaveClass('activeTab'); + expect(queryByTestId('token-instructions')).not.toBeVisible(); + expect(apiKeyTab).not.toHaveClass('activeTab'); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/MCPConnectionInstructions.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/MCPConnectionInstructions.vue index 61e8611e1b7..ef16ce1df75 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/MCPConnectionInstructions.vue +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/MCPConnectionInstructions.vue @@ -1,163 +1,83 @@ @@ -165,100 +85,6 @@ const apiKeyText = computed(() => { .container { display: flex; flex-direction: column; -} - -.instructions-container { - :global(.notice) { - margin: var(--spacing--sm) var(--spacing--lg) var(--spacing--md); - } -} - -.instructions { - display: flex; - flex-direction: column; - gap: var(--spacing--xs); - padding-left: var(--spacing--lg); - margin: var(--spacing--sm); - - li { - min-height: var(--spacing--lg); - } - - .item { - display: flex; - align-items: center; - gap: var(--spacing--2xs); - - :global(.n8n-loading) div { - height: 32px; - width: 300px; - margin: 0; - } - } - - .label { - font-size: var(--font-size--sm); - flex: none; - } - - .url { - display: flex; - align-items: stretch; - gap: var(--spacing--2xs); - background: var(--color--background--light-3); - border: var(--border); - border-radius: var(--radius); - font-size: var(--font-size--sm); - overflow: hidden; - - code { - text-overflow: ellipsis; - overflow: hidden; - white-space: pre; - padding: var(--spacing--2xs) var(--spacing--3xs); - } - - .copy-url-wrapper { - display: flex; - align-items: center; - border-left: var(--border); - } - - .copy-url-button { - border: none; - border-radius: 0; - } - - @media screen and (max-width: 820px) { - word-wrap: break-word; - margin-top: var(--spacing--2xs); - } - } -} - -.connectionString { - flex-grow: 1; - position: relative; - padding: 0 var(--spacing--lg); - - :global(.n8n-markdown) { - width: 100%; - } - code { - font-size: var(--font-size--xs); - } - - &:hover { - .copy-json-button { - display: flex; - } - } -} - -.copy-json-button { - position: absolute; - top: var(--spacing--xl); - right: var(--spacing--2xl); - display: none; + gap: var(--spacing--md); } diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/McpAccessToggle.test.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/McpAccessToggle.test.ts new file mode 100644 index 00000000000..bfa17c3b639 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/McpAccessToggle.test.ts @@ -0,0 +1,199 @@ +import { createComponentRenderer } from '@/__tests__/render'; +import McpAccessToggle from './McpAccessToggle.vue'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import userEvent from '@testing-library/user-event'; +import { waitFor } from '@testing-library/vue'; + +const renderComponent = createComponentRenderer(McpAccessToggle); + +let pinia: ReturnType; + +describe('McpAccessToggle', () => { + beforeEach(() => { + pinia = createPinia(); + setActivePinia(pinia); + }); + + describe('Component rendering', () => { + it('should handle modelValue and disabled prop combinations correctly', () => { + // Test modelValue=true, disabled=true + { + const { getByTestId, unmount } = renderComponent({ + pinia, + props: { modelValue: true, disabled: true }, + }); + const toggle = getByTestId('mcp-access-toggle'); + expect(toggle).toHaveClass('is-checked'); + expect(toggle).toHaveClass('is-disabled'); + unmount(); + } + + // Test modelValue=true, disabled=false + { + const { getByTestId, unmount } = renderComponent({ + pinia, + props: { modelValue: true, disabled: false }, + }); + const toggle = getByTestId('mcp-access-toggle'); + expect(toggle).toHaveClass('is-checked'); + expect(toggle).not.toHaveClass('is-disabled'); + unmount(); + } + + // Test modelValue=false, disabled=true + { + const { getByTestId, unmount } = renderComponent({ + pinia, + props: { modelValue: false, disabled: true }, + }); + const toggle = getByTestId('mcp-access-toggle'); + expect(toggle).not.toHaveClass('is-checked'); + expect(toggle).toHaveClass('is-disabled'); + unmount(); + } + + // Test modelValue=false, disabled=false + { + const { getByTestId, unmount } = renderComponent({ + pinia, + props: { modelValue: false, disabled: false }, + }); + const toggle = getByTestId('mcp-access-toggle'); + expect(toggle).not.toHaveClass('is-checked'); + expect(toggle).not.toHaveClass('is-disabled'); + unmount(); + } + }); + + it('should handle loading prop correctly', () => { + const { getByTestId, unmount: unmount1 } = renderComponent({ + pinia, + props: { modelValue: false, loading: true }, + }); + expect(getByTestId('mcp-access-toggle')).toBeInTheDocument(); + unmount1(); + + const { getByTestId: getByTestId2, unmount: unmount2 } = renderComponent({ + pinia, + props: { modelValue: false, loading: false }, + }); + expect(getByTestId2('mcp-access-toggle')).toBeInTheDocument(); + unmount2(); + }); + }); + + describe('Disabled state', () => { + it('should render as disabled when disabled prop is true', () => { + const { getByTestId } = renderComponent({ + pinia, + props: { + modelValue: false, + disabled: true, + }, + }); + + const toggle = getByTestId('mcp-access-toggle'); + expect(toggle).toHaveClass('is-disabled'); + }); + + it('should render as enabled when disabled prop is false', () => { + const { getByTestId } = renderComponent({ + pinia, + props: { + modelValue: false, + disabled: false, + }, + }); + + const toggle = getByTestId('mcp-access-toggle'); + expect(toggle).not.toHaveClass('is-disabled'); + }); + + it('should show tooltip when disabled', async () => { + const { container, queryByRole } = renderComponent({ + pinia, + props: { + modelValue: false, + disabled: true, + }, + }); + + const tooltipTrigger = container.querySelector('.el-tooltip__trigger'); + expect(tooltipTrigger).toBeInTheDocument(); + + await userEvent.hover(tooltipTrigger!); + await waitFor(() => { + const tooltip = queryByRole('tooltip'); + expect(tooltip).toBeInTheDocument(); + expect(tooltip).toHaveTextContent('Only instance admins can change this'); + }); + }); + + it('should not show tooltip when enabled', async () => { + const { container, queryByRole } = renderComponent({ + pinia, + props: { + modelValue: false, + disabled: false, + }, + }); + + const tooltipTrigger = container.querySelector('.el-tooltip__trigger'); + expect(tooltipTrigger).toBeInTheDocument(); + + await userEvent.hover(tooltipTrigger!); + + expect(queryByRole('tooltip')).not.toBeInTheDocument(); + }); + }); + + describe('Event emissions', () => { + it('should emit toggleMcpAccess event with true when toggled on', async () => { + const { getByTestId, emitted } = renderComponent({ + pinia, + props: { + modelValue: false, + }, + }); + + const toggle = getByTestId('mcp-access-toggle'); + await userEvent.click(toggle); + + expect(emitted()).toHaveProperty('toggleMcpAccess'); + expect(emitted().toggleMcpAccess).toHaveLength(1); + expect(emitted().toggleMcpAccess[0]).toEqual([true]); + }); + + it('should emit toggleMcpAccess event with false when toggled off', async () => { + const { getByTestId, emitted } = renderComponent({ + pinia, + props: { + modelValue: true, + }, + }); + + const toggle = getByTestId('mcp-access-toggle'); + await userEvent.click(toggle); + + expect(emitted()).toHaveProperty('toggleMcpAccess'); + expect(emitted().toggleMcpAccess).toHaveLength(1); + expect(emitted().toggleMcpAccess[0]).toEqual([false]); + }); + + it('should not emit event when disabled', async () => { + const { getByTestId, emitted } = renderComponent({ + pinia, + props: { + modelValue: false, + disabled: true, + }, + }); + + const toggle = getByTestId('mcp-access-toggle'); + await userEvent.click(toggle); + + expect(emitted()).not.toHaveProperty('toggleMcpAccess'); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/McpAccessToggle.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/McpAccessToggle.vue new file mode 100644 index 00000000000..9ad3d3b15ba --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/McpAccessToggle.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/WorkflowsTable.test.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/WorkflowsTable.test.ts new file mode 100644 index 00000000000..4f5ae334ec1 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/WorkflowsTable.test.ts @@ -0,0 +1,367 @@ +import { createComponentRenderer } from '@/__tests__/render'; +import WorkflowsTable from './WorkflowsTable.vue'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import userEvent from '@testing-library/user-event'; +import type { WorkflowListItem } from '@/Interface'; +import router from '@/app/router'; +import { VIEWS } from '@/app/constants'; + +const renderComponent = createComponentRenderer(WorkflowsTable); + +let pinia: ReturnType; + +// Mock router +vi.mock('@/app/router', () => ({ + default: { + resolve: vi.fn((route) => ({ + fullPath: `/mock-path/${route.params?.name || route.params?.projectId}`, + })), + }, +})); + +const mockWorkflow = (id: string, overrides?: Partial): WorkflowListItem => ({ + id, + name: `Workflow ${id}`, + active: true, + isArchived: false, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + versionId: 'v1', + resource: 'workflow', + homeProject: { + id: 'project-1', + name: 'Test Project', + type: 'team', + icon: { type: 'icon', value: 'layers' }, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + parentFolder: { + id: 'folder-1', + name: 'Test Folder', + parentFolderId: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ...overrides, +}); + +describe('WorkflowsTable', () => { + beforeEach(() => { + pinia = createPinia(); + setActivePinia(pinia); + vi.clearAllMocks(); + }); + + describe('Component rendering', () => { + it('should render loading state correctly', () => { + const { container, getByTestId } = renderComponent({ + pinia, + props: { + workflows: [], + loading: true, + }, + }); + + const loadingElements = container.querySelectorAll('.n8n-loading'); + expect(loadingElements.length).toBeGreaterThan(0); + + expect(() => getByTestId('mcp-workflow-table')).toThrow(); + expect(() => getByTestId('empty-workflow-list-box')).toThrow(); + }); + + it('should render empty state when no workflows exist', () => { + const { getByTestId } = renderComponent({ + pinia, + props: { + workflows: [], + loading: false, + }, + }); + + expect(getByTestId('empty-workflow-list-box')).toBeInTheDocument(); + }); + + it('should render workflows table with correct data', () => { + const workflows = [mockWorkflow('1'), mockWorkflow('2', { name: 'Second Workflow' })]; + + const { getByTestId, getByText } = renderComponent({ + pinia, + props: { + workflows, + loading: false, + }, + }); + + const table = getByTestId('mcp-workflow-table'); + expect(table).toBeInTheDocument(); + + expect(getByText(`Available Workflows (${workflows.length})`)).toBeInTheDocument(); + + workflows.forEach((workflow) => { + expect(getByText(workflow.name)).toBeInTheDocument(); + }); + }); + + it('should render refresh button', () => { + const { getByTestId } = renderComponent({ + pinia, + props: { + workflows: [mockWorkflow('1')], + loading: false, + }, + }); + + const refreshButton = getByTestId('mcp-workflows-refresh-button'); + expect(refreshButton).toBeInTheDocument(); + }); + }); + + describe('Table data display', () => { + it('should display workflow and folder names as links', () => { + const workflow = mockWorkflow('1'); + const { getByTestId } = renderComponent({ + pinia, + props: { + workflows: [workflow], + loading: false, + }, + }); + + const nameLink = getByTestId('mcp-workflow-name-link'); + expect(nameLink).toHaveTextContent(workflow.name); + expect(nameLink).toHaveAttribute('href', `/mock-path/${workflow.id}`); + + const folderElement = getByTestId('mcp-workflow-folder-link'); + expect(folderElement).toHaveTextContent(workflow.parentFolder?.name ?? ''); + expect(folderElement).toHaveAttribute( + 'href', + `/projects/${workflow.homeProject?.id}/folders/${workflow.parentFolder?.id}/workflows`, + ); + }); + + it('should render only workflow link if no parent folder', () => { + const workflow = mockWorkflow('1', { + parentFolder: undefined, + }); + + const { getByTestId, queryByTestId } = renderComponent({ + pinia, + props: { + workflows: [workflow], + loading: false, + }, + }); + + // Should render workflow link + const nameLink = getByTestId('mcp-workflow-name-link'); + expect(nameLink).toBeInTheDocument(); + expect(nameLink).toHaveTextContent(workflow.name); + + // Should not render folder elements, separators or ellipsis + expect(queryByTestId('mcp-workflow-folder-link')).not.toBeInTheDocument(); + expect(queryByTestId('mcp-workflow-folder-name')).not.toBeInTheDocument(); + expect(queryByTestId('mcp-workflow-folder-separator')).not.toBeInTheDocument(); + expect(queryByTestId('mcp-workflow-ellipsis-separator')).not.toBeInTheDocument(); + expect(queryByTestId('mcp-workflow-grandparent-folder')).not.toBeInTheDocument(); + }); + + it('should render ellipsis if parent folder has parent', () => { + const workflow = mockWorkflow('1', { + parentFolder: { + id: 'folder-1', + name: 'Child Folder', + parentFolderId: 'parent-folder-1', // This indicates it has a parent + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + }); + + const { getByTestId } = renderComponent({ + pinia, + props: { + workflows: [workflow], + loading: false, + }, + }); + + // Should render ellipsis for grandparent + const grandparentElement = getByTestId('mcp-workflow-grandparent-folder'); + expect(grandparentElement).toBeInTheDocument(); + + const ellipsis = grandparentElement.querySelector('.ellipsis'); + expect(ellipsis).toBeInTheDocument(); + expect(ellipsis).toHaveTextContent('...'); + + // Check for separator after ellipsis + const ellipsisSeparator = getByTestId('mcp-workflow-ellipsis-separator'); + expect(ellipsisSeparator).toBeInTheDocument(); + expect(ellipsisSeparator).toHaveTextContent('/'); + + // Should still render parent folder + const folderLink = getByTestId('mcp-workflow-folder-link'); + expect(folderLink).toBeInTheDocument(); + expect(folderLink).toHaveTextContent(workflow.parentFolder?.name ?? ''); + + // Should render separator after folder + const folderSeparator = getByTestId('mcp-workflow-folder-separator'); + expect(folderSeparator).toBeInTheDocument(); + expect(folderSeparator).toHaveTextContent('/'); + + // Should render workflow link + const nameLink = getByTestId('mcp-workflow-name-link'); + expect(nameLink).toBeInTheDocument(); + expect(nameLink).toHaveTextContent(workflow.name); + }); + + it('should not render ellipsis if parent folder has no parent', () => { + const workflow = mockWorkflow('1', { + parentFolder: { + id: 'folder-1', + name: 'Root Folder', + parentFolderId: null, // No parent + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + }); + + const { getByTestId, queryByTestId } = renderComponent({ + pinia, + props: { + workflows: [workflow], + loading: false, + }, + }); + + // Should NOT render ellipsis for grandparent + expect(queryByTestId('mcp-workflow-grandparent-folder')).not.toBeInTheDocument(); + + // Should NOT render ellipsis separator + expect(queryByTestId('mcp-workflow-ellipsis-separator')).not.toBeInTheDocument(); + + // Should render parent folder + const folderLink = getByTestId('mcp-workflow-folder-link'); + expect(folderLink).toBeInTheDocument(); + expect(folderLink).toHaveTextContent(workflow.parentFolder?.name ?? ''); + + // Should render separator after folder + const folderSeparator = getByTestId('mcp-workflow-folder-separator'); + expect(folderSeparator).toBeInTheDocument(); + expect(folderSeparator).toHaveTextContent('/'); + + // Should render workflow link + const nameLink = getByTestId('mcp-workflow-name-link'); + expect(nameLink).toBeInTheDocument(); + expect(nameLink).toHaveTextContent(workflow.name); + }); + + it('should display folder without link when homeProject does not exist', () => { + const workflow = mockWorkflow('1', { + homeProject: undefined, + }); + + const { getByTestId, queryByTestId } = renderComponent({ + pinia, + props: { + workflows: [workflow], + loading: false, + }, + }); + + expect(queryByTestId('mcp-workflow-folder-link')).not.toBeInTheDocument(); + const folderName = getByTestId('mcp-workflow-folder-name'); + expect(folderName).toHaveTextContent(workflow.parentFolder?.name ?? ''); + }); + + it('should display project information correctly for team project', () => { + const workflow = mockWorkflow('1'); + const { getByTestId } = renderComponent({ + pinia, + props: { + workflows: [workflow], + loading: false, + }, + }); + + const projectLink = getByTestId('mcp-workflow-project-link'); + expect(projectLink).toBeInTheDocument(); + + const projectName = getByTestId('mcp-workflow-project-name'); + expect(projectName).toHaveTextContent(workflow.homeProject?.name ?? ''); + + expect(router.resolve).toHaveBeenCalledWith({ + name: VIEWS.PROJECTS_WORKFLOWS, + params: { projectId: workflow.homeProject?.id ?? '' }, + }); + }); + + it('should display "-" when no project exists', () => { + const workflow = mockWorkflow('1', { + homeProject: undefined, + }); + + const { getByTestId } = renderComponent({ + pinia, + props: { + workflows: [workflow], + loading: false, + }, + }); + + const noProject = getByTestId('mcp-workflow-no-project'); + expect(noProject).toHaveTextContent('-'); + }); + }); + + describe('User interactions and events', () => { + it('should emit refresh event when refresh button is clicked', async () => { + const { getByTestId, emitted } = renderComponent({ + pinia, + props: { + workflows: [mockWorkflow('1')], + loading: false, + }, + }); + + const refreshButton = getByTestId('mcp-workflows-refresh-button'); + await userEvent.click(refreshButton); + + expect(emitted()).toHaveProperty('refresh'); + expect(emitted().refresh).toHaveLength(1); + }); + + it('should emit removeMcpAccess event when action is selected', async () => { + const workflow = mockWorkflow('1'); + const { getByTestId, emitted } = renderComponent({ + pinia, + props: { + workflows: [workflow], + loading: false, + }, + }); + + const actionToggle = getByTestId('mcp-workflow-action-toggle'); + expect(actionToggle).toBeInTheDocument(); + + const actionButton = actionToggle?.querySelector('[role=button]'); + if (!actionButton) { + throw new Error('Action button not found'); + } + + await userEvent.click(actionButton); + + const actionToggleId = actionButton.getAttribute('aria-controls'); + const actionDropdown = document.getElementById(actionToggleId as string) as HTMLElement; + expect(actionDropdown).toBeInTheDocument(); + + const removeAction = actionDropdown.querySelector('[data-test-id="action-removeFromMCP"]'); + expect(removeAction).toBeInTheDocument(); + await userEvent.click(removeAction!); + + expect(emitted()).toHaveProperty('removeMcpAccess'); + expect(emitted().removeMcpAccess).toHaveLength(1); + expect(emitted().removeMcpAccess[0]).toEqual([workflow]); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/WorkflowsTable.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/WorkflowsTable.vue new file mode 100644 index 00000000000..d0526769f7e --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/WorkflowsTable.vue @@ -0,0 +1,337 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/AccessTokenConnectionInstructions.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/AccessTokenConnectionInstructions.vue new file mode 100644 index 00000000000..227560927ce --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/AccessTokenConnectionInstructions.vue @@ -0,0 +1,254 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/ConnectionParameter.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/ConnectionParameter.vue new file mode 100644 index 00000000000..171a2399ea9 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/ConnectionParameter.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/OAuthClientsTable.test.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/OAuthClientsTable.test.ts new file mode 100644 index 00000000000..d676d43cad2 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/OAuthClientsTable.test.ts @@ -0,0 +1,242 @@ +import { createComponentRenderer } from '@/__tests__/render'; +import OAuthClientsTable from './OAuthClientsTable.vue'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import userEvent from '@testing-library/user-event'; +import type { OAuthClientResponseDto } from '@n8n/api-types'; + +const renderComponent = createComponentRenderer(OAuthClientsTable); + +let pinia: ReturnType; + +const mockOAuthClient = ( + id: string, + overrides?: Partial, +): OAuthClientResponseDto => ({ + id, + name: `OAuth Client ${id}`, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + redirectUris: [], + grantTypes: ['authorization_code'], + tokenEndpointAuthMethod: 'client_secret_post', + ...overrides, +}); + +describe('OAuthClientsTable', () => { + beforeEach(() => { + pinia = createPinia(); + setActivePinia(pinia); + vi.clearAllMocks(); + }); + + describe('Component rendering', () => { + it('should render loading state correctly', () => { + const { container, getByTestId } = renderComponent({ + pinia, + props: { + clients: [], + loading: true, + }, + }); + + const loadingElements = container.querySelectorAll('.n8n-loading'); + expect(loadingElements.length).toBeGreaterThan(0); + + expect(() => getByTestId('oauth-clients-data-table')).toThrow(); + expect(() => getByTestId('empty-workflow-list-box')).toThrow(); + }); + + it('should render empty state when no clients exist', () => { + const { getByTestId, getByText } = renderComponent({ + pinia, + props: { + clients: [], + loading: false, + }, + }); + + expect(getByTestId('empty-oauth-clients-list-box')).toBeInTheDocument(); + expect(getByText('Connected oAuth clients (0)')).toBeInTheDocument(); + }); + + it('should render clients table with correct data', () => { + const clients = [mockOAuthClient('1'), mockOAuthClient('2', { name: 'Second Client' })]; + + const { getByTestId, getByText } = renderComponent({ + pinia, + props: { + clients, + loading: false, + }, + }); + + const table = getByTestId('oauth-clients-data-table'); + expect(table).toBeInTheDocument(); + + expect(getByText(`Connected oAuth clients (${clients.length})`)).toBeInTheDocument(); + + clients.forEach((client) => { + expect(getByText(client.name)).toBeInTheDocument(); + }); + }); + + it('should render refresh button', () => { + const { getByTestId } = renderComponent({ + pinia, + props: { + clients: [mockOAuthClient('1')], + loading: false, + }, + }); + + const refreshButton = getByTestId('mcp-oauth-clients-refresh-button'); + expect(refreshButton).toBeInTheDocument(); + }); + }); + + describe('Table data display', () => { + it('should display client name correctly', () => { + const client = mockOAuthClient('1', { name: 'Test OAuth Client' }); + const { getByText } = renderComponent({ + pinia, + props: { + clients: [client], + loading: false, + }, + }); + + expect(getByText(client.name)).toBeInTheDocument(); + }); + + it('should display created at date', () => { + const client = mockOAuthClient('1', { + createdAt: '2024-01-01T12:00:00.000Z', + }); + const { getByTestId } = renderComponent({ + pinia, + props: { + clients: [client], + loading: false, + }, + }); + + const createdAtElement = getByTestId('mcp-client-created-at'); + expect(createdAtElement).toBeInTheDocument(); + }); + + it('should display action toggle for each client', () => { + const clients = [mockOAuthClient('1'), mockOAuthClient('2')]; + const { getAllByTestId } = renderComponent({ + pinia, + props: { + clients, + loading: false, + }, + }); + + const actionToggles = getAllByTestId('mcp-oauth-client-action-toggle'); + expect(actionToggles).toHaveLength(clients.length); + }); + }); + + describe('User interactions and events', () => { + it('should emit refresh event when refresh button is clicked', async () => { + const { getByTestId, emitted } = renderComponent({ + pinia, + props: { + clients: [mockOAuthClient('1')], + loading: false, + }, + }); + + const refreshButton = getByTestId('mcp-oauth-clients-refresh-button'); + await userEvent.click(refreshButton); + + expect(emitted()).toHaveProperty('refresh'); + expect(emitted().refresh).toHaveLength(1); + }); + + it('should emit revokeClient event when action is selected', async () => { + const client = mockOAuthClient('1'); + const { getByTestId, emitted } = renderComponent({ + pinia, + props: { + clients: [client], + loading: false, + }, + }); + + const actionToggle = getByTestId('mcp-oauth-client-action-toggle'); + expect(actionToggle).toBeInTheDocument(); + + const actionButton = actionToggle?.querySelector('[role=button]'); + if (!actionButton) { + throw new Error('Action button not found'); + } + + await userEvent.click(actionButton); + + const actionToggleId = actionButton.getAttribute('aria-controls'); + const actionDropdown = document.getElementById(actionToggleId as string) as HTMLElement; + expect(actionDropdown).toBeInTheDocument(); + + const revokeAction = actionDropdown.querySelector('[data-test-id="action-revokeClient"]'); + expect(revokeAction).toBeInTheDocument(); + await userEvent.click(revokeAction!); + + expect(emitted()).toHaveProperty('revokeClient'); + expect(emitted().revokeClient).toHaveLength(1); + expect(emitted().revokeClient[0]).toEqual([client]); + }); + + it('should not emit events when loading', () => { + const { queryByTestId, emitted } = renderComponent({ + pinia, + props: { + clients: [mockOAuthClient('1')], + loading: true, + }, + }); + + const actionToggle = queryByTestId('mcp-oauth-client-action-toggle'); + expect(actionToggle).not.toBeInTheDocument(); + + expect(emitted()).not.toHaveProperty('revokeClient'); + expect(emitted()).not.toHaveProperty('refresh'); + }); + }); + + describe('Edge cases', () => { + it('should handle empty client name gracefully', () => { + const client = mockOAuthClient('1', { name: '' }); + const { container } = renderComponent({ + pinia, + props: { + clients: [client], + loading: false, + }, + }); + + const table = container.querySelector('[data-test-id="oauth-clients-data-table"]'); + expect(table).toBeInTheDocument(); + }); + + it('should handle large number of clients', () => { + const clients = Array.from({ length: 50 }, (_, i) => + mockOAuthClient(`${i + 1}`, { name: `Client ${i + 1}` }), + ); + + const { getByText, getByTestId } = renderComponent({ + pinia, + props: { + clients, + loading: false, + }, + }); + + expect(getByText('Connected oAuth clients (50)')).toBeInTheDocument(); + expect(getByTestId('oauth-clients-data-table')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/OAuthClientsTable.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/OAuthClientsTable.vue new file mode 100644 index 00000000000..4dba004598e --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/OAuthClientsTable.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/OAuthConnectionInstructions.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/OAuthConnectionInstructions.vue new file mode 100644 index 00000000000..b2cdf8befb5 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/connectionInstructions/OAuthConnectionInstructions.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.api.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.api.ts index 3826f05fce2..49a0c44c398 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.api.ts +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.api.ts @@ -1,4 +1,8 @@ -import type { ApiKey } from '@n8n/api-types'; +import type { + ApiKey, + ListOAuthClientsResponseDto, + DeleteOAuthClientResponseDto, +} from '@n8n/api-types'; import type { IWorkflowSettings } from '@/Interface'; import type { IRestApiContext } from '@n8n/rest-api-client'; import { makeRestApiRequest } from '@n8n/rest-api-client'; @@ -42,3 +46,20 @@ export async function toggleWorkflowMcpAccessApi( }, ); } + +export async function fetchOAuthClients( + context: IRestApiContext, +): Promise { + return await makeRestApiRequest(context, 'GET', '/mcp/oauth-clients'); +} + +export async function deleteOAuthClient( + context: IRestApiContext, + clientId: string, +): Promise { + return await makeRestApiRequest( + context, + 'DELETE', + `/mcp/oauth-clients/${encodeURIComponent(clientId)}`, + ); +} diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.store.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.store.ts index 44ace857702..c1aac5ff3a8 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.store.ts +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.store.ts @@ -8,11 +8,13 @@ import { toggleWorkflowMcpAccessApi, fetchApiKey, rotateApiKey, + fetchOAuthClients, + deleteOAuthClient, } from '@/features/ai/mcpAccess/mcp.api'; import { computed, ref } from 'vue'; import { useSettingsStore } from '@/app/stores/settings.store'; import { isWorkflowListItem } from '@/app/utils/typeGuards'; -import type { ApiKey } from '@n8n/api-types'; +import type { ApiKey, OAuthClientResponseDto, DeleteOAuthClientResponseDto } from '@n8n/api-types'; export const useMCPStore = defineStore(MCP_STORE, () => { const workflowsStore = useWorkflowsStore(); @@ -20,6 +22,7 @@ export const useMCPStore = defineStore(MCP_STORE, () => { const settingsStore = useSettingsStore(); const currentUserMCPKey = ref(null); + const oauthClients = ref([]); const mcpAccessEnabled = computed(() => !!settingsStore.moduleSettings.mcp?.mcpAccessEnabled); @@ -97,6 +100,19 @@ export const useMCPStore = defineStore(MCP_STORE, () => { return apiKey; } + async function getAllOAuthClients(): Promise { + const response = await fetchOAuthClients(rootStore.restApiContext); + oauthClients.value = response.data; + return response.data; + } + + async function removeOAuthClient(clientId: string): Promise { + const response = await deleteOAuthClient(rootStore.restApiContext, clientId); + // Remove the client from the local store + oauthClients.value = oauthClients.value.filter((client) => client.id !== clientId); + return response; + } + return { mcpAccessEnabled, fetchWorkflowsAvailableForMCP, @@ -105,5 +121,8 @@ export const useMCPStore = defineStore(MCP_STORE, () => { currentUserMCPKey, getOrCreateApiKey, generateNewApiKey, + oauthClients, + getAllOAuthClients, + removeOAuthClient, }; }); diff --git a/packages/frontend/editor-ui/src/features/core/auth/components/MfaSetupModal.vue b/packages/frontend/editor-ui/src/features/core/auth/components/MfaSetupModal.vue index 4846a7b8ee3..a162259fce5 100644 --- a/packages/frontend/editor-ui/src/features/core/auth/components/MfaSetupModal.vue +++ b/packages/frontend/editor-ui/src/features/core/auth/components/MfaSetupModal.vue @@ -15,7 +15,7 @@ import QrcodeVue from 'qrcode.vue'; import { useClipboard } from '@/app/composables/useClipboard'; import { useI18n } from '@n8n/i18n'; import { useSettingsStore } from '@/app/stores/settings.store'; -import router from '@/router'; +import router from '@/app/router'; import { I18nT } from 'vue-i18n'; import { N8nButton, N8nInfoTip, N8nInput, N8nInputLabel, N8nText } from '@n8n/design-system'; diff --git a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue index 0a09c02cf80..619e285b12e 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue +++ b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue @@ -25,7 +25,11 @@ import { useMessage } from '@/app/composables/useMessage'; import { useNodeHelpers } from '@/app/composables/useNodeHelpers'; import { useToast } from '@/app/composables/useToast'; import { CREDENTIAL_EDIT_MODAL_KEY } from '../../credentials.constants'; -import { EnterpriseEditionFeature, MODAL_CONFIRM } from '@/app/constants'; +import { + EnterpriseEditionFeature, + MODAL_CONFIRM, + PLACEHOLDER_EMPTY_WORKFLOW_ID, +} from '@/app/constants'; import { useCredentialsStore } from '../../credentials.store'; import { useNDVStore } from '@/features/ndv/shared/ndv.store'; import { useNodeTypesStore } from '@/app/stores/nodeTypes.store'; @@ -884,7 +888,10 @@ async function createCredential( telemetry.track('User created credentials', { credential_type: credentialDetails.type, credential_id: credential.id, - workflow_id: workflowsStore.workflowId, + workflow_id: + workflowsStore.workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID + ? null + : workflowsStore.workflowId, }); return credential; @@ -1034,7 +1041,10 @@ async function oAuthCredentialAuthorize() { const trackProperties: ITelemetryTrackProperties = { credential_type: credentialTypeName.value, - workflow_id: workflowsStore.workflowId, + workflow_id: + workflowsStore.workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID + ? null + : workflowsStore.workflowId, credential_id: credentialId.value, is_complete: !!requiredPropertiesFilled.value, is_new: props.mode === 'new' && !credentialId.value, diff --git a/packages/frontend/editor-ui/src/features/execution/executions/components/workflow/WorkflowExecutionsCard.vue b/packages/frontend/editor-ui/src/features/execution/executions/components/workflow/WorkflowExecutionsCard.vue index 9fa135bfa0b..6fce76e4951 100644 --- a/packages/frontend/editor-ui/src/features/execution/executions/components/workflow/WorkflowExecutionsCard.vue +++ b/packages/frontend/editor-ui/src/features/execution/executions/components/workflow/WorkflowExecutionsCard.vue @@ -188,7 +188,7 @@ function onRetryMenuItemSelect(action: string): void { diff --git a/packages/frontend/editor-ui/src/features/settings/provisioning/components/EnableJitProvisioningDialog.vue b/packages/frontend/editor-ui/src/features/settings/provisioning/components/EnableJitProvisioningDialog.vue new file mode 100644 index 00000000000..d20e7edf3dd --- /dev/null +++ b/packages/frontend/editor-ui/src/features/settings/provisioning/components/EnableJitProvisioningDialog.vue @@ -0,0 +1,165 @@ + + + + diff --git a/packages/frontend/editor-ui/src/features/settings/provisioning/composables/useAccessSettingsCsvExport.ts b/packages/frontend/editor-ui/src/features/settings/provisioning/composables/useAccessSettingsCsvExport.ts new file mode 100644 index 00000000000..7741d471042 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/settings/provisioning/composables/useAccessSettingsCsvExport.ts @@ -0,0 +1,153 @@ +import { type UsersListFilterDto } from '@n8n/api-types'; +import { ref } from 'vue'; +import * as usersApi from '@n8n/rest-api-client/api/users'; +import { useRootStore } from '@n8n/stores/useRootStore'; + +interface AccessSettingsUserData { + count: number; + items: Array<{ + id: string; + email: string; + role: string; + projectRelations: Array<{ + id: string; + role: string; + name: string; + }>; + }>; +} + +function isAccessSettingsUserData(response: unknown): response is AccessSettingsUserData { + const topLevelsPropertiesMatch = + typeof response === 'object' && response !== null && 'count' in response && 'items' in response; + if (!topLevelsPropertiesMatch) { + return false; + } + if (!Array.isArray(response.items)) { + return false; + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const item: object = response.items.length ? response.items[0] : null; + if (!item) { + return true; + } + const isValidItem = + Object.hasOwn(item, 'id') && + Object.hasOwn(item, 'email') && + Object.hasOwn(item, 'role') && + Object.hasOwn(item, 'projectRelations'); + return isValidItem; +} + +export function useAccessSettingsCsvExport() { + const cachedUserData = ref(); + const rootStore = useRootStore(); + + const formatDateForFilename = (): string => { + const now = new Date(); + return `${now.getDate()}_${now.getMonth() + 1}_${now.getFullYear()}_${now.getHours()}_${now.getMinutes()}`; + }; + + const escapeCsvValue = (value: string): string => { + // If value contains comma, quote, or newline, wrap in quotes and escape quotes + if (value.includes(',') || value.includes('"')) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; + }; + + const downloadCsv = (csvContent: string, filename: string): void => { + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const tempElement = document.createElement('a'); + tempElement.setAttribute('href', url); + tempElement.setAttribute('download', filename); + tempElement.style.display = 'none'; + document.body.appendChild(tempElement); + tempElement.click(); + document.body.removeChild(tempElement); + URL.revokeObjectURL(url); // remove blob from browser memory + }; + + const getUserData = async (): Promise => { + if (cachedUserData.value) { + return cachedUserData.value; + } + // TODO: add pagination + const filter: UsersListFilterDto = { + take: -1, + select: ['email', 'role'], + sortBy: ['email:desc'], + expand: ['projectRelations'], + skip: 0, + }; + const getUsersResponse = await usersApi.getUsers(rootStore.restApiContext, filter); + if (isAccessSettingsUserData(getUsersResponse)) { + cachedUserData.value = getUsersResponse; + return cachedUserData.value; + } + // This case should never happen (as that would mean a breaking backend change) + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-explicit-any + return getUsersResponse as any; + }; + + const hasDownloadedProjectRoleCsv = ref(false); + + const downloadProjectRolesCsv = async () => { + const userData = await getUserData(); + const csvRows = ['email,project_displayname,project_id,project_role']; + + for (const user of userData.items) { + const email = escapeCsvValue(user.email ?? ''); + + if (user.projectRelations && user.projectRelations.length > 0) { + for (const project of user.projectRelations) { + const projectName = escapeCsvValue(project.name ?? ''); + const projectId = escapeCsvValue(project.id ?? ''); + const projectRole = escapeCsvValue(project.role ?? ''); + csvRows.push(`${email},${projectName},${projectId},${projectRole}`); + } + } + } + + const csvContent = csvRows.join('\n'); + const filename = `n8n_project_role_export_${formatDateForFilename()}.csv`; + + downloadCsv(csvContent, filename); + hasDownloadedProjectRoleCsv.value = true; + }; + + const hasDownloadedInstanceRoleCsv = ref(false); + + const downloadInstanceRolesCsv = async () => { + const userData = await getUserData(); + const csvRows = ['email,instance_role']; + + for (const user of userData.items) { + const email = escapeCsvValue(user.email ?? ''); + const instanceRole = escapeCsvValue(user.role ?? ''); + csvRows.push(`${email},${instanceRole}`); + } + + const csvContent = csvRows.join('\n'); + const filename = `n8n_instance_role_export_${formatDateForFilename()}.csv`; + + downloadCsv(csvContent, filename); + hasDownloadedInstanceRoleCsv.value = true; + }; + + const accessSettingsCsvExportOnModalClose = () => { + hasDownloadedInstanceRoleCsv.value = false; + hasDownloadedProjectRoleCsv.value = false; + // ensure user data is loaded freshly whenever dialog is opened the next time + cachedUserData.value = undefined; + }; + + return { + downloadProjectRolesCsv, + downloadInstanceRolesCsv, + hasDownloadedInstanceRoleCsv, + hasDownloadedProjectRoleCsv, + accessSettingsCsvExportOnModalClose, + }; +} diff --git a/packages/frontend/editor-ui/src/features/settings/provisioning/views/SettingsProvisioningView.vue b/packages/frontend/editor-ui/src/features/settings/provisioning/views/SettingsProvisioningView.vue index 9a4ab58cbaa..1a20e78946e 100644 --- a/packages/frontend/editor-ui/src/features/settings/provisioning/views/SettingsProvisioningView.vue +++ b/packages/frontend/editor-ui/src/features/settings/provisioning/views/SettingsProvisioningView.vue @@ -6,6 +6,7 @@ import { useToast } from '@/app/composables/useToast'; import { useProvisioningStore } from '../provisioning.store'; import { N8nHeading, N8nText, N8nSpinner, N8nInput, N8nButton } from '@n8n/design-system'; import { type ProvisioningConfig } from '@n8n/rest-api-client'; +import EnableJitProvisioningDialog from '../components/EnableJitProvisioningDialog.vue'; const i18n = useI18n(); const documentTitle = useDocumentTitle(); @@ -29,6 +30,7 @@ onMounted(async () => { const loading = ref(false); const saving = ref(false); +const confirmationDialogVisible = ref(false); // Form data (reactive object) const form = reactive({ @@ -64,7 +66,7 @@ const loadFormData = () => { form.provisioningEnabled = cfg.scopesProvisionInstanceRole; }; -const onSave = async () => { +const saveFormValues = async () => { saving.value = true; try { const { provisioningEnabled, ...dataToSave } = form; @@ -89,6 +91,20 @@ const onSave = async () => { saving.value = false; } }; + +const onSave = async () => { + if (form.provisioningEnabled) { + confirmationDialogVisible.value = true; + return; + } + await saveFormValues(); +}; + +const onConfirmProvisioning = async () => { + saving.value = true; + await saveFormValues(); + confirmationDialogVisible.value = false; +}; diff --git a/packages/frontend/editor-ui/src/features/settings/usage/components/CommunityPlusEnrollmentModal.vue b/packages/frontend/editor-ui/src/features/settings/usage/components/CommunityPlusEnrollmentModal.vue index dc447442197..84ecaf88901 100644 --- a/packages/frontend/editor-ui/src/features/settings/usage/components/CommunityPlusEnrollmentModal.vue +++ b/packages/frontend/editor-ui/src/features/settings/usage/components/CommunityPlusEnrollmentModal.vue @@ -100,32 +100,25 @@ const confirm = async () => { }} {{ i18n.baseText('communityPlusModal.description') }}
    -
  • - 🕰️ - - {{ i18n.baseText('communityPlusModal.features.first.title') }} - {{ i18n.baseText('communityPlusModal.features.first.description') }} - -
  • 🐞 - {{ i18n.baseText('communityPlusModal.features.second.title') }} - {{ i18n.baseText('communityPlusModal.features.second.description') }} + {{ i18n.baseText('communityPlusModal.features.debugging.title') }} + {{ i18n.baseText('communityPlusModal.features.debugging.description') }}
  • 🔎 - {{ i18n.baseText('communityPlusModal.features.third.title') }} - {{ i18n.baseText('communityPlusModal.features.third.description') }} + {{ i18n.baseText('communityPlusModal.features.execution.title') }} + {{ i18n.baseText('communityPlusModal.features.execution.description') }}
  • 📁 - {{ i18n.baseText('communityPlusModal.features.fourth.title') }} - {{ i18n.baseText('communityPlusModal.features.fourth.description') }} + {{ i18n.baseText('communityPlusModal.features.folders.title') }} + {{ i18n.baseText('communityPlusModal.features.folders.description') }}
diff --git a/packages/frontend/editor-ui/src/features/settings/usage/views/SettingsUsageAndPlan.test.ts b/packages/frontend/editor-ui/src/features/settings/usage/views/SettingsUsageAndPlan.test.ts index 9bf2a32ac8a..c649c020b1e 100644 --- a/packages/frontend/editor-ui/src/features/settings/usage/views/SettingsUsageAndPlan.test.ts +++ b/packages/frontend/editor-ui/src/features/settings/usage/views/SettingsUsageAndPlan.test.ts @@ -123,6 +123,34 @@ describe('SettingsUsageAndPlan', () => { expect(container.querySelector('.n8n-badge')).toHaveTextContent('Registered'); }); + it('should correctly call activateLicense on non-eula acceptance', async () => { + usageStore.isLoading = false; + usageStore.planName = 'Community'; + usersStore.currentUser = { + globalScopes: ['license:manage'], + } as IUser; + rbacStore.setGlobalScopes(['license:manage']); + usageStore.activateLicense.mockImplementation(async () => {}); + + const { getByRole } = renderComponent(); + + await userEvent.click(getByRole('button', { name: /activation/i })); + const input = document.querySelector('input') as HTMLInputElement; + await userEvent.type(input, 'test-key-123'); + await userEvent.click(getByRole('button', { name: /activate/i })); + + await waitFor(() => { + expect(usageStore.activateLicense).toHaveBeenCalledTimes(1); + expect(usageStore.activateLicense).toHaveBeenLastCalledWith('test-key-123', undefined); + }); + + expect(mockToast.showMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'success', + }), + ); + }); + describe('License activation with EULA', () => { it('should show EULA modal when activation fails with 400 error and eulaUrl', async () => { usageStore.isLoading = false; @@ -249,7 +277,7 @@ describe('SettingsUsageAndPlan', () => { await userEvent.click(getByRole('button', { name: /activate/i })); await waitFor(() => { - expect(mockToast.showError).toHaveBeenCalledWith(error, expect.any(String)); + expect(mockToast.showError).toHaveBeenCalledWith(error, 'Activation failed'); }); }); }); diff --git a/packages/frontend/editor-ui/src/features/settings/usage/views/SettingsUsageAndPlan.vue b/packages/frontend/editor-ui/src/features/settings/usage/views/SettingsUsageAndPlan.vue index 5af2155f28e..1a5a38e229f 100644 --- a/packages/frontend/editor-ui/src/features/settings/usage/views/SettingsUsageAndPlan.vue +++ b/packages/frontend/editor-ui/src/features/settings/usage/views/SettingsUsageAndPlan.vue @@ -103,7 +103,7 @@ const isEulaError = (error: unknown): error is EulaErrorResponse => { const onLicenseActivation = async (eulaUri?: string) => { try { - await usageStore.activateLicense(activationKey.value, eulaUri); + await usageStore.activateLicense(activationKey.value.trim(), eulaUri?.trim()); activationKeyModal.value = false; eulaModal.value = false; activationKey.value = ''; @@ -321,7 +321,7 @@ const openCommunityRegisterModal = () => { {{ locale.baseText('settings.usageAndPlan.dialog.activation.cancel') }} - + {{ locale.baseText('settings.usageAndPlan.dialog.activation.activate') }} @@ -338,7 +338,7 @@ const openCommunityRegisterModal = () => {