Merge remote-tracking branch 'origin/master' into pay-1852-public-api-delete-users-from-project

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2025-01-13 13:34:23 +01:00
commit 79d347fb23
No known key found for this signature in database
82 changed files with 3001 additions and 616 deletions

View file

@ -20,26 +20,28 @@ jobs:
- uses: actions/checkout@v4.1.1 - uses: actions/checkout@v4.1.1
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0 uses: docker/setup-qemu-action@v3.3.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0 uses: docker/setup-buildx-action@v3.8.0
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3.0.0 uses: docker/login-action@v3.3.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v3.0.0 uses: docker/login-action@v3.3.0
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build - name: Build
uses: docker/build-push-action@v5.1.0 uses: docker/build-push-action@v6.11.0
env:
DOCKER_BUILD_SUMMARY: false
with: with:
context: . context: .
file: ./docker/images/n8n-base/Dockerfile file: ./docker/images/n8n-base/Dockerfile

View file

@ -19,20 +19,22 @@ jobs:
- uses: actions/checkout@v4.1.1 - uses: actions/checkout@v4.1.1
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0 uses: docker/setup-qemu-action@v3.3.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0 uses: docker/setup-buildx-action@v3.8.0
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3.0.0 uses: docker/login-action@v3.3.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build - name: Build
uses: docker/build-push-action@v5.1.0 uses: docker/build-push-action@v6.11.0
env:
DOCKER_BUILD_SUMMARY: false
with: with:
context: . context: .
file: ./packages/@n8n/benchmark/Dockerfile file: ./packages/@n8n/benchmark/Dockerfile

View file

@ -0,0 +1,83 @@
name: Docker Custom Image CI
run-name: Build ${{ inputs.branch }} - ${{ inputs.user }}
on:
workflow_dispatch:
inputs:
branch:
description: 'GitHub branch to create image off.'
required: true
tag:
description: 'Name of the docker tag to create.'
required: true
merge-master:
description: 'Merge with master.'
type: boolean
required: true
default: false
user:
description: ''
required: false
default: 'none'
start-url:
description: 'URL to call after workflow is kicked off.'
required: false
default: ''
success-url:
description: 'URL to call after Docker Image got built successfully.'
required: false
default: ''
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Call Start URL - optionally
if: ${{ github.event.inputs.start-url != '' }}
run: curl -v -X POST -d 'url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' ${{github.event.inputs.start-url}} || echo ""
shell: bash
- name: Checkout
uses: actions/checkout@v4.1.1
with:
ref: ${{ github.event.inputs.branch }}
- name: Merge Master - optionally
if: github.event.inputs.merge-master
run: git remote add upstream https://github.com/n8n-io/n8n.git -f; git merge upstream/master --allow-unrelated-histories || echo ""
shell: bash
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.8.0
- name: Login to GHCR
uses: docker/login-action@v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image to GHCR
uses: docker/build-push-action@v6.11.0
env:
DOCKER_BUILD_SUMMARY: false
with:
context: .
file: ./docker/images/n8n-custom/Dockerfile
build-args: |
N8N_RELEASE_TYPE=development
platforms: linux/amd64
provenance: false
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ghcr.io/${{ github.repository_owner }}/n8n:${{ inputs.tag }}
- name: Call Success URL - optionally
if: ${{ github.event.inputs.success-url != '' }}
run: curl -v ${{github.event.inputs.success-url}} || echo ""
shell: bash

View file

@ -1,74 +1,42 @@
name: Docker Nightly Image CI name: Docker Nightly Image CI
run-name: Build ${{ inputs.branch }} - ${{ inputs.user }}
on: on:
schedule: schedule:
- cron: '0 1 * * *' - cron: '0 0 * * *'
workflow_dispatch: workflow_dispatch:
inputs:
branch:
description: 'GitHub branch to create image off.'
required: true
default: 'master'
tag:
description: 'Name of the docker tag to create.'
required: true
default: 'nightly'
merge-master:
description: 'Merge with master.'
type: boolean
required: true
default: false
user:
description: ''
required: false
default: 'schedule'
start-url:
description: 'URL to call after workflow is kicked off.'
required: false
default: ''
success-url:
description: 'URL to call after Docker Image got built successfully.'
required: false
default: ''
env:
N8N_TAG: ${{ inputs.tag || 'nightly' }}
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Call Start URL - optionally
run: |
[[ "${{github.event.inputs.start-url}}" != "" ]] && curl -v -X POST -d 'url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' ${{github.event.inputs.start-url}} || echo ""
shell: bash
- name: Checkout - name: Checkout
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
with: with:
ref: ${{ github.event.inputs.branch || 'master' }} ref: master
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0 uses: docker/setup-qemu-action@v3.3.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0 uses: docker/setup-buildx-action@v3.8.0
- name: Login to GHCR
uses: docker/login-action@v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v3.0.0 uses: docker/login-action@v3.3.0
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Merge Master - optionally - name: Build and push image to GHCR and DockerHub
run: | uses: docker/build-push-action@v6.11.0
[[ "${{github.event.inputs.merge-master}}" == "true" ]] && git remote add upstream https://github.com/n8n-io/n8n.git -f; git merge upstream/master --allow-unrelated-histories || echo "" env:
shell: bash DOCKER_BUILD_SUMMARY: false
- name: Build and push to DockerHub
uses: docker/build-push-action@v5.1.0
with: with:
context: . context: .
file: ./docker/images/n8n-custom/Dockerfile file: ./docker/images/n8n-custom/Dockerfile
@ -79,24 +47,6 @@ jobs:
push: true push: true
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
tags: ${{ secrets.DOCKER_USERNAME }}/n8n:${{ env.N8N_TAG }} tags: |
ghcr.io/${{ github.repository_owner }}/n8n:nightly
- name: Login to GitHub Container Registry
if: env.N8N_TAG == 'nightly'
uses: docker/login-action@v3.0.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Push image to GHCR
if: env.N8N_TAG == 'nightly'
run: |
docker buildx imagetools create \
--tag ghcr.io/${{ github.repository_owner }}/n8n:nightly \
${{ secrets.DOCKER_USERNAME }}/n8n:nightly ${{ secrets.DOCKER_USERNAME }}/n8n:nightly
- name: Call Success URL - optionally
run: |
[[ "${{github.event.inputs.success-url}}" != "" ]] && curl -v ${{github.event.inputs.success-url}} || echo ""
shell: bash

View file

@ -73,26 +73,28 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0 uses: docker/setup-qemu-action@v3.3.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0 uses: docker/setup-buildx-action@v3.8.0
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3.0.0 uses: docker/login-action@v3.3.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v3.0.0 uses: docker/login-action@v3.3.0
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build - name: Build
uses: docker/build-push-action@v5.1.0 uses: docker/build-push-action@v6.11.0
env:
DOCKER_BUILD_SUMMARY: false
with: with:
context: ./docker/images/n8n context: ./docker/images/n8n
build-args: | build-args: |

View file

@ -34,7 +34,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5 timeout-minutes: 5
steps: steps:
- uses: docker/login-action@v3.0.0 - uses: docker/login-action@v3.3.0
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
@ -46,7 +46,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5 timeout-minutes: 5
steps: steps:
- uses: docker/login-action@v3.0.0 - uses: docker/login-action@v3.3.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}

View file

@ -250,7 +250,7 @@ describe('Webhook Trigger node', () => {
}); });
// add credentials // add credentials
workflowPage.getters.nodeCredentialsSelect().click(); workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click(); workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.credentialsEditModal().should('be.visible'); credentialsModal.getters.credentialsEditModal().should('be.visible');
credentialsModal.actions.fillCredentialsForm(); credentialsModal.actions.fillCredentialsForm();
@ -293,7 +293,7 @@ describe('Webhook Trigger node', () => {
}); });
// add credentials // add credentials
workflowPage.getters.nodeCredentialsSelect().click(); workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click(); workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.credentialsEditModal().should('be.visible'); credentialsModal.getters.credentialsEditModal().should('be.visible');
credentialsModal.actions.fillCredentialsForm(); credentialsModal.actions.fillCredentialsForm();

View file

@ -297,10 +297,9 @@ describe('Credential Usage in Cross Shared Workflows', () => {
workflowsPage.actions.createWorkflowFromCard(); workflowsPage.actions.createWorkflowFromCard();
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true); workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
// Only the credential in this project (+ the 'Create new' option) should // Only the credential in this project should be in the dropdown
// be in the dropdown
workflowPage.getters.nodeCredentialsSelect().click(); workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').should('have.length', 2); getVisibleSelect().find('li').should('have.length', 1);
}); });
it('should only show credentials in their personal project for members', () => { it('should only show credentials in their personal project for members', () => {
@ -325,10 +324,9 @@ describe('Credential Usage in Cross Shared Workflows', () => {
workflowsPage.actions.createWorkflowFromCard(); workflowsPage.actions.createWorkflowFromCard();
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true); workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
// Only the own credential the shared one (+ the 'Create new' option) // Only the own credential the shared one should be in the dropdown
// should be in the dropdown
workflowPage.getters.nodeCredentialsSelect().click(); workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').should('have.length', 3); getVisibleSelect().find('li').should('have.length', 2);
}); });
it('should only show credentials in their personal project for members if the workflow was shared with them', () => { it('should only show credentials in their personal project for members if the workflow was shared with them', () => {
@ -355,10 +353,9 @@ describe('Credential Usage in Cross Shared Workflows', () => {
workflowsPage.getters.workflowCardContent(workflowName).click(); workflowsPage.getters.workflowCardContent(workflowName).click();
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true); workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
// Only the own credential the shared one (+ the 'Create new' option) // Only the own credential the shared one should be in the dropdown
// should be in the dropdown
workflowPage.getters.nodeCredentialsSelect().click(); workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').should('have.length', 2); getVisibleSelect().find('li').should('have.length', 1);
}); });
it("should show all credentials from all personal projects the workflow's been shared into for the global owner", () => { it("should show all credentials from all personal projects the workflow's been shared into for the global owner", () => {
@ -400,10 +397,9 @@ describe('Credential Usage in Cross Shared Workflows', () => {
workflowsPage.getters.workflowCardContent(workflowName).click(); workflowsPage.getters.workflowCardContent(workflowName).click();
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true); workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
// Only the personal credentials of the workflow owner and the global owner // Only the personal credentials of the workflow owner and the global owner should show up.
// should show up.
workflowPage.getters.nodeCredentialsSelect().click(); workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').should('have.length', 4); getVisibleSelect().find('li').should('have.length', 3);
}); });
it('should show all personal credentials if the global owner owns the workflow', () => { it('should show all personal credentials if the global owner owns the workflow', () => {
@ -421,6 +417,6 @@ describe('Credential Usage in Cross Shared Workflows', () => {
// Show all personal credentials // Show all personal credentials
workflowPage.getters.nodeCredentialsSelect().click(); workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').should('have.have.length', 2); getVisibleSelect().find('li').should('have.have.length', 1);
}); });
}); });

View file

@ -31,7 +31,7 @@ function createNotionCredential() {
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME); workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME);
workflowPage.actions.openNode(NOTION_NODE_NAME); workflowPage.actions.openNode(NOTION_NODE_NAME);
workflowPage.getters.nodeCredentialsSelect().click(); workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click(); workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.actions.fillCredentialsForm(); credentialsModal.actions.fillCredentialsForm();
cy.get('body').type('{esc}'); cy.get('body').type('{esc}');
workflowPage.actions.deleteNode(NOTION_NODE_NAME); workflowPage.actions.deleteNode(NOTION_NODE_NAME);
@ -79,7 +79,7 @@ describe('Credentials', () => {
workflowPage.getters.canvasNodes().last().click(); workflowPage.getters.canvasNodes().last().click();
cy.get('body').type('{enter}'); cy.get('body').type('{enter}');
workflowPage.getters.nodeCredentialsSelect().click(); workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click(); workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.credentialsEditModal().should('be.visible'); credentialsModal.getters.credentialsEditModal().should('be.visible');
credentialsModal.getters.credentialAuthTypeRadioButtons().should('have.length', 2); credentialsModal.getters.credentialAuthTypeRadioButtons().should('have.length', 2);
credentialsModal.getters.credentialAuthTypeRadioButtons().first().click(); credentialsModal.getters.credentialAuthTypeRadioButtons().first().click();
@ -99,7 +99,7 @@ describe('Credentials', () => {
cy.get('body').type('{enter}'); cy.get('body').type('{enter}');
workflowPage.getters.nodeCredentialsSelect().click(); workflowPage.getters.nodeCredentialsSelect().click();
// Add oAuth credentials // Add oAuth credentials
getVisibleSelect().find('li').last().click(); workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.credentialsEditModal().should('be.visible'); credentialsModal.getters.credentialsEditModal().should('be.visible');
credentialsModal.getters.credentialAuthTypeRadioButtons().should('have.length', 2); credentialsModal.getters.credentialAuthTypeRadioButtons().should('have.length', 2);
credentialsModal.getters.credentialAuthTypeRadioButtons().first().click(); credentialsModal.getters.credentialAuthTypeRadioButtons().first().click();
@ -107,14 +107,13 @@ describe('Credentials', () => {
cy.get('.el-message-box').find('button').contains('Close').click(); cy.get('.el-message-box').find('button').contains('Close').click();
workflowPage.getters.nodeCredentialsSelect().click(); workflowPage.getters.nodeCredentialsSelect().click();
// Add Service account credentials // Add Service account credentials
getVisibleSelect().find('li').last().click(); workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.credentialsEditModal().should('be.visible'); credentialsModal.getters.credentialsEditModal().should('be.visible');
credentialsModal.getters.credentialAuthTypeRadioButtons().should('have.length', 2); credentialsModal.getters.credentialAuthTypeRadioButtons().should('have.length', 2);
credentialsModal.getters.credentialAuthTypeRadioButtons().last().click(); credentialsModal.getters.credentialAuthTypeRadioButtons().last().click();
credentialsModal.actions.fillCredentialsForm(); credentialsModal.actions.fillCredentialsForm();
// Both (+ the 'Create new' option) should be in the dropdown
workflowPage.getters.nodeCredentialsSelect().click(); workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').should('have.length.greaterThan', 2); getVisibleSelect().find('li').should('have.length', 3);
}); });
it('should correctly render required and optional credentials', () => { it('should correctly render required and optional credentials', () => {
@ -130,13 +129,13 @@ describe('Credentials', () => {
workflowPage.getters.nodeCredentialsSelect().should('have.length', 2); workflowPage.getters.nodeCredentialsSelect().should('have.length', 2);
workflowPage.getters.nodeCredentialsSelect().first().click(); workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect().find('li').contains('Create New Credential').click(); workflowPage.getters.nodeCredentialsCreateOption().first().click();
// This one should show auth type selector // This one should show auth type selector
credentialsModal.getters.credentialAuthTypeRadioButtons().should('have.length', 2); credentialsModal.getters.credentialAuthTypeRadioButtons().should('have.length', 2);
cy.get('body').type('{esc}'); cy.get('body').type('{esc}');
workflowPage.getters.nodeCredentialsSelect().last().click(); workflowPage.getters.nodeCredentialsSelect().last().click();
getVisibleSelect().find('li').contains('Create New Credential').click(); workflowPage.getters.nodeCredentialsCreateOption().last().click();
// This one should not show auth type selector // This one should not show auth type selector
credentialsModal.getters.credentialsAuthTypeSelector().should('not.exist'); credentialsModal.getters.credentialsAuthTypeSelector().should('not.exist');
}); });
@ -148,7 +147,7 @@ describe('Credentials', () => {
workflowPage.getters.canvasNodes().last().click(); workflowPage.getters.canvasNodes().last().click();
cy.get('body').type('{enter}'); cy.get('body').type('{enter}');
workflowPage.getters.nodeCredentialsSelect().click(); workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click(); workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.credentialsAuthTypeSelector().should('not.exist'); credentialsModal.getters.credentialsAuthTypeSelector().should('not.exist');
credentialsModal.actions.fillCredentialsForm(); credentialsModal.actions.fillCredentialsForm();
workflowPage.getters workflowPage.getters
@ -164,7 +163,7 @@ describe('Credentials', () => {
workflowPage.getters.canvasNodes().last().click(); workflowPage.getters.canvasNodes().last().click();
cy.get('body').type('{enter}'); cy.get('body').type('{enter}');
workflowPage.getters.nodeCredentialsSelect().click(); workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click(); workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.actions.fillCredentialsForm(); credentialsModal.actions.fillCredentialsForm();
workflowPage.getters workflowPage.getters
.nodeCredentialsSelect() .nodeCredentialsSelect()
@ -189,7 +188,7 @@ describe('Credentials', () => {
workflowPage.getters.canvasNodes().last().click(); workflowPage.getters.canvasNodes().last().click();
cy.get('body').type('{enter}'); cy.get('body').type('{enter}');
workflowPage.getters.nodeCredentialsSelect().click(); workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click(); workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.actions.fillCredentialsForm(); credentialsModal.actions.fillCredentialsForm();
workflowPage.getters.nodeCredentialsEditButton().click(); workflowPage.getters.nodeCredentialsEditButton().click();
credentialsModal.getters.credentialsEditModal().should('be.visible'); credentialsModal.getters.credentialsEditModal().should('be.visible');
@ -232,7 +231,7 @@ describe('Credentials', () => {
cy.getByTestId('credential-select').click(); cy.getByTestId('credential-select').click();
cy.contains('Adalo API').click(); cy.contains('Adalo API').click();
workflowPage.getters.nodeCredentialsSelect().click(); workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click(); workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.actions.fillCredentialsForm(); credentialsModal.actions.fillCredentialsForm();
workflowPage.getters.nodeCredentialsEditButton().click(); workflowPage.getters.nodeCredentialsEditButton().click();
credentialsModal.getters.credentialsEditModal().should('be.visible'); credentialsModal.getters.credentialsEditModal().should('be.visible');
@ -296,7 +295,7 @@ describe('Credentials', () => {
workflowPage.getters.nodeCredentialsSelect().should('exist'); workflowPage.getters.nodeCredentialsSelect().should('exist');
workflowPage.getters.nodeCredentialsSelect().click(); workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click(); workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.actions.fillCredentialsForm(); credentialsModal.actions.fillCredentialsForm();
workflowPage.getters workflowPage.getters
.nodeCredentialsSelect() .nodeCredentialsSelect()
@ -325,7 +324,7 @@ describe('Credentials', () => {
workflowPage.actions.addNodeToCanvas('Slack', true, true, 'Get a channel'); workflowPage.actions.addNodeToCanvas('Slack', true, true, 'Get a channel');
workflowPage.getters.nodeCredentialsSelect().should('exist'); workflowPage.getters.nodeCredentialsSelect().should('exist');
workflowPage.getters.nodeCredentialsSelect().click(); workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click(); workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.credentialAuthTypeRadioButtons().first().click(); credentialsModal.getters.credentialAuthTypeRadioButtons().first().click();
nodeDetailsView.getters.copyInput().should('not.exist'); nodeDetailsView.getters.copyInput().should('not.exist');
}); });

View file

@ -89,7 +89,7 @@ describe('Community and custom nodes in canvas', () => {
workflowPage.actions.addNodeToCanvas('Manual'); workflowPage.actions.addNodeToCanvas('Manual');
workflowPage.actions.addNodeToCanvas('E2E Node with native n8n credential', true, true); workflowPage.actions.addNodeToCanvas('E2E Node with native n8n credential', true, true);
workflowPage.getters.nodeCredentialsLabel().click(); workflowPage.getters.nodeCredentialsLabel().click();
cy.contains('Create New Credential').click(); workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.editCredentialModal().should('be.visible'); credentialsModal.getters.editCredentialModal().should('be.visible');
credentialsModal.getters.editCredentialModal().should('contain.text', 'Notion API'); credentialsModal.getters.editCredentialModal().should('contain.text', 'Notion API');
}); });
@ -98,7 +98,7 @@ describe('Community and custom nodes in canvas', () => {
workflowPage.actions.addNodeToCanvas('Manual'); workflowPage.actions.addNodeToCanvas('Manual');
workflowPage.actions.addNodeToCanvas('E2E Node with custom credential', true, true); workflowPage.actions.addNodeToCanvas('E2E Node with custom credential', true, true);
workflowPage.getters.nodeCredentialsLabel().click(); workflowPage.getters.nodeCredentialsLabel().click();
cy.contains('Create New Credential').click(); workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.editCredentialModal().should('be.visible'); credentialsModal.getters.editCredentialModal().should('be.visible');
credentialsModal.getters.editCredentialModal().should('contain.text', 'Custom E2E Credential'); credentialsModal.getters.editCredentialModal().should('contain.text', 'Custom E2E Credential');
}); });

View file

@ -367,7 +367,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowPage.getters.nodeCredentialsSelect().first().click(); workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect() getVisibleSelect()
.find('li') .find('li')
.should('have.length', 2) .should('have.length', 1)
.first() .first()
.should('contain.text', 'Notion account project 1'); .should('contain.text', 'Notion account project 1');
ndv.getters.backToCanvas().click(); ndv.getters.backToCanvas().click();
@ -382,7 +382,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowPage.getters.nodeCredentialsSelect().first().click(); workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect() getVisibleSelect()
.find('li') .find('li')
.should('have.length', 2) .should('have.length', 1)
.first() .first()
.should('contain.text', 'Notion account project 1'); .should('contain.text', 'Notion account project 1');
ndv.getters.backToCanvas().click(); ndv.getters.backToCanvas().click();
@ -396,7 +396,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowPage.getters.nodeCredentialsSelect().first().click(); workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect() getVisibleSelect()
.find('li') .find('li')
.should('have.length', 2) .should('have.length', 1)
.first() .first()
.should('contain.text', 'Notion account project 2'); .should('contain.text', 'Notion account project 2');
ndv.getters.backToCanvas().click(); ndv.getters.backToCanvas().click();
@ -407,7 +407,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowPage.getters.nodeCredentialsSelect().first().click(); workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect() getVisibleSelect()
.find('li') .find('li')
.should('have.length', 2) .should('have.length', 1)
.first() .first()
.should('contain.text', 'Notion account project 2'); .should('contain.text', 'Notion account project 2');
ndv.getters.backToCanvas().click(); ndv.getters.backToCanvas().click();
@ -425,7 +425,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowPage.getters.nodeCredentialsSelect().first().click(); workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect() getVisibleSelect()
.find('li') .find('li')
.should('have.length', 2) .should('have.length', 1)
.first() .first()
.should('contain.text', 'Notion account personal project'); .should('contain.text', 'Notion account personal project');
ndv.getters.backToCanvas().click(); ndv.getters.backToCanvas().click();
@ -436,7 +436,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowPage.getters.nodeCredentialsSelect().first().click(); workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect() getVisibleSelect()
.find('li') .find('li')
.should('have.length', 2) .should('have.length', 1)
.first() .first()
.should('contain.text', 'Notion account personal project'); .should('contain.text', 'Notion account personal project');
}); });

View file

@ -5,7 +5,6 @@ import { GMAIL_NODE_NAME, SCHEDULE_TRIGGER_NODE_NAME } from '../constants';
import { CredentialsModal, CredentialsPage, NDV, WorkflowPage } from '../pages'; import { CredentialsModal, CredentialsPage, NDV, WorkflowPage } from '../pages';
import { AIAssistant } from '../pages/features/ai-assistant'; import { AIAssistant } from '../pages/features/ai-assistant';
import { NodeCreator } from '../pages/features/node-creator'; import { NodeCreator } from '../pages/features/node-creator';
import { getVisibleSelect } from '../utils';
const wf = new WorkflowPage(); const wf = new WorkflowPage();
const ndv = new NDV(); const ndv = new NDV();
@ -434,7 +433,7 @@ describe('AI Assistant Credential Help', () => {
wf.actions.addNodeToCanvas('Slack', true, true, 'Get a channel'); wf.actions.addNodeToCanvas('Slack', true, true, 'Get a channel');
wf.getters.nodeCredentialsSelect().should('exist'); wf.getters.nodeCredentialsSelect().should('exist');
wf.getters.nodeCredentialsSelect().click(); wf.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click(); wf.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.credentialAuthTypeRadioButtons().first().click(); credentialsModal.getters.credentialAuthTypeRadioButtons().first().click();
ndv.getters.copyInput().should('not.exist'); ndv.getters.copyInput().should('not.exist');
credentialsModal.getters.oauthConnectButton().should('have.length', 1); credentialsModal.getters.oauthConnectButton().should('have.length', 1);
@ -467,7 +466,7 @@ describe('AI Assistant Credential Help', () => {
wf.actions.addNodeToCanvas('Microsoft Outlook', true, true, 'Get a calendar'); wf.actions.addNodeToCanvas('Microsoft Outlook', true, true, 'Get a calendar');
wf.getters.nodeCredentialsSelect().should('exist'); wf.getters.nodeCredentialsSelect().should('exist');
wf.getters.nodeCredentialsSelect().click(); wf.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click(); wf.getters.nodeCredentialsCreateOption().click();
ndv.getters.copyInput().should('not.exist'); ndv.getters.copyInput().should('not.exist');
credentialsModal.getters.oauthConnectButton().should('have.length', 1); credentialsModal.getters.oauthConnectButton().should('have.length', 1);
credentialsModal.getters.credentialInputs().should('have.length', 1); credentialsModal.getters.credentialInputs().should('have.length', 1);

View file

@ -73,7 +73,7 @@ docker run -it --rm \
-p 5678:5678 \ -p 5678:5678 \
-v ~/.n8n:/home/node/.n8n \ -v ~/.n8n:/home/node/.n8n \
docker.n8n.io/n8nio/n8n \ docker.n8n.io/n8nio/n8n \
n8n start --tunnel start --tunnel
``` ```
## Persist data ## Persist data

View file

@ -90,6 +90,7 @@
"ws": ">=8.17.1" "ws": ">=8.17.1"
}, },
"patchedDependencies": { "patchedDependencies": {
"bull@4.12.1": "patches/bull@4.12.1.patch",
"pkce-challenge@3.0.0": "patches/pkce-challenge@3.0.0.patch", "pkce-challenge@3.0.0": "patches/pkce-challenge@3.0.0.patch",
"pyodide@0.23.4": "patches/pyodide@0.23.4.patch", "pyodide@0.23.4": "patches/pyodide@0.23.4.patch",
"@types/express-serve-static-core@4.17.43": "patches/@types__express-serve-static-core@4.17.43.patch", "@types/express-serve-static-core@4.17.43": "patches/@types__express-serve-static-core@4.17.43.patch",

View file

@ -0,0 +1,178 @@
import type { ErrorEvent } from '@sentry/types';
import { mock } from 'jest-mock-extended';
import type { ErrorReporter } from 'n8n-core';
import { TaskRunnerSentry } from '../task-runner-sentry';
describe('TaskRunnerSentry', () => {
afterEach(() => {
jest.resetAllMocks();
});
describe('filterOutUserCodeErrors', () => {
const sentry = new TaskRunnerSentry(
{
dsn: 'https://sentry.io/123',
n8nVersion: '1.0.0',
environment: 'local',
deploymentName: 'test',
},
mock(),
);
it('should filter out user code errors', () => {
const event: ErrorEvent = {
type: undefined,
exception: {
values: [
{
type: 'ReferenceError',
value: 'fetch is not defined',
stacktrace: {
frames: [
{
filename: 'app:///dist/js-task-runner/js-task-runner.js',
module: 'js-task-runner:js-task-runner',
function: 'JsTaskRunner.executeTask',
},
{
filename: 'app:///dist/js-task-runner/js-task-runner.js',
module: 'js-task-runner:js-task-runner',
function: 'JsTaskRunner.runForAllItems',
},
{
filename: '<anonymous>',
module: '<anonymous>',
function: 'new Promise',
},
{
filename: 'app:///dist/js-task-runner/js-task-runner.js',
module: 'js-task-runner:js-task-runner',
function: 'result',
},
{
filename: 'node:vm',
module: 'node:vm',
function: 'runInContext',
},
{
filename: 'node:vm',
module: 'node:vm',
function: 'Script.runInContext',
},
{
filename: 'evalmachine.<anonymous>',
module: 'evalmachine.<anonymous>',
function: '?',
},
{
filename: 'evalmachine.<anonymous>',
module: 'evalmachine.<anonymous>',
function: 'VmCodeWrapper',
},
{
filename: '<anonymous>',
module: '<anonymous>',
function: 'new Promise',
},
{
filename: 'evalmachine.<anonymous>',
module: 'evalmachine.<anonymous>',
},
],
},
mechanism: { type: 'onunhandledrejection', handled: false },
},
],
},
event_id: '18bb78bb3d9d44c4acf3d774c2cfbfd8',
platform: 'node',
contexts: {
trace: { trace_id: '3c3614d33a6b47f09b85ec7d2710acea', span_id: 'ad00fdf6d6173aeb' },
runtime: { name: 'node', version: 'v20.17.0' },
},
};
expect(sentry.filterOutUserCodeErrors(event)).toBe(true);
});
});
describe('initIfEnabled', () => {
const mockErrorReporter = mock<ErrorReporter>();
it('should not configure sentry if dsn is not set', async () => {
const sentry = new TaskRunnerSentry(
{
dsn: '',
n8nVersion: '1.0.0',
environment: 'local',
deploymentName: 'test',
},
mockErrorReporter,
);
await sentry.initIfEnabled();
expect(mockErrorReporter.init).not.toHaveBeenCalled();
});
it('should configure sentry if dsn is set', async () => {
const sentry = new TaskRunnerSentry(
{
dsn: 'https://sentry.io/123',
n8nVersion: '1.0.0',
environment: 'local',
deploymentName: 'test',
},
mockErrorReporter,
);
await sentry.initIfEnabled();
expect(mockErrorReporter.init).toHaveBeenCalledWith({
dsn: 'https://sentry.io/123',
beforeSendFilter: sentry.filterOutUserCodeErrors,
release: '1.0.0',
environment: 'local',
serverName: 'test',
serverType: 'task_runner',
});
});
});
describe('shutdown', () => {
const mockErrorReporter = mock<ErrorReporter>();
it('should not shutdown sentry if dsn is not set', async () => {
const sentry = new TaskRunnerSentry(
{
dsn: '',
n8nVersion: '1.0.0',
environment: 'local',
deploymentName: 'test',
},
mockErrorReporter,
);
await sentry.shutdown();
expect(mockErrorReporter.shutdown).not.toHaveBeenCalled();
});
it('should shutdown sentry if dsn is set', async () => {
const sentry = new TaskRunnerSentry(
{
dsn: 'https://sentry.io/123',
n8nVersion: '1.0.0',
environment: 'local',
deploymentName: 'test',
},
mockErrorReporter,
);
await sentry.shutdown();
expect(mockErrorReporter.shutdown).toHaveBeenCalled();
});
});
});

View file

@ -1,16 +1,16 @@
import './polyfills'; import './polyfills';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import type { ErrorReporter } from 'n8n-core';
import { ensureError, setGlobalState } from 'n8n-workflow'; import { ensureError, setGlobalState } from 'n8n-workflow';
import { MainConfig } from './config/main-config'; import { MainConfig } from './config/main-config';
import type { HealthCheckServer } from './health-check-server'; import type { HealthCheckServer } from './health-check-server';
import { JsTaskRunner } from './js-task-runner/js-task-runner'; import { JsTaskRunner } from './js-task-runner/js-task-runner';
import { TaskRunnerSentry } from './task-runner-sentry';
let healthCheckServer: HealthCheckServer | undefined; let healthCheckServer: HealthCheckServer | undefined;
let runner: JsTaskRunner | undefined; let runner: JsTaskRunner | undefined;
let isShuttingDown = false; let isShuttingDown = false;
let errorReporter: ErrorReporter | undefined; let sentry: TaskRunnerSentry | undefined;
function createSignalHandler(signal: string, timeoutInS = 10) { function createSignalHandler(signal: string, timeoutInS = 10) {
return async function onSignal() { return async function onSignal() {
@ -33,9 +33,9 @@ function createSignalHandler(signal: string, timeoutInS = 10) {
void healthCheckServer?.stop(); void healthCheckServer?.stop();
} }
if (errorReporter) { if (sentry) {
await errorReporter.shutdown(); await sentry.shutdown();
errorReporter = undefined; sentry = undefined;
} }
} catch (e) { } catch (e) {
const error = ensureError(e); const error = ensureError(e);
@ -54,20 +54,8 @@ void (async function start() {
defaultTimezone: config.baseRunnerConfig.timezone, defaultTimezone: config.baseRunnerConfig.timezone,
}); });
const { dsn } = config.sentryConfig; sentry = Container.get(TaskRunnerSentry);
await sentry.initIfEnabled();
if (dsn) {
const { ErrorReporter } = await import('n8n-core');
errorReporter = Container.get(ErrorReporter);
const { deploymentName, environment, n8nVersion } = config.sentryConfig;
await errorReporter.init({
serverType: 'task_runner',
dsn,
serverName: deploymentName,
environment,
release: n8nVersion,
});
}
runner = new JsTaskRunner(config); runner = new JsTaskRunner(config);
runner.on('runner:reached-idle-timeout', () => { runner.on('runner:reached-idle-timeout', () => {

View file

@ -0,0 +1,62 @@
import { Service } from '@n8n/di';
import type { ErrorEvent, Exception } from '@sentry/types';
import { ErrorReporter } from 'n8n-core';
import { SentryConfig } from './config/sentry-config';
/**
* Sentry service for the task runner.
*/
@Service()
export class TaskRunnerSentry {
constructor(
private readonly config: SentryConfig,
private readonly errorReporter: ErrorReporter,
) {}
async initIfEnabled() {
const { dsn, n8nVersion, environment, deploymentName } = this.config;
if (!dsn) return;
await this.errorReporter.init({
serverType: 'task_runner',
dsn,
release: n8nVersion,
environment,
serverName: deploymentName,
beforeSendFilter: this.filterOutUserCodeErrors,
});
}
async shutdown() {
if (!this.config.dsn) return;
await this.errorReporter.shutdown();
}
/**
* Filter out errors originating from user provided code.
* It is possible for users to create code that causes unhandledrejections
* that end up in the sentry error reporting.
*/
filterOutUserCodeErrors = (event: ErrorEvent) => {
const error = event?.exception?.values?.[0];
return error ? this.isUserCodeError(error) : false;
};
/**
* Check if the error is originating from user provided code.
* It is possible for users to create code that causes unhandledrejections
* that end up in the sentry error reporting.
*/
private isUserCodeError(error: Exception) {
const frames = error.stacktrace?.frames;
if (!frames) return false;
return frames.some(
(frame) => frame.filename === 'node:vm' && frame.function === 'runInContext',
);
}
}

View file

@ -349,15 +349,6 @@ export const schema = {
}, },
}, },
sourceControl: {
defaultKeyPairType: {
doc: 'Default SSH key type to use when generating SSH keys',
format: ['rsa', 'ed25519'] as const,
default: 'ed25519',
env: 'N8N_SOURCECONTROL_DEFAULT_SSH_KEY_TYPE',
},
},
workflowHistory: { workflowHistory: {
enabled: { enabled: {
doc: 'Whether to save workflow history versions', doc: 'Whether to save workflow history versions',

View file

@ -1,56 +1,46 @@
import type { SourceControlledFile } from '@n8n/api-types'; import type { SourceControlledFile } from '@n8n/api-types';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import mock from 'jest-mock-extended/lib/Mock'; import { mock, captor } from 'jest-mock-extended';
import { Cipher, type InstanceSettings } from 'n8n-core'; import { Cipher, type InstanceSettings } from 'n8n-core';
import { ApplicationError, deepCopy } from 'n8n-workflow'; import fsp from 'node:fs/promises';
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
import type { SharedCredentials } from '@/databases/entities/shared-credentials'; import type { SharedCredentials } from '@/databases/entities/shared-credentials';
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; import type { SharedWorkflow } from '@/databases/entities/shared-workflow';
import { mockInstance } from '@test/mocking'; import type { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
import type { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
import type { TagRepository } from '@/databases/repositories/tag.repository';
import type { WorkflowTagMappingRepository } from '@/databases/repositories/workflow-tag-mapping.repository';
import type { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import type { VariablesService } from '../../variables/variables.service.ee';
import { SourceControlExportService } from '../source-control-export.service.ee'; import { SourceControlExportService } from '../source-control-export.service.ee';
// https://github.com/jestjs/jest/issues/4715
function deepSpyOn<O extends object, M extends keyof O>(object: O, methodName: M) {
const spy = jest.fn();
const originalMethod = object[methodName];
if (typeof originalMethod !== 'function') {
throw new ApplicationError(`${methodName.toString()} is not a function`, { level: 'warning' });
}
object[methodName] = function (...args: unknown[]) {
const clonedArgs = deepCopy(args);
spy(...clonedArgs);
return originalMethod.apply(this, args);
} as O[M];
return spy;
}
describe('SourceControlExportService', () => { describe('SourceControlExportService', () => {
const cipher = Container.get(Cipher);
const sharedCredentialsRepository = mock<SharedCredentialsRepository>();
const sharedWorkflowRepository = mock<SharedWorkflowRepository>();
const workflowRepository = mock<WorkflowRepository>();
const tagRepository = mock<TagRepository>();
const workflowTagMappingRepository = mock<WorkflowTagMappingRepository>();
const variablesService = mock<VariablesService>();
const service = new SourceControlExportService( const service = new SourceControlExportService(
mock(), mock(),
mock(), variablesService,
mock(), tagRepository,
mock<InstanceSettings>({ n8nFolder: '' }), sharedCredentialsRepository,
sharedWorkflowRepository,
workflowRepository,
workflowTagMappingRepository,
mock<InstanceSettings>({ n8nFolder: '/mock/n8n' }),
); );
describe('exportCredentialsToWorkFolder', () => { const fsWriteFile = jest.spyOn(fsp, 'writeFile');
it('should export credentials to work folder', async () => {
/**
* Arrange
*/
// @ts-expect-error Private method
const replaceSpy = deepSpyOn(service, 'replaceCredentialData');
mockInstance(SharedCredentialsRepository).findByCredentialIds.mockResolvedValue([ beforeEach(() => jest.clearAllMocks());
mock<SharedCredentials>({
credentials: mock<CredentialsEntity>({ describe('exportCredentialsToWorkFolder', () => {
data: Container.get(Cipher).encrypt( const credentialData = {
JSON.stringify({
authUrl: 'test', authUrl: 'test',
accessTokenUrl: 'test', accessTokenUrl: 'test',
clientId: 'test', clientId: 'test',
@ -61,26 +51,211 @@ describe('SourceControlExportService', () => {
expires_in: 123, expires_in: 123,
refresh_token: 'test', refresh_token: 'test',
}, },
}), };
),
const mockCredentials = mock({
id: 'cred1',
name: 'Test Credential',
type: 'oauth2',
data: cipher.encrypt(credentialData),
});
it('should export credentials to work folder', async () => {
sharedCredentialsRepository.findByCredentialIds.mockResolvedValue([
mock<SharedCredentials>({
credentials: mockCredentials,
project: mock({
type: 'personal',
projectRelations: [
{
role: 'project:personalOwner',
user: mock({ email: 'user@example.com' }),
},
],
}), }),
}), }),
]); ]);
/** // Act
* Act const result = await service.exportCredentialsToWorkFolder([mock()]);
*/
await service.exportCredentialsToWorkFolder([mock<SourceControlledFile>()]);
/** // Assert
* Assert expect(result.count).toBe(1);
*/ expect(result.files).toHaveLength(1);
expect(replaceSpy).toHaveBeenCalledWith({
authUrl: 'test', const dataCaptor = captor<string>();
accessTokenUrl: 'test', expect(fsWriteFile).toHaveBeenCalledWith(
clientId: 'test', '/mock/n8n/git/credential_stubs/cred1.json',
clientSecret: 'test', dataCaptor,
}); );
expect(JSON.parse(dataCaptor.value)).toEqual({
id: 'cred1',
name: 'Test Credential',
type: 'oauth2',
data: {
authUrl: '',
accessTokenUrl: '',
clientId: '',
clientSecret: '',
},
ownedBy: {
type: 'personal',
personalEmail: 'user@example.com',
},
});
});
it('should handle team project credentials', async () => {
sharedCredentialsRepository.findByCredentialIds.mockResolvedValue([
mock<SharedCredentials>({
credentials: mockCredentials,
project: mock({
type: 'team',
id: 'team1',
name: 'Test Team',
}),
}),
]);
// Act
const result = await service.exportCredentialsToWorkFolder([
mock<SourceControlledFile>({ id: 'cred1' }),
]);
// Assert
expect(result.count).toBe(1);
const dataCaptor = captor<string>();
expect(fsWriteFile).toHaveBeenCalledWith(
'/mock/n8n/git/credential_stubs/cred1.json',
dataCaptor,
);
expect(JSON.parse(dataCaptor.value)).toEqual({
id: 'cred1',
name: 'Test Credential',
type: 'oauth2',
data: {
authUrl: '',
accessTokenUrl: '',
clientId: '',
clientSecret: '',
},
ownedBy: {
type: 'team',
teamId: 'team1',
teamName: 'Test Team',
},
});
});
it('should handle missing credentials', async () => {
// Arrange
sharedCredentialsRepository.findByCredentialIds.mockResolvedValue([]);
// Act
const result = await service.exportCredentialsToWorkFolder([
mock<SourceControlledFile>({ id: 'cred1' }),
]);
// Assert
expect(result.missingIds).toHaveLength(1);
expect(result.missingIds?.[0]).toBe('cred1');
});
});
describe('exportTagsToWorkFolder', () => {
it('should export tags to work folder', async () => {
// Arrange
tagRepository.find.mockResolvedValue([mock()]);
workflowTagMappingRepository.find.mockResolvedValue([mock()]);
// Act
const result = await service.exportTagsToWorkFolder();
// Assert
expect(result.count).toBe(1);
expect(result.files).toHaveLength(1);
});
it('should not export empty tags', async () => {
// Arrange
tagRepository.find.mockResolvedValue([]);
// Act
const result = await service.exportTagsToWorkFolder();
// Assert
expect(result.count).toBe(0);
expect(result.files).toHaveLength(0);
});
});
describe('exportVariablesToWorkFolder', () => {
it('should export variables to work folder', async () => {
// Arrange
variablesService.getAllCached.mockResolvedValue([mock()]);
// Act
const result = await service.exportVariablesToWorkFolder();
// Assert
expect(result.count).toBe(1);
expect(result.files).toHaveLength(1);
});
it('should not export empty variables', async () => {
// Arrange
variablesService.getAllCached.mockResolvedValue([]);
// Act
const result = await service.exportVariablesToWorkFolder();
// Assert
expect(result.count).toBe(0);
expect(result.files).toHaveLength(0);
});
});
describe('exportWorkflowsToWorkFolder', () => {
it('should export workflows to work folder', async () => {
// Arrange
workflowRepository.findByIds.mockResolvedValue([mock()]);
sharedWorkflowRepository.findByWorkflowIds.mockResolvedValue([
mock<SharedWorkflow>({
project: mock({
type: 'personal',
projectRelations: [{ role: 'project:personalOwner', user: mock() }],
}),
workflow: mock(),
}),
]);
// Act
const result = await service.exportWorkflowsToWorkFolder([mock()]);
// Assert
expect(result.count).toBe(1);
expect(result.files).toHaveLength(1);
});
it('should throw an error if workflow has no owner', async () => {
// Arrange
sharedWorkflowRepository.findByWorkflowIds.mockResolvedValue([
mock<SharedWorkflow>({
project: mock({
type: 'personal',
projectRelations: [],
}),
workflow: mock({
display: () => 'TestWorkflow',
}),
}),
]);
// Act & Assert
await expect(service.exportWorkflowsToWorkFolder([mock()])).rejects.toThrow(
'Workflow TestWorkflow has no owner',
);
}); });
}); });
}); });

View file

@ -1,6 +1,7 @@
import type { SourceControlledFile } from '@n8n/api-types'; import type { SourceControlledFile } from '@n8n/api-types';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { constants as fsConstants, accessSync } from 'fs'; import { constants as fsConstants, accessSync } from 'fs';
import { mock } from 'jest-mock-extended';
import { InstanceSettings } from 'n8n-core'; import { InstanceSettings } from 'n8n-core';
import path from 'path'; import path from 'path';
@ -16,10 +17,8 @@ import {
getTrackingInformationFromPullResult, getTrackingInformationFromPullResult,
sourceControlFoldersExistCheck, sourceControlFoldersExistCheck,
} from '@/environments.ee/source-control/source-control-helper.ee'; } from '@/environments.ee/source-control/source-control-helper.ee';
import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee'; import type { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee';
import type { SourceControlPreferences } from '@/environments.ee/source-control/types/source-control-preferences'; import type { License } from '@/license';
import { License } from '@/license';
import { mockInstance } from '@test/mocking';
const pushResult: SourceControlledFile[] = [ const pushResult: SourceControlledFile[] = [
{ {
@ -151,12 +150,13 @@ const pullResult: SourceControlledFile[] = [
}, },
]; ];
const license = mockInstance(License); const license = mock<License>();
const sourceControlPreferencesService = mock<SourceControlPreferencesService>();
beforeAll(async () => { beforeAll(async () => {
jest.resetAllMocks(); jest.resetAllMocks();
license.isSourceControlLicensed.mockReturnValue(true); license.isSourceControlLicensed.mockReturnValue(true);
Container.get(SourceControlPreferencesService).getPreferences = () => ({ sourceControlPreferencesService.getPreferences.mockReturnValue({
branchName: 'main', branchName: 'main',
connected: true, connected: true,
repositoryUrl: 'git@example.com:n8ntest/n8n_testrepo.git', repositoryUrl: 'git@example.com:n8ntest/n8n_testrepo.git',
@ -245,17 +245,4 @@ describe('Source Control', () => {
workflowUpdates: 3, workflowUpdates: 3,
}); });
}); });
it('should class validate correct preferences', async () => {
const validPreferences: Partial<SourceControlPreferences> = {
branchName: 'main',
repositoryUrl: 'git@example.com:n8ntest/n8n_testrepo.git',
branchReadOnly: false,
branchColor: '#5296D6',
};
const validationResult = await Container.get(
SourceControlPreferencesService,
).validateSourceControlPreferences(validPreferences);
expect(validationResult).toBeTruthy();
});
}); });

View file

@ -0,0 +1,180 @@
import * as fastGlob from 'fast-glob';
import { mock } from 'jest-mock-extended';
import { type InstanceSettings } from 'n8n-core';
import fsp from 'node:fs/promises';
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
import type { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import { SourceControlImportService } from '../source-control-import.service.ee';
jest.mock('fast-glob');
describe('SourceControlImportService', () => {
const workflowRepository = mock<WorkflowRepository>();
const service = new SourceControlImportService(
mock(),
mock(),
mock(),
mock(),
mock(),
mock(),
mock(),
mock(),
mock(),
mock(),
mock(),
workflowRepository,
mock(),
mock<InstanceSettings>({ n8nFolder: '/mock/n8n' }),
);
const globMock = fastGlob.default as unknown as jest.Mock<Promise<string[]>, string[]>;
const fsReadFile = jest.spyOn(fsp, 'readFile');
beforeEach(() => jest.clearAllMocks());
describe('getRemoteVersionIdsFromFiles', () => {
const mockWorkflowFile = '/mock/workflow1.json';
it('should parse workflow files correctly', async () => {
globMock.mockResolvedValue([mockWorkflowFile]);
const mockWorkflowData = {
id: 'workflow1',
versionId: 'v1',
name: 'Test Workflow',
};
fsReadFile.mockResolvedValue(JSON.stringify(mockWorkflowData));
const result = await service.getRemoteVersionIdsFromFiles();
expect(fsReadFile).toHaveBeenCalledWith(mockWorkflowFile, { encoding: 'utf8' });
expect(result).toHaveLength(1);
expect(result[0]).toEqual(
expect.objectContaining({
id: 'workflow1',
versionId: 'v1',
name: 'Test Workflow',
}),
);
});
it('should filter out files without valid workflow data', async () => {
globMock.mockResolvedValue(['/mock/invalid.json']);
fsReadFile.mockResolvedValue('{}');
const result = await service.getRemoteVersionIdsFromFiles();
expect(result).toHaveLength(0);
});
});
describe('getRemoteCredentialsFromFiles', () => {
it('should parse credential files correctly', async () => {
globMock.mockResolvedValue(['/mock/credential1.json']);
const mockCredentialData = {
id: 'cred1',
name: 'Test Credential',
type: 'oauth2',
};
fsReadFile.mockResolvedValue(JSON.stringify(mockCredentialData));
const result = await service.getRemoteCredentialsFromFiles();
expect(result).toHaveLength(1);
expect(result[0]).toEqual(
expect.objectContaining({
id: 'cred1',
name: 'Test Credential',
type: 'oauth2',
}),
);
});
it('should filter out files without valid credential data', async () => {
globMock.mockResolvedValue(['/mock/invalid.json']);
fsReadFile.mockResolvedValue('{}');
const result = await service.getRemoteCredentialsFromFiles();
expect(result).toHaveLength(0);
});
});
describe('getRemoteVariablesFromFile', () => {
it('should parse variables file correctly', async () => {
globMock.mockResolvedValue(['/mock/variables.json']);
const mockVariablesData = [
{ key: 'VAR1', value: 'value1' },
{ key: 'VAR2', value: 'value2' },
];
fsReadFile.mockResolvedValue(JSON.stringify(mockVariablesData));
const result = await service.getRemoteVariablesFromFile();
expect(result).toEqual(mockVariablesData);
});
it('should return empty array if no variables file found', async () => {
globMock.mockResolvedValue([]);
const result = await service.getRemoteVariablesFromFile();
expect(result).toHaveLength(0);
});
});
describe('getRemoteTagsAndMappingsFromFile', () => {
it('should parse tags and mappings file correctly', async () => {
globMock.mockResolvedValue(['/mock/tags.json']);
const mockTagsData = {
tags: [{ id: 'tag1', name: 'Tag 1' }],
mappings: [{ workflowId: 'workflow1', tagId: 'tag1' }],
};
fsReadFile.mockResolvedValue(JSON.stringify(mockTagsData));
const result = await service.getRemoteTagsAndMappingsFromFile();
expect(result.tags).toEqual(mockTagsData.tags);
expect(result.mappings).toEqual(mockTagsData.mappings);
});
it('should return empty tags and mappings if no file found', async () => {
globMock.mockResolvedValue([]);
const result = await service.getRemoteTagsAndMappingsFromFile();
expect(result.tags).toHaveLength(0);
expect(result.mappings).toHaveLength(0);
});
});
describe('getLocalVersionIdsFromDb', () => {
const now = new Date();
jest.useFakeTimers({ now });
it('should replace invalid updatedAt with current timestamp', async () => {
const mockWorkflows = [
{
id: 'workflow1',
name: 'Test Workflow',
updatedAt: 'invalid-date',
},
] as unknown as WorkflowEntity[];
workflowRepository.find.mockResolvedValue(mockWorkflows);
const result = await service.getLocalVersionIdsFromDb();
expect(result[0].updatedAt).toBe(now.toISOString());
});
});
});

View file

@ -0,0 +1,27 @@
import { mock } from 'jest-mock-extended';
import type { InstanceSettings } from 'n8n-core';
import { SourceControlPreferencesService } from '../source-control-preferences.service.ee';
import type { SourceControlPreferences } from '../types/source-control-preferences';
describe('SourceControlPreferencesService', () => {
const instanceSettings = mock<InstanceSettings>({ n8nFolder: '' });
const service = new SourceControlPreferencesService(
instanceSettings,
mock(),
mock(),
mock(),
mock(),
);
it('should class validate correct preferences', async () => {
const validPreferences: Partial<SourceControlPreferences> = {
branchName: 'main',
repositoryUrl: 'git@example.com:n8ntest/n8n_testrepo.git',
branchReadOnly: false,
branchColor: '#5296D6',
};
const validationResult = await service.validateSourceControlPreferences(validPreferences);
expect(validationResult).toBeTruthy();
});
});

View file

@ -10,6 +10,8 @@ describe('SourceControlService', () => {
Container.get(InstanceSettings), Container.get(InstanceSettings),
mock(), mock(),
mock(), mock(),
mock(),
mock(),
); );
const sourceControlService = new SourceControlService( const sourceControlService = new SourceControlService(
mock(), mock(),

View file

@ -1,5 +1,5 @@
import type { SourceControlledFile } from '@n8n/api-types'; import type { SourceControlledFile } from '@n8n/api-types';
import { Container, Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { rmSync } from 'fs'; import { rmSync } from 'fs';
import { Credentials, InstanceSettings, Logger } from 'n8n-core'; import { Credentials, InstanceSettings, Logger } from 'n8n-core';
import { ApplicationError, type ICredentialDataDecryptedObject } from 'n8n-workflow'; import { ApplicationError, type ICredentialDataDecryptedObject } from 'n8n-workflow';
@ -44,6 +44,10 @@ export class SourceControlExportService {
private readonly logger: Logger, private readonly logger: Logger,
private readonly variablesService: VariablesService, private readonly variablesService: VariablesService,
private readonly tagRepository: TagRepository, private readonly tagRepository: TagRepository,
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly workflowRepository: WorkflowRepository,
private readonly workflowTagMappingRepository: WorkflowTagMappingRepository,
instanceSettings: InstanceSettings, instanceSettings: InstanceSettings,
) { ) {
this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER); this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER);
@ -106,17 +110,16 @@ export class SourceControlExportService {
try { try {
sourceControlFoldersExistCheck([this.workflowExportFolder]); sourceControlFoldersExistCheck([this.workflowExportFolder]);
const workflowIds = candidates.map((e) => e.id); const workflowIds = candidates.map((e) => e.id);
const sharedWorkflows = const sharedWorkflows = await this.sharedWorkflowRepository.findByWorkflowIds(workflowIds);
await Container.get(SharedWorkflowRepository).findByWorkflowIds(workflowIds); const workflows = await this.workflowRepository.findByIds(workflowIds);
const workflows = await Container.get(WorkflowRepository).findByIds(workflowIds);
// determine owner of each workflow to be exported // determine owner of each workflow to be exported
const owners: Record<string, ResourceOwner> = {}; const owners: Record<string, ResourceOwner> = {};
sharedWorkflows.forEach((e) => { sharedWorkflows.forEach((sharedWorkflow) => {
const project = e.project; const project = sharedWorkflow.project;
if (!project) { if (!project) {
throw new ApplicationError(`Workflow ${e.workflow.display()} has no owner`); throw new ApplicationError(`Workflow ${sharedWorkflow.workflow.display()} has no owner`);
} }
if (project.type === 'personal') { if (project.type === 'personal') {
@ -124,14 +127,16 @@ export class SourceControlExportService {
(pr) => pr.role === 'project:personalOwner', (pr) => pr.role === 'project:personalOwner',
); );
if (!ownerRelation) { if (!ownerRelation) {
throw new ApplicationError(`Workflow ${e.workflow.display()} has no owner`); throw new ApplicationError(
`Workflow ${sharedWorkflow.workflow.display()} has no owner`,
);
} }
owners[e.workflowId] = { owners[sharedWorkflow.workflowId] = {
type: 'personal', type: 'personal',
personalEmail: ownerRelation.user.email, personalEmail: ownerRelation.user.email,
}; };
} else if (project.type === 'team') { } else if (project.type === 'team') {
owners[e.workflowId] = { owners[sharedWorkflow.workflowId] = {
type: 'team', type: 'team',
teamId: project.id, teamId: project.id,
teamName: project.name, teamName: project.name,
@ -156,6 +161,7 @@ export class SourceControlExportService {
})), })),
}; };
} catch (error) { } catch (error) {
if (error instanceof ApplicationError) throw error;
throw new ApplicationError('Failed to export workflows to work folder', { cause: error }); throw new ApplicationError('Failed to export workflows to work folder', { cause: error });
} }
} }
@ -204,7 +210,7 @@ export class SourceControlExportService {
files: [], files: [],
}; };
} }
const mappings = await Container.get(WorkflowTagMappingRepository).find(); const mappings = await this.workflowTagMappingRepository.find();
const fileName = path.join(this.gitFolder, SOURCE_CONTROL_TAGS_EXPORT_FILE); const fileName = path.join(this.gitFolder, SOURCE_CONTROL_TAGS_EXPORT_FILE);
await fsWriteFile( await fsWriteFile(
fileName, fileName,
@ -260,9 +266,10 @@ export class SourceControlExportService {
try { try {
sourceControlFoldersExistCheck([this.credentialExportFolder]); sourceControlFoldersExistCheck([this.credentialExportFolder]);
const credentialIds = candidates.map((e) => e.id); const credentialIds = candidates.map((e) => e.id);
const credentialsToBeExported = await Container.get( const credentialsToBeExported = await this.sharedCredentialsRepository.findByCredentialIds(
SharedCredentialsRepository, credentialIds,
).findByCredentialIds(credentialIds, 'credential:owner'); 'credential:owner',
);
let missingIds: string[] = []; let missingIds: string[] = [];
if (credentialsToBeExported.length !== credentialIds.length) { if (credentialsToBeExported.length !== credentialIds.length) {
const foundCredentialIds = credentialsToBeExported.map((e) => e.credentialsId); const foundCredentialIds = credentialsToBeExported.map((e) => e.credentialsId);

View file

@ -1,5 +1,5 @@
import type { SourceControlledFile } from '@n8n/api-types'; import type { SourceControlledFile } from '@n8n/api-types';
import { Container, Service } from '@n8n/di'; import { Service } from '@n8n/di';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm'; import { In } from '@n8n/typeorm';
import glob from 'fast-glob'; import glob from 'fast-glob';
@ -53,7 +53,15 @@ export class SourceControlImportService {
private readonly errorReporter: ErrorReporter, private readonly errorReporter: ErrorReporter,
private readonly variablesService: VariablesService, private readonly variablesService: VariablesService,
private readonly activeWorkflowManager: ActiveWorkflowManager, private readonly activeWorkflowManager: ActiveWorkflowManager,
private readonly credentialsRepository: CredentialsRepository,
private readonly projectRepository: ProjectRepository,
private readonly tagRepository: TagRepository, private readonly tagRepository: TagRepository,
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly userRepository: UserRepository,
private readonly variablesRepository: VariablesRepository,
private readonly workflowRepository: WorkflowRepository,
private readonly workflowTagMappingRepository: WorkflowTagMappingRepository,
instanceSettings: InstanceSettings, instanceSettings: InstanceSettings,
) { ) {
this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER); this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER);
@ -91,7 +99,7 @@ export class SourceControlImportService {
} }
async getLocalVersionIdsFromDb(): Promise<SourceControlWorkflowVersionId[]> { async getLocalVersionIdsFromDb(): Promise<SourceControlWorkflowVersionId[]> {
const localWorkflows = await Container.get(WorkflowRepository).find({ const localWorkflows = await this.workflowRepository.find({
select: ['id', 'name', 'versionId', 'updatedAt'], select: ['id', 'name', 'versionId', 'updatedAt'],
}); });
return localWorkflows.map((local) => { return localWorkflows.map((local) => {
@ -146,7 +154,7 @@ export class SourceControlImportService {
} }
async getLocalCredentialsFromDb(): Promise<Array<ExportableCredential & { filename: string }>> { async getLocalCredentialsFromDb(): Promise<Array<ExportableCredential & { filename: string }>> {
const localCredentials = await Container.get(CredentialsRepository).find({ const localCredentials = await this.credentialsRepository.find({
select: ['id', 'name', 'type'], select: ['id', 'name', 'type'],
}); });
return localCredentials.map((local) => ({ return localCredentials.map((local) => ({
@ -201,24 +209,22 @@ export class SourceControlImportService {
const localTags = await this.tagRepository.find({ const localTags = await this.tagRepository.find({
select: ['id', 'name'], select: ['id', 'name'],
}); });
const localMappings = await Container.get(WorkflowTagMappingRepository).find({ const localMappings = await this.workflowTagMappingRepository.find({
select: ['workflowId', 'tagId'], select: ['workflowId', 'tagId'],
}); });
return { tags: localTags, mappings: localMappings }; return { tags: localTags, mappings: localMappings };
} }
async importWorkflowFromWorkFolder(candidates: SourceControlledFile[], userId: string) { async importWorkflowFromWorkFolder(candidates: SourceControlledFile[], userId: string) {
const personalProject = const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail(userId);
await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(userId);
const workflowManager = this.activeWorkflowManager; const workflowManager = this.activeWorkflowManager;
const candidateIds = candidates.map((c) => c.id); const candidateIds = candidates.map((c) => c.id);
const existingWorkflows = await Container.get(WorkflowRepository).findByIds(candidateIds, { const existingWorkflows = await this.workflowRepository.findByIds(candidateIds, {
fields: ['id', 'name', 'versionId', 'active'], fields: ['id', 'name', 'versionId', 'active'],
}); });
const allSharedWorkflows = await Container.get(SharedWorkflowRepository).findWithFields( const allSharedWorkflows = await this.sharedWorkflowRepository.findWithFields(candidateIds, {
candidateIds, select: ['workflowId', 'role', 'projectId'],
{ select: ['workflowId', 'role', 'projectId'] }, });
);
const importWorkflowsResult = []; const importWorkflowsResult = [];
// Due to SQLite concurrency issues, we cannot save all workflows at once // Due to SQLite concurrency issues, we cannot save all workflows at once
@ -235,9 +241,7 @@ export class SourceControlImportService {
const existingWorkflow = existingWorkflows.find((e) => e.id === importedWorkflow.id); const existingWorkflow = existingWorkflows.find((e) => e.id === importedWorkflow.id);
importedWorkflow.active = existingWorkflow?.active ?? false; importedWorkflow.active = existingWorkflow?.active ?? false;
this.logger.debug(`Updating workflow id ${importedWorkflow.id ?? 'new'}`); this.logger.debug(`Updating workflow id ${importedWorkflow.id ?? 'new'}`);
const upsertResult = await Container.get(WorkflowRepository).upsert({ ...importedWorkflow }, [ const upsertResult = await this.workflowRepository.upsert({ ...importedWorkflow }, ['id']);
'id',
]);
if (upsertResult?.identifiers?.length !== 1) { if (upsertResult?.identifiers?.length !== 1) {
throw new ApplicationError('Failed to upsert workflow', { throw new ApplicationError('Failed to upsert workflow', {
extra: { workflowId: importedWorkflow.id ?? 'new' }, extra: { workflowId: importedWorkflow.id ?? 'new' },
@ -253,7 +257,7 @@ export class SourceControlImportService {
? await this.findOrCreateOwnerProject(importedWorkflow.owner) ? await this.findOrCreateOwnerProject(importedWorkflow.owner)
: null; : null;
await Container.get(SharedWorkflowRepository).upsert( await this.sharedWorkflowRepository.upsert(
{ {
workflowId: importedWorkflow.id, workflowId: importedWorkflow.id,
projectId: remoteOwnerProject?.id ?? personalProject.id, projectId: remoteOwnerProject?.id ?? personalProject.id,
@ -276,7 +280,7 @@ export class SourceControlImportService {
const error = ensureError(e); const error = ensureError(e);
this.logger.error(`Failed to activate workflow ${existingWorkflow.id}`, { error }); this.logger.error(`Failed to activate workflow ${existingWorkflow.id}`, { error });
} finally { } finally {
await Container.get(WorkflowRepository).update( await this.workflowRepository.update(
{ id: existingWorkflow.id }, { id: existingWorkflow.id },
{ versionId: importedWorkflow.versionId }, { versionId: importedWorkflow.versionId },
); );
@ -295,16 +299,15 @@ export class SourceControlImportService {
} }
async importCredentialsFromWorkFolder(candidates: SourceControlledFile[], userId: string) { async importCredentialsFromWorkFolder(candidates: SourceControlledFile[], userId: string) {
const personalProject = const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail(userId);
await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(userId);
const candidateIds = candidates.map((c) => c.id); const candidateIds = candidates.map((c) => c.id);
const existingCredentials = await Container.get(CredentialsRepository).find({ const existingCredentials = await this.credentialsRepository.find({
where: { where: {
id: In(candidateIds), id: In(candidateIds),
}, },
select: ['id', 'name', 'type', 'data'], select: ['id', 'name', 'type', 'data'],
}); });
const existingSharedCredentials = await Container.get(SharedCredentialsRepository).find({ const existingSharedCredentials = await this.sharedCredentialsRepository.find({
select: ['credentialsId', 'role'], select: ['credentialsId', 'role'],
where: { where: {
credentialsId: In(candidateIds), credentialsId: In(candidateIds),
@ -336,7 +339,7 @@ export class SourceControlImportService {
} }
this.logger.debug(`Updating credential id ${newCredentialObject.id as string}`); this.logger.debug(`Updating credential id ${newCredentialObject.id as string}`);
await Container.get(CredentialsRepository).upsert(newCredentialObject, ['id']); await this.credentialsRepository.upsert(newCredentialObject, ['id']);
const isOwnedLocally = existingSharedCredentials.some( const isOwnedLocally = existingSharedCredentials.some(
(c) => c.credentialsId === credential.id && c.role === 'credential:owner', (c) => c.credentialsId === credential.id && c.role === 'credential:owner',
@ -352,7 +355,7 @@ export class SourceControlImportService {
newSharedCredential.projectId = remoteOwnerProject?.id ?? personalProject.id; newSharedCredential.projectId = remoteOwnerProject?.id ?? personalProject.id;
newSharedCredential.role = 'credential:owner'; newSharedCredential.role = 'credential:owner';
await Container.get(SharedCredentialsRepository).upsert({ ...newSharedCredential }, [ await this.sharedCredentialsRepository.upsert({ ...newSharedCredential }, [
'credentialsId', 'credentialsId',
'projectId', 'projectId',
]); ]);
@ -388,7 +391,7 @@ export class SourceControlImportService {
const existingWorkflowIds = new Set( const existingWorkflowIds = new Set(
( (
await Container.get(WorkflowRepository).find({ await this.workflowRepository.find({
select: ['id'], select: ['id'],
}) })
).map((e) => e.id), ).map((e) => e.id),
@ -417,7 +420,7 @@ export class SourceControlImportService {
await Promise.all( await Promise.all(
mappedTags.mappings.map(async (mapping) => { mappedTags.mappings.map(async (mapping) => {
if (!existingWorkflowIds.has(String(mapping.workflowId))) return; if (!existingWorkflowIds.has(String(mapping.workflowId))) return;
await Container.get(WorkflowTagMappingRepository).upsert( await this.workflowTagMappingRepository.upsert(
{ tagId: String(mapping.tagId), workflowId: String(mapping.workflowId) }, { tagId: String(mapping.tagId), workflowId: String(mapping.workflowId) },
{ {
skipUpdateIfNoValuesChanged: true, skipUpdateIfNoValuesChanged: true,
@ -464,12 +467,12 @@ export class SourceControlImportService {
overriddenKeys.splice(overriddenKeys.indexOf(variable.key), 1); overriddenKeys.splice(overriddenKeys.indexOf(variable.key), 1);
} }
try { try {
await Container.get(VariablesRepository).upsert({ ...variable }, ['id']); await this.variablesRepository.upsert({ ...variable }, ['id']);
} catch (errorUpsert) { } catch (errorUpsert) {
if (isUniqueConstraintError(errorUpsert as Error)) { if (isUniqueConstraintError(errorUpsert as Error)) {
this.logger.debug(`Variable ${variable.key} already exists, updating instead`); this.logger.debug(`Variable ${variable.key} already exists, updating instead`);
try { try {
await Container.get(VariablesRepository).update({ key: variable.key }, { ...variable }); await this.variablesRepository.update({ key: variable.key }, { ...variable });
} catch (errorUpdate) { } catch (errorUpdate) {
this.logger.debug(`Failed to update variable ${variable.key}, skipping`); this.logger.debug(`Failed to update variable ${variable.key}, skipping`);
this.logger.debug((errorUpdate as Error).message); this.logger.debug((errorUpdate as Error).message);
@ -484,11 +487,11 @@ export class SourceControlImportService {
if (overriddenKeys.length > 0 && valueOverrides) { if (overriddenKeys.length > 0 && valueOverrides) {
for (const key of overriddenKeys) { for (const key of overriddenKeys) {
result.imported.push(key); result.imported.push(key);
const newVariable = Container.get(VariablesRepository).create({ const newVariable = this.variablesRepository.create({
key, key,
value: valueOverrides[key], value: valueOverrides[key],
}); });
await Container.get(VariablesRepository).save(newVariable, { transaction: false }); await this.variablesRepository.save(newVariable, { transaction: false });
} }
} }
@ -498,32 +501,30 @@ export class SourceControlImportService {
} }
private async findOrCreateOwnerProject(owner: ResourceOwner): Promise<Project | null> { private async findOrCreateOwnerProject(owner: ResourceOwner): Promise<Project | null> {
const projectRepository = Container.get(ProjectRepository);
const userRepository = Container.get(UserRepository);
if (typeof owner === 'string' || owner.type === 'personal') { if (typeof owner === 'string' || owner.type === 'personal') {
const email = typeof owner === 'string' ? owner : owner.personalEmail; const email = typeof owner === 'string' ? owner : owner.personalEmail;
const user = await userRepository.findOne({ const user = await this.userRepository.findOne({
where: { email }, where: { email },
}); });
if (!user) { if (!user) {
return null; return null;
} }
return await projectRepository.getPersonalProjectForUserOrFail(user.id); return await this.projectRepository.getPersonalProjectForUserOrFail(user.id);
} else if (owner.type === 'team') { } else if (owner.type === 'team') {
let teamProject = await projectRepository.findOne({ let teamProject = await this.projectRepository.findOne({
where: { id: owner.teamId }, where: { id: owner.teamId },
}); });
if (!teamProject) { if (!teamProject) {
try { try {
teamProject = await projectRepository.save( teamProject = await this.projectRepository.save(
projectRepository.create({ this.projectRepository.create({
id: owner.teamId, id: owner.teamId,
name: owner.teamName, name: owner.teamName,
type: 'team', type: 'team',
}), }),
); );
} catch (e) { } catch (e) {
teamProject = await projectRepository.findOne({ teamProject = await this.projectRepository.findOne({
where: { id: owner.teamId }, where: { id: owner.teamId },
}); });
if (!teamProject) { if (!teamProject) {

View file

@ -1,4 +1,4 @@
import { Container, Service } from '@n8n/di'; import { Service } from '@n8n/di';
import type { ValidationError } from 'class-validator'; import type { ValidationError } from 'class-validator';
import { validate } from 'class-validator'; import { validate } from 'class-validator';
import { rm as fsRm } from 'fs/promises'; import { rm as fsRm } from 'fs/promises';
@ -7,7 +7,6 @@ import { ApplicationError, jsonParse } from 'n8n-workflow';
import { writeFile, chmod, readFile } from 'node:fs/promises'; import { writeFile, chmod, readFile } from 'node:fs/promises';
import path from 'path'; import path from 'path';
import config from '@/config';
import { SettingsRepository } from '@/databases/repositories/settings.repository'; import { SettingsRepository } from '@/databases/repositories/settings.repository';
import { import {
@ -17,6 +16,7 @@ import {
SOURCE_CONTROL_PREFERENCES_DB_KEY, SOURCE_CONTROL_PREFERENCES_DB_KEY,
} from './constants'; } from './constants';
import { generateSshKeyPair, isSourceControlLicensed } from './source-control-helper.ee'; import { generateSshKeyPair, isSourceControlLicensed } from './source-control-helper.ee';
import { SourceControlConfig } from './source-control.config';
import type { KeyPairType } from './types/key-pair-type'; import type { KeyPairType } from './types/key-pair-type';
import { SourceControlPreferences } from './types/source-control-preferences'; import { SourceControlPreferences } from './types/source-control-preferences';
@ -34,6 +34,8 @@ export class SourceControlPreferencesService {
private readonly instanceSettings: InstanceSettings, private readonly instanceSettings: InstanceSettings,
private readonly logger: Logger, private readonly logger: Logger,
private readonly cipher: Cipher, private readonly cipher: Cipher,
private readonly settingsRepository: SettingsRepository,
private readonly sourceControlConfig: SourceControlConfig,
) { ) {
this.sshFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_SSH_FOLDER); this.sshFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_SSH_FOLDER);
this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER); this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER);
@ -64,9 +66,7 @@ export class SourceControlPreferencesService {
} }
private async getKeyPairFromDatabase() { private async getKeyPairFromDatabase() {
const dbSetting = await Container.get(SettingsRepository).findByKey( const dbSetting = await this.settingsRepository.findByKey('features.sourceControl.sshKeys');
'features.sourceControl.sshKeys',
);
if (!dbSetting?.value) return null; if (!dbSetting?.value) return null;
@ -120,7 +120,7 @@ export class SourceControlPreferencesService {
async deleteKeyPair() { async deleteKeyPair() {
try { try {
await fsRm(this.sshFolder, { recursive: true }); await fsRm(this.sshFolder, { recursive: true });
await Container.get(SettingsRepository).delete({ key: 'features.sourceControl.sshKeys' }); await this.settingsRepository.delete({ key: 'features.sourceControl.sshKeys' });
} catch (e) { } catch (e) {
const error = e instanceof Error ? e : new Error(`${e}`); const error = e instanceof Error ? e : new Error(`${e}`);
this.logger.error(`Failed to delete SSH key pair: ${error.message}`); this.logger.error(`Failed to delete SSH key pair: ${error.message}`);
@ -133,14 +133,12 @@ export class SourceControlPreferencesService {
async generateAndSaveKeyPair(keyPairType?: KeyPairType): Promise<SourceControlPreferences> { async generateAndSaveKeyPair(keyPairType?: KeyPairType): Promise<SourceControlPreferences> {
if (!keyPairType) { if (!keyPairType) {
keyPairType = keyPairType =
this.getPreferences().keyGeneratorType ?? this.getPreferences().keyGeneratorType ?? this.sourceControlConfig.defaultKeyPairType;
(config.get('sourceControl.defaultKeyPairType') as KeyPairType) ??
'ed25519';
} }
const keyPair = await generateSshKeyPair(keyPairType); const keyPair = await generateSshKeyPair(keyPairType);
try { try {
await Container.get(SettingsRepository).save({ await this.settingsRepository.save({
key: 'features.sourceControl.sshKeys', key: 'features.sourceControl.sshKeys',
value: JSON.stringify({ value: JSON.stringify({
encryptedPrivateKey: this.cipher.encrypt(keyPair.privateKey), encryptedPrivateKey: this.cipher.encrypt(keyPair.privateKey),
@ -211,7 +209,7 @@ export class SourceControlPreferencesService {
if (saveToDb) { if (saveToDb) {
const settingsValue = JSON.stringify(this._sourceControlPreferences); const settingsValue = JSON.stringify(this._sourceControlPreferences);
try { try {
await Container.get(SettingsRepository).save( await this.settingsRepository.save(
{ {
key: SOURCE_CONTROL_PREFERENCES_DB_KEY, key: SOURCE_CONTROL_PREFERENCES_DB_KEY,
value: settingsValue, value: settingsValue,
@ -229,7 +227,7 @@ export class SourceControlPreferencesService {
async loadFromDbAndApplySourceControlPreferences(): Promise< async loadFromDbAndApplySourceControlPreferences(): Promise<
SourceControlPreferences | undefined SourceControlPreferences | undefined
> { > {
const loadedPreferences = await Container.get(SettingsRepository).findOne({ const loadedPreferences = await this.settingsRepository.findOne({
where: { key: SOURCE_CONTROL_PREFERENCES_DB_KEY }, where: { key: SOURCE_CONTROL_PREFERENCES_DB_KEY },
}); });
if (loadedPreferences) { if (loadedPreferences) {

View file

@ -0,0 +1,8 @@
import { Config, Env } from '@n8n/config';
@Config
export class SourceControlConfig {
/** Default SSH key type to use when generating SSH keys. */
@Env('N8N_SOURCECONTROL_DEFAULT_SSH_KEY_TYPE')
defaultKeyPairType: 'ed25519' | 'rsa' = 'ed25519';
}

View file

@ -1,27 +0,0 @@
import { Container } from '@n8n/di';
import { License } from '@/license';
export function isVariablesEnabled(): boolean {
const license = Container.get(License);
return license.isVariablesEnabled();
}
export function canCreateNewVariable(variableCount: number): boolean {
if (!isVariablesEnabled()) {
return false;
}
const license = Container.get(License);
// This defaults to -1 which is what we want if we've enabled
// variables via the config
const limit = license.getVariablesLimit();
if (limit === -1) {
return true;
}
return limit > variableCount;
}
export function getVariablesLimit(): number {
const license = Container.get(License);
return license.getVariablesLimit();
}

View file

@ -1,4 +1,4 @@
import { Container, Service } from '@n8n/di'; import { Service } from '@n8n/di';
import type { Variables } from '@/databases/entities/variables'; import type { Variables } from '@/databases/entities/variables';
import { VariablesRepository } from '@/databases/repositories/variables.repository'; import { VariablesRepository } from '@/databases/repositories/variables.repository';
@ -6,23 +6,21 @@ import { generateNanoId } from '@/databases/utils/generators';
import { VariableCountLimitReachedError } from '@/errors/variable-count-limit-reached.error'; import { VariableCountLimitReachedError } from '@/errors/variable-count-limit-reached.error';
import { VariableValidationError } from '@/errors/variable-validation.error'; import { VariableValidationError } from '@/errors/variable-validation.error';
import { EventService } from '@/events/event.service'; import { EventService } from '@/events/event.service';
import { License } from '@/license';
import { CacheService } from '@/services/cache/cache.service'; import { CacheService } from '@/services/cache/cache.service';
import { canCreateNewVariable } from './environment-helpers';
@Service() @Service()
export class VariablesService { export class VariablesService {
constructor( constructor(
protected cacheService: CacheService, private readonly cacheService: CacheService,
protected variablesRepository: VariablesRepository, private readonly variablesRepository: VariablesRepository,
private readonly eventService: EventService, private readonly eventService: EventService,
private readonly license: License,
) {} ) {}
async getAllCached(state?: 'empty'): Promise<Variables[]> { async getAllCached(state?: 'empty'): Promise<Variables[]> {
let variables = await this.cacheService.get('variables', { let variables = await this.cacheService.get('variables', {
async refreshFn() { refreshFn: async () => await this.findAll(),
return await Container.get(VariablesService).findAll();
},
}); });
if (variables === undefined) { if (variables === undefined) {
@ -77,7 +75,7 @@ export class VariablesService {
} }
async create(variable: Omit<Variables, 'id'>): Promise<Variables> { async create(variable: Omit<Variables, 'id'>): Promise<Variables> {
if (!canCreateNewVariable(await this.getCount())) { if (!this.canCreateNewVariable(await this.getCount())) {
throw new VariableCountLimitReachedError('Variables limit reached'); throw new VariableCountLimitReachedError('Variables limit reached');
} }
this.validateVariable(variable); this.validateVariable(variable);
@ -100,4 +98,17 @@ export class VariablesService {
await this.updateCache(); await this.updateCache();
return (await this.getCached(id))!; return (await this.getCached(id))!;
} }
private canCreateNewVariable(variableCount: number): boolean {
if (!this.license.isVariablesEnabled()) {
return false;
}
// This defaults to -1 which is what we want if we've enabled
// variables via the config
const limit = this.license.getVariablesLimit();
if (limit === -1) {
return true;
}
return limit > variableCount;
}
} }

View file

@ -19,7 +19,7 @@ import type { WorkflowRepository } from '@/databases/repositories/workflow.repos
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { NodeTypes } from '@/node-types'; import { NodeTypes } from '@/node-types';
import type { WorkflowRunner } from '@/workflow-runner'; import type { WorkflowRunner } from '@/workflow-runner';
import { mockInstance } from '@test/mocking'; import { mockInstance, mockLogger } from '@test/mocking';
import { mockNodeTypesData } from '@test-integration/utils/node-types-data'; import { mockNodeTypesData } from '@test-integration/utils/node-types-data';
import { TestRunnerService } from '../test-runner.service.ee'; import { TestRunnerService } from '../test-runner.service.ee';
@ -129,6 +129,9 @@ function mockEvaluationExecutionData(metrics: Record<string, GenericValue>) {
}); });
} }
const errorReporter = mock<ErrorReporter>();
const logger = mockLogger();
describe('TestRunnerService', () => { describe('TestRunnerService', () => {
const executionRepository = mock<ExecutionRepository>(); const executionRepository = mock<ExecutionRepository>();
const workflowRepository = mock<WorkflowRepository>(); const workflowRepository = mock<WorkflowRepository>();
@ -176,6 +179,7 @@ describe('TestRunnerService', () => {
test('should create an instance of TestRunnerService', async () => { test('should create an instance of TestRunnerService', async () => {
const testRunnerService = new TestRunnerService( const testRunnerService = new TestRunnerService(
logger,
workflowRepository, workflowRepository,
workflowRunner, workflowRunner,
executionRepository, executionRepository,
@ -183,7 +187,7 @@ describe('TestRunnerService', () => {
testRunRepository, testRunRepository,
testMetricRepository, testMetricRepository,
mockNodeTypes, mockNodeTypes,
mock<ErrorReporter>(), errorReporter,
); );
expect(testRunnerService).toBeInstanceOf(TestRunnerService); expect(testRunnerService).toBeInstanceOf(TestRunnerService);
@ -191,6 +195,7 @@ describe('TestRunnerService', () => {
test('should create and run test cases from past executions', async () => { test('should create and run test cases from past executions', async () => {
const testRunnerService = new TestRunnerService( const testRunnerService = new TestRunnerService(
logger,
workflowRepository, workflowRepository,
workflowRunner, workflowRunner,
executionRepository, executionRepository,
@ -198,7 +203,7 @@ describe('TestRunnerService', () => {
testRunRepository, testRunRepository,
testMetricRepository, testMetricRepository,
mockNodeTypes, mockNodeTypes,
mock<ErrorReporter>(), errorReporter,
); );
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({ workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
@ -229,6 +234,7 @@ describe('TestRunnerService', () => {
test('should run both workflow under test and evaluation workflow', async () => { test('should run both workflow under test and evaluation workflow', async () => {
const testRunnerService = new TestRunnerService( const testRunnerService = new TestRunnerService(
logger,
workflowRepository, workflowRepository,
workflowRunner, workflowRunner,
executionRepository, executionRepository,
@ -236,7 +242,7 @@ describe('TestRunnerService', () => {
testRunRepository, testRunRepository,
testMetricRepository, testMetricRepository,
mockNodeTypes, mockNodeTypes,
mock<ErrorReporter>(), errorReporter,
); );
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({ workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
@ -330,6 +336,7 @@ describe('TestRunnerService', () => {
test('should properly count passed and failed executions', async () => { test('should properly count passed and failed executions', async () => {
const testRunnerService = new TestRunnerService( const testRunnerService = new TestRunnerService(
logger,
workflowRepository, workflowRepository,
workflowRunner, workflowRunner,
executionRepository, executionRepository,
@ -337,7 +344,7 @@ describe('TestRunnerService', () => {
testRunRepository, testRunRepository,
testMetricRepository, testMetricRepository,
mockNodeTypes, mockNodeTypes,
mock<ErrorReporter>(), errorReporter,
); );
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({ workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
@ -388,6 +395,7 @@ describe('TestRunnerService', () => {
test('should properly count failed test executions', async () => { test('should properly count failed test executions', async () => {
const testRunnerService = new TestRunnerService( const testRunnerService = new TestRunnerService(
logger,
workflowRepository, workflowRepository,
workflowRunner, workflowRunner,
executionRepository, executionRepository,
@ -395,7 +403,7 @@ describe('TestRunnerService', () => {
testRunRepository, testRunRepository,
testMetricRepository, testMetricRepository,
mockNodeTypes, mockNodeTypes,
mock<ErrorReporter>(), errorReporter,
); );
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({ workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
@ -442,6 +450,7 @@ describe('TestRunnerService', () => {
test('should properly count failed evaluations', async () => { test('should properly count failed evaluations', async () => {
const testRunnerService = new TestRunnerService( const testRunnerService = new TestRunnerService(
logger,
workflowRepository, workflowRepository,
workflowRunner, workflowRunner,
executionRepository, executionRepository,
@ -449,7 +458,7 @@ describe('TestRunnerService', () => {
testRunRepository, testRunRepository,
testMetricRepository, testMetricRepository,
mockNodeTypes, mockNodeTypes,
mock<ErrorReporter>(), errorReporter,
); );
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({ workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
@ -500,6 +509,7 @@ describe('TestRunnerService', () => {
test('should specify correct start nodes when running workflow under test', async () => { test('should specify correct start nodes when running workflow under test', async () => {
const testRunnerService = new TestRunnerService( const testRunnerService = new TestRunnerService(
logger,
workflowRepository, workflowRepository,
workflowRunner, workflowRunner,
executionRepository, executionRepository,
@ -507,7 +517,7 @@ describe('TestRunnerService', () => {
testRunRepository, testRunRepository,
testMetricRepository, testMetricRepository,
mockNodeTypes, mockNodeTypes,
mock<ErrorReporter>(), errorReporter,
); );
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({ workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
@ -574,6 +584,7 @@ describe('TestRunnerService', () => {
test('should properly choose trigger and start nodes', async () => { test('should properly choose trigger and start nodes', async () => {
const testRunnerService = new TestRunnerService( const testRunnerService = new TestRunnerService(
logger,
workflowRepository, workflowRepository,
workflowRunner, workflowRunner,
executionRepository, executionRepository,
@ -581,7 +592,7 @@ describe('TestRunnerService', () => {
testRunRepository, testRunRepository,
testMetricRepository, testMetricRepository,
mockNodeTypes, mockNodeTypes,
mock<ErrorReporter>(), errorReporter,
); );
const startNodesData = (testRunnerService as any).getStartNodesData( const startNodesData = (testRunnerService as any).getStartNodesData(
@ -599,6 +610,7 @@ describe('TestRunnerService', () => {
test('should properly choose trigger and start nodes 2', async () => { test('should properly choose trigger and start nodes 2', async () => {
const testRunnerService = new TestRunnerService( const testRunnerService = new TestRunnerService(
logger,
workflowRepository, workflowRepository,
workflowRunner, workflowRunner,
executionRepository, executionRepository,
@ -606,7 +618,7 @@ describe('TestRunnerService', () => {
testRunRepository, testRunRepository,
testMetricRepository, testMetricRepository,
mockNodeTypes, mockNodeTypes,
mock<ErrorReporter>(), errorReporter,
); );
const startNodesData = (testRunnerService as any).getStartNodesData( const startNodesData = (testRunnerService as any).getStartNodesData(

View file

@ -1,6 +1,6 @@
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { parse } from 'flatted'; import { parse } from 'flatted';
import { ErrorReporter } from 'n8n-core'; import { ErrorReporter, Logger } from 'n8n-core';
import { NodeConnectionType, Workflow } from 'n8n-workflow'; import { NodeConnectionType, Workflow } from 'n8n-workflow';
import type { import type {
IDataObject, IDataObject,
@ -39,6 +39,7 @@ import { createPinData, getPastExecutionTriggerNode } from './utils.ee';
@Service() @Service()
export class TestRunnerService { export class TestRunnerService {
constructor( constructor(
private readonly logger: Logger,
private readonly workflowRepository: WorkflowRepository, private readonly workflowRepository: WorkflowRepository,
private readonly workflowRunner: WorkflowRunner, private readonly workflowRunner: WorkflowRunner,
private readonly executionRepository: ExecutionRepository, private readonly executionRepository: ExecutionRepository,
@ -115,8 +116,9 @@ export class TestRunnerService {
executionMode: 'evaluation', executionMode: 'evaluation',
runData: {}, runData: {},
pinData, pinData,
workflowData: workflow, workflowData: { ...workflow, pinData },
userId, userId,
partialExecutionVersion: '1',
}; };
// Trigger the workflow under test with mocked data // Trigger the workflow under test with mocked data
@ -203,6 +205,8 @@ export class TestRunnerService {
* Creates a new test run for the given test definition. * Creates a new test run for the given test definition.
*/ */
async runTest(user: User, test: TestDefinition): Promise<void> { async runTest(user: User, test: TestDefinition): Promise<void> {
this.logger.debug('Starting new test run', { testId: test.id });
const workflow = await this.workflowRepository.findById(test.workflowId); const workflow = await this.workflowRepository.findById(test.workflowId);
assert(workflow, 'Workflow not found'); assert(workflow, 'Workflow not found');
@ -227,6 +231,8 @@ export class TestRunnerService {
.andWhere('execution.workflowId = :workflowId', { workflowId: test.workflowId }) .andWhere('execution.workflowId = :workflowId', { workflowId: test.workflowId })
.getMany(); .getMany();
this.logger.debug('Found past executions', { count: pastExecutions.length });
// Get the metrics to collect from the evaluation workflow // Get the metrics to collect from the evaluation workflow
const testMetricNames = await this.getTestMetricNames(test.id); const testMetricNames = await this.getTestMetricNames(test.id);
@ -238,6 +244,8 @@ export class TestRunnerService {
const metrics = new EvaluationMetrics(testMetricNames); const metrics = new EvaluationMetrics(testMetricNames);
for (const { id: pastExecutionId } of pastExecutions) { for (const { id: pastExecutionId } of pastExecutions) {
this.logger.debug('Running test case', { pastExecutionId });
try { try {
// Fetch past execution with data // Fetch past execution with data
const pastExecution = await this.executionRepository.findOne({ const pastExecution = await this.executionRepository.findOne({
@ -257,6 +265,8 @@ export class TestRunnerService {
user.id, user.id,
); );
this.logger.debug('Test case execution finished', { pastExecutionId });
// In case of a permission check issue, the test case execution will be undefined. // In case of a permission check issue, the test case execution will be undefined.
// Skip them, increment the failed count and continue with the next test case // Skip them, increment the failed count and continue with the next test case
if (!testCaseExecution) { if (!testCaseExecution) {
@ -279,6 +289,8 @@ export class TestRunnerService {
); );
assert(evalExecution); assert(evalExecution);
this.logger.debug('Evaluation execution finished', { pastExecutionId });
metrics.addResults(this.extractEvaluationResult(evalExecution)); metrics.addResults(this.extractEvaluationResult(evalExecution));
if (evalExecution.data.resultData.error) { if (evalExecution.data.resultData.error) {
@ -297,5 +309,7 @@ export class TestRunnerService {
const aggregatedMetrics = metrics.getAggregatedMetrics(); const aggregatedMetrics = metrics.getAggregatedMetrics();
await this.testRunRepository.markAsCompleted(testRun.id, aggregatedMetrics); await this.testRunRepository.markAsCompleted(testRun.id, aggregatedMetrics);
this.logger.debug('Test run finished', { testId: test.id });
} }
} }

View file

@ -71,7 +71,11 @@ export class ManualExecutionService {
}, },
}; };
const workflowExecute = new WorkflowExecute(additionalData, 'manual', executionData); const workflowExecute = new WorkflowExecute(
additionalData,
data.executionMode,
executionData,
);
return workflowExecute.processRunExecutionData(workflow); return workflowExecute.processRunExecutionData(workflow);
} else if ( } else if (
data.runData === undefined || data.runData === undefined ||

View file

@ -1,6 +1,6 @@
import { GlobalConfig } from '@n8n/config'; import { GlobalConfig } from '@n8n/config';
import { Container, Service } from '@n8n/di'; import { Container, Service } from '@n8n/di';
import { ErrorReporter, InstanceSettings, Logger } from 'n8n-core'; import { ErrorReporter, InstanceSettings, isObjectLiteral, Logger } from 'n8n-core';
import { import {
ApplicationError, ApplicationError,
BINARY_ENCODING, BINARY_ENCODING,
@ -93,6 +93,12 @@ export class ScalingService {
void this.queue.process(JOB_TYPE_NAME, concurrency, async (job: Job) => { void this.queue.process(JOB_TYPE_NAME, concurrency, async (job: Job) => {
try { try {
if (!this.hasValidJobData(job)) {
throw new ApplicationError('Worker received invalid job', {
extra: { jobData: jsonStringify(job, { replaceCircularRefs: true }) },
});
}
await this.jobProcessor.processJob(job); await this.jobProcessor.processJob(job);
} catch (error) { } catch (error) {
await this.reportJobProcessingError(ensureError(error), job); await this.reportJobProcessingError(ensureError(error), job);
@ -503,5 +509,9 @@ export class ScalingService {
: jsonStringify(error, { replaceCircularRefs: true }); : jsonStringify(error, { replaceCircularRefs: true });
} }
private hasValidJobData(job: Job) {
return isObjectLiteral(job.data) && 'executionId' in job.data && 'loadStaticData' in job.data;
}
// #endregion // #endregion
} }

View file

@ -12,7 +12,6 @@ import config from '@/config';
import { inE2ETests, LICENSE_FEATURES, N8N_VERSION } from '@/constants'; import { inE2ETests, LICENSE_FEATURES, N8N_VERSION } from '@/constants';
import { CredentialTypes } from '@/credential-types'; import { CredentialTypes } from '@/credential-types';
import { CredentialsOverwrites } from '@/credentials-overwrites'; import { CredentialsOverwrites } from '@/credentials-overwrites';
import { getVariablesLimit } from '@/environments.ee/variables/environment-helpers';
import { getLdapLoginLabel } from '@/ldap.ee/helpers.ee'; import { getLdapLoginLabel } from '@/ldap.ee/helpers.ee';
import { License } from '@/license'; import { License } from '@/license';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
@ -326,7 +325,7 @@ export class FrontendService {
} }
if (this.license.isVariablesEnabled()) { if (this.license.isVariablesEnabled()) {
this.settings.variables.limit = getVariablesLimit(); this.settings.variables.limit = this.license.getVariablesLimit();
} }
if (this.license.isWorkflowHistoryLicensed() && config.getEnv('workflowHistory.enabled')) { if (this.license.isWorkflowHistoryLicensed() && config.getEnv('workflowHistory.enabled')) {

View file

@ -454,7 +454,7 @@ export async function executeWebhook(
} }
let pinData: IPinData | undefined; let pinData: IPinData | undefined;
const usePinData = executionMode === 'manual'; const usePinData = ['manual', 'evaluation'].includes(executionMode);
if (usePinData) { if (usePinData) {
pinData = workflowData.pinData; pinData = workflowData.pinData;
runExecutionData.resultData.pinData = pinData; runExecutionData.resultData.pinData = pinData;

View file

@ -234,7 +234,7 @@ export class WorkflowRunner {
} }
let pinData: IPinData | undefined; let pinData: IPinData | undefined;
if (data.executionMode === 'manual') { if (['manual', 'evaluation'].includes(data.executionMode)) {
pinData = data.pinData ?? data.workflowData.pinData; pinData = data.pinData ?? data.workflowData.pinData;
} }

View file

@ -10,6 +10,7 @@ import fsp from 'node:fs/promises';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
import { ProjectRepository } from '@/databases/repositories/project.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository';
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
import { UserRepository } from '@/databases/repositories/user.repository';
import { SourceControlImportService } from '@/environments.ee/source-control/source-control-import.service.ee'; import { SourceControlImportService } from '@/environments.ee/source-control/source-control-import.service.ee';
import type { ExportableCredential } from '@/environments.ee/source-control/types/exportable-credential'; import type { ExportableCredential } from '@/environments.ee/source-control/types/exportable-credential';
@ -21,20 +22,36 @@ import { randomCredentialPayload } from '../shared/random';
import * as testDb from '../shared/test-db'; import * as testDb from '../shared/test-db';
describe('SourceControlImportService', () => { describe('SourceControlImportService', () => {
let credentialsRepository: CredentialsRepository;
let projectRepository: ProjectRepository;
let sharedCredentialsRepository: SharedCredentialsRepository;
let userRepository: UserRepository;
let service: SourceControlImportService; let service: SourceControlImportService;
const cipher = mockInstance(Cipher); const cipher = mockInstance(Cipher);
beforeAll(async () => { beforeAll(async () => {
await testDb.init();
credentialsRepository = Container.get(CredentialsRepository);
projectRepository = Container.get(ProjectRepository);
sharedCredentialsRepository = Container.get(SharedCredentialsRepository);
userRepository = Container.get(UserRepository);
service = new SourceControlImportService( service = new SourceControlImportService(
mock(), mock(),
mock(), mock(),
mock(), mock(),
mock(), mock(),
credentialsRepository,
projectRepository,
mock(),
mock(),
sharedCredentialsRepository,
userRepository,
mock(),
mock(),
mock(), mock(),
mock<InstanceSettings>({ n8nFolder: '/some-path' }), mock<InstanceSettings>({ n8nFolder: '/some-path' }),
); );
await testDb.init();
}); });
afterEach(async () => { afterEach(async () => {
@ -75,7 +92,7 @@ describe('SourceControlImportService', () => {
const personalProject = await getPersonalProject(member); const personalProject = await getPersonalProject(member);
const sharing = await Container.get(SharedCredentialsRepository).findOneBy({ const sharing = await sharedCredentialsRepository.findOneBy({
credentialsId: CREDENTIAL_ID, credentialsId: CREDENTIAL_ID,
projectId: personalProject.id, projectId: personalProject.id,
role: 'credential:owner', role: 'credential:owner',
@ -112,7 +129,7 @@ describe('SourceControlImportService', () => {
const personalProject = await getPersonalProject(importingUser); const personalProject = await getPersonalProject(importingUser);
const sharing = await Container.get(SharedCredentialsRepository).findOneBy({ const sharing = await sharedCredentialsRepository.findOneBy({
credentialsId: CREDENTIAL_ID, credentialsId: CREDENTIAL_ID,
projectId: personalProject.id, projectId: personalProject.id,
role: 'credential:owner', role: 'credential:owner',
@ -149,7 +166,7 @@ describe('SourceControlImportService', () => {
const personalProject = await getPersonalProject(importingUser); const personalProject = await getPersonalProject(importingUser);
const sharing = await Container.get(SharedCredentialsRepository).findOneBy({ const sharing = await sharedCredentialsRepository.findOneBy({
credentialsId: CREDENTIAL_ID, credentialsId: CREDENTIAL_ID,
projectId: personalProject.id, projectId: personalProject.id,
role: 'credential:owner', role: 'credential:owner',
@ -190,7 +207,7 @@ describe('SourceControlImportService', () => {
const personalProject = await getPersonalProject(importingUser); const personalProject = await getPersonalProject(importingUser);
const sharing = await Container.get(SharedCredentialsRepository).findOneBy({ const sharing = await sharedCredentialsRepository.findOneBy({
credentialsId: CREDENTIAL_ID, credentialsId: CREDENTIAL_ID,
projectId: personalProject.id, projectId: personalProject.id,
role: 'credential:owner', role: 'credential:owner',
@ -223,7 +240,7 @@ describe('SourceControlImportService', () => {
cipher.encrypt.mockReturnValue('some-encrypted-data'); cipher.encrypt.mockReturnValue('some-encrypted-data');
{ {
const project = await Container.get(ProjectRepository).findOne({ const project = await projectRepository.findOne({
where: [ where: [
{ {
id: '1234-asdf', id: '1234-asdf',
@ -241,7 +258,7 @@ describe('SourceControlImportService', () => {
importingUser.id, importingUser.id,
); );
const sharing = await Container.get(SharedCredentialsRepository).findOne({ const sharing = await sharedCredentialsRepository.findOne({
where: { where: {
credentialsId: CREDENTIAL_ID, credentialsId: CREDENTIAL_ID,
role: 'credential:owner', role: 'credential:owner',
@ -288,7 +305,7 @@ describe('SourceControlImportService', () => {
importingUser.id, importingUser.id,
); );
const sharing = await Container.get(SharedCredentialsRepository).findOneBy({ const sharing = await sharedCredentialsRepository.findOneBy({
credentialsId: CREDENTIAL_ID, credentialsId: CREDENTIAL_ID,
projectId: project.id, projectId: project.id,
role: 'credential:owner', role: 'credential:owner',
@ -332,7 +349,7 @@ describe('SourceControlImportService', () => {
); );
await expect( await expect(
Container.get(SharedCredentialsRepository).findBy({ sharedCredentialsRepository.findBy({
credentialsId: credential.id, credentialsId: credential.id,
}), }),
).resolves.toMatchObject([ ).resolves.toMatchObject([
@ -342,7 +359,7 @@ describe('SourceControlImportService', () => {
}, },
]); ]);
await expect( await expect(
Container.get(CredentialsRepository).findBy({ credentialsRepository.findBy({
id: credential.id, id: credential.id,
}), }),
).resolves.toMatchObject([ ).resolves.toMatchObject([

View file

@ -1,7 +1,6 @@
import type { SourceControlledFile } from '@n8n/api-types'; import type { SourceControlledFile } from '@n8n/api-types';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import config from '@/config';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee'; import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee';
import { SourceControlService } from '@/environments.ee/source-control/source-control.service.ee'; import { SourceControlService } from '@/environments.ee/source-control/source-control.service.ee';
@ -21,11 +20,17 @@ const testServer = utils.setupTestServer({
enabledFeatures: ['feat:sourceControl', 'feat:sharing'], enabledFeatures: ['feat:sourceControl', 'feat:sharing'],
}); });
let sourceControlPreferencesService: SourceControlPreferencesService;
beforeAll(async () => { beforeAll(async () => {
owner = await createUser({ role: 'global:owner' }); owner = await createUser({ role: 'global:owner' });
authOwnerAgent = testServer.authAgentFor(owner); authOwnerAgent = testServer.authAgentFor(owner);
Container.get(SourceControlPreferencesService).isSourceControlConnected = () => true; sourceControlPreferencesService = Container.get(SourceControlPreferencesService);
await sourceControlPreferencesService.setPreferences({
connected: true,
keyGeneratorType: 'rsa',
});
}); });
describe('GET /sourceControl/preferences', () => { describe('GET /sourceControl/preferences', () => {
@ -65,19 +70,11 @@ describe('GET /sourceControl/preferences', () => {
}); });
test('refreshing key pairsshould return new rsa key', async () => { test('refreshing key pairsshould return new rsa key', async () => {
config.set('sourceControl.defaultKeyPairType', 'rsa'); const res = await authOwnerAgent.post('/source-control/generate-key-pair').send().expect(200);
await authOwnerAgent
.post('/source-control/generate-key-pair')
.send()
.expect(200)
.expect((res) => {
expect(
Container.get(SourceControlPreferencesService).getPreferences().keyGeneratorType,
).toBe('rsa');
expect(res.body.data).toHaveProperty('publicKey'); expect(res.body.data).toHaveProperty('publicKey');
expect(res.body.data).toHaveProperty('keyGeneratorType'); expect(res.body.data).toHaveProperty('keyGeneratorType');
expect(res.body.data.keyGeneratorType).toBe('rsa'); expect(res.body.data.keyGeneratorType).toBe('rsa');
expect(res.body.data.publicKey).toContain('ssh-rsa'); expect(res.body.data.publicKey).toContain('ssh-rsa');
}); });
}); });
});

View file

@ -80,8 +80,8 @@ export abstract class DirectoryLoader {
constructor( constructor(
readonly directory: string, readonly directory: string,
protected readonly excludeNodes: string[] = [], protected excludeNodes: string[] = [],
protected readonly includeNodes: string[] = [], protected includeNodes: string[] = [],
) {} ) {}
abstract packageName: string; abstract packageName: string;
@ -121,13 +121,12 @@ export abstract class DirectoryLoader {
this.addCodex(tempNode, filePath); this.addCodex(tempNode, filePath);
const nodeType = tempNode.description.name; const nodeType = tempNode.description.name;
const fullNodeType = `${this.packageName}.${nodeType}`;
if (this.includeNodes.length && !this.includeNodes.includes(fullNodeType)) { if (this.includeNodes.length && !this.includeNodes.includes(nodeType)) {
return; return;
} }
if (this.excludeNodes.includes(fullNodeType)) { if (this.excludeNodes.includes(nodeType)) {
return; return;
} }
@ -151,7 +150,7 @@ export abstract class DirectoryLoader {
if (currentVersionNode.hasOwnProperty('executeSingle')) { if (currentVersionNode.hasOwnProperty('executeSingle')) {
throw new ApplicationError( throw new ApplicationError(
'"executeSingle" has been removed. Please update the code of this node to use "execute" instead.', '"executeSingle" has been removed. Please update the code of this node to use "execute" instead.',
{ extra: { nodeType: fullNodeType } }, { extra: { nodeType } },
); );
} }
} else { } else {
@ -430,9 +429,25 @@ export class CustomDirectoryLoader extends DirectoryLoader {
* e.g. /nodes-base or community packages. * e.g. /nodes-base or community packages.
*/ */
export class PackageDirectoryLoader extends DirectoryLoader { export class PackageDirectoryLoader extends DirectoryLoader {
packageJson: n8n.PackageJson = this.readJSONSync('package.json'); packageJson: n8n.PackageJson;
packageName = this.packageJson.name; packageName: string;
constructor(directory: string, excludeNodes: string[] = [], includeNodes: string[] = []) {
super(directory, excludeNodes, includeNodes);
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);
}
override async loadAll() { override async loadAll() {
const { n8n } = this.packageJson; const { n8n } = this.packageJson;
@ -524,9 +539,8 @@ export class LazyPackageDirectoryLoader extends PackageDirectoryLoader {
if (this.includeNodes.length) { if (this.includeNodes.length) {
const allowedNodes: typeof this.known.nodes = {}; const allowedNodes: typeof this.known.nodes = {};
for (const fullNodeType of this.includeNodes) { for (const nodeType of this.includeNodes) {
const [packageName, nodeType] = fullNodeType.split('.'); if (nodeType in this.known.nodes) {
if (packageName === this.packageName && nodeType in this.known.nodes) {
allowedNodes[nodeType] = this.known.nodes[nodeType]; allowedNodes[nodeType] = this.known.nodes[nodeType];
} }
} }
@ -538,12 +552,9 @@ export class LazyPackageDirectoryLoader extends PackageDirectoryLoader {
} }
if (this.excludeNodes.length) { if (this.excludeNodes.length) {
for (const fullNodeType of this.excludeNodes) { for (const nodeType of this.excludeNodes) {
const [packageName, nodeType] = fullNodeType.split('.');
if (packageName === this.packageName) {
delete this.known.nodes[nodeType]; delete this.known.nodes[nodeType];
} }
}
this.types.nodes = this.types.nodes.filter( this.types.nodes = this.types.nodes.filter(
(nodeType) => !this.excludeNodes.includes(nodeType.name), (nodeType) => !this.excludeNodes.includes(nodeType.name),

View file

@ -15,6 +15,11 @@ type ErrorReporterInitOptions = {
release: string; release: string;
environment: string; environment: string;
serverName: string; serverName: string;
/**
* Function to allow filtering out errors before they are sent to Sentry.
* Return true if the error should be filtered out.
*/
beforeSendFilter?: (event: ErrorEvent, hint: EventHint) => boolean;
}; };
@Service() @Service()
@ -24,6 +29,8 @@ export class ErrorReporter {
private report: (error: Error | string, options?: ReportingOptions) => void; private report: (error: Error | string, options?: ReportingOptions) => void;
private beforeSendFilter?: (event: ErrorEvent, hint: EventHint) => boolean;
constructor(private readonly logger: Logger) { constructor(private readonly logger: Logger) {
// eslint-disable-next-line @typescript-eslint/unbound-method // eslint-disable-next-line @typescript-eslint/unbound-method
this.report = this.defaultReport; this.report = this.defaultReport;
@ -52,7 +59,14 @@ export class ErrorReporter {
await close(timeoutInMs); await close(timeoutInMs);
} }
async init({ dsn, serverType, release, environment, serverName }: ErrorReporterInitOptions) { async init({
beforeSendFilter,
dsn,
serverType,
release,
environment,
serverName,
}: ErrorReporterInitOptions) {
process.on('uncaughtException', (error) => { process.on('uncaughtException', (error) => {
this.error(error); this.error(error);
}); });
@ -100,31 +114,34 @@ export class ErrorReporter {
setTag('server_type', serverType); setTag('server_type', serverType);
this.report = (error, options) => captureException(error, options); this.report = (error, options) => captureException(error, options);
this.beforeSendFilter = beforeSendFilter;
} }
async beforeSend(event: ErrorEvent, { originalException }: EventHint) { async beforeSend(event: ErrorEvent, hint: EventHint) {
let { originalException } = hint;
if (!originalException) return null; if (!originalException) return null;
if (originalException instanceof Promise) { if (originalException instanceof Promise) {
originalException = await originalException.catch((error) => error as Error); originalException = await originalException.catch((error) => error as Error);
} }
if (originalException instanceof AxiosError) return null;
if ( if (
originalException instanceof Error && this.beforeSendFilter?.(event, {
originalException.name === 'QueryFailedError' && ...hint,
['SQLITE_FULL', 'SQLITE_IOERR'].some((errMsg) => originalException.message.includes(errMsg)) originalException,
})
) { ) {
return null; return null;
} }
if (originalException instanceof ApplicationError) { if (originalException instanceof AxiosError) return null;
const { level, extra, tags } = originalException;
if (level === 'warning') return null; if (this.isIgnoredSqliteError(originalException)) return null;
event.level = level; if (this.isApplicationError(originalException)) {
if (extra) event.extra = { ...event.extra, ...extra }; if (this.isIgnoredApplicationError(originalException)) return null;
if (tags) event.tags = { ...event.tags, ...tags };
this.extractEventDetailsFromApplicationError(event, originalException);
} }
if ( if (
@ -166,4 +183,31 @@ export class ErrorReporter {
if (typeof e === 'string') return new ApplicationError(e); if (typeof e === 'string') return new ApplicationError(e);
return; return;
} }
/** @returns true if the error should be filtered out */
private isIgnoredSqliteError(error: unknown) {
return (
error instanceof Error &&
error.name === 'QueryFailedError' &&
['SQLITE_FULL', 'SQLITE_IOERR'].some((errMsg) => error.message.includes(errMsg))
);
}
private isApplicationError(error: unknown): error is ApplicationError {
return error instanceof ApplicationError;
}
private isIgnoredApplicationError(error: ApplicationError) {
return error.level === 'warning';
}
private extractEventDetailsFromApplicationError(
event: ErrorEvent,
originalException: ApplicationError,
) {
const { level, extra, tags } = originalException;
event.level = level;
if (extra) event.extra = { ...event.extra, ...extra };
if (tags) event.tags = { ...event.tags, ...tags };
}
} }

View file

@ -246,4 +246,67 @@ describe('validateValueAgainstSchema', () => {
// value should be type number // value should be type number
expect(typeof result).toEqual('number'); expect(typeof result).toEqual('number');
}); });
describe('when the mode is in Fixed mode, and the node is a resource mapper', () => {
const nodeType = {
description: {
properties: [
{
name: 'operation',
type: 'resourceMapper',
typeOptions: {
resourceMapper: {
mode: 'add',
},
},
},
],
},
} as unknown as INodeType;
const node = {
parameters: {
operation: {
schema: [
{ id: 'num', type: 'number', required: true },
{ id: 'str', type: 'string', required: true },
{ id: 'obj', type: 'object', required: true },
{ id: 'arr', type: 'array', required: true },
],
attemptToConvertTypes: true,
mappingMode: '',
value: '',
},
},
} as unknown as INode;
const parameterName = 'operation.value';
describe('should correctly validate values for', () => {
test.each([
{ num: 0 },
{ num: 23 },
{ num: -0 },
{ num: -Infinity },
{ num: Infinity },
{ str: '' },
{ str: ' ' },
{ str: 'hello' },
{ arr: [] },
{ obj: {} },
])('%s', (value) => {
expect(() =>
validateValueAgainstSchema(node, nodeType, value, parameterName, 0, 0),
).not.toThrow();
});
});
describe('should throw an error for', () => {
test.each([{ num: NaN }, { num: undefined }, { num: null }])('%s', (value) => {
expect(() =>
validateValueAgainstSchema(node, nodeType, value, parameterName, 0, 0),
).toThrow();
});
});
});
}); });

View file

@ -44,7 +44,7 @@ const validateResourceMapperValue = (
!skipRequiredCheck && !skipRequiredCheck &&
schemaEntry?.required === true && schemaEntry?.required === true &&
schemaEntry.type !== 'boolean' && schemaEntry.type !== 'boolean' &&
!resolvedValue (resolvedValue === undefined || resolvedValue === null)
) { ) {
return { return {
valid: false, valid: false,

View file

@ -235,10 +235,7 @@ describe('DirectoryLoader', () => {
return JSON.stringify({}); return JSON.stringify({});
} }
if (path.endsWith('types/nodes.json')) { if (path.endsWith('types/nodes.json')) {
return JSON.stringify([ return JSON.stringify([{ name: 'node1' }, { name: 'node2' }]);
{ name: 'n8n-nodes-testing.node1' },
{ name: 'n8n-nodes-testing.node2' },
]);
} }
if (path.endsWith('types/credentials.json')) { if (path.endsWith('types/credentials.json')) {
return JSON.stringify([]); return JSON.stringify([]);
@ -254,7 +251,7 @@ describe('DirectoryLoader', () => {
node1: { className: 'Node1', sourcePath: 'dist/Node1/Node1.node.js' }, node1: { className: 'Node1', sourcePath: 'dist/Node1/Node1.node.js' },
}); });
expect(loader.types.nodes).toHaveLength(1); expect(loader.types.nodes).toHaveLength(1);
expect(loader.types.nodes[0].name).toBe('n8n-nodes-testing.node1'); expect(loader.types.nodes[0].name).toBe('node1');
expect(classLoader.loadClassInIsolation).not.toHaveBeenCalled(); expect(classLoader.loadClassInIsolation).not.toHaveBeenCalled();
}); });
@ -274,10 +271,7 @@ describe('DirectoryLoader', () => {
return JSON.stringify({}); return JSON.stringify({});
} }
if (path.endsWith('types/nodes.json')) { if (path.endsWith('types/nodes.json')) {
return JSON.stringify([ return JSON.stringify([{ name: 'node1' }, { name: 'node2' }]);
{ name: 'n8n-nodes-testing.node1' },
{ name: 'n8n-nodes-testing.node2' },
]);
} }
if (path.endsWith('types/credentials.json')) { if (path.endsWith('types/credentials.json')) {
return JSON.stringify([]); return JSON.stringify([]);
@ -314,10 +308,7 @@ describe('DirectoryLoader', () => {
return JSON.stringify({}); return JSON.stringify({});
} }
if (path.endsWith('types/nodes.json')) { if (path.endsWith('types/nodes.json')) {
return JSON.stringify([ return JSON.stringify([{ name: 'node1' }, { name: 'node2' }]);
{ name: 'n8n-nodes-testing.node1' },
{ name: 'n8n-nodes-testing.node2' },
]);
} }
if (path.endsWith('types/credentials.json')) { if (path.endsWith('types/credentials.json')) {
return JSON.stringify([]); return JSON.stringify([]);
@ -333,7 +324,7 @@ describe('DirectoryLoader', () => {
node2: { className: 'Node2', sourcePath: 'dist/Node2/Node2.node.js' }, node2: { className: 'Node2', sourcePath: 'dist/Node2/Node2.node.js' },
}); });
expect(loader.types.nodes).toHaveLength(1); expect(loader.types.nodes).toHaveLength(1);
expect(loader.types.nodes[0].name).toBe('n8n-nodes-testing.node2'); expect(loader.types.nodes[0].name).toBe('node2');
expect(classLoader.loadClassInIsolation).not.toHaveBeenCalled(); expect(classLoader.loadClassInIsolation).not.toHaveBeenCalled();
}); });
}); });
@ -654,18 +645,6 @@ describe('DirectoryLoader', () => {
expect(nodeWithIcon.description.icon).toBeUndefined(); expect(nodeWithIcon.description.icon).toBeUndefined();
}); });
it('should skip node if included in excludeNodes', () => {
const loader = new CustomDirectoryLoader(directory, ['CUSTOM.node1']);
const filePath = 'dist/Node1/Node1.node.js';
loader.loadNodeFromFile(filePath);
expect(loader.nodeTypes).toEqual({});
expect(loader.known.nodes).toEqual({});
expect(loader.types.nodes).toEqual([]);
expect(loader.loadedNodes).toEqual([]);
});
it('should skip node if not in includeNodes', () => { it('should skip node if not in includeNodes', () => {
const loader = new CustomDirectoryLoader(directory, [], ['CUSTOM.other']); const loader = new CustomDirectoryLoader(directory, [], ['CUSTOM.other']);
const filePath = 'dist/Node1/Node1.node.js'; const filePath = 'dist/Node1/Node1.node.js';

View file

@ -101,6 +101,37 @@ describe('ErrorReporter', () => {
const result = await errorReporter.beforeSend(event, { originalException }); const result = await errorReporter.beforeSend(event, { originalException });
expect(result).toBeNull(); expect(result).toBeNull();
}); });
describe('beforeSendFilter', () => {
const newErrorReportedWithBeforeSendFilter = (beforeSendFilter: jest.Mock) => {
const errorReporter = new ErrorReporter(mock());
// @ts-expect-error - beforeSendFilter is private
errorReporter.beforeSendFilter = beforeSendFilter;
return errorReporter;
};
it('should filter out based on the beforeSendFilter', async () => {
const beforeSendFilter = jest.fn().mockReturnValue(true);
const errorReporter = newErrorReportedWithBeforeSendFilter(beforeSendFilter);
const hint = { originalException: new Error() };
const result = await errorReporter.beforeSend(event, hint);
expect(result).toBeNull();
expect(beforeSendFilter).toHaveBeenCalledWith(event, hint);
});
it('should not filter out when beforeSendFilter returns false', async () => {
const beforeSendFilter = jest.fn().mockReturnValue(false);
const errorReporter = newErrorReportedWithBeforeSendFilter(beforeSendFilter);
const hint = { originalException: new Error() };
const result = await errorReporter.beforeSend(event, hint);
expect(result).toEqual(event);
expect(beforeSendFilter).toHaveBeenCalledWith(event, hint);
});
});
}); });
describe('error', () => { describe('error', () => {

View file

@ -136,6 +136,12 @@ defineExpose({
<template v-if="$slots.suffix" #suffix> <template v-if="$slots.suffix" #suffix>
<slot name="suffix" /> <slot name="suffix" />
</template> </template>
<template v-if="$slots.footer" #footer>
<slot name="footer" />
</template>
<template v-if="$slots.empty" #empty>
<slot name="empty" />
</template>
<slot></slot> <slot></slot>
</ElSelect> </ElSelect>
</div> </div>

View file

@ -37,6 +37,8 @@
// Danger // Danger
--color-danger-shade-1: var(--prim-color-alt-c-shade-100); --color-danger-shade-1: var(--prim-color-alt-c-shade-100);
--color-danger: var(--prim-color-alt-c); --color-danger: var(--prim-color-alt-c);
--color-danger-light: var(--prim-color-alt-c-tint-150);
--color-danger-light-2: var(--prim-color-alt-c-tint-250);
--color-danger-tint-1: var(--prim-color-alt-c-tint-400); --color-danger-tint-1: var(--prim-color-alt-c-tint-400);
--color-danger-tint-2: var(--prim-color-alt-c-tint-450); --color-danger-tint-2: var(--prim-color-alt-c-tint-450);

View file

@ -32,6 +32,7 @@ export async function getAllCredentials(
): Promise<ICredentialsResponse[]> { ): Promise<ICredentialsResponse[]> {
return await makeRestApiRequest(context, 'GET', '/credentials', { return await makeRestApiRequest(context, 'GET', '/credentials', {
...(includeScopes ? { includeScopes } : {}), ...(includeScopes ? { includeScopes } : {}),
includeData: true,
...(filter ? { filter } : {}), ...(filter ? { filter } : {}),
}); });
} }

View file

@ -33,7 +33,7 @@ export const pushWorkfolder = async (
export const pullWorkfolder = async ( export const pullWorkfolder = async (
context: IRestApiContext, context: IRestApiContext,
data: PullWorkFolderRequestDto, data: PullWorkFolderRequestDto,
): Promise<void> => { ): Promise<SourceControlledFile[]> => {
return await makeRestApiRequest(context, 'POST', `${sourceControlApiRoot}/pull-workfolder`, data); return await makeRestApiRequest(context, 'POST', `${sourceControlApiRoot}/pull-workfolder`, data);
}; };

View file

@ -29,6 +29,7 @@ const props = withDefaults(
defineProps<{ defineProps<{
data: ICredentialsResponse; data: ICredentialsResponse;
readOnly?: boolean; readOnly?: boolean;
needsSetup?: boolean;
}>(), }>(),
{ {
data: () => ({ data: () => ({
@ -146,6 +147,9 @@ function moveResource() {
<N8nBadge v-if="readOnly" class="ml-3xs" theme="tertiary" bold> <N8nBadge v-if="readOnly" class="ml-3xs" theme="tertiary" bold>
{{ locale.baseText('credentials.item.readonly') }} {{ locale.baseText('credentials.item.readonly') }}
</N8nBadge> </N8nBadge>
<N8nBadge v-if="needsSetup" class="ml-3xs" theme="warning">
{{ locale.baseText('credentials.item.needsSetup') }}
</N8nBadge>
</n8n-heading> </n8n-heading>
</template> </template>
<div :class="$style.cardDescription"> <div :class="$style.cardDescription">
@ -195,10 +199,6 @@ function moveResource() {
.cardHeading { .cardHeading {
font-size: var(--font-size-s); font-size: var(--font-size-s);
padding: var(--spacing-s) 0 0; padding: var(--spacing-s) 0 0;
span {
color: var(--color-text-light);
}
} }
.cardDescription { .cardDescription {

View file

@ -3,7 +3,7 @@ import { waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { merge } from 'lodash-es'; import { merge } from 'lodash-es';
import { SOURCE_CONTROL_PULL_MODAL_KEY, STORES } from '@/constants'; import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY, STORES } from '@/constants';
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils'; import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue'; import MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue';
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
@ -18,8 +18,9 @@ let rbacStore: ReturnType<typeof useRBACStore>;
const showMessage = vi.fn(); const showMessage = vi.fn();
const showError = vi.fn(); const showError = vi.fn();
const showToast = vi.fn();
vi.mock('@/composables/useToast', () => ({ vi.mock('@/composables/useToast', () => ({
useToast: () => ({ showMessage, showError }), useToast: () => ({ showMessage, showError, showToast }),
})); }));
const renderComponent = createComponentRenderer(MainSidebarSourceControl); const renderComponent = createComponentRenderer(MainSidebarSourceControl);
@ -131,5 +132,129 @@ describe('MainSidebarSourceControl', () => {
), ),
); );
}); });
it('should open push modal when there are changes', async () => {
const status = [
{
id: '014da93897f146d2b880-baa374b9d02d',
name: 'vuelfow2',
type: 'workflow' as const,
status: 'created' as const,
location: 'local' as const,
conflict: false,
file: '/014da93897f146d2b880-baa374b9d02d.json',
updatedAt: '2025-01-09T13:12:24.580Z',
},
];
vi.spyOn(sourceControlStore, 'getAggregatedStatus').mockResolvedValueOnce(status);
const openModalSpy = vi.spyOn(uiStore, 'openModalWithData');
const { getAllByRole } = renderComponent({
pinia,
props: { isCollapsed: false },
});
await userEvent.click(getAllByRole('button')[1]);
await waitFor(() =>
expect(openModalSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: SOURCE_CONTROL_PUSH_MODAL_KEY,
data: expect.objectContaining({
status,
}),
}),
),
);
});
it("should show user's feedback when pulling", async () => {
vi.spyOn(sourceControlStore, 'pullWorkfolder').mockResolvedValueOnce([
{
id: '014da93897f146d2b880-baa374b9d02d',
name: 'vuelfow2',
type: 'workflow',
status: 'created',
location: 'remote',
conflict: false,
file: '/014da93897f146d2b880-baa374b9d02d.json',
updatedAt: '2025-01-09T13:12:24.580Z',
},
{
id: 'a102c0b9-28ac-43cb-950e-195723a56d54',
name: 'Gmail account',
type: 'credential',
status: 'created',
location: 'remote',
conflict: false,
file: '/a102c0b9-28ac-43cb-950e-195723a56d54.json',
updatedAt: '2025-01-09T13:12:24.586Z',
},
{
id: 'variables',
name: 'variables',
type: 'variables',
status: 'modified',
location: 'remote',
conflict: false,
file: '/variable_stubs.json',
updatedAt: '2025-01-09T13:12:24.588Z',
},
{
id: 'mappings',
name: 'tags',
type: 'tags',
status: 'modified',
location: 'remote',
conflict: false,
file: '/tags.json',
updatedAt: '2024-12-16T12:53:12.155Z',
},
]);
const { getAllByRole } = renderComponent({
pinia,
props: { isCollapsed: false },
});
await userEvent.click(getAllByRole('button')[0]);
await waitFor(() => {
expect(showToast).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
title: 'Finish setting up your new variables to use in workflows',
}),
);
expect(showToast).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
title: 'Finish setting up your new credentials to use in workflows',
}),
);
expect(showToast).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
message: '1 Workflow, 1 Credential, Variables, and Tags were pulled',
}),
);
});
});
it('should show feedback where there are no change to pull', async () => {
vi.spyOn(sourceControlStore, 'pullWorkfolder').mockResolvedValueOnce([]);
const { getAllByRole } = renderComponent({
pinia,
props: { isCollapsed: false },
});
await userEvent.click(getAllByRole('button')[0]);
await waitFor(() => {
expect(showMessage).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Up to date',
}),
);
});
});
}); });
}); });

View file

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, nextTick, ref } from 'vue'; import { computed, h, nextTick, ref } from 'vue';
import { createEventBus } from 'n8n-design-system/utils'; import { createEventBus } from 'n8n-design-system/utils';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { hasPermission } from '@/utils/rbac/permissions'; import { hasPermission } from '@/utils/rbac/permissions';
@ -9,6 +9,9 @@ import { useUIStore } from '@/stores/ui.store';
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY } from '@/constants'; import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY } from '@/constants';
import { sourceControlEventBus } from '@/event-bus/source-control'; import { sourceControlEventBus } from '@/event-bus/source-control';
import { groupBy } from 'lodash-es';
import { RouterLink } from 'vue-router';
import { VIEWS } from '@/constants';
import type { SourceControlledFile } from '@n8n/api-types'; import type { SourceControlledFile } from '@n8n/api-types';
defineProps<{ defineProps<{
@ -64,48 +67,106 @@ async function pushWorkfolder() {
} }
} }
const variablesToast = {
title: i18n.baseText('settings.sourceControl.pull.upToDate.variables.title'),
message: h(RouterLink, { to: { name: VIEWS.VARIABLES } }, () =>
i18n.baseText('settings.sourceControl.pull.upToDate.variables.description'),
),
type: 'info' as const,
closeOnClick: true,
duration: 0,
};
const credentialsToast = {
title: i18n.baseText('settings.sourceControl.pull.upToDate.credentials.title'),
message: h(RouterLink, { to: { name: VIEWS.CREDENTIALS, query: { setupNeeded: 'true' } } }, () =>
i18n.baseText('settings.sourceControl.pull.upToDate.credentials.description'),
),
type: 'info' as const,
closeOnClick: true,
duration: 0,
};
const pullMessage = ({
credential,
tags,
variables,
workflow,
}: Partial<Record<SourceControlledFile['type'], SourceControlledFile[]>>) => {
const messages: string[] = [];
if (workflow?.length) {
messages.push(
i18n.baseText('generic.workflow', {
adjustToNumber: workflow.length,
interpolate: { count: workflow.length },
}),
);
}
if (credential?.length) {
messages.push(
i18n.baseText('generic.credential', {
adjustToNumber: credential.length,
interpolate: { count: credential.length },
}),
);
}
if (variables?.length) {
messages.push(i18n.baseText('generic.variable_plural'));
}
if (tags?.length) {
messages.push(i18n.baseText('generic.tag_plural'));
}
return [
new Intl.ListFormat(i18n.locale, { style: 'long', type: 'conjunction' }).format(messages),
'were pulled',
].join(' ');
};
async function pullWorkfolder() { async function pullWorkfolder() {
loadingService.startLoading(); loadingService.startLoading();
loadingService.setLoadingText(i18n.baseText('settings.sourceControl.loading.pull')); loadingService.setLoadingText(i18n.baseText('settings.sourceControl.loading.pull'));
try { try {
const status: SourceControlledFile[] = const status = await sourceControlStore.pullWorkfolder(false);
((await sourceControlStore.pullWorkfolder(false)) as unknown as SourceControlledFile[]) || [];
const statusWithoutLocallyCreatedWorkflows = status.filter((file) => { if (!status.length) {
return !(file.type === 'workflow' && file.status === 'created' && file.location === 'local');
});
if (statusWithoutLocallyCreatedWorkflows.length === 0) {
toast.showMessage({ toast.showMessage({
title: i18n.baseText('settings.sourceControl.pull.upToDate.title'), title: i18n.baseText('settings.sourceControl.pull.upToDate.title'),
message: i18n.baseText('settings.sourceControl.pull.upToDate.description'), message: i18n.baseText('settings.sourceControl.pull.upToDate.description'),
type: 'success', type: 'success',
}); });
} else { return;
toast.showMessage({ }
const { credential, tags, variables, workflow } = groupBy(status, 'type');
const toastMessages = [
...(variables?.length ? [variablesToast] : []),
...(credential?.length ? [credentialsToast] : []),
{
title: i18n.baseText('settings.sourceControl.pull.success.title'), title: i18n.baseText('settings.sourceControl.pull.success.title'),
type: 'success', message: pullMessage({ credential, tags, variables, workflow }),
}); type: 'success' as const,
},
];
const incompleteFileTypes = ['variables', 'credential']; for (const message of toastMessages) {
const hasVariablesOrCredentials = (status || []).some((file) => { /**
return incompleteFileTypes.includes(file.type); * the toasts stack in a reversed way, resulting in
}); * Success
* Credentials
* Variables
*/
//
toast.showToast(message);
await nextTick();
}
if (hasVariablesOrCredentials) {
void nextTick(() => {
toast.showMessage({
message: i18n.baseText('settings.sourceControl.pull.oneLastStep.description'),
title: i18n.baseText('settings.sourceControl.pull.oneLastStep.title'),
type: 'info',
duration: 0,
showClose: true,
offset: 0,
});
});
}
}
sourceControlEventBus.emit('pull'); sourceControlEventBus.emit('pull');
} catch (error) { } catch (error) {
const errorResponse = error.response; const errorResponse = error.response;

View file

@ -141,9 +141,10 @@ export function AIView(_nodes: SimplifiedNodeType[]): NodeView {
const chainNodes = getAiNodesBySubcategory(nodeTypesStore.allLatestNodeTypes, AI_CATEGORY_CHAINS); const chainNodes = getAiNodesBySubcategory(nodeTypesStore.allLatestNodeTypes, AI_CATEGORY_CHAINS);
const agentNodes = getAiNodesBySubcategory(nodeTypesStore.allLatestNodeTypes, AI_CATEGORY_AGENTS); const agentNodes = getAiNodesBySubcategory(nodeTypesStore.allLatestNodeTypes, AI_CATEGORY_AGENTS);
const websiteCategoryURL = templatesStore.websiteTemplateRepositoryParameters; const websiteCategoryURLParams = templatesStore.websiteTemplateRepositoryParameters;
websiteCategoryURLParams.append('utm_user_role', 'AdvancedAI');
websiteCategoryURL.append('utm_user_role', 'AdvancedAI'); const websiteCategoryURL =
templatesStore.constructTemplateRepositoryURL(websiteCategoryURLParams);
return { return {
value: AI_NODE_CREATOR_VIEW, value: AI_NODE_CREATOR_VIEW,
@ -158,7 +159,7 @@ export function AIView(_nodes: SimplifiedNodeType[]): NodeView {
icon: 'box-open', icon: 'box-open',
description: i18n.baseText('nodeCreator.aiPanel.linkItem.description'), description: i18n.baseText('nodeCreator.aiPanel.linkItem.description'),
name: 'ai_templates_root', name: 'ai_templates_root',
url: websiteCategoryURL.toString(), url: websiteCategoryURL,
tag: { tag: {
type: 'info', type: 'info',
text: i18n.baseText('nodeCreator.triggerHelperPanel.manualTriggerTag'), text: i18n.baseText('nodeCreator.triggerHelperPanel.manualTriggerTag'),

View file

@ -1,5 +1,6 @@
import { describe, it } from 'vitest'; import { describe, it } from 'vitest';
import { fireEvent, screen } from '@testing-library/vue'; import { screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import NodeCredentials from './NodeCredentials.vue'; import NodeCredentials from './NodeCredentials.vue';
import type { RenderOptions } from '@/__tests__/render'; import type { RenderOptions } from '@/__tests__/render';
@ -8,6 +9,7 @@ import { useCredentialsStore } from '@/stores/credentials.store';
import { mockedStore } from '@/__tests__/utils'; import { mockedStore } from '@/__tests__/utils';
import type { INodeUi } from '@/Interface'; import type { INodeUi } from '@/Interface';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import { useUIStore } from '../stores/ui.store';
const httpNode: INodeUi = { const httpNode: INodeUi = {
parameters: { parameters: {
@ -67,6 +69,7 @@ describe('NodeCredentials', () => {
const credentialsStore = mockedStore(useCredentialsStore); const credentialsStore = mockedStore(useCredentialsStore);
const ndvStore = mockedStore(useNDVStore); const ndvStore = mockedStore(useNDVStore);
const uiStore = mockedStore(useUIStore);
beforeAll(() => { beforeAll(() => {
credentialsStore.state.credentialTypes = { credentialsStore.state.credentialTypes = {
@ -120,7 +123,7 @@ describe('NodeCredentials', () => {
const credentialsSelect = screen.getByTestId('node-credentials-select'); const credentialsSelect = screen.getByTestId('node-credentials-select');
await fireEvent.click(credentialsSelect); await userEvent.click(credentialsSelect);
expect(screen.queryByText('OpenAi account')).toBeInTheDocument(); expect(screen.queryByText('OpenAi account')).toBeInTheDocument();
}); });
@ -150,7 +153,7 @@ describe('NodeCredentials', () => {
const credentialsSelect = screen.getByTestId('node-credentials-select'); const credentialsSelect = screen.getByTestId('node-credentials-select');
await fireEvent.click(credentialsSelect); await userEvent.click(credentialsSelect);
expect(screen.queryByText('OpenAi account')).toBeInTheDocument(); expect(screen.queryByText('OpenAi account')).toBeInTheDocument();
expect(screen.queryByText('OpenAi account 2')).not.toBeInTheDocument(); expect(screen.queryByText('OpenAi account 2')).not.toBeInTheDocument();
@ -188,9 +191,69 @@ describe('NodeCredentials', () => {
const credentialsSelect = screen.getByTestId('node-credentials-select'); const credentialsSelect = screen.getByTestId('node-credentials-select');
await fireEvent.click(credentialsSelect); await userEvent.click(credentialsSelect);
expect(screen.queryByText('OpenAi account')).toBeInTheDocument(); expect(screen.queryByText('OpenAi account')).toBeInTheDocument();
expect(screen.queryByText('OpenAi account 2')).toBeInTheDocument(); expect(screen.queryByText('OpenAi account 2')).toBeInTheDocument();
}); });
it('should filter available credentials in the dropdown', async () => {
ndvStore.activeNode = httpNode;
credentialsStore.state.credentials = {
c8vqdPpPClh4TgIO: {
id: 'c8vqdPpPClh4TgIO',
name: 'OpenAi account',
type: 'openAiApi',
isManaged: false,
createdAt: '',
updatedAt: '',
},
test: {
id: 'test',
name: 'Test OpenAi account',
type: 'openAiApi',
isManaged: false,
createdAt: '',
updatedAt: '',
},
};
renderComponent();
const credentialsSelect = screen.getByTestId('node-credentials-select');
await userEvent.click(credentialsSelect);
expect(screen.queryByText('OpenAi account')).toBeInTheDocument();
expect(screen.queryByText('Test OpenAi account')).toBeInTheDocument();
const credentialSearch = credentialsSelect.querySelector('input') as HTMLElement;
await userEvent.type(credentialSearch, 'test');
expect(screen.queryByText('OpenAi account')).not.toBeInTheDocument();
expect(screen.queryByText('Test OpenAi account')).toBeInTheDocument();
});
it('should open the new credential modal when clicked', async () => {
ndvStore.activeNode = httpNode;
credentialsStore.state.credentials = {
c8vqdPpPClh4TgIO: {
id: 'c8vqdPpPClh4TgIO',
name: 'OpenAi account',
type: 'openAiApi',
isManaged: false,
createdAt: '',
updatedAt: '',
},
};
renderComponent();
const credentialsSelect = screen.getByTestId('node-credentials-select');
await userEvent.click(credentialsSelect);
await userEvent.click(screen.getByTestId('node-credentials-select-item-new'));
expect(uiStore.openNewCredential).toHaveBeenCalledWith('openAiApi', true);
});
}); });

View file

@ -2,11 +2,12 @@
import type { ICredentialsResponse, INodeUi, INodeUpdatePropertiesInformation } from '@/Interface'; import type { ICredentialsResponse, INodeUi, INodeUpdatePropertiesInformation } from '@/Interface';
import { import {
HTTP_REQUEST_NODE_TYPE, HTTP_REQUEST_NODE_TYPE,
type ICredentialType,
type INodeCredentialDescription, type INodeCredentialDescription,
type INodeCredentialsDetails, type INodeCredentialsDetails,
type NodeParameterValueType, type NodeParameterValueType,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useNodeHelpers } from '@/composables/useNodeHelpers'; import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
@ -31,6 +32,7 @@ import {
updateNodeAuthType, updateNodeAuthType,
} from '@/utils/nodeTypesUtils'; } from '@/utils/nodeTypesUtils';
import { import {
N8nIcon,
N8nInput, N8nInput,
N8nInputLabel, N8nInputLabel,
N8nOption, N8nOption,
@ -67,7 +69,7 @@ const emit = defineEmits<{
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const i18n = useI18n(); const i18n = useI18n();
const NEW_CREDENTIALS_TEXT = `- ${i18n.baseText('nodeCredentials.createNew')} -`; const NEW_CREDENTIALS_TEXT = i18n.baseText('nodeCredentials.createNew');
const credentialsStore = useCredentialsStore(); const credentialsStore = useCredentialsStore();
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
@ -79,7 +81,9 @@ const nodeHelpers = useNodeHelpers();
const toast = useToast(); const toast = useToast();
const subscribedToCredentialType = ref(''); const subscribedToCredentialType = ref('');
const filter = ref('');
const listeningForAuthChange = ref(false); const listeningForAuthChange = ref(false);
const selectRefs = ref<Array<InstanceType<typeof N8nSelect>>>([]);
const credentialTypesNode = computed(() => const credentialTypesNode = computed(() =>
credentialTypesNodeDescription.value.map( credentialTypesNodeDescription.value.map(
@ -344,9 +348,8 @@ function onCredentialSelected(
credentialId: string | null | undefined, credentialId: string | null | undefined,
showAuthOptions = false, showAuthOptions = false,
) { ) {
const newCredentialOptionSelected = credentialId === NEW_CREDENTIALS_TEXT; if (!credentialId) {
if (!credentialId || newCredentialOptionSelected) { createNewCredential(credentialType, false, showAuthOptions);
createNewCredential(credentialType, newCredentialOptionSelected, showAuthOptions);
return; return;
} }
@ -501,6 +504,20 @@ function getCredentialsFieldLabel(credentialType: INodeCredentialDescription): s
} }
return i18n.baseText('nodeCredentials.credentialsLabel'); return i18n.baseText('nodeCredentials.credentialsLabel');
} }
function setFilter(newFilter = '') {
filter.value = newFilter;
}
function matches(needle: string, haystack: string) {
return haystack.toLocaleLowerCase().includes(needle);
}
async function onClickCreateCredential(type: ICredentialType | INodeCredentialDescription) {
selectRefs.value.forEach((select) => select.blur());
await nextTick();
createNewCredential(type.name, true, showMixedCredentials(type));
}
</script> </script>
<template> <template>
@ -530,16 +547,20 @@ function getCredentialsFieldLabel(credentialType: INodeCredentialDescription): s
data-test-id="node-credentials-select" data-test-id="node-credentials-select"
> >
<N8nSelect <N8nSelect
ref="selectRefs"
:model-value="getSelectedId(type.name)" :model-value="getSelectedId(type.name)"
:placeholder="getSelectPlaceholder(type.name, getIssues(type.name))" :placeholder="getSelectPlaceholder(type.name, getIssues(type.name))"
size="small" size="small"
filterable
:filter-method="setFilter"
:popper-class="$style.selectPopper"
@update:model-value=" @update:model-value="
(value: string) => onCredentialSelected(type.name, value, showMixedCredentials(type)) (value: string) => onCredentialSelected(type.name, value, showMixedCredentials(type))
" "
@blur="emit('blur', 'credentials')" @blur="emit('blur', 'credentials')"
> >
<N8nOption <N8nOption
v-for="item in options" v-for="item in options.filter((o) => matches(filter, o.name))"
:key="item.id" :key="item.id"
:data-test-id="`node-credentials-select-item-${item.id}`" :data-test-id="`node-credentials-select-item-${item.id}`"
:label="item.name" :label="item.name"
@ -550,13 +571,17 @@ function getCredentialsFieldLabel(credentialType: INodeCredentialDescription): s
<N8nText size="small">{{ item.typeDisplayName }}</N8nText> <N8nText size="small">{{ item.typeDisplayName }}</N8nText>
</div> </div>
</N8nOption> </N8nOption>
<N8nOption <template #empty> </template>
:key="NEW_CREDENTIALS_TEXT" <template #footer>
<div
data-test-id="node-credentials-select-item-new" data-test-id="node-credentials-select-item-new"
:value="NEW_CREDENTIALS_TEXT" :class="['clickable', $style.newCredential]"
:label="NEW_CREDENTIALS_TEXT" @click="onClickCreateCredential(type)"
> >
</N8nOption> <N8nIcon size="xsmall" icon="plus" />
<N8nText bold>{{ NEW_CREDENTIALS_TEXT }}</N8nText>
</div>
</template>
</N8nSelect> </N8nSelect>
<div v-if="getIssues(type.name).length && !hideIssues" :class="$style.warning"> <div v-if="getIssues(type.name).length && !hideIssues" :class="$style.warning">
@ -567,7 +592,7 @@ function getCredentialsFieldLabel(credentialType: INodeCredentialDescription): s
:items="getIssues(type.name)" :items="getIssues(type.name)"
/> />
</template> </template>
<font-awesome-icon icon="exclamation-triangle" /> <N8nIcon icon="exclamation-triangle" />
</N8nTooltip> </N8nTooltip>
</div> </div>
@ -576,7 +601,7 @@ function getCredentialsFieldLabel(credentialType: INodeCredentialDescription): s
:class="$style.edit" :class="$style.edit"
data-test-id="credential-edit-button" data-test-id="credential-edit-button"
> >
<font-awesome-icon <N8nIcon
icon="pen" icon="pen"
class="clickable" class="clickable"
:title="i18n.baseText('nodeCredentials.updateCredential')" :title="i18n.baseText('nodeCredentials.updateCredential')"
@ -598,10 +623,25 @@ function getCredentialsFieldLabel(credentialType: INodeCredentialDescription): s
} }
} }
.selectPopper {
:global(.el-select-dropdown__list) {
padding: 0;
}
:has(.newCredential:hover) :global(.hover) {
background-color: transparent;
}
&:not(:has(li)) .newCredential {
border-top: none;
box-shadow: none;
border-radius: var(--border-radius-base);
}
}
.warning { .warning {
min-width: 20px; margin-left: var(--spacing-4xs);
margin-left: 5px; color: var(--color-danger-light);
color: #ff8080;
font-size: var(--font-size-s); font-size: var(--font-size-s);
} }
@ -610,8 +650,7 @@ function getCredentialsFieldLabel(credentialType: INodeCredentialDescription): s
justify-content: center; justify-content: center;
align-items: center; align-items: center;
color: var(--color-text-base); color: var(--color-text-base);
min-width: 20px; margin-left: var(--spacing-3xs);
margin-left: 5px;
font-size: var(--font-size-s); font-size: var(--font-size-s);
} }
@ -629,4 +668,21 @@ function getCredentialsFieldLabel(credentialType: INodeCredentialDescription): s
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.newCredential {
display: flex;
gap: var(--spacing-3xs);
align-items: center;
font-weight: var(--font-weight-bold);
padding: var(--spacing-xs) var(--spacing-m);
background-color: var(--color-background-light);
border-top: var(--border-base);
box-shadow: var(--box-shadow-light);
clip-path: inset(-12px 0 0 0); // Only show box shadow on top
&:hover {
color: var(--color-primary);
}
}
</style> </style>

View file

@ -4,7 +4,6 @@ import {
NodeConnectionType, NodeConnectionType,
type IRunData, type IRunData,
type IRunExecutionData, type IRunExecutionData,
type NodeError,
type Workflow, type Workflow,
} from 'n8n-workflow'; } from 'n8n-workflow';
import RunData from './RunData.vue'; import RunData from './RunData.vue';
@ -120,14 +119,17 @@ const hasAiMetadata = computed(() => {
return false; return false;
}); });
// Determine the initial output mode to logs if the node has an error and the logs are available const hasError = computed(() =>
const defaultOutputMode = computed<OutputType>(() => { Boolean(
const hasError =
workflowRunData.value && workflowRunData.value &&
node.value && node.value &&
(workflowRunData.value[node.value.name]?.[props.runIndex]?.error as NodeError); workflowRunData.value[node.value.name]?.[props.runIndex]?.error,
),
);
return Boolean(hasError) && hasAiMetadata.value ? OUTPUT_TYPE.LOGS : OUTPUT_TYPE.REGULAR; // Determine the initial output mode to logs if the node has an error and the logs are available
const defaultOutputMode = computed<OutputType>(() => {
return hasError.value && hasAiMetadata.value ? OUTPUT_TYPE.LOGS : OUTPUT_TYPE.REGULAR;
}); });
const isNodeRunning = computed(() => { const isNodeRunning = computed(() => {
@ -216,7 +218,7 @@ const canPinData = computed(() => {
}); });
const allToolsWereUnusedNotice = computed(() => { const allToolsWereUnusedNotice = computed(() => {
if (!node.value || runsCount.value === 0) return undefined; if (!node.value || runsCount.value === 0 || hasError.value) return undefined;
// With pinned data there's no clear correct answer for whether // With pinned data there's no clear correct answer for whether
// we should use historic or current parents, so we don't show the notice, // we should use historic or current parents, so we don't show the notice,

View file

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import TitledList from '@/components/TitledList.vue'; import TitledList from '@/components/TitledList.vue';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { N8nTooltip, N8nIcon } from 'n8n-design-system';
defineProps<{ defineProps<{
issues: string[]; issues: string[];
@ -11,22 +12,21 @@ const i18n = useI18n();
<template> <template>
<div v-if="issues.length" :class="$style['parameter-issues']" data-test-id="parameter-issues"> <div v-if="issues.length" :class="$style['parameter-issues']" data-test-id="parameter-issues">
<n8n-tooltip placement="top"> <N8nTooltip placement="top">
<template #content> <template #content>
<TitledList :title="`${i18n.baseText('parameterInput.issues')}:`" :items="issues" /> <TitledList :title="`${i18n.baseText('parameterInput.issues')}:`" :items="issues" />
</template> </template>
<font-awesome-icon icon="exclamation-triangle" /> <N8nIcon icon="exclamation-triangle" />
</n8n-tooltip> </N8nTooltip>
</div> </div>
</template> </template>
<style module lang="scss"> <style module lang="scss">
.parameter-issues { .parameter-issues {
width: 20px;
text-align: right; text-align: right;
float: right; float: right;
color: #ff8080; color: var(--color-danger-light);
font-size: var(--font-size-s); font-size: var(--font-size-s);
padding-left: var(--spacing-4xs); padding-left: var(--spacing-3xs);
} }
</style> </style>

View file

@ -6,7 +6,13 @@ import type {
CanvasEventBusEvents, CanvasEventBusEvents,
ConnectStartEvent, ConnectStartEvent,
} from '@/types'; } from '@/types';
import type { Connection, XYPosition, NodeDragEvent, GraphNode } from '@vue-flow/core'; import type {
Connection,
XYPosition,
NodeDragEvent,
NodeMouseEvent,
GraphNode,
} from '@vue-flow/core';
import { useVueFlow, VueFlow, PanelPosition, MarkerType } from '@vue-flow/core'; import { useVueFlow, VueFlow, PanelPosition, MarkerType } from '@vue-flow/core';
import { MiniMap } from '@vue-flow/minimap'; import { MiniMap } from '@vue-flow/minimap';
import Node from './elements/nodes/CanvasNode.vue'; import Node from './elements/nodes/CanvasNode.vue';
@ -272,6 +278,14 @@ function onNodeDragStop(event: NodeDragEvent) {
onUpdateNodesPosition(event.nodes.map(({ id, position }) => ({ id, position }))); onUpdateNodesPosition(event.nodes.map(({ id, position }) => ({ id, position })));
} }
function onNodeClick({ event, node }: NodeMouseEvent) {
if (event.ctrlKey || event.metaKey || selectedNodes.value.length < 2) {
return;
}
onSelectNodes({ ids: [node.id] });
}
function onSelectionDragStop(event: NodeDragEvent) { function onSelectionDragStop(event: NodeDragEvent) {
onUpdateNodesPosition(event.nodes.map(({ id, position }) => ({ id, position }))); onUpdateNodesPosition(event.nodes.map(({ id, position }) => ({ id, position })));
} }
@ -676,6 +690,7 @@ provide(CanvasKey, {
@move-start="onPaneMoveStart" @move-start="onPaneMoveStart"
@move-end="onPaneMoveEnd" @move-end="onPaneMoveEnd"
@node-drag-stop="onNodeDragStop" @node-drag-stop="onNodeDragStop"
@node-click="onNodeClick"
@selection-drag-stop="onSelectionDragStop" @selection-drag-stop="onSelectionDragStop"
@dragover="onDragOver" @dragover="onDragOver"
@drop="onDrop" @drop="onDrop"

View file

@ -44,7 +44,18 @@ const filtersLength = computed(() => {
} }
const value = props.modelValue[key]; const value = props.modelValue[key];
length += (Array.isArray(value) ? value.length > 0 : value !== '') ? 1 : 0;
if (value === true) {
length += 1;
}
if (Array.isArray(value) && value.length) {
length += 1;
}
if (typeof value === 'string' && value !== '') {
length += 1;
}
}); });
return length; return length;

View file

@ -168,13 +168,19 @@ const focusSearchInput = () => {
}; };
const hasAppliedFilters = (): boolean => { const hasAppliedFilters = (): boolean => {
return !!filterKeys.value.find( return !!filterKeys.value.find((key) => {
(key) => if (key === 'search') return false;
key !== 'search' &&
(Array.isArray(props.filters[key]) if (typeof props.filters[key] === 'boolean') {
? props.filters[key].length > 0 return props.filters[key];
: props.filters[key] !== ''), }
);
if (Array.isArray(props.filters[key])) {
return props.filters[key].length > 0;
}
return props.filters[key] !== '';
});
}; };
const setRowsPerPage = (numberOfRowsPerPage: number) => { const setRowsPerPage = (numberOfRowsPerPage: number) => {

View file

@ -995,6 +995,18 @@ describe('useCanvasOperations', () => {
expect(ndvStore.activeNodeName).toBe('Existing Node'); expect(ndvStore.activeNodeName).toBe('Existing Node');
}); });
it('should set node as dirty when node is set active', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const node = createTestNode();
workflowsStore.getNodeById.mockImplementation(() => node);
const { setNodeActive } = useCanvasOperations({ router });
setNodeActive(node.id);
expect(workflowsStore.setNodePristine).toHaveBeenCalledWith(node.name, false);
});
}); });
describe('setNodeActiveByName', () => { describe('setNodeActiveByName', () => {

View file

@ -381,6 +381,7 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
return; return;
} }
workflowsStore.setNodePristine(node.name, false);
setNodeActiveByName(node.name); setNodeActiveByName(node.name);
} }
@ -1923,7 +1924,7 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
workflowsStore.setWorkflowExecutionData(data); workflowsStore.setWorkflowExecutionData(data);
if (data.mode !== 'manual') { if (!['manual', 'evaluation'].includes(data.mode)) {
workflowsStore.setWorkflowPinData({}); workflowsStore.setWorkflowPinData({});
} }

View file

@ -597,7 +597,6 @@
"credentialsList.confirmMessage.confirmButtonText": "Yes, delete", "credentialsList.confirmMessage.confirmButtonText": "Yes, delete",
"credentialsList.confirmMessage.headline": "Delete Credential?", "credentialsList.confirmMessage.headline": "Delete Credential?",
"credentialsList.confirmMessage.message": "Are you sure you want to delete {credentialName}?", "credentialsList.confirmMessage.message": "Are you sure you want to delete {credentialName}?",
"credentialsList.createNewCredential": "Create New Credential",
"credentialsList.created": "Created", "credentialsList.created": "Created",
"credentialsList.credentials": "Credentials", "credentialsList.credentials": "Credentials",
"credentialsList.deleteCredential": "Delete Credential", "credentialsList.deleteCredential": "Delete Credential",
@ -625,8 +624,11 @@
"credentials.item.created": "Created", "credentials.item.created": "Created",
"credentials.item.owner": "Owner", "credentials.item.owner": "Owner",
"credentials.item.readonly": "Read only", "credentials.item.readonly": "Read only",
"credentials.item.needsSetup": "Needs first setup",
"credentials.search.placeholder": "Search credentials...", "credentials.search.placeholder": "Search credentials...",
"credentials.filters.type": "Type", "credentials.filters.type": "Type",
"credentials.filters.setup": "Needs first setup",
"credentials.filters.status": "Status",
"credentials.filters.active": "Some credentials may be hidden since filters are applied.", "credentials.filters.active": "Some credentials may be hidden since filters are applied.",
"credentials.filters.active.reset": "Remove filters", "credentials.filters.active.reset": "Remove filters",
"credentials.sort.lastUpdated": "Sort by last updated", "credentials.sort.lastUpdated": "Sort by last updated",
@ -1187,7 +1189,7 @@
"nodeCreator.aiPanel.workflowTriggerDescription": "Runs the flow when called by the Execute Workflow node from a different workflow", "nodeCreator.aiPanel.workflowTriggerDescription": "Runs the flow when called by the Execute Workflow node from a different workflow",
"nodeCreator.nodeItem.triggerIconTitle": "Trigger Node", "nodeCreator.nodeItem.triggerIconTitle": "Trigger Node",
"nodeCreator.nodeItem.aiIconTitle": "LangChain AI Node", "nodeCreator.nodeItem.aiIconTitle": "LangChain AI Node",
"nodeCredentials.createNew": "Create New Credential", "nodeCredentials.createNew": "Create new credential",
"nodeCredentials.credentialFor": "Credential for {credentialType}", "nodeCredentials.credentialFor": "Credential for {credentialType}",
"nodeCredentials.credentialsLabel": "Credential to connect with", "nodeCredentials.credentialsLabel": "Credential to connect with",
"nodeCredentials.issues": "Issues", "nodeCredentials.issues": "Issues",
@ -1968,6 +1970,10 @@
"settings.sourceControl.pull.success.title": "Pulled successfully", "settings.sourceControl.pull.success.title": "Pulled successfully",
"settings.sourceControl.pull.upToDate.title": "Up to date", "settings.sourceControl.pull.upToDate.title": "Up to date",
"settings.sourceControl.pull.upToDate.description": "No workflow changes to pull from Git", "settings.sourceControl.pull.upToDate.description": "No workflow changes to pull from Git",
"settings.sourceControl.pull.upToDate.variables.title": "Finish setting up your new variables to use in workflows",
"settings.sourceControl.pull.upToDate.variables.description": "Review Variables",
"settings.sourceControl.pull.upToDate.credentials.title": "Finish setting up your new credentials to use in workflows",
"settings.sourceControl.pull.upToDate.credentials.description": "Review Credentials",
"settings.sourceControl.modals.pull.title": "Pull changes", "settings.sourceControl.modals.pull.title": "Pull changes",
"settings.sourceControl.modals.pull.description": "These workflows will be updated, and any local changes to them will be overridden. To keep the local version, push it before pulling.", "settings.sourceControl.modals.pull.description": "These workflows will be updated, and any local changes to them will be overridden. To keep the local version, push it before pulling.",
"settings.sourceControl.modals.pull.description.learnMore": "More info", "settings.sourceControl.modals.pull.description.learnMore": "More info",

View file

@ -167,6 +167,10 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, () => {
`${TEMPLATES_URLS.BASE_WEBSITE_URL}?${websiteTemplateRepositoryParameters.value.toString()}`, `${TEMPLATES_URLS.BASE_WEBSITE_URL}?${websiteTemplateRepositoryParameters.value.toString()}`,
); );
const constructTemplateRepositoryURL = (params: URLSearchParams): string => {
return `${TEMPLATES_URLS.BASE_WEBSITE_URL}?${params.toString()}`;
};
const addCategories = (_categories: ITemplatesCategory[]): void => { const addCategories = (_categories: ITemplatesCategory[]): void => {
categories.value = _categories; categories.value = _categories;
}; };
@ -427,6 +431,7 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, () => {
isSearchFinished, isSearchFinished,
hasCustomTemplatesHost, hasCustomTemplatesHost,
websiteTemplateRepositoryURL, websiteTemplateRepositoryURL,
constructTemplateRepositoryURL,
websiteTemplateRepositoryParameters, websiteTemplateRepositoryParameters,
addCategories, addCategories,
addCollections, addCollections,

View file

@ -5,28 +5,27 @@ import CredentialsView from '@/views/CredentialsView.vue';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { mockedStore } from '@/__tests__/utils'; import { mockedStore } from '@/__tests__/utils';
import { waitFor, within, fireEvent } from '@testing-library/vue'; import { waitFor, within, fireEvent } from '@testing-library/vue';
import { CREDENTIAL_SELECT_MODAL_KEY, STORES } from '@/constants'; import { CREDENTIAL_SELECT_MODAL_KEY, STORES, VIEWS } from '@/constants';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import type { Project } from '@/types/projects.types'; import type { Project } from '@/types/projects.types';
import { useRouter } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
import { flushPromises } from '@vue/test-utils';
import { CREDENTIAL_EMPTY_VALUE } from 'n8n-workflow';
vi.mock('@/composables/useGlobalEntityCreation', () => ({ vi.mock('@/composables/useGlobalEntityCreation', () => ({
useGlobalEntityCreation: () => ({ useGlobalEntityCreation: () => ({
menu: [], menu: [],
}), }),
})); }));
vi.mock('vue-router', async () => { const router = createRouter({
const actual = await vi.importActual('vue-router'); history: createWebHistory(),
const push = vi.fn(); routes: [
const replace = vi.fn(); {
return { path: '/:credentialId?',
...actual, name: VIEWS.CREDENTIALS,
// your mocked methods component: { template: '<div></div>' },
useRouter: () => ({ },
push, ],
replace,
}),
};
}); });
const initialState = { const initialState = {
@ -36,14 +35,14 @@ const initialState = {
}; };
const renderComponent = createComponentRenderer(CredentialsView, { const renderComponent = createComponentRenderer(CredentialsView, {
global: { stubs: { ProjectHeader: true } }, global: { stubs: { ProjectHeader: true }, plugins: [router] },
}); });
let router: ReturnType<typeof useRouter>;
describe('CredentialsView', () => { describe('CredentialsView', () => {
beforeEach(() => { beforeEach(async () => {
createTestingPinia({ initialState }); createTestingPinia({ initialState });
router = useRouter(); await router.push('/');
await router.isReady();
}); });
afterEach(() => { afterEach(() => {
@ -115,6 +114,7 @@ describe('CredentialsView', () => {
}); });
it('should update credentialId route param when opened', async () => { it('should update credentialId route param when opened', async () => {
const replaceSpy = vi.spyOn(router, 'replace');
const projectsStore = mockedStore(useProjectsStore); const projectsStore = mockedStore(useProjectsStore);
projectsStore.isProjectHome = false; projectsStore.isProjectHome = false;
projectsStore.currentProject = { scopes: ['credential:read'] } as Project; projectsStore.currentProject = { scopes: ['credential:read'] } as Project;
@ -137,8 +137,147 @@ describe('CredentialsView', () => {
*/ */
await fireEvent.click(getByTestId('resources-list-item')); await fireEvent.click(getByTestId('resources-list-item'));
await waitFor(() => await waitFor(() =>
expect(router.replace).toHaveBeenCalledWith({ params: { credentialId: '1' } }), expect(replaceSpy).toHaveBeenCalledWith(
expect.objectContaining({ params: { credentialId: '1' } }),
),
); );
}); });
}); });
describe('filters', () => {
it('should filter by type', async () => {
await router.push({ name: VIEWS.CREDENTIALS, query: { type: ['test'] } });
const credentialsStore = mockedStore(useCredentialsStore);
credentialsStore.allCredentialTypes = [
{
name: 'test',
displayName: 'test',
properties: [],
},
];
credentialsStore.allCredentials = [
{
id: '1',
name: 'test',
type: 'test',
createdAt: '2021-05-05T00:00:00Z',
updatedAt: '2021-05-05T00:00:00Z',
scopes: ['credential:update'],
isManaged: false,
},
{
id: '1',
name: 'test',
type: 'another',
createdAt: '2021-05-05T00:00:00Z',
updatedAt: '2021-05-05T00:00:00Z',
scopes: ['credential:update'],
isManaged: false,
},
];
const { getAllByTestId } = renderComponent();
expect(getAllByTestId('resources-list-item').length).toBe(1);
});
it('should filter by setupNeeded', async () => {
await router.push({ name: VIEWS.CREDENTIALS, query: { setupNeeded: 'true' } });
const credentialsStore = mockedStore(useCredentialsStore);
credentialsStore.allCredentials = [
{
id: '1',
name: 'test',
type: 'test',
createdAt: '2021-05-05T00:00:00Z',
updatedAt: '2021-05-05T00:00:00Z',
scopes: ['credential:update'],
isManaged: false,
data: {} as unknown as string,
},
{
id: '1',
name: 'test',
type: 'another',
createdAt: '2021-05-05T00:00:00Z',
updatedAt: '2021-05-05T00:00:00Z',
scopes: ['credential:update'],
isManaged: false,
data: { anyKey: 'any' } as unknown as string,
},
];
const { getAllByTestId, getByTestId } = renderComponent();
await flushPromises();
expect(getAllByTestId('resources-list-item').length).toBe(1);
await fireEvent.click(getByTestId('credential-filter-setup-needed'));
await waitFor(() => expect(getAllByTestId('resources-list-item').length).toBe(2));
});
it('should filter by setupNeeded when object keys are empty', async () => {
await router.push({ name: VIEWS.CREDENTIALS, query: { setupNeeded: 'true' } });
const credentialsStore = mockedStore(useCredentialsStore);
credentialsStore.allCredentials = [
{
id: '1',
name: 'credential needs setup',
type: 'test',
createdAt: '2021-05-05T00:00:00Z',
updatedAt: '2021-05-05T00:00:00Z',
scopes: ['credential:update'],
isManaged: false,
data: { anyKey: '' } as unknown as string,
},
{
id: '2',
name: 'random',
type: 'test',
createdAt: '2021-05-05T00:00:00Z',
updatedAt: '2021-05-05T00:00:00Z',
scopes: ['credential:update'],
isManaged: false,
data: { anyKey: 'any value' } as unknown as string,
},
];
const { getAllByTestId, getByTestId } = renderComponent();
await flushPromises();
expect(getAllByTestId('resources-list-item').length).toBe(1);
expect(getByTestId('resources-list-item').textContent).toContain('credential needs setup');
await fireEvent.click(getByTestId('credential-filter-setup-needed'));
await waitFor(() => expect(getAllByTestId('resources-list-item').length).toBe(2));
});
it('should filter by setupNeeded when object keys are "CREDENTIAL_EMPTY_VALUE"', async () => {
await router.push({ name: VIEWS.CREDENTIALS, query: { setupNeeded: 'true' } });
const credentialsStore = mockedStore(useCredentialsStore);
credentialsStore.allCredentials = [
{
id: '1',
name: 'credential needs setup',
type: 'test',
createdAt: '2021-05-05T00:00:00Z',
updatedAt: '2021-05-05T00:00:00Z',
scopes: ['credential:update'],
isManaged: false,
data: { anyKey: CREDENTIAL_EMPTY_VALUE } as unknown as string,
},
{
id: '2',
name: 'random',
type: 'test',
createdAt: '2021-05-05T00:00:00Z',
updatedAt: '2021-05-05T00:00:00Z',
scopes: ['credential:update'],
isManaged: false,
data: { anyKey: 'any value' } as unknown as string,
},
];
const { getAllByTestId, getByTestId } = renderComponent();
await flushPromises();
expect(getAllByTestId('resources-list-item').length).toBe(1);
expect(getByTestId('resources-list-item').textContent).toContain('credential needs setup');
await fireEvent.click(getByTestId('credential-filter-setup-needed'));
await waitFor(() => expect(getAllByTestId('resources-list-item').length).toBe(2));
});
});
}); });

View file

@ -1,13 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'; import { ref, computed, onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter, type LocationQueryRaw } from 'vue-router';
import type { ICredentialsResponse, ICredentialTypeMap } from '@/Interface'; import type { ICredentialsResponse, ICredentialTypeMap } from '@/Interface';
import type { ICredentialType, ICredentialsDecrypted } from 'n8n-workflow';
import ResourcesListLayout, { import ResourcesListLayout, {
type IResource, type IResource,
type IFilters, type IFilters,
} from '@/components/layouts/ResourcesListLayout.vue'; } from '@/components/layouts/ResourcesListLayout.vue';
import CredentialCard from '@/components/CredentialCard.vue'; import CredentialCard from '@/components/CredentialCard.vue';
import type { ICredentialType } from 'n8n-workflow';
import { import {
CREDENTIAL_SELECT_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY,
CREDENTIAL_EDIT_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY,
@ -27,6 +27,9 @@ import { useDocumentTitle } from '@/composables/useDocumentTitle';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import ProjectHeader from '@/components/Projects/ProjectHeader.vue'; import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
import { N8nCheckbox } from 'n8n-design-system';
import { pickBy } from 'lodash-es';
import { CREDENTIAL_EMPTY_VALUE } from 'n8n-workflow';
const props = defineProps<{ const props = defineProps<{
credentialId?: string; credentialId?: string;
@ -46,14 +49,26 @@ const router = useRouter();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const i18n = useI18n(); const i18n = useI18n();
const filters = ref<IFilters>({ type Filters = IFilters & { type?: string[]; setupNeeded?: boolean };
search: '', const updateFilter = (state: Filters) => {
homeProject: '', void router.replace({ query: pickBy(state) as LocationQueryRaw });
type: [], };
});
const filters = computed<Filters>(
() =>
({ ...route.query, setupNeeded: route.query.setupNeeded?.toString() === 'true' }) as Filters,
);
const loading = ref(false); const loading = ref(false);
const needsSetup = (data: string | undefined): boolean => {
const dataObject = data as unknown as ICredentialsDecrypted['data'];
if (!dataObject) return false;
if (Object.keys(dataObject).length === 0) return true;
return Object.values(dataObject).every((value) => !value || value === CREDENTIAL_EMPTY_VALUE);
};
const allCredentials = computed<IResource[]>(() => const allCredentials = computed<IResource[]>(() =>
credentialsStore.allCredentials.map((credential) => ({ credentialsStore.allCredentials.map((credential) => ({
id: credential.id, id: credential.id,
@ -66,6 +81,7 @@ const allCredentials = computed<IResource[]>(() =>
type: credential.type, type: credential.type,
sharedWithProjects: credential.sharedWithProjects, sharedWithProjects: credential.sharedWithProjects,
readOnly: !getResourcePermissions(credential.scopes).credential.update, readOnly: !getResourcePermissions(credential.scopes).credential.update,
needsSetup: needsSetup(credential.data),
})), })),
); );
@ -84,7 +100,7 @@ const projectPermissions = computed(() =>
); );
const setRouteCredentialId = (credentialId?: string) => { const setRouteCredentialId = (credentialId?: string) => {
void router.replace({ params: { credentialId } }); void router.replace({ params: { credentialId }, query: route.query });
}; };
const addCredential = () => { const addCredential = () => {
@ -98,7 +114,7 @@ listenForModalChanges({
store: uiStore, store: uiStore,
onModalClosed(modalName) { onModalClosed(modalName) {
if ([CREDENTIAL_SELECT_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY].includes(modalName as string)) { if ([CREDENTIAL_SELECT_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY].includes(modalName as string)) {
void router.replace({ params: { credentialId: '' } }); void router.replace({ params: { credentialId: '' }, query: route.query });
} }
}, },
}); });
@ -121,9 +137,9 @@ watch(
); );
const onFilter = (resource: IResource, newFilters: IFilters, matches: boolean): boolean => { const onFilter = (resource: IResource, newFilters: IFilters, matches: boolean): boolean => {
const iResource = resource as ICredentialsResponse; const iResource = resource as ICredentialsResponse & { needsSetup: boolean };
const filtersToApply = newFilters as IFilters & { type: string[] }; const filtersToApply = newFilters as Filters;
if (filtersToApply.type.length > 0) { if (filtersToApply.type && filtersToApply.type.length > 0) {
matches = matches && filtersToApply.type.includes(iResource.type); matches = matches && filtersToApply.type.includes(iResource.type);
} }
@ -136,6 +152,10 @@ const onFilter = (resource: IResource, newFilters: IFilters, matches: boolean):
credentialTypesById.value[iResource.type].displayName.toLowerCase().includes(searchString)); credentialTypesById.value[iResource.type].displayName.toLowerCase().includes(searchString));
} }
if (filtersToApply.setupNeeded) {
matches = matches && iResource.needsSetup;
}
return matches; return matches;
}; };
@ -156,6 +176,14 @@ const initialize = async () => {
loading.value = false; loading.value = false;
}; };
credentialsStore.$onAction(({ name, after }) => {
if (name === 'createNewCredential') {
after(() => {
void credentialsStore.fetchAllCredentials(route?.params?.projectId as string | undefined);
});
}
});
sourceControlStore.$onAction(({ name, after }) => { sourceControlStore.$onAction(({ name, after }) => {
if (name !== 'pullWorkfolder') return; if (name !== 'pullWorkfolder') return;
after(() => { after(() => {
@ -181,7 +209,7 @@ onMounted(() => {
:type-props="{ itemSize: 77 }" :type-props="{ itemSize: 77 }"
:loading="loading" :loading="loading"
:disabled="readOnlyEnv || !projectPermissions.credential.create" :disabled="readOnlyEnv || !projectPermissions.credential.create"
@update:filters="filters = $event" @update:filters="updateFilter"
> >
<template #header> <template #header>
<ProjectHeader /> <ProjectHeader />
@ -192,6 +220,7 @@ onMounted(() => {
class="mb-2xs" class="mb-2xs"
:data="data" :data="data"
:read-only="data.readOnly" :read-only="data.readOnly"
:needs-setup="data.needsSetup"
@click="setRouteCredentialId" @click="setRouteCredentialId"
/> />
</template> </template>
@ -221,6 +250,23 @@ onMounted(() => {
/> />
</N8nSelect> </N8nSelect>
</div> </div>
<div class="mb-s">
<N8nInputLabel
:label="i18n.baseText('credentials.filters.status')"
:bold="false"
size="small"
color="text-base"
class="mb-3xs"
/>
<N8nCheckbox
:label="i18n.baseText('credentials.filters.setup')"
data-test-id="credential-filter-setup-needed"
:model-value="filters.setupNeeded"
@update:model-value="setKeyValue('setupNeeded', $event)"
>
</N8nCheckbox>
</div>
</template> </template>
<template #empty> <template #empty>
<n8n-action-box <n8n-action-box

View file

@ -996,7 +996,11 @@ async function onRevertAddNode({ node }: { node: INodeUi }) {
} }
async function onSwitchActiveNode(nodeName: string) { async function onSwitchActiveNode(nodeName: string) {
const node = workflowsStore.getNodeByName(nodeName);
if (!node) return;
setNodeActiveByName(nodeName); setNodeActiveByName(nodeName);
selectNodes([node.id]);
} }
async function onOpenSelectiveNodeCreator(node: string, connectionType: NodeConnectionType) { async function onOpenSelectiveNodeCreator(node: string, connectionType: NodeConnectionType) {
@ -1385,7 +1389,8 @@ async function onPostMessageReceived(messageEvent: MessageEvent) {
try { try {
// If this NodeView is used in preview mode (in iframe) it will not have access to the main app store // If this NodeView is used in preview mode (in iframe) it will not have access to the main app store
// so everything it needs has to be sent using post messages and passed down to child components // so everything it needs has to be sent using post messages and passed down to child components
isProductionExecutionPreview.value = json.executionMode !== 'manual'; isProductionExecutionPreview.value =
json.executionMode !== 'manual' && json.executionMode !== 'evaluation';
await onOpenExecution(json.executionId); await onOpenExecution(json.executionId);
canOpenNDV.value = json.canOpenNDV ?? true; canOpenNDV.value = json.canOpenNDV ?? true;

View file

@ -84,6 +84,7 @@ export const calendarFields: INodeProperties[] = [
show: { show: {
operation: ['availability'], operation: ['availability'],
resource: ['calendar'], resource: ['calendar'],
'@version': [{ _cnd: { lt: 1.3 } }],
}, },
}, },
default: '', default: '',
@ -98,11 +99,44 @@ export const calendarFields: INodeProperties[] = [
show: { show: {
operation: ['availability'], operation: ['availability'],
resource: ['calendar'], resource: ['calendar'],
'@version': [{ _cnd: { lt: 1.3 } }],
}, },
}, },
default: '', default: '',
description: 'End of the interval', description: 'End of the interval',
}, },
{
displayName: 'Start Time',
name: 'timeMin',
type: 'dateTime',
required: true,
displayOptions: {
show: {
operation: ['availability'],
resource: ['calendar'],
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
default: '={{ $now }}',
description:
'Start of the interval, use <a href="https://docs.n8n.io/code/cookbook/luxon/" target="_blank">expression</a> to set a date, or switch to fixed mode to choose date from widget',
},
{
displayName: 'End Time',
name: 'timeMax',
type: 'dateTime',
required: true,
displayOptions: {
show: {
operation: ['availability'],
resource: ['calendar'],
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
default: "={{ $now.plus(1, 'hour') }}",
description:
'End of the interval, use <a href="https://docs.n8n.io/code/cookbook/luxon/" target="_blank">expression</a> to set a date, or switch to fixed mode to choose date from widget',
},
{ {
displayName: 'Options', displayName: 'Options',
name: 'options', name: 'options',

View file

@ -112,6 +112,7 @@ export const eventFields: INodeProperties[] = [
show: { show: {
operation: ['create'], operation: ['create'],
resource: ['event'], resource: ['event'],
'@version': [{ _cnd: { lt: 1.3 } }],
}, },
}, },
default: '', default: '',
@ -126,11 +127,44 @@ export const eventFields: INodeProperties[] = [
show: { show: {
operation: ['create'], operation: ['create'],
resource: ['event'], resource: ['event'],
'@version': [{ _cnd: { lt: 1.3 } }],
}, },
}, },
default: '', default: '',
description: 'End time of the event', description: 'End time of the event',
}, },
{
displayName: 'Start',
name: 'start',
type: 'dateTime',
required: true,
displayOptions: {
show: {
operation: ['create'],
resource: ['event'],
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
default: '={{ $now }}',
description:
'Start time of the event, use <a href="https://docs.n8n.io/code/cookbook/luxon/" target="_blank">expression</a> to set a date, or switch to fixed mode to choose date from widget',
},
{
displayName: 'End',
name: 'end',
type: 'dateTime',
required: true,
displayOptions: {
show: {
operation: ['create'],
resource: ['event'],
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
default: "={{ $now.plus(1, 'hour') }}",
description:
'End time of the event, use <a href="https://docs.n8n.io/code/cookbook/luxon/" target="_blank">expression</a> to set a date, or switch to fixed mode to choose date from widget',
},
{ {
displayName: 'Use Default Reminders', displayName: 'Use Default Reminders',
name: 'useDefaultReminders', name: 'useDefaultReminders',
@ -553,6 +587,19 @@ export const eventFields: INodeProperties[] = [
description: description:
'The maximum number of attendees to include in the response. If there are more than the specified number of attendees, only the participant is returned.', 'The maximum number of attendees to include in the response. If there are more than the specified number of attendees, only the participant is returned.',
}, },
{
displayName: 'Return Next Instance of Recurring Event',
name: 'returnNextInstance',
type: 'boolean',
default: false,
description:
'Whether to return the next instance of a recurring event instead of the event itself',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
},
{ {
displayName: 'Timezone', displayName: 'Timezone',
name: 'timeZone', name: 'timeZone',
@ -629,6 +676,36 @@ export const eventFields: INodeProperties[] = [
default: 50, default: 50,
description: 'Max number of results to return', description: 'Max number of results to return',
}, },
{
displayName: 'After',
name: 'timeMin',
type: 'dateTime',
default: '={{ $now }}',
description:
'At least some part of the event must be after this time, use <a href="https://docs.n8n.io/code/cookbook/luxon/" target="_blank">expression</a> to set a date, or switch to fixed mode to choose date from widget',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.3 } }],
operation: ['getAll'],
resource: ['event'],
},
},
},
{
displayName: 'Before',
name: 'timeMax',
type: 'dateTime',
default: '={{ $now.plus({ week: 1 }) }}',
description:
'At least some part of the event must be before this time, use <a href="https://docs.n8n.io/code/cookbook/luxon/" target="_blank">expression</a> to set a date, or switch to fixed mode to choose date from widget',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.3 } }],
operation: ['getAll'],
resource: ['event'],
},
},
},
{ {
displayName: 'Options', displayName: 'Options',
name: 'options', name: 'options',
@ -647,14 +724,39 @@ export const eventFields: INodeProperties[] = [
name: 'timeMin', name: 'timeMin',
type: 'dateTime', type: 'dateTime',
default: '', default: '',
description: 'At least some part of the event must be after this time', description:
'At least some part of the event must be after this time, use <a href="https://docs.n8n.io/code/cookbook/luxon/" target="_blank">expression</a> to set a date, or switch to fixed mode to choose date from widget',
displayOptions: {
hide: {
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
}, },
{ {
displayName: 'Before', displayName: 'Before',
name: 'timeMax', name: 'timeMax',
type: 'dateTime', type: 'dateTime',
default: '', default: '',
description: 'At least some part of the event must be before this time', description:
'At least some part of the event must be before this time, use <a href="https://docs.n8n.io/code/cookbook/luxon/" target="_blank">expression</a> to set a date, or switch to fixed mode to choose date from widget',
displayOptions: {
hide: {
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
},
{
displayName: 'Expand Events',
name: 'singleEvents',
type: 'boolean',
default: false,
description:
'Whether to expand recurring events into instances and only return single one-off events and instances of recurring events, but not the underlying recurring events themselves',
displayOptions: {
hide: {
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
}, },
{ {
displayName: 'Fields', displayName: 'Fields',
@ -708,6 +810,34 @@ export const eventFields: INodeProperties[] = [
description: description:
'Free text search terms to find events that match these terms in any field, except for extended properties', 'Free text search terms to find events that match these terms in any field, except for extended properties',
}, },
{
displayName: 'Recurring Event Handling',
name: 'recurringEventHandling',
type: 'options',
default: 'expand',
options: [
{
name: 'All Occurrences',
value: 'expand',
description: 'Return all instances of recurring event for specified time range',
},
{
name: 'First Occurrence',
value: 'first',
description: 'Return event with specified recurrence rule',
},
{
name: 'Next Occurrence',
value: 'next',
description: 'Return next instance of recurring event',
},
],
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
},
{ {
displayName: 'Show Deleted', displayName: 'Show Deleted',
name: 'showDeleted', name: 'showDeleted',
@ -723,14 +853,7 @@ export const eventFields: INodeProperties[] = [
default: false, default: false,
description: 'Whether to include hidden invitations in the result', description: 'Whether to include hidden invitations in the result',
}, },
{
displayName: 'Single Events',
name: 'singleEvents',
type: 'boolean',
default: false,
description:
'Whether to expand recurring events into instances and only return single one-off events and instances of recurring events, but not the underlying recurring events themselves',
},
{ {
displayName: 'Timezone', displayName: 'Timezone',
name: 'timeZone', name: 'timeZone',
@ -797,6 +920,30 @@ export const eventFields: INodeProperties[] = [
}, },
default: '', default: '',
}, },
{
displayName: 'Modify',
name: 'modifyTarget',
type: 'options',
options: [
{
name: 'Recurring Event Instance',
value: 'instance',
},
{
name: 'Recurring Event',
value: 'event',
},
],
default: 'instance',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.3 } }],
resource: ['event'],
operation: ['update'],
eventId: [{ _cnd: { includes: '_' } }],
},
},
},
{ {
displayName: 'Use Default Reminders', displayName: 'Use Default Reminders',
name: 'useDefaultReminders', name: 'useDefaultReminders',

View file

@ -34,3 +34,8 @@ export interface IEvent {
visibility?: string; visibility?: string;
conferenceData?: IConferenceData; conferenceData?: IConferenceData;
} }
export type RecurringEventInstance = {
recurringEventId?: string;
start: { dateTime: string; date: string };
};

View file

@ -1,18 +1,22 @@
import { DateTime } from 'luxon';
import moment from 'moment-timezone'; import moment from 'moment-timezone';
import type { import type {
IDataObject, IDataObject,
IExecuteFunctions, IExecuteFunctions,
IHttpRequestMethods, IHttpRequestMethods,
ILoadOptionsFunctions, ILoadOptionsFunctions,
INode,
INodeListSearchItems, INodeListSearchItems,
INodeListSearchResult, INodeListSearchResult,
IPollFunctions, IPollFunctions,
IRequestOptions, IRequestOptions,
JsonObject, JsonObject,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow'; import { NodeApiError, NodeOperationError, sleep } from 'n8n-workflow';
import { RRule } from 'rrule'; import { RRule } from 'rrule';
import type { RecurringEventInstance } from './EventInterface';
export async function googleApiRequest( export async function googleApiRequest(
this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions, this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions,
method: IHttpRequestMethods, method: IHttpRequestMethods,
@ -50,7 +54,6 @@ export async function googleApiRequestAllItems(
propertyName: string, propertyName: string,
method: IHttpRequestMethods, method: IHttpRequestMethods,
endpoint: string, endpoint: string,
body: any = {}, body: any = {},
query: IDataObject = {}, query: IDataObject = {},
): Promise<any> { ): Promise<any> {
@ -127,58 +130,75 @@ export async function getTimezones(
return { results }; return { results };
} }
type RecurentEvent = { export type RecurrentEvent = {
start: { start: {
dateTime: string; date?: string;
timeZone: string; dateTime?: string;
timeZone?: string;
}; };
end: { end: {
dateTime: string; date?: string;
timeZone: string; dateTime?: string;
timeZone?: string;
}; };
recurrence: string[]; recurrence: string[];
nextOccurrence?: { nextOccurrence?: {
start: { start: {
dateTime: string; dateTime: string;
timeZone: string; timeZone?: string;
}; };
end: { end: {
dateTime: string; dateTime: string;
timeZone: string; timeZone?: string;
}; };
}; };
}; };
export function addNextOccurrence(items: RecurentEvent[]) { export function addNextOccurrence(items: RecurrentEvent[]) {
for (const item of items) { for (const item of items) {
if (item.recurrence) { if (item.recurrence) {
let eventRecurrence; let eventRecurrence;
try { try {
eventRecurrence = item.recurrence.find((r) => r.toUpperCase().startsWith('RRULE')); eventRecurrence = item.recurrence.find((r) => r.toUpperCase().startsWith('RRULE'));
if (!eventRecurrence) continue; if (!eventRecurrence) continue;
const rrule = RRule.fromString(eventRecurrence); const start = moment(item.start.dateTime || item.end.date).utc();
const end = moment(item.end.dateTime || item.end.date).utc();
const rruleWithStartDate = `DTSTART:${start.format(
'YYYYMMDDTHHmmss',
)}Z\n${eventRecurrence}`;
const rrule = RRule.fromString(rruleWithStartDate);
const until = rrule.options?.until; const until = rrule.options?.until;
const now = new Date(); const now = moment().utc();
if (until && until < now) {
if (until && moment(until).isBefore(now)) {
continue; continue;
} }
const nextOccurrence = rrule.after(new Date()); const nextDate = rrule.after(now.toDate(), false);
if (nextDate) {
const nextStart = moment(nextDate);
const duration = moment.duration(moment(end).diff(moment(start)));
const nextEnd = moment(nextStart).add(duration);
item.nextOccurrence = { item.nextOccurrence = {
start: { start: {
dateTime: moment(nextOccurrence).format(), dateTime: nextStart.format(),
timeZone: item.start.timeZone, timeZone: item.start.timeZone,
}, },
end: { end: {
dateTime: moment(nextOccurrence) dateTime: nextEnd.format(),
.add(moment(item.end.dateTime).diff(moment(item.start.dateTime)))
.format(),
timeZone: item.end.timeZone, timeZone: item.end.timeZone,
}, },
}; };
}
} catch (error) { } catch (error) {
console.log(`Error adding next occurrence ${eventRecurrence}`); console.log(`Error adding next occurrence ${eventRecurrence}`);
} }
@ -193,3 +213,92 @@ export function addTimezoneToDate(date: string, timezone: string) {
if (hasTimezone(date)) return date; if (hasTimezone(date)) return date;
return moment.tz(date, timezone).utc().format(); return moment.tz(date, timezone).utc().format();
} }
async function requestWithRetries(
node: INode,
requestFn: () => Promise<any>,
retryCount: number = 0,
maxRetries: number = 10,
itemIndex: number = 0,
): Promise<any> {
try {
return await requestFn();
} catch (error) {
if (!(error instanceof NodeApiError)) {
throw new NodeOperationError(node, error.message, { itemIndex });
}
if (retryCount >= maxRetries) throw error;
if (error.httpCode === '403' || error.httpCode === '429') {
const delay = 1000 * Math.pow(2, retryCount);
console.log(`Rate limit hit. Retrying in ${delay}ms... (Attempt ${retryCount + 1})`);
await sleep(delay);
return await requestWithRetries(node, requestFn, retryCount + 1, maxRetries, itemIndex);
}
throw error;
}
}
export async function googleApiRequestWithRetries({
context,
method,
resource,
body = {},
qs = {},
uri,
headers = {},
itemIndex = 0,
}: {
context: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions;
method: IHttpRequestMethods;
resource: string;
body?: any;
qs?: IDataObject;
uri?: string;
headers?: IDataObject;
itemIndex?: number;
}) {
const requestFn = async (): Promise<any> => {
return await googleApiRequest.call(context, method, resource, body, qs, uri, headers);
};
const retryCount = 0;
const maxRetries = 10;
return await requestWithRetries(context.getNode(), requestFn, retryCount, maxRetries, itemIndex);
}
export const eventExtendYearIntoFuture = (
data: RecurringEventInstance[],
timezone: string,
currentYear?: number, // for testing purposes
) => {
const thisYear = currentYear || moment().tz(timezone).year();
return data.some((event) => {
if (!event.recurringEventId) return false;
const eventStart = event.start.dateTime || event.start.date;
const eventDateTime = moment(eventStart).tz(timezone);
if (!eventDateTime.isValid()) return false;
const targetYear = eventDateTime.year();
if (targetYear - thisYear >= 1) {
return true;
} else {
return false;
}
});
};
export function dateObjectToISO<T>(date: T): string {
if (date instanceof DateTime) return date.toISO();
if (date instanceof Date) return date.toISOString();
return date as string;
}

View file

@ -36,7 +36,7 @@
"url": "https://n8n.io/blog/your-business-doesnt-need-you-to-operate/" "url": "https://n8n.io/blog/your-business-doesnt-need-you-to-operate/"
}, },
{ {
"label": "5 workflow automations for Mattermost that we love at n8n", "label": "5 workflow automation for Mattermost that we love at n8n",
"icon": "🤖", "icon": "🤖",
"url": "https://n8n.io/blog/5-workflow-automations-for-mattermost-that-we-love-at-n8n/" "url": "https://n8n.io/blog/5-workflow-automations-for-mattermost-that-we-love-at-n8n/"
}, },

View file

@ -8,22 +8,33 @@ import type {
INodeType, INodeType,
INodeTypeDescription, INodeTypeDescription,
JsonObject, JsonObject,
NodeExecutionHint,
} from 'n8n-workflow';
import {
NodeConnectionType,
NodeApiError,
NodeOperationError,
NodeExecutionOutput,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { NodeConnectionType, NodeApiError, NodeOperationError } from 'n8n-workflow';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { calendarFields, calendarOperations } from './CalendarDescription'; import { calendarFields, calendarOperations } from './CalendarDescription';
import { eventFields, eventOperations } from './EventDescription'; import { eventFields, eventOperations } from './EventDescription';
import type { IEvent } from './EventInterface'; import type { IEvent, RecurringEventInstance } from './EventInterface';
import { import {
addNextOccurrence, addNextOccurrence,
addTimezoneToDate, addTimezoneToDate,
dateObjectToISO,
encodeURIComponentOnce, encodeURIComponentOnce,
eventExtendYearIntoFuture,
getCalendars, getCalendars,
getTimezones, getTimezones,
googleApiRequest, googleApiRequest,
googleApiRequestAllItems, googleApiRequestAllItems,
googleApiRequestWithRetries,
type RecurrentEvent,
} from './GenericFunctions'; } from './GenericFunctions';
import { sortItemKeysByPriorityList } from '../../../utils/utilities';
export class GoogleCalendar implements INodeType { export class GoogleCalendar implements INodeType {
description: INodeTypeDescription = { description: INodeTypeDescription = {
@ -31,7 +42,7 @@ export class GoogleCalendar implements INodeType {
name: 'googleCalendar', name: 'googleCalendar',
icon: 'file:googleCalendar.svg', icon: 'file:googleCalendar.svg',
group: ['input'], group: ['input'],
version: [1, 1.1, 1.2], version: [1, 1.1, 1.2, 1.3],
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Google Calendar API', description: 'Consume Google Calendar API',
defaults: { defaults: {
@ -132,6 +143,7 @@ export class GoogleCalendar implements INodeType {
const returnData: INodeExecutionData[] = []; const returnData: INodeExecutionData[] = [];
const length = items.length; const length = items.length;
const qs: IDataObject = {}; const qs: IDataObject = {};
const hints: NodeExecutionHint[] = [];
let responseData; let responseData;
const resource = this.getNodeParameter('resource', 0); const resource = this.getNodeParameter('resource', 0);
@ -148,8 +160,8 @@ export class GoogleCalendar implements INodeType {
const calendarId = decodeURIComponent( const calendarId = decodeURIComponent(
this.getNodeParameter('calendar', i, '', { extractValue: true }) as string, this.getNodeParameter('calendar', i, '', { extractValue: true }) as string,
); );
const timeMin = this.getNodeParameter('timeMin', i) as string; const timeMin = dateObjectToISO(this.getNodeParameter('timeMin', i));
const timeMax = this.getNodeParameter('timeMax', i) as string; const timeMax = dateObjectToISO(this.getNodeParameter('timeMax', i));
const options = this.getNodeParameter('options', i); const options = this.getNodeParameter('options', i);
const outputFormat = options.outputFormat || 'availability'; const outputFormat = options.outputFormat || 'availability';
const tz = this.getNodeParameter('options.timezone', i, '', { const tz = this.getNodeParameter('options.timezone', i, '', {
@ -200,8 +212,8 @@ export class GoogleCalendar implements INodeType {
const calendarId = encodeURIComponentOnce( const calendarId = encodeURIComponentOnce(
this.getNodeParameter('calendar', i, '', { extractValue: true }) as string, this.getNodeParameter('calendar', i, '', { extractValue: true }) as string,
); );
const start = this.getNodeParameter('start', i) as string; const start = dateObjectToISO(this.getNodeParameter('start', i));
const end = this.getNodeParameter('end', i) as string; const end = dateObjectToISO(this.getNodeParameter('end', i));
const useDefaultReminders = this.getNodeParameter('useDefaultReminders', i) as boolean; const useDefaultReminders = this.getNodeParameter('useDefaultReminders', i) as boolean;
const additionalFields = this.getNodeParameter('additionalFields', i); const additionalFields = this.getNodeParameter('additionalFields', i);
@ -379,16 +391,33 @@ export class GoogleCalendar implements INodeType {
if (tz) { if (tz) {
qs.timeZone = tz; qs.timeZone = tz;
} }
responseData = await googleApiRequest.call( responseData = (await googleApiRequest.call(
this, this,
'GET', 'GET',
`/calendar/v3/calendars/${calendarId}/events/${eventId}`, `/calendar/v3/calendars/${calendarId}/events/${eventId}`,
{}, {},
qs, qs,
); )) as IDataObject;
if (responseData) { if (responseData) {
responseData = addNextOccurrence([responseData]); if (nodeVersion >= 1.3 && options.returnNextInstance && responseData.recurrence) {
const eventInstances =
((
(await googleApiRequest.call(
this,
'GET',
`/calendar/v3/calendars/${calendarId}/events/${responseData.id}/instances`,
{},
{
timeMin: new Date().toISOString(),
maxResults: 1,
},
)) as IDataObject
).items as IDataObject[]) || [];
responseData = eventInstances[0] ? [eventInstances[0]] : [responseData];
} else {
responseData = addNextOccurrence([responseData as RecurrentEvent]);
}
} }
} }
//https://developers.google.com/calendar/v3/reference/events/list //https://developers.google.com/calendar/v3/reference/events/list
@ -401,6 +430,22 @@ export class GoogleCalendar implements INodeType {
const tz = this.getNodeParameter('options.timeZone', i, '', { const tz = this.getNodeParameter('options.timeZone', i, '', {
extractValue: true, extractValue: true,
}) as string; }) as string;
if (nodeVersion >= 1.3) {
const timeMin = dateObjectToISO(this.getNodeParameter('timeMin', i));
const timeMax = dateObjectToISO(this.getNodeParameter('timeMax', i));
if (timeMin) {
qs.timeMin = addTimezoneToDate(timeMin as string, tz || timezone);
}
if (timeMax) {
qs.timeMax = addTimezoneToDate(timeMax as string, tz || timezone);
}
if (!options.recurringEventHandling || options.recurringEventHandling === 'expand') {
qs.singleEvents = true;
}
}
if (options.iCalUID) { if (options.iCalUID) {
qs.iCalUID = options.iCalUID as string; qs.iCalUID = options.iCalUID as string;
} }
@ -423,16 +468,19 @@ export class GoogleCalendar implements INodeType {
qs.singleEvents = options.singleEvents as boolean; qs.singleEvents = options.singleEvents as boolean;
} }
if (options.timeMax) { if (options.timeMax) {
qs.timeMax = addTimezoneToDate(options.timeMax as string, tz || timezone); qs.timeMax = addTimezoneToDate(dateObjectToISO(options.timeMax), tz || timezone);
} }
if (options.timeMin) { if (options.timeMin) {
qs.timeMin = addTimezoneToDate(options.timeMin as string, tz || timezone); qs.timeMin = addTimezoneToDate(dateObjectToISO(options.timeMin), tz || timezone);
} }
if (tz) { if (tz) {
qs.timeZone = tz; qs.timeZone = tz;
} }
if (options.updatedMin) { if (options.updatedMin) {
qs.updatedMin = addTimezoneToDate(options.updatedMin as string, tz || timezone); qs.updatedMin = addTimezoneToDate(
dateObjectToISO(options.updatedMin),
tz || timezone,
);
} }
if (options.fields) { if (options.fields) {
qs.fields = options.fields as string; qs.fields = options.fields as string;
@ -460,15 +508,99 @@ export class GoogleCalendar implements INodeType {
} }
if (responseData) { if (responseData) {
if (nodeVersion >= 1.3 && options.recurringEventHandling === 'next') {
const updatedEvents: IDataObject[] = [];
for (const event of responseData) {
if (event.recurrence) {
const eventInstances =
((
(await googleApiRequestWithRetries({
context: this,
method: 'GET',
resource: `/calendar/v3/calendars/${calendarId}/events/${event.id}/instances`,
qs: {
timeMin: new Date().toISOString(),
maxResults: 1,
},
itemIndex: i,
})) as IDataObject
).items as IDataObject[]) || [];
updatedEvents.push(eventInstances[0] || event);
continue;
}
updatedEvents.push(event);
}
responseData = updatedEvents;
} else if (nodeVersion >= 1.3 && options.recurringEventHandling === 'first') {
responseData = responseData.filter((event: IDataObject) => {
if (
qs.timeMin &&
event.recurrence &&
event.created &&
event.created < qs.timeMin
) {
return false;
}
if (
qs.timeMax &&
event.recurrence &&
event.created &&
event.created > qs.timeMax
) {
return false;
}
return true;
});
} else if (nodeVersion < 1.3) {
// in node version above or equal to 1.3, this would correspond to the 'expand' option,
// so no need to add the next occurrence as event instances returned by the API
responseData = addNextOccurrence(responseData); responseData = addNextOccurrence(responseData);
} }
if (
!qs.timeMax &&
(!options.recurringEventHandling || options.recurringEventHandling === 'expand')
) {
const suggestTrim = eventExtendYearIntoFuture(
responseData as RecurringEventInstance[],
timezone,
);
if (suggestTrim) {
hints.push({
message:
"Some events repeat far into the future. To return less of them, add a 'Before' date or change the 'Recurring Event Handling' option.",
location: 'outputPane',
});
}
}
}
} }
//https://developers.google.com/calendar/v3/reference/events/patch //https://developers.google.com/calendar/v3/reference/events/patch
if (operation === 'update') { if (operation === 'update') {
const calendarId = encodeURIComponentOnce( const calendarId = encodeURIComponentOnce(
this.getNodeParameter('calendar', i, '', { extractValue: true }) as string, this.getNodeParameter('calendar', i, '', { extractValue: true }) as string,
); );
const eventId = this.getNodeParameter('eventId', i) as string; let eventId = this.getNodeParameter('eventId', i) as string;
if (nodeVersion >= 1.3) {
const modifyTarget = this.getNodeParameter('modifyTarget', i, 'instance') as string;
if (modifyTarget === 'event') {
const instance = (await googleApiRequest.call(
this,
'GET',
`/calendar/v3/calendars/${calendarId}/events/${eventId}`,
{},
qs,
)) as IDataObject;
eventId = instance.recurringEventId as string;
}
}
const useDefaultReminders = this.getNodeParameter('useDefaultReminders', i) as boolean; const useDefaultReminders = this.getNodeParameter('useDefaultReminders', i) as boolean;
const updateFields = this.getNodeParameter('updateFields', i); const updateFields = this.getNodeParameter('updateFields', i);
let updateTimezone = updateFields.timezone as string; let updateTimezone = updateFields.timezone as string;
@ -658,6 +790,30 @@ export class GoogleCalendar implements INodeType {
} }
} }
} }
return [returnData];
const keysPriorityList = [
'id',
'summary',
'start',
'end',
'attendees',
'creator',
'organizer',
'description',
'location',
'created',
'updated',
];
let nodeExecutionData = returnData;
if (nodeVersion >= 1.3) {
nodeExecutionData = sortItemKeysByPriorityList(returnData, keysPriorityList);
}
if (hints.length) {
return new NodeExecutionOutput([nodeExecutionData], hints);
}
return [nodeExecutionData];
} }
} }

View file

@ -1,4 +1,7 @@
import { addTimezoneToDate } from '../GenericFunctions'; import { DateTime } from 'luxon';
import type { RecurringEventInstance } from '../EventInterface';
import { addTimezoneToDate, dateObjectToISO, eventExtendYearIntoFuture } from '../GenericFunctions';
describe('addTimezoneToDate', () => { describe('addTimezoneToDate', () => {
it('should add timezone to date', () => { it('should add timezone to date', () => {
@ -18,3 +21,87 @@ describe('addTimezoneToDate', () => {
expect(result4).toBe('2021-09-01T12:00:00.000+08:00'); expect(result4).toBe('2021-09-01T12:00:00.000+08:00');
}); });
}); });
describe('dateObjectToISO', () => {
test('should return ISO string for DateTime instance', () => {
const mockDateTime = DateTime.fromISO('2025-01-07T12:00:00');
const result = dateObjectToISO(mockDateTime);
expect(result).toBe('2025-01-07T12:00:00.000+00:00');
});
test('should return ISO string for Date instance', () => {
const mockDate = new Date('2025-01-07T12:00:00Z');
const result = dateObjectToISO(mockDate);
expect(result).toBe('2025-01-07T12:00:00.000Z');
});
test('should return string when input is not a DateTime or Date instance', () => {
const inputString = '2025-01-07T12:00:00';
const result = dateObjectToISO(inputString);
expect(result).toBe(inputString);
});
});
describe('eventExtendYearIntoFuture', () => {
const timezone = 'UTC';
it('should return true if any event extends into the next year', () => {
const events = [
{
recurringEventId: '123',
start: { dateTime: '2026-01-01T00:00:00Z', date: null },
},
] as unknown as RecurringEventInstance[];
const result = eventExtendYearIntoFuture(events, timezone, 2025);
expect(result).toBe(true);
});
it('should return false if no event extends into the next year', () => {
const events = [
{
recurringEventId: '123',
start: { dateTime: '2025-12-31T23:59:59Z', date: null },
},
] as unknown as RecurringEventInstance[];
const result = eventExtendYearIntoFuture(events, timezone, 2025);
expect(result).toBe(false);
});
it('should return false for invalid event start dates', () => {
const events = [
{
recurringEventId: '123',
start: { dateTime: 'invalid-date', date: null },
},
] as unknown as RecurringEventInstance[];
const result = eventExtendYearIntoFuture(events, timezone, 2025);
expect(result).toBe(false);
});
it('should return false for events without a recurringEventId', () => {
const events = [
{
recurringEventId: null,
start: { dateTime: '2025-01-01T00:00:00Z', date: null },
},
] as unknown as RecurringEventInstance[];
const result = eventExtendYearIntoFuture(events, timezone, 2025);
expect(result).toBe(false);
});
it('should handle events with only a date and no time', () => {
const events = [
{
recurringEventId: '123',
start: { dateTime: null, date: '2026-01-01' },
},
] as unknown as RecurringEventInstance[];
const result = eventExtendYearIntoFuture(events, timezone, 2025);
expect(result).toBe(true);
});
});

View file

@ -0,0 +1,89 @@
import moment from 'moment-timezone';
import type { RecurrentEvent } from '../GenericFunctions';
import { addNextOccurrence } from '../GenericFunctions';
const mockNow = '2024-09-06T16:30:00+03:00';
jest.spyOn(global.Date, 'now').mockImplementation(() => moment(mockNow).valueOf());
describe('addNextOccurrence', () => {
it('should not modify event if no recurrence exists', () => {
const event: RecurrentEvent[] = [
{
start: {
dateTime: '2024-09-01T08:00:00Z',
timeZone: 'UTC',
},
end: {
dateTime: '2024-09-01T09:00:00Z',
timeZone: 'UTC',
},
recurrence: [],
},
];
const result = addNextOccurrence(event);
expect(result[0].nextOccurrence).toBeUndefined();
});
it('should handle event with no RRULE correctly', () => {
const event: RecurrentEvent[] = [
{
start: {
dateTime: '2024-09-01T08:00:00Z',
timeZone: 'UTC',
},
end: {
dateTime: '2024-09-01T09:00:00Z',
timeZone: 'UTC',
},
recurrence: ['FREQ=WEEKLY;COUNT=2'],
},
];
const result = addNextOccurrence(event);
expect(result[0].nextOccurrence).toBeUndefined();
});
it('should ignore recurrence if until date is in the past', () => {
const event: RecurrentEvent[] = [
{
start: {
dateTime: '2024-08-01T08:00:00Z',
timeZone: 'UTC',
},
end: {
dateTime: '2024-08-01T09:00:00Z',
timeZone: 'UTC',
},
recurrence: ['RRULE:FREQ=DAILY;UNTIL=20240805T000000Z'],
},
];
const result = addNextOccurrence(event);
expect(result[0].nextOccurrence).toBeUndefined();
});
it('should handle errors gracefully without breaking and return unchanged event', () => {
const event: RecurrentEvent[] = [
{
start: {
dateTime: '2024-09-06T17:30:00+03:00',
timeZone: 'Europe/Berlin',
},
end: {
dateTime: '2024-09-06T18:00:00+03:00',
timeZone: 'Europe/Berlin',
},
recurrence: ['xxxxx'],
},
];
const result = addNextOccurrence(event);
expect(result).toEqual(event);
});
});

View file

@ -0,0 +1,221 @@
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type { INode, IExecuteFunctions, IDataObject, NodeExecutionOutput } from 'n8n-workflow';
import * as genericFunctions from '../../GenericFunctions';
import { GoogleCalendar } from '../../GoogleCalendar.node';
let response: IDataObject[] | undefined = [];
let responseWithRetries: IDataObject | undefined = {};
jest.mock('../../GenericFunctions', () => {
const originalModule = jest.requireActual('../../GenericFunctions');
return {
...originalModule,
getTimezones: jest.fn(),
googleApiRequest: jest.fn(),
googleApiRequestAllItems: jest.fn(async function () {
return (() => response)();
}),
googleApiRequestWithRetries: jest.fn(async function () {
return (() => responseWithRetries)();
}),
addNextOccurrence: jest.fn(function (data: IDataObject[]) {
return data;
}),
};
});
describe('Google Calendar Node', () => {
let googleCalendar: GoogleCalendar;
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
beforeEach(() => {
googleCalendar = new GoogleCalendar();
mockExecuteFunctions = mock<IExecuteFunctions>({
getInputData: jest.fn(),
getNode: jest.fn(),
getNodeParameter: jest.fn(),
getTimezone: jest.fn(),
helpers: {
constructExecutionMetaData: jest.fn().mockReturnValue([]),
},
});
response = undefined;
responseWithRetries = undefined;
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Google Calendar > Event > Get Many', () => {
it('should configure get all request parameters in version 1.3', async () => {
// pre loop setup
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('event');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('getAll');
mockExecuteFunctions.getTimezone.mockReturnValueOnce('Europe/Berlin');
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.3 }));
//operation setup
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(true); //returnAll
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('myCalendar'); //calendar
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
iCalUID: 'uid',
maxAttendees: 25,
orderBy: 'startTime',
query: 'test query',
recurringEventHandling: 'expand',
showDeleted: true,
showHiddenInvitations: true,
updatedMin: '2024-12-21T00:00:00',
}); //options
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('Europe/Berlin'); //options.timeZone
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-20T00:00:00'); //timeMin
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-26T00:00:00'); //timeMax
await googleCalendar.execute.call(mockExecuteFunctions);
expect(genericFunctions.googleApiRequestAllItems).toHaveBeenCalledWith(
'items',
'GET',
'/calendar/v3/calendars/myCalendar/events',
{},
{
iCalUID: 'uid',
maxAttendees: 25,
orderBy: 'startTime',
q: 'test query',
showDeleted: true,
showHiddenInvitations: true,
singleEvents: true,
timeMax: '2024-12-25T23:00:00Z',
timeMin: '2024-12-19T23:00:00Z',
timeZone: 'Europe/Berlin',
updatedMin: '2024-12-20T23:00:00Z',
},
);
});
it('should configure get all recurringEventHandling equals next in version 1.3', async () => {
// pre loop setup
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('event');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('getAll');
mockExecuteFunctions.getTimezone.mockReturnValueOnce('Europe/Berlin');
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.3 }));
//operation setup
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(true); //returnAll
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('myCalendar'); //calendar
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
recurringEventHandling: 'next',
}); //options
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('Europe/Berlin'); //options.timeZone
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-20T00:00:00'); //timeMin
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-26T00:00:00'); //timeMax
response = [
{
recurrence: ['RRULE:FREQ=DAILY;COUNT=5'],
},
];
responseWithRetries = { items: [] };
const result = await googleCalendar.execute.call(mockExecuteFunctions);
expect(genericFunctions.googleApiRequestAllItems).toHaveBeenCalledWith(
'items',
'GET',
'/calendar/v3/calendars/myCalendar/events',
{},
{
timeMax: '2024-12-25T23:00:00Z',
timeMin: '2024-12-19T23:00:00Z',
timeZone: 'Europe/Berlin',
},
);
expect(genericFunctions.googleApiRequestWithRetries).toHaveBeenCalledWith(
expect.objectContaining({
method: 'GET',
itemIndex: 0,
resource: '/calendar/v3/calendars/myCalendar/events/undefined/instances',
}),
);
expect(result).toEqual([[]]);
});
it('should configure get all recurringEventHandling equals first in version 1.3', async () => {
// pre loop setup
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('event');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('getAll');
mockExecuteFunctions.getTimezone.mockReturnValueOnce('Europe/Berlin');
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.3 }));
//operation setup
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(true); //returnAll
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('myCalendar'); //calendar
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
recurringEventHandling: 'first',
}); //options
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('Europe/Berlin'); //options.timeZone
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-20T00:00:00'); //timeMin
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-26T00:00:00'); //timeMax
response = [
{
recurrence: ['RRULE:FREQ=DAILY;COUNT=5'],
created: '2024-12-19T00:00:00',
},
{
recurrence: ['RRULE:FREQ=DAILY;COUNT=5'],
created: '2024-12-27T00:00:00',
},
];
const result = await googleCalendar.execute.call(mockExecuteFunctions);
expect(result).toEqual([[]]);
});
it('should configure get all should have hint in version 1.3', async () => {
// pre loop setup
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('event');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('getAll');
mockExecuteFunctions.getTimezone.mockReturnValueOnce('Europe/Berlin');
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.3 }));
//operation setup
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(true); //returnAll
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('myCalendar'); //calendar
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); //options
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('Europe/Berlin'); //options.timeZone
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-20T00:00:00'); //timeMin
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(''); //timeMax
response = [
{
recurrence: ['RRULE:FREQ=DAILY;COUNT=5'],
created: '2024-12-25T00:00:00',
recurringEventId: '1',
start: { dateTime: '2027-12-25T00:00:00' },
},
];
const result = await googleCalendar.execute.call(mockExecuteFunctions);
expect((result as NodeExecutionOutput).getHints()).toEqual([
{
message:
"Some events repeat far into the future. To return less of them, add a 'Before' date or change the 'Recurring Event Handling' option.",
location: 'outputPane',
},
]);
});
});
});

View file

@ -14,7 +14,7 @@ jest.mock('../../GenericFunctions', () => ({
encodeURIComponentOnce: jest.fn(), encodeURIComponentOnce: jest.fn(),
})); }));
describe('RespondToWebhook Node', () => { describe('Google Calendar Node', () => {
let googleCalendar: GoogleCalendar; let googleCalendar: GoogleCalendar;
let mockExecuteFunctions: MockProxy<IExecuteFunctions>; let mockExecuteFunctions: MockProxy<IExecuteFunctions>;

View file

@ -1,3 +1,5 @@
import type { INodeExecutionData } from 'n8n-workflow';
import { import {
compareItems, compareItems,
flattenKeys, flattenKeys,
@ -5,6 +7,7 @@ import {
getResolvables, getResolvables,
keysToLowercase, keysToLowercase,
shuffleArray, shuffleArray,
sortItemKeysByPriorityList,
wrapData, wrapData,
} from '@utils/utilities'; } from '@utils/utilities';
@ -252,3 +255,60 @@ describe('compareItems', () => {
expect(result).toBe(true); expect(result).toBe(true);
}); });
}); });
describe('sortItemKeysByPriorityList', () => {
it('should reorder keys based on priority list', () => {
const data: INodeExecutionData[] = [{ json: { c: 3, a: 1, b: 2 } }];
const priorityList = ['b', 'a'];
const result = sortItemKeysByPriorityList(data, priorityList);
expect(Object.keys(result[0].json)).toEqual(['b', 'a', 'c']);
});
it('should sort keys not in the priority list alphabetically', () => {
const data: INodeExecutionData[] = [{ json: { c: 3, a: 1, b: 2, d: 4 } }];
const priorityList = ['b', 'a'];
const result = sortItemKeysByPriorityList(data, priorityList);
expect(Object.keys(result[0].json)).toEqual(['b', 'a', 'c', 'd']);
});
it('should sort all keys alphabetically when priority list is empty', () => {
const data: INodeExecutionData[] = [{ json: { c: 3, a: 1, b: 2 } }];
const priorityList: string[] = [];
const result = sortItemKeysByPriorityList(data, priorityList);
expect(Object.keys(result[0].json)).toEqual(['a', 'b', 'c']);
});
it('should handle an empty data array', () => {
const data: INodeExecutionData[] = [];
const priorityList = ['b', 'a'];
const result = sortItemKeysByPriorityList(data, priorityList);
// Expect an empty array since there is no data
expect(result).toEqual([]);
});
it('should handle a single object in the data array', () => {
const data: INodeExecutionData[] = [{ json: { d: 4, b: 2, a: 1 } }];
const priorityList = ['a', 'b', 'c'];
const result = sortItemKeysByPriorityList(data, priorityList);
expect(Object.keys(result[0].json)).toEqual(['a', 'b', 'd']);
});
it('should handle duplicate keys in the priority list gracefully', () => {
const data: INodeExecutionData[] = [{ json: { d: 4, b: 2, a: 1 } }];
const priorityList = ['a', 'b', 'a'];
const result = sortItemKeysByPriorityList(data, priorityList);
expect(Object.keys(result[0].json)).toEqual(['a', 'b', 'd']);
});
});

View file

@ -427,3 +427,37 @@ export function escapeHtml(text: string): string {
} }
}); });
} }
/**
* Sorts each item json's keys by a priority list
*
* @param {INodeExecutionData[]} data The array of items which keys will be sorted
* @param {string[]} priorityList The priority list, keys of item.json will be sorted in this order first then alphabetically
*/
export function sortItemKeysByPriorityList(data: INodeExecutionData[], priorityList: string[]) {
return data.map((item) => {
const itemKeys = Object.keys(item.json);
const updatedKeysOrder = itemKeys.sort((a, b) => {
const indexA = priorityList.indexOf(a);
const indexB = priorityList.indexOf(b);
if (indexA !== -1 && indexB !== -1) {
return indexA - indexB;
} else if (indexA !== -1) {
return -1;
} else if (indexB !== -1) {
return 1;
}
return a.localeCompare(b);
});
const updatedItem: IDataObject = {};
for (const key of updatedKeysOrder) {
updatedItem[key] = item.json[key];
}
item.json = updatedItem;
return item;
});
}

View file

@ -1404,7 +1404,7 @@ function addToIssuesIfMissing(
if ( if (
(nodeProperties.type === 'string' && (value === '' || value === undefined)) || (nodeProperties.type === 'string' && (value === '' || value === undefined)) ||
(nodeProperties.type === 'multiOptions' && Array.isArray(value) && value.length === 0) || (nodeProperties.type === 'multiOptions' && Array.isArray(value) && value.length === 0) ||
(nodeProperties.type === 'dateTime' && value === undefined) || (nodeProperties.type === 'dateTime' && (value === '' || value === undefined)) ||
(nodeProperties.type === 'options' && (value === '' || value === undefined)) || (nodeProperties.type === 'options' && (value === '' || value === undefined)) ||
((nodeProperties.type === 'resourceLocator' || nodeProperties.type === 'workflowSelector') && ((nodeProperties.type === 'resourceLocator' || nodeProperties.type === 'workflowSelector') &&
!isValidResourceLocatorParameterValue(value as INodeParameterResourceLocator)) !isValidResourceLocatorParameterValue(value as INodeParameterResourceLocator))

View file

@ -4195,4 +4195,57 @@ describe('NodeHelpers', () => {
}); });
} }
}); });
describe('getParameterIssues, required parameters validation', () => {
const testNode: INode = {
id: '12345',
name: 'Test Node',
typeVersion: 1,
type: 'n8n-nodes-base.testNode',
position: [1, 1],
parameters: {},
};
it('Should validate required dateTime parameters if empty string', () => {
const nodeProperties: INodeProperties = {
displayName: 'Date Time',
name: 'testDateTime',
type: 'dateTime',
default: '',
required: true,
};
const nodeValues: INodeParameters = {
testDateTime: '',
};
const result = getParameterIssues(nodeProperties, nodeValues, '', testNode);
expect(result).toEqual({
parameters: {
testDateTime: ['Parameter "Date Time" is required.'],
},
});
});
it('Should validate required dateTime parameters if empty undefined', () => {
const nodeProperties: INodeProperties = {
displayName: 'Date Time',
name: 'testDateTime',
type: 'dateTime',
default: '',
required: true,
};
const nodeValues: INodeParameters = {
testDateTime: undefined,
};
const result = getParameterIssues(nodeProperties, nodeValues, '', testNode);
expect(result).toEqual({
parameters: {
testDateTime: ['Parameter "Date Time" is required.'],
},
});
});
});
}); });

21
patches/bull@4.12.1.patch Normal file
View file

@ -0,0 +1,21 @@
diff --git a/lib/job.js b/lib/job.js
index 6a3606974fd3e397c6c5b2b6e65b20670c68f753..4cdbed1d564ceeb5a80c92eb605e49cfd3c8ccdd 100644
--- a/lib/job.js
+++ b/lib/job.js
@@ -511,9 +511,14 @@ Job.prototype.finished = async function() {
}
};
- const onFailed = (jobId, failedReason) => {
+ const onFailed = async (jobId, failedReason) => {
if (String(jobId) === String(this.id)) {
- reject(new Error(failedReason));
+ const job = await Job.fromId(this.queue, this.id);
+ const error = new Error(failedReason);
+ if (job && job.stacktrace && job.stacktrace.length > 0) {
+ error.stack = job.stacktrace.join('\n');
+ }
+ reject(error);
removeListeners();
}
};

View file

@ -130,6 +130,9 @@ patchedDependencies:
'@types/ws@8.5.4': '@types/ws@8.5.4':
hash: nbzuqaoyqbrfwipijj5qriqqju hash: nbzuqaoyqbrfwipijj5qriqqju
path: patches/@types__ws@8.5.4.patch path: patches/@types__ws@8.5.4.patch
bull@4.12.1:
hash: ep6h4rqtpclldfcdohxlgcb3aq
path: patches/bull@4.12.1.patch
pkce-challenge@3.0.0: pkce-challenge@3.0.0:
hash: dypouzb3lve7vncq25i5fuanki hash: dypouzb3lve7vncq25i5fuanki
path: patches/pkce-challenge@3.0.0.patch path: patches/pkce-challenge@3.0.0.patch
@ -822,7 +825,7 @@ importers:
version: 2.4.3 version: 2.4.3
bull: bull:
specifier: 4.12.1 specifier: 4.12.1
version: 4.12.1 version: 4.12.1(patch_hash=ep6h4rqtpclldfcdohxlgcb3aq)
cache-manager: cache-manager:
specifier: 5.2.3 specifier: 5.2.3
version: 5.2.3 version: 5.2.3
@ -19827,7 +19830,7 @@ snapshots:
- supports-color - supports-color
optional: true optional: true
bull@4.12.1: bull@4.12.1(patch_hash=ep6h4rqtpclldfcdohxlgcb3aq):
dependencies: dependencies:
cron-parser: 4.9.0 cron-parser: 4.9.0
get-port: 5.1.1 get-port: 5.1.1