Merge remote-tracking branch 'origin/master' into fix-CAT-337-put-parent-workflow-to-wait

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2024-12-03 14:31:33 +01:00
commit e4a870d3fc
No known key found for this signature in database
126 changed files with 3760 additions and 785 deletions

View file

@ -4,18 +4,70 @@ on:
schedule: schedule:
- cron: '0 2 * * *' - cron: '0 2 * * *'
workflow_dispatch: workflow_dispatch:
pull_request:
paths:
- packages/core/package.json
- packages/nodes-base/package.json
- packages/@n8n/nodes-langchain/package.json
- .github/workflows/test-workflows.yml
pull_request_review:
types: [submitted]
jobs: jobs:
run-test-workflows: build:
name: Install & Build
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name != 'pull_request_review' || startsWith(github.event.pull_request.base.ref, 'release/')
timeout-minutes: 30
steps: steps:
- name: Checkout - uses: actions/checkout@v4.1.1
uses: actions/checkout@v4.1.1 - run: corepack enable
- uses: actions/setup-node@v4.0.2
with: with:
path: n8n node-version: 20.x
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Setup build cache
uses: rharkor/caching-for-turbo@v1.5
- name: Build Backend
run: pnpm build:backend
- name: Cache build artifacts
uses: actions/cache/save@v4.0.0
with:
path: ./packages/**/dist
key: ${{ github.sha }}:workflow-tests
run-test-workflows:
name: Workflow Tests
runs-on: ubuntu-latest
needs: build
timeout-minutes: 10
steps:
- uses: actions/checkout@v4.1.1
- run: corepack enable
- uses: actions/setup-node@v4.0.2
with:
node-version: 20.x
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Setup build cache
uses: rharkor/caching-for-turbo@v1.5
- name: Restore cached build artifacts
uses: actions/cache/restore@v4.0.0
with:
path: ./packages/**/dist
key: ${{ github.sha }}:workflow-tests
- name: Install OS dependencies
run: |
sudo apt update -y
echo 'tzdata tzdata/Areas select Europe' | sudo debconf-set-selections
echo 'tzdata tzdata/Zones/Europe select Paris' | sudo debconf-set-selections
DEBIAN_FRONTEND="noninteractive" sudo apt-get install -y graphicsmagick
- name: Checkout workflows repo - name: Checkout workflows repo
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
@ -23,52 +75,24 @@ jobs:
repository: n8n-io/test-workflows repository: n8n-io/test-workflows
path: test-workflows path: test-workflows
- run: corepack enable
working-directory: n8n
- uses: actions/setup-node@v4.0.2
with:
node-version: 20.x
cache: 'pnpm'
cache-dependency-path: 'n8n/pnpm-lock.yaml'
- name: Install dependencies
run: |
sudo apt update -y
echo 'tzdata tzdata/Areas select Europe' | sudo debconf-set-selections
echo 'tzdata tzdata/Zones/Europe select Paris' | sudo debconf-set-selections
DEBIAN_FRONTEND="noninteractive" sudo apt-get install -y graphicsmagick
shell: bash
- name: pnpm install and build
working-directory: n8n
run: |
pnpm install --frozen-lockfile
pnpm build:backend
shell: bash
- name: Import credentials - name: Import credentials
run: n8n/packages/cli/bin/n8n import:credentials --input=test-workflows/credentials.json run: packages/cli/bin/n8n import:credentials --input=test-workflows/credentials.json
shell: bash
env: env:
N8N_ENCRYPTION_KEY: ${{secrets.ENCRYPTION_KEY}} N8N_ENCRYPTION_KEY: ${{secrets.ENCRYPTION_KEY}}
- name: Import workflows - name: Import workflows
run: n8n/packages/cli/bin/n8n import:workflow --separate --input=test-workflows/workflows run: packages/cli/bin/n8n import:workflow --separate --input=test-workflows/workflows
shell: bash
env: env:
N8N_ENCRYPTION_KEY: ${{secrets.ENCRYPTION_KEY}} N8N_ENCRYPTION_KEY: ${{secrets.ENCRYPTION_KEY}}
- name: Copy static assets - name: Copy static assets
run: | run: |
cp n8n/assets/n8n-logo.png /tmp/n8n-logo.png cp assets/n8n-logo.png /tmp/n8n-logo.png
cp n8n/assets/n8n-screenshot.png /tmp/n8n-screenshot.png cp assets/n8n-screenshot.png /tmp/n8n-screenshot.png
cp test-workflows/testData/pdfs/*.pdf /tmp/ cp test-workflows/testData/pdfs/*.pdf /tmp/
shell: bash
- name: Run tests - name: Run tests
run: n8n/packages/cli/bin/n8n executeBatch --shallow --skipList=test-workflows/skipList.txt --githubWorkflow --shortOutput --concurrency=16 --compare=test-workflows/snapshots run: packages/cli/bin/n8n executeBatch --shallow --skipList=test-workflows/skipList.txt --githubWorkflow --shortOutput --concurrency=16 --compare=test-workflows/snapshots
shell: bash
id: tests id: tests
env: env:
N8N_ENCRYPTION_KEY: ${{secrets.ENCRYPTION_KEY}} N8N_ENCRYPTION_KEY: ${{secrets.ENCRYPTION_KEY}}
@ -76,23 +100,6 @@ jobs:
DB_SQLITE_POOL_SIZE: 4 DB_SQLITE_POOL_SIZE: 4
N8N_SENTRY_DSN: ${{secrets.CI_SENTRY_DSN}} N8N_SENTRY_DSN: ${{secrets.CI_SENTRY_DSN}}
# -
# name: Export credentials
# if: always()
# run: n8n/packages/cli/bin/n8n export:credentials --output=test-workflows/credentials.json --all --pretty
# shell: bash
# env:
# N8N_ENCRYPTION_KEY: ${{secrets.ENCRYPTION_KEY}}
# -
# name: Commit and push credential changes
# if: always()
# run: |
# cd test-workflows
# git config --global user.name 'n8n test bot'
# git config --global user.email 'n8n-test-bot@users.noreply.github.com'
# git commit -am "Automated credential update"
# git push --force --quiet "https://janober:${{ secrets.TOKEN }}@github.com/n8n-io/test-workflows.git" main:main
- name: Notify Slack on failure - name: Notify Slack on failure
uses: act10ns/slack@v2.0.0 uses: act10ns/slack@v2.0.0
if: failure() && github.ref == 'refs/heads/master' if: failure() && github.ref == 'refs/heads/master'

View file

@ -40,6 +40,7 @@ export function saveCredential() {
.within(() => { .within(() => {
cy.get('button').should('not.exist'); cy.get('button').should('not.exist');
}); });
getCredentialSaveButton().should('have.text', 'Saved');
} }
export function closeCredentialModal() { export function closeCredentialModal() {

View file

@ -1,3 +1,4 @@
import { saveCredential } from '../composables/modals/credential-modal';
import * as projects from '../composables/projects'; import * as projects from '../composables/projects';
import { INSTANCE_MEMBERS, INSTANCE_OWNER, INSTANCE_ADMIN, NOTION_NODE_NAME } from '../constants'; import { INSTANCE_MEMBERS, INSTANCE_OWNER, INSTANCE_ADMIN, NOTION_NODE_NAME } from '../constants';
import { import {
@ -225,8 +226,7 @@ describe('Sharing', { disableAutoLogin: true }, () => {
.filter(':contains("Development")') .filter(':contains("Development")')
.should('have.length', 1) .should('have.length', 1)
.click(); .click();
credentialsModal.getters.saveButton().click(); saveCredential();
credentialsModal.getters.saveButton().should('have.text', 'Saved');
credentialsModal.actions.close(); credentialsModal.actions.close();
projects.getProjectTabWorkflows().click(); projects.getProjectTabWorkflows().click();
@ -252,8 +252,7 @@ describe('Sharing', { disableAutoLogin: true }, () => {
credentialsModal.actions.changeTab('Sharing'); credentialsModal.actions.changeTab('Sharing');
credentialsModal.getters.usersSelect().click(); credentialsModal.getters.usersSelect().click();
getVisibleSelect().find('li').should('have.length', 4).first().click(); getVisibleSelect().find('li').should('have.length', 4).first().click();
credentialsModal.getters.saveButton().click(); saveCredential();
credentialsModal.getters.saveButton().should('have.text', 'Saved');
credentialsModal.actions.close(); credentialsModal.actions.close();
credentialsPage.getters credentialsPage.getters

View file

@ -1,5 +1,6 @@
import { type ICredentialType } from 'n8n-workflow'; import { type ICredentialType } from 'n8n-workflow';
import { getCredentialSaveButton, saveCredential } from '../composables/modals/credential-modal';
import { import {
AGENT_NODE_NAME, AGENT_NODE_NAME,
AI_TOOL_HTTP_NODE_NAME, AI_TOOL_HTTP_NODE_NAME,
@ -194,7 +195,7 @@ describe('Credentials', () => {
credentialsModal.getters.credentialsEditModal().should('be.visible'); credentialsModal.getters.credentialsEditModal().should('be.visible');
credentialsModal.getters.name().click(); credentialsModal.getters.name().click();
credentialsModal.actions.renameCredential(NEW_CREDENTIAL_NAME); credentialsModal.actions.renameCredential(NEW_CREDENTIAL_NAME);
credentialsModal.getters.saveButton().click(); saveCredential();
credentialsModal.getters.closeButton().click(); credentialsModal.getters.closeButton().click();
workflowPage.getters workflowPage.getters
.nodeCredentialsSelect() .nodeCredentialsSelect()
@ -212,7 +213,7 @@ describe('Credentials', () => {
credentialsModal.getters.credentialsEditModal().should('be.visible'); credentialsModal.getters.credentialsEditModal().should('be.visible');
credentialsModal.getters.name().click(); credentialsModal.getters.name().click();
credentialsModal.actions.renameCredential(NEW_CREDENTIAL_NAME2); credentialsModal.actions.renameCredential(NEW_CREDENTIAL_NAME2);
credentialsModal.getters.saveButton().click(); saveCredential();
credentialsModal.getters.closeButton().click(); credentialsModal.getters.closeButton().click();
workflowPage.getters workflowPage.getters
.nodeCredentialsSelect() .nodeCredentialsSelect()
@ -237,7 +238,7 @@ describe('Credentials', () => {
credentialsModal.getters.credentialsEditModal().should('be.visible'); credentialsModal.getters.credentialsEditModal().should('be.visible');
credentialsModal.getters.name().click(); credentialsModal.getters.name().click();
credentialsModal.actions.renameCredential(NEW_CREDENTIAL_NAME); credentialsModal.actions.renameCredential(NEW_CREDENTIAL_NAME);
credentialsModal.getters.saveButton().click(); saveCredential();
credentialsModal.getters.closeButton().click(); credentialsModal.getters.closeButton().click();
workflowPage.getters workflowPage.getters
.nodeCredentialsSelect() .nodeCredentialsSelect()
@ -342,7 +343,8 @@ describe('Credentials', () => {
credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890'); credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890');
credentialsModal.actions.setName('My awesome Notion account'); credentialsModal.actions.setName('My awesome Notion account');
credentialsModal.getters.saveButton().click({ force: true }); getCredentialSaveButton().click();
errorToast().should('have.length', 1); errorToast().should('have.length', 1);
errorToast().should('be.visible'); errorToast().should('be.visible');

View file

@ -49,33 +49,35 @@ describe('Two-factor authentication', { disableAutoLogin: true }, () => {
cy.intercept('GET', '/rest/mfa/qr').as('getMfaQrCode'); cy.intercept('GET', '/rest/mfa/qr').as('getMfaQrCode');
}); });
it('Should be able to login with MFA token', () => { it('Should be able to login with MFA code', () => {
const { email, password } = user; const { email, password } = user;
signinPage.actions.loginWithEmailAndPassword(email, password); signinPage.actions.loginWithEmailAndPassword(email, password);
personalSettingsPage.actions.enableMfa(); personalSettingsPage.actions.enableMfa();
mainSidebar.actions.signout(); mainSidebar.actions.signout();
const token = generateOTPToken(user.mfaSecret); const mfaCode = generateOTPToken(user.mfaSecret);
mfaLoginPage.actions.loginWithMfaToken(email, password, token); mfaLoginPage.actions.loginWithMfaCode(email, password, mfaCode);
mainSidebar.actions.signout(); mainSidebar.actions.signout();
}); });
it('Should be able to login with recovery code', () => { it('Should be able to login with MFA recovery code', () => {
const { email, password } = user; const { email, password } = user;
signinPage.actions.loginWithEmailAndPassword(email, password); signinPage.actions.loginWithEmailAndPassword(email, password);
personalSettingsPage.actions.enableMfa(); personalSettingsPage.actions.enableMfa();
mainSidebar.actions.signout(); mainSidebar.actions.signout();
mfaLoginPage.actions.loginWithRecoveryCode(email, password, user.mfaRecoveryCodes[0]); mfaLoginPage.actions.loginWithMfaRecoveryCode(email, password, user.mfaRecoveryCodes[0]);
mainSidebar.actions.signout(); mainSidebar.actions.signout();
}); });
it('Should be able to disable MFA in account', () => { it('Should be able to disable MFA in account with MFA code ', () => {
const { email, password } = user; const { email, password } = user;
signinPage.actions.loginWithEmailAndPassword(email, password); signinPage.actions.loginWithEmailAndPassword(email, password);
personalSettingsPage.actions.enableMfa(); personalSettingsPage.actions.enableMfa();
mainSidebar.actions.signout(); mainSidebar.actions.signout();
const token = generateOTPToken(user.mfaSecret); const loginToken = generateOTPToken(user.mfaSecret);
mfaLoginPage.actions.loginWithMfaToken(email, password, token); mfaLoginPage.actions.loginWithMfaCode(email, password, loginToken);
personalSettingsPage.actions.disableMfa(); const disableToken = generateOTPToken(user.mfaSecret);
personalSettingsPage.actions.disableMfa(disableToken);
personalSettingsPage.getters.enableMfaButton().should('exist');
mainSidebar.actions.signout(); mainSidebar.actions.signout();
}); });
}); });

View file

@ -1,3 +1,4 @@
import { getCredentialSaveButton } from '../composables/modals/credential-modal';
import { CredentialsPage, CredentialsModal } from '../pages'; import { CredentialsPage, CredentialsModal } from '../pages';
const credentialsPage = new CredentialsPage(); const credentialsPage = new CredentialsPage();
@ -40,7 +41,7 @@ describe('Credentials', () => {
}); });
// Check that the credential was saved and connected successfully // Check that the credential was saved and connected successfully
credentialsModal.getters.saveButton().should('contain.text', 'Saved'); getCredentialSaveButton().should('contain.text', 'Saved');
credentialsModal.getters.oauthConnectSuccessBanner().should('be.visible'); credentialsModal.getters.oauthConnectSuccessBanner().should('be.visible');
}); });
}); });

View file

@ -8,18 +8,18 @@ export class MfaLoginPage extends BasePage {
getters = { getters = {
form: () => cy.getByTestId('mfa-login-form'), form: () => cy.getByTestId('mfa-login-form'),
token: () => cy.getByTestId('token'), mfaCode: () => cy.getByTestId('mfaCode'),
recoveryCode: () => cy.getByTestId('recoveryCode'), mfaRecoveryCode: () => cy.getByTestId('mfaRecoveryCode'),
enterRecoveryCodeButton: () => cy.getByTestId('mfa-enter-recovery-code-button'), enterRecoveryCodeButton: () => cy.getByTestId('mfa-enter-recovery-code-button'),
}; };
actions = { actions = {
loginWithMfaToken: (email: string, password: string, mfaToken: string) => { loginWithMfaCode: (email: string, password: string, mfaCode: string) => {
const signinPage = new SigninPage(); const signinPage = new SigninPage();
const workflowsPage = new WorkflowsPage(); const workflowsPage = new WorkflowsPage();
cy.session( cy.session(
[mfaToken], [mfaCode],
() => { () => {
cy.visit(signinPage.url); cy.visit(signinPage.url);
@ -30,7 +30,7 @@ export class MfaLoginPage extends BasePage {
}); });
this.getters.form().within(() => { this.getters.form().within(() => {
this.getters.token().type(mfaToken); this.getters.mfaCode().type(mfaCode);
}); });
// we should be redirected to /workflows // we should be redirected to /workflows
@ -43,12 +43,12 @@ export class MfaLoginPage extends BasePage {
}, },
); );
}, },
loginWithRecoveryCode: (email: string, password: string, recoveryCode: string) => { loginWithMfaRecoveryCode: (email: string, password: string, mfaRecoveryCode: string) => {
const signinPage = new SigninPage(); const signinPage = new SigninPage();
const workflowsPage = new WorkflowsPage(); const workflowsPage = new WorkflowsPage();
cy.session( cy.session(
[recoveryCode], [mfaRecoveryCode],
() => { () => {
cy.visit(signinPage.url); cy.visit(signinPage.url);
@ -61,7 +61,7 @@ export class MfaLoginPage extends BasePage {
this.getters.enterRecoveryCodeButton().click(); this.getters.enterRecoveryCodeButton().click();
this.getters.form().within(() => { this.getters.form().within(() => {
this.getters.recoveryCode().type(recoveryCode); this.getters.mfaRecoveryCode().type(mfaRecoveryCode);
}); });
// we should be redirected to /workflows // we should be redirected to /workflows

View file

@ -1,3 +1,4 @@
import { getCredentialSaveButton, saveCredential } from '../../composables/modals/credential-modal';
import { getVisibleSelect } from '../../utils'; import { getVisibleSelect } from '../../utils';
import { BasePage } from '../base'; import { BasePage } from '../base';
@ -13,8 +14,6 @@ export class CredentialsModal extends BasePage {
this.getters.credentialInputs().find(`:contains('${fieldName}') .n8n-input input`), this.getters.credentialInputs().find(`:contains('${fieldName}') .n8n-input input`),
name: () => cy.getByTestId('credential-name'), name: () => cy.getByTestId('credential-name'),
nameInput: () => cy.getByTestId('credential-name').find('input'), nameInput: () => cy.getByTestId('credential-name').find('input'),
// Saving of the credentials takes a while on the CI so we need to increase the timeout
saveButton: () => cy.getByTestId('credential-save-button', { timeout: 5000 }),
deleteButton: () => cy.getByTestId('credential-delete-button'), deleteButton: () => cy.getByTestId('credential-delete-button'),
closeButton: () => this.getters.editCredentialModal().find('.el-dialog__close').first(), closeButton: () => this.getters.editCredentialModal().find('.el-dialog__close').first(),
oauthConnectButton: () => cy.getByTestId('oauth-connect-button'), oauthConnectButton: () => cy.getByTestId('oauth-connect-button'),
@ -41,17 +40,17 @@ export class CredentialsModal extends BasePage {
}, },
save: (test = false) => { save: (test = false) => {
cy.intercept('POST', '/rest/credentials').as('saveCredential'); cy.intercept('POST', '/rest/credentials').as('saveCredential');
this.getters.saveButton().click({ force: true }); saveCredential();
cy.wait('@saveCredential'); cy.wait('@saveCredential');
if (test) cy.wait('@testCredential'); if (test) cy.wait('@testCredential');
this.getters.saveButton().should('contain.text', 'Saved'); getCredentialSaveButton().should('contain.text', 'Saved');
}, },
saveSharing: () => { saveSharing: () => {
cy.intercept('PUT', '/rest/credentials/*/share').as('shareCredential'); cy.intercept('PUT', '/rest/credentials/*/share').as('shareCredential');
this.getters.saveButton().click({ force: true }); saveCredential();
cy.wait('@shareCredential'); cy.wait('@shareCredential');
this.getters.saveButton().should('contain.text', 'Saved'); getCredentialSaveButton().should('contain.text', 'Saved');
}, },
close: () => { close: () => {
this.getters.closeButton().click(); this.getters.closeButton().click();
@ -65,7 +64,7 @@ export class CredentialsModal extends BasePage {
.each(($el) => { .each(($el) => {
cy.wrap($el).type('test'); cy.wrap($el).type('test');
}); });
this.getters.saveButton().click(); saveCredential();
if (closeModal) { if (closeModal) {
this.getters.closeButton().click(); this.getters.closeButton().click();
} }

View file

@ -22,6 +22,8 @@ export class PersonalSettingsPage extends BasePage {
saveSettingsButton: () => cy.getByTestId('save-settings-button'), saveSettingsButton: () => cy.getByTestId('save-settings-button'),
enableMfaButton: () => cy.getByTestId('enable-mfa-button'), enableMfaButton: () => cy.getByTestId('enable-mfa-button'),
disableMfaButton: () => cy.getByTestId('disable-mfa-button'), disableMfaButton: () => cy.getByTestId('disable-mfa-button'),
mfaCodeOrMfaRecoveryCodeInput: () => cy.getByTestId('mfa-code-or-recovery-code-input'),
mfaSaveButton: () => cy.getByTestId('mfa-save-button'),
themeSelector: () => cy.getByTestId('theme-select'), themeSelector: () => cy.getByTestId('theme-select'),
selectOptionsVisible: () => cy.get('.el-select-dropdown:visible .el-select-dropdown__item'), selectOptionsVisible: () => cy.get('.el-select-dropdown:visible .el-select-dropdown__item'),
}; };
@ -83,9 +85,11 @@ export class PersonalSettingsPage extends BasePage {
mfaSetupModal.getters.saveButton().click(); mfaSetupModal.getters.saveButton().click();
}); });
}, },
disableMfa: () => { disableMfa: (mfaCodeOrRecoveryCode: string) => {
cy.visit(this.url); cy.visit(this.url);
this.getters.disableMfaButton().click(); this.getters.disableMfaButton().click();
this.getters.mfaCodeOrMfaRecoveryCodeInput().type(mfaCodeOrRecoveryCode);
this.getters.mfaSaveButton().click();
}, },
}; };
} }

View file

@ -6,10 +6,6 @@ export class WorkflowsConfig {
@Env('WORKFLOWS_DEFAULT_NAME') @Env('WORKFLOWS_DEFAULT_NAME')
defaultName: string = 'My workflow'; defaultName: string = 'My workflow';
/** Show onboarding flow in new workflow */
@Env('N8N_ONBOARDING_FLOW_DISABLED')
onboardingFlowDisabled: boolean = false;
/** Default option for which workflows may call the current workflow */ /** Default option for which workflows may call the current workflow */
@Env('N8N_WORKFLOW_CALLER_POLICY_DEFAULT_OPTION') @Env('N8N_WORKFLOW_CALLER_POLICY_DEFAULT_OPTION')
callerPolicyDefaultOption: 'any' | 'none' | 'workflowsFromAList' | 'workflowsFromSameOwner' = callerPolicyDefaultOption: 'any' | 'none' | 'workflowsFromAList' | 'workflowsFromSameOwner' =

View file

@ -150,7 +150,6 @@ describe('GlobalConfig', () => {
}, },
workflows: { workflows: {
defaultName: 'My workflow', defaultName: 'My workflow',
onboardingFlowDisabled: false,
callerPolicyDefaultOption: 'workflowsFromSameOwner', callerPolicyDefaultOption: 'workflowsFromSameOwner',
}, },
endpoints: { endpoints: {

View file

@ -1,2 +1,2 @@
export type * from './types'; export type * from './types';
export { jsonSchemaToZod } from './json-schema-to-zod.js'; export { jsonSchemaToZod } from './json-schema-to-zod';

View file

@ -1,8 +1,10 @@
import type { ZodObjectAny } from '@langchain/core/dist/types/zod'; import type { z } from 'zod';
import type { BaseOutputParser } from '@langchain/core/output_parsers'; import type { BaseOutputParser } from '@langchain/core/output_parsers';
import type { DynamicStructuredTool, Tool } from 'langchain/tools'; import type { DynamicStructuredTool, Tool } from 'langchain/tools';
import { NodeOperationError, type IExecuteFunctions, type INode } from 'n8n-workflow'; import { NodeOperationError, type IExecuteFunctions, type INode } from 'n8n-workflow';
type ZodObjectAny = z.ZodObject<any, any, any, any>;
export async function extractParsedOutput( export async function extractParsedOutput(
ctx: IExecuteFunctions, ctx: IExecuteFunctions,
outputParser: BaseOutputParser<unknown>, outputParser: BaseOutputParser<unknown>,

View file

@ -135,47 +135,47 @@
"@getzep/zep-js": "0.9.0", "@getzep/zep-js": "0.9.0",
"@google-ai/generativelanguage": "2.6.0", "@google-ai/generativelanguage": "2.6.0",
"@google-cloud/resource-manager": "5.3.0", "@google-cloud/resource-manager": "5.3.0",
"@google/generative-ai": "0.19.0", "@google/generative-ai": "0.21.0",
"@huggingface/inference": "2.8.0", "@huggingface/inference": "2.8.0",
"@langchain/anthropic": "0.3.7", "@langchain/anthropic": "0.3.8",
"@langchain/aws": "0.1.1", "@langchain/aws": "0.1.2",
"@langchain/cohere": "0.3.1", "@langchain/cohere": "0.3.1",
"@langchain/community": "0.3.11", "@langchain/community": "0.3.15",
"@langchain/core": "catalog:", "@langchain/core": "catalog:",
"@langchain/google-genai": "0.1.2", "@langchain/google-genai": "0.1.4",
"@langchain/google-vertexai": "0.1.0", "@langchain/google-vertexai": "0.1.3",
"@langchain/groq": "0.1.2", "@langchain/groq": "0.1.2",
"@langchain/mistralai": "0.1.1", "@langchain/mistralai": "0.2.0",
"@langchain/ollama": "0.1.1", "@langchain/ollama": "0.1.2",
"@langchain/openai": "0.3.11", "@langchain/openai": "0.3.14",
"@langchain/pinecone": "0.1.1", "@langchain/pinecone": "0.1.3",
"@langchain/qdrant": "0.1.0", "@langchain/qdrant": "0.1.1",
"@langchain/redis": "0.1.0", "@langchain/redis": "0.1.0",
"@langchain/textsplitters": "0.1.0", "@langchain/textsplitters": "0.1.0",
"@mozilla/readability": "0.5.0", "@mozilla/readability": "0.5.0",
"@n8n/json-schema-to-zod": "workspace:*", "@n8n/json-schema-to-zod": "workspace:*",
"@n8n/typeorm": "0.3.20-12", "@n8n/typeorm": "0.3.20-12",
"@n8n/vm2": "3.9.25", "@n8n/vm2": "3.9.25",
"@pinecone-database/pinecone": "3.0.3", "@pinecone-database/pinecone": "4.0.0",
"@qdrant/js-client-rest": "1.11.0", "@qdrant/js-client-rest": "1.11.0",
"@supabase/supabase-js": "2.45.4", "@supabase/supabase-js": "2.45.4",
"@xata.io/client": "0.28.4", "@xata.io/client": "0.28.4",
"basic-auth": "catalog:", "basic-auth": "catalog:",
"cheerio": "1.0.0", "cheerio": "1.0.0",
"cohere-ai": "7.13.2", "cohere-ai": "7.14.0",
"d3-dsv": "2.0.0", "d3-dsv": "2.0.0",
"epub2": "3.0.2", "epub2": "3.0.2",
"form-data": "catalog:", "form-data": "catalog:",
"generate-schema": "2.6.0", "generate-schema": "2.6.0",
"html-to-text": "9.0.5", "html-to-text": "9.0.5",
"jsdom": "23.0.1", "jsdom": "23.0.1",
"langchain": "0.3.5", "langchain": "0.3.6",
"lodash": "catalog:", "lodash": "catalog:",
"mammoth": "1.7.2", "mammoth": "1.7.2",
"mime-types": "2.1.35", "mime-types": "2.1.35",
"n8n-nodes-base": "workspace:*", "n8n-nodes-base": "workspace:*",
"n8n-workflow": "workspace:*", "n8n-workflow": "workspace:*",
"openai": "4.69.0", "openai": "4.73.1",
"pdf-parse": "1.1.1", "pdf-parse": "1.1.1",
"pg": "8.12.0", "pg": "8.12.0",
"redis": "4.6.12", "redis": "4.6.12",

View file

@ -32,7 +32,9 @@ export class N8nStructuredOutputParser extends StructuredOutputParser<
[{ json: { action: 'parse', text } }], [{ json: { action: 'parse', text } }],
]); ]);
try { try {
const parsed = await super.parse(text); const jsonString = text.includes('```') ? text.split(/```(?:json)?/)[1] : text;
const json = JSON.parse(jsonString.trim());
const parsed = await this.schema.parseAsync(json);
const result = (get(parsed, [STRUCTURED_OUTPUT_KEY, STRUCTURED_OUTPUT_OBJECT_KEY]) ?? const result = (get(parsed, [STRUCTURED_OUTPUT_KEY, STRUCTURED_OUTPUT_OBJECT_KEY]) ??
get(parsed, [STRUCTURED_OUTPUT_KEY, STRUCTURED_OUTPUT_ARRAY_KEY]) ?? get(parsed, [STRUCTURED_OUTPUT_KEY, STRUCTURED_OUTPUT_ARRAY_KEY]) ??

View file

@ -94,7 +94,7 @@
"@n8n/permissions": "workspace:*", "@n8n/permissions": "workspace:*",
"@n8n/task-runner": "workspace:*", "@n8n/task-runner": "workspace:*",
"@n8n/typeorm": "0.3.20-12", "@n8n/typeorm": "0.3.20-12",
"@n8n_io/ai-assistant-sdk": "1.10.3", "@n8n_io/ai-assistant-sdk": "1.12.0",
"@n8n_io/license-sdk": "2.13.1", "@n8n_io/license-sdk": "2.13.1",
"@oclif/core": "4.0.7", "@oclif/core": "4.0.7",
"@rudderstack/rudder-sdk-node": "2.0.9", "@rudderstack/rudder-sdk-node": "2.0.9",

View file

@ -0,0 +1,61 @@
import { GlobalConfig } from '@n8n/config';
import type { ClientOptions, ErrorEvent } from '@sentry/types';
import { strict as assert } from 'node:assert';
import { Container } from 'typedi';
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
const init = jest.fn();
jest.mock('@sentry/integrations');
jest.mock('@sentry/node', () => ({
init,
setTag: jest.fn(),
captureException: jest.fn(),
Integrations: {},
}));
jest.spyOn(process, 'on');
describe('initErrorHandling', () => {
let beforeSend: ClientOptions['beforeSend'];
beforeAll(async () => {
Container.get(GlobalConfig).sentry.backendDsn = 'backend-dsn';
const errorReporting = require('@/error-reporting');
await errorReporting.initErrorHandling();
const options = (init.mock.calls[0] as [ClientOptions])[0];
beforeSend = options.beforeSend;
});
it('ignores errors with level warning', async () => {
const originalException = new InternalServerError('test');
originalException.level = 'warning';
const event = {} as ErrorEvent;
assert(beforeSend);
expect(await beforeSend(event, { originalException })).toEqual(null);
});
it('keeps events with a cause with error level', async () => {
const cause = new Error('cause-error');
const originalException = new InternalServerError('test', cause);
const event = {} as ErrorEvent;
assert(beforeSend);
expect(await beforeSend(event, { originalException })).toEqual(event);
});
it('ignores events with error cause with warning level', async () => {
const cause: Error & { level?: 'warning' } = new Error('cause-error');
cause.level = 'warning';
const originalException = new InternalServerError('test', cause);
const event = {} as ErrorEvent;
assert(beforeSend);
expect(await beforeSend(event, { originalException })).toEqual(null);
});
});

View file

@ -1,6 +1,5 @@
import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk'; import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk';
import type { Response } from 'express'; import type { Response } from 'express';
import { ErrorReporterProxy } from 'n8n-workflow';
import { strict as assert } from 'node:assert'; import { strict as assert } from 'node:assert';
import { WritableStream } from 'node:stream/web'; import { WritableStream } from 'node:stream/web';
@ -33,8 +32,7 @@ export class AiController {
} }
} catch (e) { } catch (e) {
assert(e instanceof Error); assert(e instanceof Error);
ErrorReporterProxy.error(e); throw new InternalServerError(e.message, e);
throw new InternalServerError(`Something went wrong: ${e.message}`);
} }
} }
@ -46,8 +44,7 @@ export class AiController {
return await this.aiService.applySuggestion(req.body, req.user); return await this.aiService.applySuggestion(req.body, req.user);
} catch (e) { } catch (e) {
assert(e instanceof Error); assert(e instanceof Error);
ErrorReporterProxy.error(e); throw new InternalServerError(e.message, e);
throw new InternalServerError(`Something went wrong: ${e.message}`);
} }
} }
@ -57,8 +54,7 @@ export class AiController {
return await this.aiService.askAi(req.body, req.user); return await this.aiService.askAi(req.body, req.user);
} catch (e) { } catch (e) {
assert(e instanceof Error); assert(e instanceof Error);
ErrorReporterProxy.error(e); throw new InternalServerError(e.message, e);
throw new InternalServerError(`Something went wrong: ${e.message}`);
} }
} }
} }

View file

@ -41,7 +41,7 @@ export class AuthController {
/** Log in a user */ /** Log in a user */
@Post('/login', { skipAuth: true, rateLimit: true }) @Post('/login', { skipAuth: true, rateLimit: true })
async login(req: LoginRequest, res: Response): Promise<PublicUser | undefined> { async login(req: LoginRequest, res: Response): Promise<PublicUser | undefined> {
const { email, password, mfaToken, mfaRecoveryCode } = req.body; const { email, password, mfaCode, mfaRecoveryCode } = req.body;
if (!email) throw new ApplicationError('Email is required to log in'); if (!email) throw new ApplicationError('Email is required to log in');
if (!password) throw new ApplicationError('Password is required to log in'); if (!password) throw new ApplicationError('Password is required to log in');
@ -75,16 +75,16 @@ export class AuthController {
if (user) { if (user) {
if (user.mfaEnabled) { if (user.mfaEnabled) {
if (!mfaToken && !mfaRecoveryCode) { if (!mfaCode && !mfaRecoveryCode) {
throw new AuthError('MFA Error', 998); throw new AuthError('MFA Error', 998);
} }
const isMFATokenValid = await this.mfaService.validateMfa( const isMfaCodeOrMfaRecoveryCodeValid = await this.mfaService.validateMfa(
user.id, user.id,
mfaToken, mfaCode,
mfaRecoveryCode, mfaRecoveryCode,
); );
if (!isMFATokenValid) { if (!isMfaCodeOrMfaRecoveryCodeValid) {
throw new AuthError('Invalid mfa token or recovery code'); throw new AuthError('Invalid mfa token or recovery code');
} }
} }

View file

@ -201,7 +201,7 @@ export class CommunityPackagesController {
error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON, error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON,
].join(':'); ].join(':');
throw new InternalServerError(message); throw new InternalServerError(message, error);
} }
// broadcast to connected frontends that node list has been updated // broadcast to connected frontends that node list has been updated
@ -283,7 +283,7 @@ export class CommunityPackagesController {
error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON, error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON,
].join(':'); ].join(':');
throw new InternalServerError(message); throw new InternalServerError(message, error);
} }
} }
} }

View file

@ -68,8 +68,8 @@ export class MeController {
throw new BadRequestError('Two-factor code is required to change email'); throw new BadRequestError('Two-factor code is required to change email');
} }
const isMfaTokenValid = await this.mfaService.validateMfa(userId, payload.mfaCode, undefined); const isMfaCodeValid = await this.mfaService.validateMfa(userId, payload.mfaCode, undefined);
if (!isMfaTokenValid) { if (!isMfaCodeValid) {
throw new InvalidMfaCodeError(); throw new InvalidMfaCodeError();
} }
} }
@ -142,8 +142,8 @@ export class MeController {
throw new BadRequestError('Two-factor code is required to change password.'); throw new BadRequestError('Two-factor code is required to change password.');
} }
const isMfaTokenValid = await this.mfaService.validateMfa(user.id, mfaCode, undefined); const isMfaCodeValid = await this.mfaService.validateMfa(user.id, mfaCode, undefined);
if (!isMfaTokenValid) { if (!isMfaCodeValid) {
throw new InvalidMfaCodeError(); throw new InvalidMfaCodeError();
} }
} }

View file

@ -59,7 +59,7 @@ export class MFAController {
@Post('/enable', { rateLimit: true }) @Post('/enable', { rateLimit: true })
async activateMFA(req: MFA.Activate) { async activateMFA(req: MFA.Activate) {
const { token = null } = req.body; const { mfaCode = null } = req.body;
const { id, mfaEnabled } = req.user; const { id, mfaEnabled } = req.user;
await this.externalHooks.run('mfa.beforeSetup', [req.user]); await this.externalHooks.run('mfa.beforeSetup', [req.user]);
@ -67,7 +67,7 @@ export class MFAController {
const { decryptedSecret: secret, decryptedRecoveryCodes: recoveryCodes } = const { decryptedSecret: secret, decryptedRecoveryCodes: recoveryCodes } =
await this.mfaService.getSecretAndRecoveryCodes(id); await this.mfaService.getSecretAndRecoveryCodes(id);
if (!token) throw new BadRequestError('Token is required to enable MFA feature'); if (!mfaCode) throw new BadRequestError('Token is required to enable MFA feature');
if (mfaEnabled) throw new BadRequestError('MFA already enabled'); if (mfaEnabled) throw new BadRequestError('MFA already enabled');
@ -75,10 +75,10 @@ export class MFAController {
throw new BadRequestError('Cannot enable MFA without generating secret and recovery codes'); throw new BadRequestError('Cannot enable MFA without generating secret and recovery codes');
} }
const verified = this.mfaService.totp.verifySecret({ secret, token, window: 10 }); const verified = this.mfaService.totp.verifySecret({ secret, mfaCode, window: 10 });
if (!verified) if (!verified)
throw new BadRequestError('MFA token expired. Close the modal and enable MFA again', 997); throw new BadRequestError('MFA code expired. Close the modal and enable MFA again', 997);
await this.mfaService.enableMfa(id); await this.mfaService.enableMfa(id);
} }
@ -86,27 +86,27 @@ export class MFAController {
@Post('/disable', { rateLimit: true }) @Post('/disable', { rateLimit: true })
async disableMFA(req: MFA.Disable) { async disableMFA(req: MFA.Disable) {
const { id: userId } = req.user; const { id: userId } = req.user;
const { token = null } = req.body; const { mfaCode = null } = req.body;
if (typeof token !== 'string' || !token) { if (typeof mfaCode !== 'string' || !mfaCode) {
throw new BadRequestError('Token is required to disable MFA feature'); throw new BadRequestError('Token is required to disable MFA feature');
} }
await this.mfaService.disableMfa(userId, token); await this.mfaService.disableMfa(userId, mfaCode);
} }
@Post('/verify', { rateLimit: true }) @Post('/verify', { rateLimit: true })
async verifyMFA(req: MFA.Verify) { async verifyMFA(req: MFA.Verify) {
const { id } = req.user; const { id } = req.user;
const { token } = req.body; const { mfaCode } = req.body;
const { decryptedSecret: secret } = await this.mfaService.getSecretAndRecoveryCodes(id); const { decryptedSecret: secret } = await this.mfaService.getSecretAndRecoveryCodes(id);
if (!token) throw new BadRequestError('Token is required to enable MFA feature'); if (!mfaCode) throw new BadRequestError('MFA code is required to enable MFA feature');
if (!secret) throw new BadRequestError('No MFA secret se for this user'); if (!secret) throw new BadRequestError('No MFA secret se for this user');
const verified = this.mfaService.totp.verifySecret({ secret, token }); const verified = this.mfaService.totp.verifySecret({ secret, mfaCode });
if (!verified) throw new BadRequestError('MFA secret could not be verified'); if (!verified) throw new BadRequestError('MFA secret could not be verified');
} }

View file

@ -120,7 +120,7 @@ export class PasswordResetController {
publicApi: false, publicApi: false,
}); });
if (error instanceof Error) { if (error instanceof Error) {
throw new InternalServerError(`Please contact your administrator: ${error.message}`); throw new InternalServerError(`Please contact your administrator: ${error.message}`, error);
} }
} }
@ -171,7 +171,7 @@ export class PasswordResetController {
*/ */
@Post('/change-password', { skipAuth: true }) @Post('/change-password', { skipAuth: true })
async changePassword(req: PasswordResetRequest.NewPassword, res: Response) { async changePassword(req: PasswordResetRequest.NewPassword, res: Response) {
const { token, password, mfaToken } = req.body; const { token, password, mfaCode } = req.body;
if (!token || !password) { if (!token || !password) {
this.logger.debug( this.logger.debug(
@ -189,11 +189,11 @@ export class PasswordResetController {
if (!user) throw new NotFoundError(''); if (!user) throw new NotFoundError('');
if (user.mfaEnabled) { if (user.mfaEnabled) {
if (!mfaToken) throw new BadRequestError('If MFA enabled, mfaToken is required.'); if (!mfaCode) throw new BadRequestError('If MFA enabled, mfaCode is required.');
const { decryptedSecret: secret } = await this.mfaService.getSecretAndRecoveryCodes(user.id); const { decryptedSecret: secret } = await this.mfaService.getSecretAndRecoveryCodes(user.id);
const validToken = this.mfaService.totp.verifySecret({ secret, token: mfaToken }); const validToken = this.mfaService.totp.verifySecret({ secret, mfaCode });
if (!validToken) throw new BadRequestError('Invalid MFA token.'); if (!validToken) throw new BadRequestError('Invalid MFA token.');
} }

View file

@ -54,7 +54,7 @@ export class TranslationController {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return // eslint-disable-next-line @typescript-eslint/no-unsafe-return
return require(NODE_HEADERS_PATH); return require(NODE_HEADERS_PATH);
} catch (error) { } catch (error) {
throw new InternalServerError('Failed to load headers file'); throw new InternalServerError('Failed to load headers file', error);
} }
} }
} }

View file

@ -90,6 +90,17 @@ export const initErrorHandling = async () => {
if (tags) event.tags = { ...event.tags, ...tags }; if (tags) event.tags = { ...event.tags, ...tags };
} }
if (
originalException instanceof Error &&
'cause' in originalException &&
originalException.cause instanceof Error &&
'level' in originalException.cause &&
originalException.cause.level === 'warning'
) {
// handle underlying errors propagating from dependencies like ai-assistant-sdk
return null;
}
if (originalException instanceof Error && originalException.stack) { if (originalException instanceof Error && originalException.stack) {
const eventHash = createHash('sha1').update(originalException.stack).digest('base64'); const eventHash = createHash('sha1').update(originalException.stack).digest('base64');
if (seenErrors.has(eventHash)) return null; if (seenErrors.has(eventHash)) return null;

View file

@ -16,8 +16,9 @@ export abstract class ResponseError extends ApplicationError {
readonly errorCode: number = httpStatusCode, readonly errorCode: number = httpStatusCode,
// The error hint the response // The error hint the response
readonly hint: string | undefined = undefined, readonly hint: string | undefined = undefined,
cause?: unknown,
) { ) {
super(message); super(message, { cause });
this.name = 'ResponseError'; this.name = 'ResponseError';
} }
} }

View file

@ -1,7 +1,7 @@
import { ResponseError } from './abstract/response.error'; import { ResponseError } from './abstract/response.error';
export class InternalServerError extends ResponseError { export class InternalServerError extends ResponseError {
constructor(message: string, errorCode = 500) { constructor(message: string, cause?: unknown) {
super(message, 500, errorCode); super(message, 500, 500, undefined, cause);
} }
} }

View file

@ -0,0 +1,72 @@
import { EvaluationMetrics } from '../evaluation-metrics.ee';
describe('EvaluationMetrics', () => {
test('should aggregate metrics correctly', () => {
const testMetricNames = new Set(['metric1', 'metric2']);
const metrics = new EvaluationMetrics(testMetricNames);
metrics.addResults({ metric1: 1, metric2: 0 });
metrics.addResults({ metric1: 0.5, metric2: 0.2 });
const aggregatedMetrics = metrics.getAggregatedMetrics();
expect(aggregatedMetrics).toEqual({ metric1: 0.75, metric2: 0.1 });
});
test('should aggregate only numbers', () => {
const testMetricNames = new Set(['metric1', 'metric2']);
const metrics = new EvaluationMetrics(testMetricNames);
metrics.addResults({ metric1: 1, metric2: 0 });
metrics.addResults({ metric1: '0.5', metric2: 0.2 });
metrics.addResults({ metric1: 'not a number', metric2: [1, 2, 3] });
const aggregatedUpMetrics = metrics.getAggregatedMetrics();
expect(aggregatedUpMetrics).toEqual({ metric1: 1, metric2: 0.1 });
});
test('should handle missing values', () => {
const testMetricNames = new Set(['metric1', 'metric2']);
const metrics = new EvaluationMetrics(testMetricNames);
metrics.addResults({ metric1: 1 });
metrics.addResults({ metric2: 0.2 });
const aggregatedMetrics = metrics.getAggregatedMetrics();
expect(aggregatedMetrics).toEqual({ metric1: 1, metric2: 0.2 });
});
test('should handle empty metrics', () => {
const testMetricNames = new Set(['metric1', 'metric2']);
const metrics = new EvaluationMetrics(testMetricNames);
const aggregatedMetrics = metrics.getAggregatedMetrics();
expect(aggregatedMetrics).toEqual({});
});
test('should handle empty testMetrics', () => {
const metrics = new EvaluationMetrics(new Set());
metrics.addResults({ metric1: 1, metric2: 0 });
metrics.addResults({ metric1: 0.5, metric2: 0.2 });
const aggregatedMetrics = metrics.getAggregatedMetrics();
expect(aggregatedMetrics).toEqual({});
});
test('should ignore non-relevant values', () => {
const testMetricNames = new Set(['metric1']);
const metrics = new EvaluationMetrics(testMetricNames);
metrics.addResults({ metric1: 1, notRelevant: 0 });
metrics.addResults({ metric1: 0.5, notRelevant2: { foo: 'bar' } });
const aggregatedMetrics = metrics.getAggregatedMetrics();
expect(aggregatedMetrics).toEqual({ metric1: 0.75 });
});
});

View file

@ -57,6 +57,12 @@
"name": "success", "name": "success",
"value": true, "value": true,
"type": "boolean" "type": "boolean"
},
{
"id": "877d1bf8-31a7-4571-9293-a6837b51d22b",
"name": "metric1",
"value": 0.1,
"type": "number"
} }
] ]
}, },

View file

@ -2,15 +2,17 @@ import type { SelectQueryBuilder } from '@n8n/typeorm';
import { stringify } from 'flatted'; import { stringify } from 'flatted';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import { mock, mockDeep } from 'jest-mock-extended'; import { mock, mockDeep } from 'jest-mock-extended';
import type { IRun } from 'n8n-workflow'; import type { GenericValue, IRun } from 'n8n-workflow';
import path from 'path'; import path from 'path';
import type { ActiveExecutions } from '@/active-executions'; import type { ActiveExecutions } from '@/active-executions';
import type { ExecutionEntity } from '@/databases/entities/execution-entity'; import type { ExecutionEntity } from '@/databases/entities/execution-entity';
import type { TestDefinition } from '@/databases/entities/test-definition.ee'; import type { TestDefinition } from '@/databases/entities/test-definition.ee';
import type { TestMetric } from '@/databases/entities/test-metric.ee';
import type { TestRun } from '@/databases/entities/test-run.ee'; import type { TestRun } from '@/databases/entities/test-run.ee';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
import type { ExecutionRepository } from '@/databases/repositories/execution.repository'; import type { ExecutionRepository } from '@/databases/repositories/execution.repository';
import type { TestMetricRepository } from '@/databases/repositories/test-metric.repository.ee';
import type { TestRunRepository } from '@/databases/repositories/test-run.repository.ee'; import type { TestRunRepository } from '@/databases/repositories/test-run.repository.ee';
import type { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import type { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import type { WorkflowRunner } from '@/workflow-runner'; import type { WorkflowRunner } from '@/workflow-runner';
@ -58,12 +60,38 @@ function mockExecutionData() {
}); });
} }
function mockEvaluationExecutionData(metrics: Record<string, GenericValue>) {
return mock<IRun>({
data: {
resultData: {
lastNodeExecuted: 'lastNode',
runData: {
lastNode: [
{
data: {
main: [
[
{
json: metrics,
},
],
],
},
},
],
},
},
},
});
}
describe('TestRunnerService', () => { describe('TestRunnerService', () => {
const executionRepository = mock<ExecutionRepository>(); const executionRepository = mock<ExecutionRepository>();
const workflowRepository = mock<WorkflowRepository>(); const workflowRepository = mock<WorkflowRepository>();
const workflowRunner = mock<WorkflowRunner>(); const workflowRunner = mock<WorkflowRunner>();
const activeExecutions = mock<ActiveExecutions>(); const activeExecutions = mock<ActiveExecutions>();
const testRunRepository = mock<TestRunRepository>(); const testRunRepository = mock<TestRunRepository>();
const testMetricRepository = mock<TestMetricRepository>();
beforeEach(() => { beforeEach(() => {
const executionsQbMock = mockDeep<SelectQueryBuilder<ExecutionEntity>>({ const executionsQbMock = mockDeep<SelectQueryBuilder<ExecutionEntity>>({
@ -80,6 +108,11 @@ describe('TestRunnerService', () => {
.mockResolvedValueOnce(executionMocks[1]); .mockResolvedValueOnce(executionMocks[1]);
testRunRepository.createTestRun.mockResolvedValue(mock<TestRun>({ id: 'test-run-id' })); testRunRepository.createTestRun.mockResolvedValue(mock<TestRun>({ id: 'test-run-id' }));
testMetricRepository.find.mockResolvedValue([
mock<TestMetric>({ name: 'metric1' }),
mock<TestMetric>({ name: 'metric2' }),
]);
}); });
afterEach(() => { afterEach(() => {
@ -97,6 +130,7 @@ describe('TestRunnerService', () => {
executionRepository, executionRepository,
activeExecutions, activeExecutions,
testRunRepository, testRunRepository,
testMetricRepository,
); );
expect(testRunnerService).toBeInstanceOf(TestRunnerService); expect(testRunnerService).toBeInstanceOf(TestRunnerService);
@ -109,6 +143,7 @@ describe('TestRunnerService', () => {
executionRepository, executionRepository,
activeExecutions, activeExecutions,
testRunRepository, testRunRepository,
testMetricRepository,
); );
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({ workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
@ -143,6 +178,7 @@ describe('TestRunnerService', () => {
executionRepository, executionRepository,
activeExecutions, activeExecutions,
testRunRepository, testRunRepository,
testMetricRepository,
); );
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({ workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
@ -166,17 +202,17 @@ describe('TestRunnerService', () => {
.mockResolvedValue(mockExecutionData()); .mockResolvedValue(mockExecutionData());
activeExecutions.getPostExecutePromise activeExecutions.getPostExecutePromise
.calledWith('some-execution-id-2') .calledWith('some-execution-id-3')
.mockResolvedValue(mockExecutionData()); .mockResolvedValue(mockExecutionData());
// Mock executions of evaluation workflow // Mock executions of evaluation workflow
activeExecutions.getPostExecutePromise activeExecutions.getPostExecutePromise
.calledWith('some-execution-id-3') .calledWith('some-execution-id-2')
.mockResolvedValue(mockExecutionData()); .mockResolvedValue(mockEvaluationExecutionData({ metric1: 1, metric2: 0 }));
activeExecutions.getPostExecutePromise activeExecutions.getPostExecutePromise
.calledWith('some-execution-id-4') .calledWith('some-execution-id-4')
.mockResolvedValue(mockExecutionData()); .mockResolvedValue(mockEvaluationExecutionData({ metric1: 0.5 }));
await testRunnerService.runTest( await testRunnerService.runTest(
mock<User>(), mock<User>(),
@ -225,7 +261,8 @@ describe('TestRunnerService', () => {
expect(testRunRepository.markAsRunning).toHaveBeenCalledWith('test-run-id'); expect(testRunRepository.markAsRunning).toHaveBeenCalledWith('test-run-id');
expect(testRunRepository.markAsCompleted).toHaveBeenCalledTimes(1); expect(testRunRepository.markAsCompleted).toHaveBeenCalledTimes(1);
expect(testRunRepository.markAsCompleted).toHaveBeenCalledWith('test-run-id', { expect(testRunRepository.markAsCompleted).toHaveBeenCalledWith('test-run-id', {
success: false, metric1: 0.75,
metric2: 0,
}); });
}); });
}); });

View file

@ -0,0 +1,32 @@
import type { IDataObject } from 'n8n-workflow';
export class EvaluationMetrics {
private readonly rawMetricsByName = new Map<string, number[]>();
constructor(private readonly metricNames: Set<string>) {
for (const metricName of metricNames) {
this.rawMetricsByName.set(metricName, []);
}
}
addResults(result: IDataObject) {
for (const [metricName, metricValue] of Object.entries(result)) {
if (typeof metricValue === 'number' && this.metricNames.has(metricName)) {
this.rawMetricsByName.get(metricName)!.push(metricValue);
}
}
}
getAggregatedMetrics() {
const aggregatedMetrics: Record<string, number> = {};
for (const [metricName, metricValues] of this.rawMetricsByName.entries()) {
if (metricValues.length > 0) {
const metricSum = metricValues.reduce((acc, val) => acc + val, 0);
aggregatedMetrics[metricName] = metricSum / metricValues.length;
}
}
return aggregatedMetrics;
}
}

View file

@ -15,11 +15,13 @@ import type { TestDefinition } from '@/databases/entities/test-definition.ee';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { ExecutionRepository } from '@/databases/repositories/execution.repository';
import { TestMetricRepository } from '@/databases/repositories/test-metric.repository.ee';
import { TestRunRepository } from '@/databases/repositories/test-run.repository.ee'; import { TestRunRepository } from '@/databases/repositories/test-run.repository.ee';
import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import { getRunData } from '@/workflow-execute-additional-data'; import { getRunData } from '@/workflow-execute-additional-data';
import { WorkflowRunner } from '@/workflow-runner'; import { WorkflowRunner } from '@/workflow-runner';
import { EvaluationMetrics } from './evaluation-metrics.ee';
import { createPinData, getPastExecutionStartNode } from './utils.ee'; import { createPinData, getPastExecutionStartNode } from './utils.ee';
/** /**
@ -40,6 +42,7 @@ export class TestRunnerService {
private readonly executionRepository: ExecutionRepository, private readonly executionRepository: ExecutionRepository,
private readonly activeExecutions: ActiveExecutions, private readonly activeExecutions: ActiveExecutions,
private readonly testRunRepository: TestRunRepository, private readonly testRunRepository: TestRunRepository,
private readonly testMetricRepository: TestMetricRepository,
) {} ) {}
/** /**
@ -113,6 +116,11 @@ export class TestRunnerService {
return await executePromise; return await executePromise;
} }
/**
* Evaluation result is the first item in the output of the last node
* executed in the evaluation workflow. Defaults to an empty object
* in case the node doesn't produce any output items.
*/
private extractEvaluationResult(execution: IRun): IDataObject { private extractEvaluationResult(execution: IRun): IDataObject {
const lastNodeExecuted = execution.data.resultData.lastNodeExecuted; const lastNodeExecuted = execution.data.resultData.lastNodeExecuted;
assert(lastNodeExecuted, 'Could not find the last node executed in evaluation workflow'); assert(lastNodeExecuted, 'Could not find the last node executed in evaluation workflow');
@ -124,6 +132,21 @@ export class TestRunnerService {
return mainConnectionData?.[0]?.json ?? {}; return mainConnectionData?.[0]?.json ?? {};
} }
/**
* Get the metrics to collect from the evaluation workflow execution results.
*/
private async getTestMetricNames(testDefinitionId: string) {
const metrics = await this.testMetricRepository.find({
where: {
testDefinition: {
id: testDefinitionId,
},
},
});
return new Set(metrics.map((m) => m.name));
}
/** /**
* Creates a new test run for the given test definition. * Creates a new test run for the given test definition.
*/ */
@ -152,11 +175,15 @@ export class TestRunnerService {
.andWhere('execution.workflowId = :workflowId', { workflowId: test.workflowId }) .andWhere('execution.workflowId = :workflowId', { workflowId: test.workflowId })
.getMany(); .getMany();
// Get the metrics to collect from the evaluation workflow
const testMetricNames = await this.getTestMetricNames(test.id);
// 2. Run over all the test cases // 2. Run over all the test cases
await this.testRunRepository.markAsRunning(testRun.id); await this.testRunRepository.markAsRunning(testRun.id);
const metrics = []; // Object to collect the results of the evaluation workflow executions
const metrics = new EvaluationMetrics(testMetricNames);
for (const { id: pastExecutionId } of pastExecutions) { for (const { id: pastExecutionId } of pastExecutions) {
// Fetch past execution with data // Fetch past execution with data
@ -192,12 +219,10 @@ export class TestRunnerService {
assert(evalExecution); assert(evalExecution);
// Extract the output of the last node executed in the evaluation workflow // Extract the output of the last node executed in the evaluation workflow
metrics.push(this.extractEvaluationResult(evalExecution)); metrics.addResults(this.extractEvaluationResult(evalExecution));
} }
// TODO: 3. Aggregate the results const aggregatedMetrics = metrics.getAggregatedMetrics();
// Now we just set success to true if all the test cases passed
const aggregatedMetrics = { success: metrics.every((metric) => metric.success) };
await this.testRunRepository.markAsCompleted(testRun.id, aggregatedMetrics); await this.testRunRepository.markAsCompleted(testRun.id, aggregatedMetrics);
} }

View file

@ -251,7 +251,7 @@ export class ExecutionService {
requestFilters = requestFiltersRaw as IGetExecutionsQueryFilter; requestFilters = requestFiltersRaw as IGetExecutionsQueryFilter;
} }
} catch (error) { } catch (error) {
throw new InternalServerError('Parameter "filter" contained invalid JSON string.'); throw new InternalServerError('Parameter "filter" contained invalid JSON string.', error);
} }
} }

View file

@ -56,13 +56,13 @@ export class MfaService {
async validateMfa( async validateMfa(
userId: string, userId: string,
mfaToken: string | undefined, mfaCode: string | undefined,
mfaRecoveryCode: string | undefined, mfaRecoveryCode: string | undefined,
) { ) {
const user = await this.authUserRepository.findOneByOrFail({ id: userId }); const user = await this.authUserRepository.findOneByOrFail({ id: userId });
if (mfaToken) { if (mfaCode) {
const decryptedSecret = this.cipher.decrypt(user.mfaSecret!); const decryptedSecret = this.cipher.decrypt(user.mfaSecret!);
return this.totp.verifySecret({ secret: decryptedSecret, token: mfaToken }); return this.totp.verifySecret({ secret: decryptedSecret, mfaCode });
} }
if (mfaRecoveryCode) { if (mfaRecoveryCode) {
@ -85,8 +85,8 @@ export class MfaService {
return await this.authUserRepository.save(user); return await this.authUserRepository.save(user);
} }
async disableMfa(userId: string, mfaToken: string) { async disableMfa(userId: string, mfaCode: string) {
const isValidToken = await this.validateMfa(userId, mfaToken, undefined); const isValidToken = await this.validateMfa(userId, mfaCode, undefined);
if (!isValidToken) { if (!isValidToken) {
throw new InvalidMfaCodeError(); throw new InvalidMfaCodeError();
} }

View file

@ -23,10 +23,14 @@ export class TOTPService {
}).toString(); }).toString();
} }
verifySecret({ secret, token, window = 2 }: { secret: string; token: string; window?: number }) { verifySecret({
secret,
mfaCode,
window = 2,
}: { secret: string; mfaCode: string; window?: number }) {
return new OTPAuth.TOTP({ return new OTPAuth.TOTP({
secret: OTPAuth.Secret.fromBase32(secret), secret: OTPAuth.Secret.fromBase32(secret),
}).validate({ token, window }) === null }).validate({ token: mfaCode, window }) === null
? false ? false
: true; : true;
} }

View file

@ -228,7 +228,7 @@ export declare namespace PasswordResetRequest {
export type NewPassword = AuthlessRequest< export type NewPassword = AuthlessRequest<
{}, {},
{}, {},
Pick<PublicUser, 'password'> & { token?: string; userId?: string; mfaToken?: string } Pick<PublicUser, 'password'> & { token?: string; userId?: string; mfaCode?: string }
>; >;
} }
@ -306,7 +306,7 @@ export type LoginRequest = AuthlessRequest<
{ {
email: string; email: string;
password: string; password: string;
mfaToken?: string; mfaCode?: string;
mfaRecoveryCode?: string; mfaRecoveryCode?: string;
} }
>; >;
@ -316,9 +316,9 @@ export type LoginRequest = AuthlessRequest<
// ---------------------------------- // ----------------------------------
export declare namespace MFA { export declare namespace MFA {
type Verify = AuthenticatedRequest<{}, {}, { token: string }, {}>; type Verify = AuthenticatedRequest<{}, {}, { mfaCode: string }, {}>;
type Activate = AuthenticatedRequest<{}, {}, { token: string }, {}>; type Activate = AuthenticatedRequest<{}, {}, { mfaCode: string }, {}>;
type Disable = AuthenticatedRequest<{}, {}, { token: string }, {}>; type Disable = AuthenticatedRequest<{}, {}, { mfaCode: string }, {}>;
type Config = AuthenticatedRequest<{}, {}, { login: { enabled: boolean } }, {}>; type Config = AuthenticatedRequest<{}, {}, { login: { enabled: boolean } }, {}>;
type ValidateRecoveryCode = AuthenticatedRequest< type ValidateRecoveryCode = AuthenticatedRequest<
{}, {},

View file

@ -1,72 +0,0 @@
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm';
import { Service } from 'typedi';
import type { User } from '@/databases/entities/user';
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import { UserService } from '@/services/user.service';
@Service()
export class UserOnboardingService {
constructor(
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly workflowRepository: WorkflowRepository,
private readonly userService: UserService,
) {}
/**
* Check if user owns more than 15 workflows or more than 2 workflows with at least 2 nodes.
* If user does, set flag in its settings.
*/
async isBelowThreshold(user: User): Promise<boolean> {
let belowThreshold = true;
const skippedTypes = ['n8n-nodes-base.start', 'n8n-nodes-base.stickyNote'];
const ownedWorkflowsIds = await this.sharedWorkflowRepository
.find({
where: {
project: {
projectRelations: {
role: 'project:personalOwner',
userId: user.id,
},
},
role: 'workflow:owner',
},
select: ['workflowId'],
})
.then((ownedWorkflows) => ownedWorkflows.map(({ workflowId }) => workflowId));
if (ownedWorkflowsIds.length > 15) {
belowThreshold = false;
} else {
// just fetch workflows' nodes to keep memory footprint low
const workflows = await this.workflowRepository.find({
where: { id: In(ownedWorkflowsIds) },
select: ['nodes'],
});
// valid workflow: 2+ nodes without start node
const validWorkflowCount = workflows.reduce((counter, workflow) => {
if (counter <= 2 && workflow.nodes.length > 2) {
const nodes = workflow.nodes.filter((node) => !skippedTypes.includes(node.type));
if (nodes.length >= 2) {
return counter + 1;
}
}
return counter;
}, 0);
// more than 2 valid workflows required
belowThreshold = validWorkflowCount <= 2;
}
// user is above threshold --> set flag in settings
if (!belowThreshold) {
void this.userService.updateSettings(user.id, { isOnboarded: true });
}
return belowThreshold;
}
}

View file

@ -1,5 +1,5 @@
import type { IUserSettings } from 'n8n-workflow'; import type { IUserSettings } from 'n8n-workflow';
import { ApplicationError, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; import { ApplicationError } from 'n8n-workflow';
import { Service } from 'typedi'; import { Service } from 'typedi';
import type { User, AssignableRole } from '@/databases/entities/user'; import type { User, AssignableRole } from '@/databases/entities/user';
@ -213,9 +213,8 @@ export class UserService {
), ),
); );
} catch (error) { } catch (error) {
ErrorReporter.error(error);
this.logger.error('Failed to create user shells', { userShells: createdUsers }); this.logger.error('Failed to create user shells', { userShells: createdUsers });
throw new InternalServerError('An error occurred during user creation'); throw new InternalServerError('An error occurred during user creation', error);
} }
pendingUsersToInvite.forEach(({ email, id }) => createdUsers.set(email, id)); pendingUsersToInvite.forEach(({ email, id }) => createdUsers.set(email, id));

View file

@ -125,7 +125,7 @@ export class UserManagementMailer {
const error = toError(e); const error = toError(e);
throw new InternalServerError(`Please contact your administrator: ${error.message}`); throw new InternalServerError(`Please contact your administrator: ${error.message}`, e);
} }
} }
@ -180,7 +180,7 @@ export class UserManagementMailer {
const error = toError(e); const error = toError(e);
throw new InternalServerError(`Please contact your administrator: ${error.message}`); throw new InternalServerError(`Please contact your administrator: ${error.message}`, e);
} }
} }

View file

@ -773,7 +773,7 @@ export async function executeWebhook(
); );
} }
const internalServerError = new InternalServerError(e.message); const internalServerError = new InternalServerError(e.message, e);
if (e instanceof ExecutionCancelledError) internalServerError.level = 'warning'; if (e instanceof ExecutionCancelledError) internalServerError.level = 'warning';
throw internalServerError; throw internalServerError;
}); });

View file

@ -33,7 +33,6 @@ import * as ResponseHelper from '@/response-helper';
import { NamingService } from '@/services/naming.service'; import { NamingService } from '@/services/naming.service';
import { ProjectService } from '@/services/project.service'; import { ProjectService } from '@/services/project.service';
import { TagService } from '@/services/tag.service'; import { TagService } from '@/services/tag.service';
import { UserOnboardingService } from '@/services/user-onboarding.service';
import { UserManagementMailer } from '@/user-management/email'; import { UserManagementMailer } from '@/user-management/email';
import * as utils from '@/utils'; import * as utils from '@/utils';
import * as WorkflowHelpers from '@/workflow-helpers'; import * as WorkflowHelpers from '@/workflow-helpers';
@ -55,7 +54,6 @@ export class WorkflowsController {
private readonly workflowHistoryService: WorkflowHistoryService, private readonly workflowHistoryService: WorkflowHistoryService,
private readonly tagService: TagService, private readonly tagService: TagService,
private readonly namingService: NamingService, private readonly namingService: NamingService,
private readonly userOnboardingService: UserOnboardingService,
private readonly workflowRepository: WorkflowRepository, private readonly workflowRepository: WorkflowRepository,
private readonly workflowService: WorkflowService, private readonly workflowService: WorkflowService,
private readonly workflowExecutionService: WorkflowExecutionService, private readonly workflowExecutionService: WorkflowExecutionService,
@ -213,13 +211,7 @@ export class WorkflowsController {
const requestedName = req.query.name ?? this.globalConfig.workflows.defaultName; const requestedName = req.query.name ?? this.globalConfig.workflows.defaultName;
const name = await this.namingService.getUniqueWorkflowName(requestedName); const name = await this.namingService.getUniqueWorkflowName(requestedName);
return { name };
const onboardingFlowEnabled =
!this.globalConfig.workflows.onboardingFlowDisabled &&
!req.user.settings?.isOnboarded &&
(await this.userOnboardingService.isBelowThreshold(req.user));
return { name, onboardingFlowEnabled };
} }
@Get('/from-url') @Get('/from-url')

View file

@ -89,7 +89,7 @@ describe('POST /login', () => {
const response = await testServer.authlessAgent.post('/login').send({ const response = await testServer.authlessAgent.post('/login').send({
email: owner.email, email: owner.email,
password: ownerPassword, password: ownerPassword,
mfaToken: mfaService.totp.generateTOTP(secret), mfaCode: mfaService.totp.generateTOTP(secret),
}); });
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);

View file

@ -55,8 +55,8 @@ describe('Enable MFA setup', () => {
secondCall.body.data.recoveryCodes.join(''), secondCall.body.data.recoveryCodes.join(''),
); );
const token = new TOTPService().generateTOTP(firstCall.body.data.secret); const mfaCode = new TOTPService().generateTOTP(firstCall.body.data.secret);
await testServer.authAgentFor(owner).post('/mfa/disable').send({ token }).expect(200); await testServer.authAgentFor(owner).post('/mfa/disable').send({ mfaCode }).expect(200);
const thirdCall = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200); const thirdCall = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
@ -84,22 +84,22 @@ describe('Enable MFA setup', () => {
await testServer.authlessAgent.post('/mfa/verify').expect(401); await testServer.authlessAgent.post('/mfa/verify').expect(401);
}); });
test('POST /verify should fail due to invalid MFA token', async () => { test('POST /verify should fail due to invalid MFA code', async () => {
await testServer.authAgentFor(owner).post('/mfa/verify').send({ token: '123' }).expect(400); await testServer.authAgentFor(owner).post('/mfa/verify').send({ mfaCode: '123' }).expect(400);
}); });
test('POST /verify should fail due to missing token parameter', async () => { test('POST /verify should fail due to missing mfaCode parameter', async () => {
await testServer.authAgentFor(owner).get('/mfa/qr').expect(200); await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
await testServer.authAgentFor(owner).post('/mfa/verify').send({ token: '' }).expect(400); await testServer.authAgentFor(owner).post('/mfa/verify').send({ mfaCode: '' }).expect(400);
}); });
test('POST /verify should validate MFA token', async () => { test('POST /verify should validate MFA code', async () => {
const response = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200); const response = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
const { secret } = response.body.data; const { secret } = response.body.data;
const token = new TOTPService().generateTOTP(secret); const mfaCode = new TOTPService().generateTOTP(secret);
await testServer.authAgentFor(owner).post('/mfa/verify').send({ token }).expect(200); await testServer.authAgentFor(owner).post('/mfa/verify').send({ mfaCode }).expect(200);
}); });
}); });
@ -108,13 +108,13 @@ describe('Enable MFA setup', () => {
await testServer.authlessAgent.post('/mfa/enable').expect(401); await testServer.authlessAgent.post('/mfa/enable').expect(401);
}); });
test('POST /verify should fail due to missing token parameter', async () => { test('POST /verify should fail due to missing mfaCode parameter', async () => {
await testServer.authAgentFor(owner).post('/mfa/verify').send({ token: '' }).expect(400); await testServer.authAgentFor(owner).post('/mfa/verify').send({ mfaCode: '' }).expect(400);
}); });
test('POST /enable should fail due to invalid MFA token', async () => { test('POST /enable should fail due to invalid MFA code', async () => {
await testServer.authAgentFor(owner).get('/mfa/qr').expect(200); await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
await testServer.authAgentFor(owner).post('/mfa/enable').send({ token: '123' }).expect(400); await testServer.authAgentFor(owner).post('/mfa/enable').send({ mfaCode: '123' }).expect(400);
}); });
test('POST /enable should fail due to empty secret and recovery codes', async () => { test('POST /enable should fail due to empty secret and recovery codes', async () => {
@ -125,10 +125,10 @@ describe('Enable MFA setup', () => {
const response = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200); const response = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
const { secret } = response.body.data; const { secret } = response.body.data;
const token = new TOTPService().generateTOTP(secret); const mfaCode = new TOTPService().generateTOTP(secret);
await testServer.authAgentFor(owner).post('/mfa/verify').send({ token }).expect(200); await testServer.authAgentFor(owner).post('/mfa/verify').send({ mfaCode }).expect(200);
await testServer.authAgentFor(owner).post('/mfa/enable').send({ token }).expect(200); await testServer.authAgentFor(owner).post('/mfa/enable').send({ mfaCode }).expect(200);
const user = await Container.get(AuthUserRepository).findOneOrFail({ const user = await Container.get(AuthUserRepository).findOneOrFail({
where: {}, where: {},
@ -145,13 +145,13 @@ describe('Enable MFA setup', () => {
const response = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200); const response = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
const { secret } = response.body.data; const { secret } = response.body.data;
const token = new TOTPService().generateTOTP(secret); const mfaCode = new TOTPService().generateTOTP(secret);
await testServer.authAgentFor(owner).post('/mfa/verify').send({ token }).expect(200); await testServer.authAgentFor(owner).post('/mfa/verify').send({ mfaCode }).expect(200);
externalHooks.run.mockRejectedValue(new BadRequestError('Error message')); externalHooks.run.mockRejectedValue(new BadRequestError('Error message'));
await testServer.authAgentFor(owner).post('/mfa/enable').send({ token }).expect(400); await testServer.authAgentFor(owner).post('/mfa/enable').send({ mfaCode }).expect(400);
const user = await Container.get(AuthUserRepository).findOneOrFail({ const user = await Container.get(AuthUserRepository).findOneOrFail({
where: {}, where: {},
@ -165,13 +165,13 @@ describe('Enable MFA setup', () => {
describe('Disable MFA setup', () => { describe('Disable MFA setup', () => {
test('POST /disable should disable login with MFA', async () => { test('POST /disable should disable login with MFA', async () => {
const { user, rawSecret } = await createUserWithMfaEnabled(); const { user, rawSecret } = await createUserWithMfaEnabled();
const token = new TOTPService().generateTOTP(rawSecret); const mfaCode = new TOTPService().generateTOTP(rawSecret);
await testServer await testServer
.authAgentFor(user) .authAgentFor(user)
.post('/mfa/disable') .post('/mfa/disable')
.send({ .send({
token, mfaCode,
}) })
.expect(200); .expect(200);
@ -184,21 +184,21 @@ describe('Disable MFA setup', () => {
expect(dbUser.mfaRecoveryCodes.length).toBe(0); expect(dbUser.mfaRecoveryCodes.length).toBe(0);
}); });
test('POST /disable should fail if invalid token is given', async () => { test('POST /disable should fail if invalid mfaCode is given', async () => {
const { user } = await createUserWithMfaEnabled(); const { user } = await createUserWithMfaEnabled();
await testServer await testServer
.authAgentFor(user) .authAgentFor(user)
.post('/mfa/disable') .post('/mfa/disable')
.send({ .send({
token: 'invalid token', mfaCode: 'invalid token',
}) })
.expect(403); .expect(403);
}); });
}); });
describe('Change password with MFA enabled', () => { describe('Change password with MFA enabled', () => {
test('POST /change-password should fail due to missing MFA token', async () => { test('POST /change-password should fail due to missing MFA code', async () => {
await createUserWithMfaEnabled(); await createUserWithMfaEnabled();
const newPassword = randomValidPassword(); const newPassword = randomValidPassword();
@ -210,7 +210,7 @@ describe('Change password with MFA enabled', () => {
.expect(404); .expect(404);
}); });
test('POST /change-password should fail due to invalid MFA token', async () => { test('POST /change-password should fail due to invalid MFA code', async () => {
await createUserWithMfaEnabled(); await createUserWithMfaEnabled();
const newPassword = randomValidPassword(); const newPassword = randomValidPassword();
@ -221,7 +221,7 @@ describe('Change password with MFA enabled', () => {
.send({ .send({
password: newPassword, password: newPassword,
token: resetPasswordToken, token: resetPasswordToken,
mfaToken: randomInt(10), mfaCode: randomInt(10),
}) })
.expect(404); .expect(404);
}); });
@ -235,14 +235,14 @@ describe('Change password with MFA enabled', () => {
const resetPasswordToken = Container.get(AuthService).generatePasswordResetToken(user); const resetPasswordToken = Container.get(AuthService).generatePasswordResetToken(user);
const mfaToken = new TOTPService().generateTOTP(rawSecret); const mfaCode = new TOTPService().generateTOTP(rawSecret);
await testServer.authlessAgent await testServer.authlessAgent
.post('/change-password') .post('/change-password')
.send({ .send({
password: newPassword, password: newPassword,
token: resetPasswordToken, token: resetPasswordToken,
mfaToken, mfaCode,
}) })
.expect(200); .expect(200);
@ -252,7 +252,7 @@ describe('Change password with MFA enabled', () => {
.send({ .send({
email: user.email, email: user.email,
password: newPassword, password: newPassword,
mfaToken: new TOTPService().generateTOTP(rawSecret), mfaCode: new TOTPService().generateTOTP(rawSecret),
}) })
.expect(200); .expect(200);
@ -315,7 +315,7 @@ describe('Login', () => {
await testServer.authlessAgent await testServer.authlessAgent
.post('/login') .post('/login')
.send({ email: user.email, password: rawPassword, mfaToken: 'wrongvalue' }) .send({ email: user.email, password: rawPassword, mfaCode: 'wrongvalue' })
.expect(401); .expect(401);
}); });
@ -337,7 +337,7 @@ describe('Login', () => {
const response = await testServer.authlessAgent const response = await testServer.authlessAgent
.post('/login') .post('/login')
.send({ email: user.email, password: rawPassword, mfaToken: token }) .send({ email: user.email, password: rawPassword, mfaCode: token })
.expect(200); .expect(200);
const data = response.body.data; const data = response.body.data;

View file

@ -1194,7 +1194,7 @@ export class WorkflowExecute {
} }
if (nodeSuccessData instanceof NodeExecutionOutput) { if (nodeSuccessData instanceof NodeExecutionOutput) {
const hints: NodeExecutionHint[] = nodeSuccessData.getHints(); const hints = (nodeSuccessData as NodeExecutionOutput).getHints();
executionHints.push(...hints); executionHints.push(...hints);
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

View file

@ -250,7 +250,6 @@ export interface IWorkflowToShare extends IWorkflowDataUpdate {
export interface NewWorkflowResponse { export interface NewWorkflowResponse {
name: string; name: string;
onboardingFlowEnabled?: boolean;
defaultSettings: IWorkflowSettings; defaultSettings: IWorkflowSettings;
} }
@ -277,7 +276,6 @@ export interface IWorkflowTemplate {
export interface INewWorkflowData { export interface INewWorkflowData {
name: string; name: string;
onboardingFlowEnabled: boolean;
} }
export interface WorkflowMetadata { export interface WorkflowMetadata {

View file

@ -11,19 +11,22 @@ export async function getMfaQR(
return await makeRestApiRequest(context, 'GET', '/mfa/qr'); return await makeRestApiRequest(context, 'GET', '/mfa/qr');
} }
export async function enableMfa(context: IRestApiContext, data: { token: string }): Promise<void> { export async function enableMfa(
context: IRestApiContext,
data: { mfaCode: string },
): Promise<void> {
return await makeRestApiRequest(context, 'POST', '/mfa/enable', data); return await makeRestApiRequest(context, 'POST', '/mfa/enable', data);
} }
export async function verifyMfaToken( export async function verifyMfaCode(
context: IRestApiContext, context: IRestApiContext,
data: { token: string }, data: { mfaCode: string },
): Promise<void> { ): Promise<void> {
return await makeRestApiRequest(context, 'POST', '/mfa/verify', data); return await makeRestApiRequest(context, 'POST', '/mfa/verify', data);
} }
export type DisableMfaParams = { export type DisableMfaParams = {
token: string; mfaCode: string;
}; };
export async function disableMfa(context: IRestApiContext, data: DisableMfaParams): Promise<void> { export async function disableMfa(context: IRestApiContext, data: DisableMfaParams): Promise<void> {

View file

@ -21,7 +21,7 @@ export async function loginCurrentUser(
export async function login( export async function login(
context: IRestApiContext, context: IRestApiContext,
params: { email: string; password: string; mfaToken?: string; mfaRecoveryToken?: string }, params: { email: string; password: string; mfaCode?: string; mfaRecoveryToken?: string },
): Promise<CurrentUserResponse> { ): Promise<CurrentUserResponse> {
return await makeRestApiRequest(context, 'POST', '/login', params); return await makeRestApiRequest(context, 'POST', '/login', params);
} }
@ -84,7 +84,7 @@ export async function validatePasswordToken(
export async function changePassword( export async function changePassword(
context: IRestApiContext, context: IRestApiContext,
params: { token: string; password: string; mfaToken?: string }, params: { token: string; password: string; mfaCode?: string },
): Promise<void> { ): Promise<void> {
await makeRestApiRequest(context, 'POST', '/change-password', params); await makeRestApiRequest(context, 'POST', '/change-password', params);
} }

View file

@ -22,7 +22,6 @@ export async function getNewWorkflow(context: IRestApiContext, data?: IDataObjec
); );
return { return {
name: response.name, name: response.name,
onboardingFlowEnabled: response.onboardingFlowEnabled === true,
settings: response.defaultSettings, settings: response.defaultSettings,
}; };
} }

View file

@ -283,7 +283,7 @@ watchEffect(() => {
:style="rootStyles" :style="rootStyles"
@resize="onResizeDebounced" @resize="onResizeDebounced"
> >
<div ref="container" :class="$style.container"> <div ref="container" :class="[$style.container, 'ignore-key-press-canvas']" tabindex="0">
<div v-if="isChatOpen || isLogsOpen" :class="$style.chatResizer"> <div v-if="isChatOpen || isLogsOpen" :class="$style.chatResizer">
<n8n-resize-wrapper <n8n-resize-wrapper
v-if="isChatOpen" v-if="isChatOpen"

View file

@ -177,7 +177,7 @@ function copySessionId() {
</div> </div>
</header> </header>
<main :class="$style.chatBody"> <main :class="$style.chatBody">
<MessagesList :messages="messages" :class="[$style.messages, 'ignore-key-press-canvas']"> <MessagesList :messages="messages" :class="$style.messages">
<template #beforeMessage="{ message }"> <template #beforeMessage="{ message }">
<MessageOptionTooltip <MessageOptionTooltip
v-if="message.sender === 'bot' && !message.id.includes('preload')" v-if="message.sender === 'bot' && !message.id.includes('preload')"

View file

@ -219,7 +219,7 @@ const onUserActionToggle = (action: string) => {
onLogout(); onLogout();
break; break;
case 'settings': case 'settings':
void router.push({ name: VIEWS.PERSONAL_SETTINGS }); void router.push({ name: VIEWS.SETTINGS });
break; break;
default: default:
break; break;

View file

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import Modal from './Modal.vue'; import Modal from './Modal.vue';
import { import {
MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH, MFA_AUTHENTICATION_CODE_INPUT_MAX_LENGTH,
MFA_AUTHENTICATION_TOKEN_WINDOW_EXPIRED, MFA_AUTHENTICATION_CODE_WINDOW_EXPIRED,
MFA_SETUP_MODAL_KEY, MFA_SETUP_MODAL_KEY,
} from '../constants'; } from '../constants';
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
@ -53,12 +53,12 @@ const closeDialog = () => {
}; };
const onInput = (value: string) => { const onInput = (value: string) => {
if (value.length !== MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH) { if (value.length !== MFA_AUTHENTICATION_CODE_INPUT_MAX_LENGTH) {
infoTextErrorMessage.value = ''; infoTextErrorMessage.value = '';
return; return;
} }
userStore userStore
.verifyMfaToken({ token: value }) .verifyMfaCode({ mfaCode: value })
.then(() => { .then(() => {
showRecoveryCodes.value = true; showRecoveryCodes.value = true;
authenticatorCode.value = value; authenticatorCode.value = value;
@ -98,14 +98,14 @@ const onDownloadClick = () => {
const onSetupClick = async () => { const onSetupClick = async () => {
try { try {
await userStore.enableMfa({ token: authenticatorCode.value }); await userStore.enableMfa({ mfaCode: authenticatorCode.value });
closeDialog(); closeDialog();
toast.showMessage({ toast.showMessage({
type: 'success', type: 'success',
title: i18n.baseText('mfa.setup.step2.toast.setupFinished.message'), title: i18n.baseText('mfa.setup.step2.toast.setupFinished.message'),
}); });
} catch (e) { } catch (e) {
if (e.errorCode === MFA_AUTHENTICATION_TOKEN_WINDOW_EXPIRED) { if (e.errorCode === MFA_AUTHENTICATION_CODE_WINDOW_EXPIRED) {
toast.showMessage({ toast.showMessage({
type: 'error', type: 'error',
title: i18n.baseText('mfa.setup.step2.toast.tokenExpired.error.message'), title: i18n.baseText('mfa.setup.step2.toast.tokenExpired.error.message'),

View file

@ -54,7 +54,7 @@ function onFormReady(isReady: boolean) {
<template #content> <template #content>
<div :class="[$style.formContainer]"> <div :class="[$style.formContainer]">
<n8n-form-inputs <n8n-form-inputs
data-test-id="mfa-code-form" data-test-id="mfa-code-or-recovery-code-input"
:inputs="formFields" :inputs="formFields"
:event-bus="formBus" :event-bus="formBus"
@submit="onSubmit" @submit="onSubmit"

View file

@ -258,7 +258,7 @@ export const webhookModalDescription = [
default: {}, default: {},
options: [ options: [
{ {
displayName: 'Ignore SSL Issues', displayName: 'Ignore SSL Issues (Insecure)',
name: 'allowUnauthorizedCerts', name: 'allowUnauthorizedCerts',
type: 'boolean', type: 'boolean',
noDataExpression: true, noDataExpression: true,

View file

@ -7,7 +7,6 @@ import type { Workflow } from 'n8n-workflow';
import { isNumber, isString } from '@/utils/typeGuards'; import { isNumber, isString } from '@/utils/typeGuards';
import type { INodeUi, XYPosition } from '@/Interface'; import type { INodeUi, XYPosition } from '@/Interface';
import { QUICKSTART_NOTE_NAME } from '@/constants';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
@ -205,16 +204,7 @@ const onEdit = (edit: boolean) => {
const onMarkdownClick = (link: HTMLAnchorElement) => { const onMarkdownClick = (link: HTMLAnchorElement) => {
if (link) { if (link) {
const isOnboardingNote = props.name === QUICKSTART_NOTE_NAME; telemetry.track('User clicked note link', { type: 'other' });
const isWelcomeVideo = link.querySelector('img[alt="n8n quickstart video"]');
const type =
isOnboardingNote && isWelcomeVideo
? 'welcome_video'
: isOnboardingNote && link.getAttribute('href') === '/templates'
? 'templates'
: 'other';
telemetry.track('User clicked note link', { type });
} }
}; };

View file

@ -25,7 +25,6 @@ import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { import {
EnterpriseEditionFeature, EnterpriseEditionFeature,
FORM_TRIGGER_NODE_TYPE, FORM_TRIGGER_NODE_TYPE,
QUICKSTART_NOTE_NAME,
STICKY_NODE_TYPE, STICKY_NODE_TYPE,
UPDATE_WEBHOOK_ID_NODE_TYPES, UPDATE_WEBHOOK_ID_NODE_TYPES,
WEBHOOK_NODE_TYPE, WEBHOOK_NODE_TYPE,
@ -365,7 +364,6 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
if (node.type === STICKY_NODE_TYPE) { if (node.type === STICKY_NODE_TYPE) {
telemetry.track('User deleted workflow note', { telemetry.track('User deleted workflow note', {
workflow_id: workflowsStore.workflowId, workflow_id: workflowsStore.workflowId,
is_welcome_note: node.name === QUICKSTART_NOTE_NAME,
}); });
} else { } else {
void externalHooks.run('node.deleteNode', { node }); void externalHooks.run('node.deleteNode', { node });

View file

@ -35,7 +35,6 @@ export const MIN_WORKFLOW_NAME_LENGTH = 1;
export const MAX_WORKFLOW_NAME_LENGTH = 128; export const MAX_WORKFLOW_NAME_LENGTH = 128;
export const DUPLICATE_POSTFFIX = ' copy'; export const DUPLICATE_POSTFFIX = ' copy';
export const NODE_OUTPUT_DEFAULT_KEY = '_NODE_OUTPUT_DEFAULT_KEY_'; export const NODE_OUTPUT_DEFAULT_KEY = '_NODE_OUTPUT_DEFAULT_KEY_';
export const QUICKSTART_NOTE_NAME = '_QUICKSTART_NOTE_';
// tags // tags
export const MAX_TAG_NAME_LENGTH = 24; export const MAX_TAG_NAME_LENGTH = 24;
@ -487,6 +486,7 @@ export const enum VIEWS {
SETUP = 'SetupView', SETUP = 'SetupView',
FORGOT_PASSWORD = 'ForgotMyPasswordView', FORGOT_PASSWORD = 'ForgotMyPasswordView',
CHANGE_PASSWORD = 'ChangePasswordView', CHANGE_PASSWORD = 'ChangePasswordView',
SETTINGS = 'Settings',
USERS_SETTINGS = 'UsersSettings', USERS_SETTINGS = 'UsersSettings',
LDAP_SETTINGS = 'LdapSettings', LDAP_SETTINGS = 'LdapSettings',
PERSONAL_SETTINGS = 'PersonalSettings', PERSONAL_SETTINGS = 'PersonalSettings',
@ -723,9 +723,9 @@ export const MFA_FORM = {
export const MFA_AUTHENTICATION_REQUIRED_ERROR_CODE = 998; export const MFA_AUTHENTICATION_REQUIRED_ERROR_CODE = 998;
export const MFA_AUTHENTICATION_TOKEN_WINDOW_EXPIRED = 997; export const MFA_AUTHENTICATION_CODE_WINDOW_EXPIRED = 997;
export const MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH = 6; export const MFA_AUTHENTICATION_CODE_INPUT_MAX_LENGTH = 6;
export const MFA_AUTHENTICATION_RECOVERY_CODE_INPUT_MAX_LENGTH = 36; export const MFA_AUTHENTICATION_RECOVERY_CODE_INPUT_MAX_LENGTH = 36;

View file

@ -1381,7 +1381,6 @@
"nodeWebhooks.webhookUrls": "Webhook URLs", "nodeWebhooks.webhookUrls": "Webhook URLs",
"nodeWebhooks.webhookUrls.formTrigger": "Form URLs", "nodeWebhooks.webhookUrls.formTrigger": "Form URLs",
"nodeWebhooks.webhookUrls.chatTrigger": "Chat URL", "nodeWebhooks.webhookUrls.chatTrigger": "Chat URL",
"onboardingWorkflow.stickyContent": "## 👇 Get started faster \nLightning tour of the key concepts [4 min] \n\n[![n8n quickstart video](/static/quickstart_thumbnail.png#full-width)](https://www.youtube.com/watch?v=1MwSoB0gnM4)",
"openWorkflow.workflowImportError": "Could not import workflow", "openWorkflow.workflowImportError": "Could not import workflow",
"openWorkflow.workflowNotFoundError": "Could not find workflow", "openWorkflow.workflowNotFoundError": "Could not find workflow",
"parameterInput.expressionResult": "e.g. {result}", "parameterInput.expressionResult": "e.g. {result}",

View file

@ -480,8 +480,10 @@ export const routes: RouteRecordRaw[] = [
}, },
{ {
path: '/settings', path: '/settings',
name: VIEWS.SETTINGS,
component: SettingsView, component: SettingsView,
props: true, props: true,
redirect: { name: VIEWS.USAGE },
children: [ children: [
{ {
path: 'usage', path: 'usage',

View file

@ -172,7 +172,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
const loginWithCreds = async (params: { const loginWithCreds = async (params: {
email: string; email: string;
password: string; password: string;
mfaToken?: string; mfaCode?: string;
mfaRecoveryCode?: string; mfaRecoveryCode?: string;
}) => { }) => {
const user = await usersApi.login(rootStore.restApiContext, params); const user = await usersApi.login(rootStore.restApiContext, params);
@ -232,7 +232,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
await usersApi.validatePasswordToken(rootStore.restApiContext, params); await usersApi.validatePasswordToken(rootStore.restApiContext, params);
}; };
const changePassword = async (params: { token: string; password: string; mfaToken?: string }) => { const changePassword = async (params: { token: string; password: string; mfaCode?: string }) => {
await usersApi.changePassword(rootStore.restApiContext, params); await usersApi.changePassword(rootStore.restApiContext, params);
}; };
@ -316,15 +316,15 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
return await mfaApi.getMfaQR(rootStore.restApiContext); return await mfaApi.getMfaQR(rootStore.restApiContext);
}; };
const verifyMfaToken = async (data: { token: string }) => { const verifyMfaCode = async (data: { mfaCode: string }) => {
return await mfaApi.verifyMfaToken(rootStore.restApiContext, data); return await mfaApi.verifyMfaCode(rootStore.restApiContext, data);
}; };
const canEnableMFA = async () => { const canEnableMFA = async () => {
return await mfaApi.canEnableMFA(rootStore.restApiContext); return await mfaApi.canEnableMFA(rootStore.restApiContext);
}; };
const enableMfa = async (data: { token: string }) => { const enableMfa = async (data: { mfaCode: string }) => {
await mfaApi.enableMfa(rootStore.restApiContext, data); await mfaApi.enableMfa(rootStore.restApiContext, data);
if (currentUser.value) { if (currentUser.value) {
currentUser.value.mfaEnabled = true; currentUser.value.mfaEnabled = true;
@ -333,7 +333,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
const disableMfa = async (mfaCode: string) => { const disableMfa = async (mfaCode: string) => {
await mfaApi.disableMfa(rootStore.restApiContext, { await mfaApi.disableMfa(rootStore.restApiContext, {
token: mfaCode, mfaCode,
}); });
if (currentUser.value) { if (currentUser.value) {
@ -404,7 +404,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
submitPersonalizationSurvey, submitPersonalizationSurvey,
showPersonalizationSurvey, showPersonalizationSurvey,
fetchMfaQR, fetchMfaQR,
verifyMfaToken, verifyMfaCode,
enableMfa, enableMfa,
disableMfa, disableMfa,
canEnableMFA, canEnableMFA,

View file

@ -468,7 +468,6 @@ describe('useWorkflowsStore', () => {
const expectedName = `${name}${DUPLICATE_POSTFFIX}`; const expectedName = `${name}${DUPLICATE_POSTFFIX}`;
vi.mocked(workflowsApi).getNewWorkflow.mockResolvedValue({ vi.mocked(workflowsApi).getNewWorkflow.mockResolvedValue({
name: expectedName, name: expectedName,
onboardingFlowEnabled: false,
settings: {} as IWorkflowSettings, settings: {} as IWorkflowSettings,
}); });
const newName = await workflowsStore.getDuplicateCurrentWorkflowName(name); const newName = await workflowsStore.getDuplicateCurrentWorkflowName(name);

View file

@ -494,7 +494,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
async function getNewWorkflowData(name?: string, projectId?: string): Promise<INewWorkflowData> { async function getNewWorkflowData(name?: string, projectId?: string): Promise<INewWorkflowData> {
let workflowData = { let workflowData = {
name: '', name: '',
onboardingFlowEnabled: false,
settings: { ...defaults.settings }, settings: { ...defaults.settings },
}; };
try { try {

View file

@ -9,7 +9,7 @@ import { useToast } from '@/composables/useToast';
import { useUsersStore } from '@/stores/users.store'; import { useUsersStore } from '@/stores/users.store';
import type { IFormBoxConfig } from '@/Interface'; import type { IFormBoxConfig } from '@/Interface';
import { MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH, VIEWS } from '@/constants'; import { MFA_AUTHENTICATION_CODE_INPUT_MAX_LENGTH, VIEWS } from '@/constants';
const usersStore = useUsersStore(); const usersStore = useUsersStore();
@ -56,7 +56,7 @@ const onSubmit = async (values: { [key: string]: string }) => {
const changePasswordParameters = { const changePasswordParameters = {
token, token,
password: password.value, password: password.value,
...(values.mfaToken && { mfaToken: values.mfaToken }), ...(values.mfaCode && { mfaCode: values.mfaCode }),
}; };
await usersStore.changePassword(changePasswordParameters); await usersStore.changePassword(changePasswordParameters);
@ -129,13 +129,13 @@ onMounted(async () => {
if (mfaEnabled) { if (mfaEnabled) {
form.inputs.push({ form.inputs.push({
name: 'mfaToken', name: 'mfaCode',
initialValue: '', initialValue: '',
properties: { properties: {
required: true, required: true,
label: locale.baseText('mfa.code.input.label'), label: locale.baseText('mfa.code.input.label'),
placeholder: locale.baseText('mfa.code.input.placeholder'), placeholder: locale.baseText('mfa.code.input.placeholder'),
maxlength: MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH, maxlength: MFA_AUTHENTICATION_CODE_INPUT_MAX_LENGTH,
capitalize: true, capitalize: true,
validateOnBlur: true, validateOnBlur: true,
}, },

View file

@ -3,7 +3,7 @@ import type { IFormInputs } from '@/Interface';
import Logo from '../components/Logo.vue'; import Logo from '../components/Logo.vue';
import { import {
MFA_AUTHENTICATION_RECOVERY_CODE_INPUT_MAX_LENGTH, MFA_AUTHENTICATION_RECOVERY_CODE_INPUT_MAX_LENGTH,
MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH, MFA_AUTHENTICATION_CODE_INPUT_MAX_LENGTH,
MFA_FORM, MFA_FORM,
} from '@/constants'; } from '@/constants';
import { mfaEventBus } from '@/event-bus'; import { mfaEventBus } from '@/event-bus';
@ -29,7 +29,7 @@ const hasAnyChanges = ref(false);
const formBus = ref(mfaEventBus); const formBus = ref(mfaEventBus);
const formInputs = ref<null | IFormInputs>(null); const formInputs = ref<null | IFormInputs>(null);
const showRecoveryCodeForm = ref(false); const showRecoveryCodeForm = ref(false);
const verifyingMfaToken = ref(false); const verifyingMfaCode = ref(false);
const formError = ref(''); const formError = ref('');
const { reportError } = toRefs(props); const { reportError } = toRefs(props);
@ -48,7 +48,7 @@ const i18 = useI18n();
const emit = defineEmits<{ const emit = defineEmits<{
onFormChanged: [formField: string]; onFormChanged: [formField: string];
onBackClick: [formField: string]; onBackClick: [formField: string];
submit: [{ token: string; recoveryCode: string }]; submit: [{ mfaCode: string; mfaRecoveryCode: string }];
}>(); }>();
// #endregion // #endregion
@ -94,11 +94,11 @@ const onBackClick = () => {
showRecoveryCodeForm.value = false; showRecoveryCodeForm.value = false;
hasAnyChanges.value = true; hasAnyChanges.value = true;
formInputs.value = [mfaTokenFieldWithDefaults()]; formInputs.value = [mfaCodeFieldWithDefaults()];
emit('onBackClick', MFA_FORM.MFA_RECOVERY_CODE); emit('onBackClick', MFA_FORM.MFA_RECOVERY_CODE);
}; };
const onSubmit = async (form: { token: string; recoveryCode: string }) => { const onSubmit = async (form: { mfaCode: string; mfaRecoveryCode: string }) => {
formError.value = !showRecoveryCodeForm.value formError.value = !showRecoveryCodeForm.value
? i18.baseText('mfa.code.invalid') ? i18.baseText('mfa.code.invalid')
: i18.baseText('mfa.recovery.invalid'); : i18.baseText('mfa.recovery.invalid');
@ -106,9 +106,9 @@ const onSubmit = async (form: { token: string; recoveryCode: string }) => {
}; };
const onInput = ({ target: { value, name } }: { target: { value: string; name: string } }) => { const onInput = ({ target: { value, name } }: { target: { value: string; name: string } }) => {
const isSubmittingMfaToken = name === 'token'; const isSubmittingMfaCode = name === 'mfaCode';
const inputValidLength = isSubmittingMfaToken const inputValidLength = isSubmittingMfaCode
? MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH ? MFA_AUTHENTICATION_CODE_INPUT_MAX_LENGTH
: MFA_AUTHENTICATION_RECOVERY_CODE_INPUT_MAX_LENGTH; : MFA_AUTHENTICATION_RECOVERY_CODE_INPUT_MAX_LENGTH;
if (value.length !== inputValidLength) { if (value.length !== inputValidLength) {
@ -116,33 +116,33 @@ const onInput = ({ target: { value, name } }: { target: { value: string; name: s
return; return;
} }
verifyingMfaToken.value = true; verifyingMfaCode.value = true;
hasAnyChanges.value = true; hasAnyChanges.value = true;
const dataToSubmit = isSubmittingMfaToken const dataToSubmit = isSubmittingMfaCode
? { token: value, recoveryCode: '' } ? { mfaCode: value, mfaRecoveryCode: '' }
: { token: '', recoveryCode: value }; : { mfaCode: '', mfaRecoveryCode: value };
onSubmit(dataToSubmit) onSubmit(dataToSubmit)
.catch(() => {}) .catch(() => {})
.finally(() => (verifyingMfaToken.value = false)); .finally(() => (verifyingMfaCode.value = false));
}; };
const mfaRecoveryCodeFieldWithDefaults = () => { const mfaRecoveryCodeFieldWithDefaults = () => {
return formField( return formField(
'recoveryCode', 'mfaRecoveryCode',
i18.baseText('mfa.recovery.input.label'), i18.baseText('mfa.recovery.input.label'),
i18.baseText('mfa.recovery.input.placeholder'), i18.baseText('mfa.recovery.input.placeholder'),
MFA_AUTHENTICATION_RECOVERY_CODE_INPUT_MAX_LENGTH, MFA_AUTHENTICATION_RECOVERY_CODE_INPUT_MAX_LENGTH,
); );
}; };
const mfaTokenFieldWithDefaults = () => { const mfaCodeFieldWithDefaults = () => {
return formField( return formField(
'token', 'mfaCode',
i18.baseText('mfa.code.input.label'), i18.baseText('mfa.code.input.label'),
i18.baseText('mfa.code.input.placeholder'), i18.baseText('mfa.code.input.placeholder'),
MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH, MFA_AUTHENTICATION_CODE_INPUT_MAX_LENGTH,
); );
}; };
@ -157,7 +157,7 @@ const onSaveClick = () => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
onMounted(() => { onMounted(() => {
formInputs.value = [mfaTokenFieldWithDefaults()]; formInputs.value = [mfaCodeFieldWithDefaults()];
}); });
// #endregion // #endregion
@ -211,7 +211,7 @@ onMounted(() => {
<div> <div>
<n8n-button <n8n-button
float="right" float="right"
:loading="verifyingMfaToken" :loading="verifyingMfaCode"
:label=" :label="
showRecoveryCodeForm showRecoveryCodeForm
? i18.baseText('mfa.recovery.button.verify') ? i18.baseText('mfa.recovery.button.verify')

View file

@ -24,7 +24,6 @@ import {
MODAL_CANCEL, MODAL_CANCEL,
MODAL_CONFIRM, MODAL_CONFIRM,
PLACEHOLDER_EMPTY_WORKFLOW_ID, PLACEHOLDER_EMPTY_WORKFLOW_ID,
QUICKSTART_NOTE_NAME,
START_NODE_TYPE, START_NODE_TYPE,
STICKY_NODE_TYPE, STICKY_NODE_TYPE,
VIEWS, VIEWS,
@ -3581,7 +3580,6 @@ export default defineComponent({
if (node.type === STICKY_NODE_TYPE) { if (node.type === STICKY_NODE_TYPE) {
this.$telemetry.track('User deleted workflow note', { this.$telemetry.track('User deleted workflow note', {
workflow_id: this.workflowsStore.workflowId, workflow_id: this.workflowsStore.workflowId,
is_welcome_note: node.name === QUICKSTART_NOTE_NAME,
}); });
} else { } else {
void this.externalHooks.run('node.deleteNode', { node }); void this.externalHooks.run('node.deleteNode', { node });

View file

@ -0,0 +1,122 @@
import { createTestingPinia } from '@pinia/testing';
import { createRouter, createWebHistory } from 'vue-router';
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import SettingsView from '@/views/SettingsView.vue';
import { VIEWS } from '@/constants';
import { routes as originalRoutes } from '@/router';
const component = { template: '<div />' };
const settingsRoute = originalRoutes.find((route) => route.name === VIEWS.SETTINGS);
const settingsRouteChildren =
settingsRoute?.children?.map(({ name, path }) => ({
name,
path,
component,
})) ?? [];
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
redirect: '/home',
},
{
path: '/home',
name: 'Homepage',
redirect: '/home/workflows',
children: [
{
path: 'workflows',
name: 'Workflows',
component,
},
{
path: 'credentials',
name: 'Credentials',
component,
},
{
path: 'executions',
name: 'Executions',
component,
},
],
},
{
path: '/settings',
name: VIEWS.SETTINGS,
component: SettingsView,
props: true,
redirect: { name: VIEWS.USAGE },
children: settingsRouteChildren,
},
],
});
const renderView = createComponentRenderer(SettingsView, {
pinia: createTestingPinia(),
global: {
plugins: [router],
},
});
const renderApp = createComponentRenderer(
{
template: '<router-view />',
},
{
pinia: createTestingPinia(),
global: {
plugins: [router],
stubs: {
SettingsSidebar: {
template: '<div><button data-test-id="back" @click="$emit(\'return\')"></button></div>',
},
},
},
},
);
describe('SettingsView', () => {
it('should render the view without throwing', () => {
expect(() => renderView()).not.toThrow();
});
test.each([
['/', ['/settings', '/settings/users'], '/home/workflows'],
['/home', ['/settings', '/settings/users'], '/home/workflows'],
['/home/workflows', ['/settings', '/settings/personal'], '/home/workflows'],
[
'/home/credentials',
['/settings', '/settings/personal', '/settings/api', '/settings/environments'],
'/home/credentials',
],
['/home/executions', ['/settings'], '/home/executions'],
['/settings', [], '/home/workflows'],
[
'/settings',
['/settings/personal', '/settings/api', '/settings/environments'],
'/home/workflows',
],
['/settings/personal', [], '/home/workflows'],
['/settings/usage', ['/settings/api', '/settings/environments'], '/home/workflows'],
])(
'should start from "%s" and visit %s routes and go back to "%s"',
async (startRoute, routes, expectedRoute) => {
const { getByTestId } = renderApp();
await router.push(startRoute);
for (const route of routes) {
await router.push(route);
}
await userEvent.click(getByTestId('back'));
expect(router.currentRoute.value.path).toBe(expectedRoute);
},
);
});

View file

@ -11,9 +11,16 @@ const router = useRouter();
const previousRoute = ref<HistoryState[string] | undefined>(); const previousRoute = ref<HistoryState[string] | undefined>();
function onReturn() { function onReturn() {
void router.push( const resolvedSettingsRoute = router.resolve({ name: VIEWS.SETTINGS });
isRouteLocationRaw(previousRoute.value) ? previousRoute.value : { name: VIEWS.HOMEPAGE }, const resolvedPreviousRoute = isRouteLocationRaw(previousRoute.value)
); ? router.resolve(previousRoute.value)
: null;
const backRoute =
!resolvedPreviousRoute || resolvedPreviousRoute.path.startsWith(resolvedSettingsRoute.path)
? { name: VIEWS.HOMEPAGE }
: resolvedPreviousRoute;
void router.push(backRoute);
} }
onMounted(() => { onMounted(() => {

View file

@ -87,7 +87,7 @@ describe('SigninView', () => {
expect(usersStore.loginWithCreds).toHaveBeenCalledWith({ expect(usersStore.loginWithCreds).toHaveBeenCalledWith({
email: 'test@n8n.io', email: 'test@n8n.io',
password: 'password', password: 'password',
mfaToken: undefined, mfaCode: undefined,
mfaRecoveryCode: undefined, mfaRecoveryCode: undefined,
}); });

View file

@ -78,12 +78,12 @@ const formConfig: IFormBoxConfig = reactive({
], ],
}); });
const onMFASubmitted = async (form: { token?: string; recoveryCode?: string }) => { const onMFASubmitted = async (form: { mfaCode?: string; mfaRecoveryCode?: string }) => {
await login({ await login({
email: email.value, email: email.value,
password: password.value, password: password.value,
token: form.token, mfaCode: form.mfaCode,
recoveryCode: form.recoveryCode, mfaRecoveryCode: form.mfaRecoveryCode,
}); });
}; };
@ -114,16 +114,16 @@ const getRedirectQueryParameter = () => {
const login = async (form: { const login = async (form: {
email: string; email: string;
password: string; password: string;
token?: string; mfaCode?: string;
recoveryCode?: string; mfaRecoveryCode?: string;
}) => { }) => {
try { try {
loading.value = true; loading.value = true;
await usersStore.loginWithCreds({ await usersStore.loginWithCreds({
email: form.email, email: form.email,
password: form.password, password: form.password,
mfaToken: form.token, mfaCode: form.mfaCode,
mfaRecoveryCode: form.recoveryCode, mfaRecoveryCode: form.mfaRecoveryCode,
}); });
loading.value = false; loading.value = false;
if (settingsStore.isCloudDeployment) { if (settingsStore.isCloudDeployment) {

View file

@ -35,7 +35,7 @@ export class CrowdDevApi implements ICredentialType {
default: '', default: '',
}, },
{ {
displayName: 'Ignore SSL Issues', displayName: 'Ignore SSL Issues (Insecure)',
name: 'allowUnauthorizedCerts', name: 'allowUnauthorizedCerts',
type: 'boolean', type: 'boolean',
description: 'Whether to connect even if SSL certificate validation is not possible', description: 'Whether to connect even if SSL certificate validation is not possible',

View file

@ -40,7 +40,7 @@ export class DfirIrisApi implements ICredentialType {
default: '', default: '',
}, },
{ {
displayName: 'Ignore SSL Issues', displayName: 'Ignore SSL Issues (Insecure)',
name: 'skipSslCertificateValidation', name: 'skipSslCertificateValidation',
type: 'boolean', type: 'boolean',
default: false, default: false,

View file

@ -93,7 +93,7 @@ export class ERPNextApi implements ICredentialType {
}, },
}, },
{ {
displayName: 'Ignore SSL Issues', displayName: 'Ignore SSL Issues (Insecure)',
name: 'allowUnauthorizedCerts', name: 'allowUnauthorizedCerts',
type: 'boolean', type: 'boolean',
description: 'Whether to connect even if SSL certificate validation is not possible', description: 'Whether to connect even if SSL certificate validation is not possible',

View file

@ -37,7 +37,7 @@ export class ElasticsearchApi implements ICredentialType {
description: "Referred to as Elasticsearch 'endpoint' in the Elastic deployment dashboard", description: "Referred to as Elasticsearch 'endpoint' in the Elastic deployment dashboard",
}, },
{ {
displayName: 'Ignore SSL Issues', displayName: 'Ignore SSL Issues (Insecure)',
name: 'ignoreSSLIssues', name: 'ignoreSSLIssues',
type: 'boolean', type: 'boolean',
default: false, default: false,

View file

@ -36,7 +36,7 @@ export class GotifyApi implements ICredentialType {
description: 'The URL of the Gotify host', description: 'The URL of the Gotify host',
}, },
{ {
displayName: 'Ignore SSL Issues', displayName: 'Ignore SSL Issues (Insecure)',
name: 'ignoreSSLIssues', name: 'ignoreSSLIssues',
type: 'boolean', type: 'boolean',
default: false, default: false,

View file

@ -27,7 +27,7 @@ export class MattermostApi implements ICredentialType {
default: '', default: '',
}, },
{ {
displayName: 'Ignore SSL Issues', displayName: 'Ignore SSL Issues (Insecure)',
name: 'allowUnauthorizedCerts', name: 'allowUnauthorizedCerts',
type: 'boolean', type: 'boolean',
description: 'Whether to connect even if SSL certificate validation is not possible', description: 'Whether to connect even if SSL certificate validation is not possible',

View file

@ -54,7 +54,7 @@ export class MicrosoftSql implements ICredentialType {
default: true, default: true,
}, },
{ {
displayName: 'Ignore SSL Issues', displayName: 'Ignore SSL Issues (Insecure)',
name: 'allowUnauthorizedCerts', name: 'allowUnauthorizedCerts',
type: 'boolean', type: 'boolean',
default: false, default: false,

View file

@ -108,7 +108,7 @@ export class OAuth2Api implements ICredentialType {
default: 'header', default: 'header',
}, },
{ {
displayName: 'Ignore SSL Issues', displayName: 'Ignore SSL Issues (Insecure)',
name: 'ignoreSSLIssues', name: 'ignoreSSLIssues',
type: 'boolean', type: 'boolean',
default: false, default: false,

View file

@ -37,7 +37,7 @@ export class Postgres implements ICredentialType {
default: '', default: '',
}, },
{ {
displayName: 'Ignore SSL Issues', displayName: 'Ignore SSL Issues (Insecure)',
name: 'allowUnauthorizedCerts', name: 'allowUnauthorizedCerts',
type: 'boolean', type: 'boolean',
default: false, default: false,

View file

@ -42,7 +42,7 @@ export class S3 implements ICredentialType {
default: false, default: false,
}, },
{ {
displayName: 'Ignore SSL Issues', displayName: 'Ignore SSL Issues (Insecure)',
name: 'ignoreSSLIssues', name: 'ignoreSSLIssues',
type: 'boolean', type: 'boolean',
default: false, default: false,

View file

@ -49,7 +49,7 @@ export class TheHiveApi implements ICredentialType {
], ],
}, },
{ {
displayName: 'Ignore SSL Issues', displayName: 'Ignore SSL Issues (Insecure)',
name: 'allowUnauthorizedCerts', name: 'allowUnauthorizedCerts',
type: 'boolean', type: 'boolean',
description: 'Whether to connect even if SSL certificate validation is not possible', description: 'Whether to connect even if SSL certificate validation is not possible',

View file

@ -31,7 +31,7 @@ export class TheHiveProjectApi implements ICredentialType {
placeholder: 'https://localhost:9000', placeholder: 'https://localhost:9000',
}, },
{ {
displayName: 'Ignore SSL Issues', displayName: 'Ignore SSL Issues (Insecure)',
name: 'allowUnauthorizedCerts', name: 'allowUnauthorizedCerts',
type: 'boolean', type: 'boolean',
description: 'Whether to connect even if SSL certificate validation is not possible', description: 'Whether to connect even if SSL certificate validation is not possible',

View file

@ -36,7 +36,7 @@ export class TimescaleDb implements ICredentialType {
default: '', default: '',
}, },
{ {
displayName: 'Ignore SSL Issues', displayName: 'Ignore SSL Issues (Insecure)',
name: 'allowUnauthorizedCerts', name: 'allowUnauthorizedCerts',
type: 'boolean', type: 'boolean',
default: false, default: false,

View file

@ -36,7 +36,7 @@ export class WordpressApi implements ICredentialType {
placeholder: 'https://example.com', placeholder: 'https://example.com',
}, },
{ {
displayName: 'Ignore SSL Issues', displayName: 'Ignore SSL Issues (Insecure)',
name: 'allowUnauthorizedCerts', name: 'allowUnauthorizedCerts',
type: 'boolean', type: 'boolean',
description: 'Whether to connect even if SSL certificate validation is not possible', description: 'Whether to connect even if SSL certificate validation is not possible',

View file

@ -35,7 +35,7 @@ export class ZammadBasicAuthApi implements ICredentialType {
required: true, required: true,
}, },
{ {
displayName: 'Ignore SSL Issues', displayName: 'Ignore SSL Issues (Insecure)',
name: 'allowUnauthorizedCerts', name: 'allowUnauthorizedCerts',
type: 'boolean', type: 'boolean',
description: 'Whether to connect even if SSL certificate validation is not possible', description: 'Whether to connect even if SSL certificate validation is not possible',

View file

@ -27,7 +27,7 @@ export class ZammadTokenAuthApi implements ICredentialType {
required: true, required: true,
}, },
{ {
displayName: 'Ignore SSL Issues', displayName: 'Ignore SSL Issues (Insecure)',
name: 'allowUnauthorizedCerts', name: 'allowUnauthorizedCerts',
type: 'boolean', type: 'boolean',
description: 'Whether to connect even if SSL certificate validation is not possible', description: 'Whether to connect even if SSL certificate validation is not possible',

View file

@ -200,7 +200,7 @@ const versionDescription: INodeTypeDescription = {
'Custom email fetching rules. See <a href="https://github.com/mscdex/node-imap">node-imap</a>\'s search function for more details.', 'Custom email fetching rules. See <a href="https://github.com/mscdex/node-imap">node-imap</a>\'s search function for more details.',
}, },
{ {
displayName: 'Ignore SSL Issues', displayName: 'Ignore SSL Issues (Insecure)',
name: 'allowUnauthorizedCerts', name: 'allowUnauthorizedCerts',
type: 'boolean', type: 'boolean',
default: false, default: false,

View file

@ -110,7 +110,7 @@ const versionDescription: INodeTypeDescription = {
default: {}, default: {},
options: [ options: [
{ {
displayName: 'Ignore SSL Issues', displayName: 'Ignore SSL Issues (Insecure)',
name: 'allowUnauthorizedCerts', name: 'allowUnauthorizedCerts',
type: 'boolean', type: 'boolean',
default: false, default: false,

View file

@ -167,7 +167,7 @@ const properties: INodeProperties[] = [
description: 'Email address of BCC recipient', description: 'Email address of BCC recipient',
}, },
{ {
displayName: 'Ignore SSL Issues', displayName: 'Ignore SSL Issues (Insecure)',
name: 'allowUnauthorizedCerts', name: 'allowUnauthorizedCerts',
type: 'boolean', type: 'boolean',
default: false, default: false,

View file

@ -189,7 +189,7 @@ export class FacebookGraphApi implements INodeType {
placeholder: 'videos', placeholder: 'videos',
}, },
{ {
displayName: 'Ignore SSL Issues', displayName: 'Ignore SSL Issues (Insecure)',
name: 'allowUnauthorizedCerts', name: 'allowUnauthorizedCerts',
type: 'boolean', type: 'boolean',
default: false, default: false,

View file

@ -160,7 +160,7 @@ export class GraphQL implements INodeType {
required: true, required: true,
}, },
{ {
displayName: 'Ignore SSL Issues', displayName: 'Ignore SSL Issues (Insecure)',
name: 'allowUnauthorizedCerts', name: 'allowUnauthorizedCerts',
type: 'boolean', type: 'boolean',
default: false, default: false,
@ -418,40 +418,49 @@ export class GraphQL implements INodeType {
const gqlQuery = this.getNodeParameter('query', itemIndex, '') as string; const gqlQuery = this.getNodeParameter('query', itemIndex, '') as string;
if (requestMethod === 'GET') { if (requestMethod === 'GET') {
if (!requestOptions.qs) { requestOptions.qs = requestOptions.qs ?? {};
requestOptions.qs = {};
}
requestOptions.qs.query = gqlQuery; requestOptions.qs.query = gqlQuery;
} else { }
if (requestFormat === 'json') {
const jsonBody = { if (requestFormat === 'json') {
...requestOptions.body, const variables = this.getNodeParameter('variables', itemIndex, {});
query: gqlQuery,
variables: this.getNodeParameter('variables', itemIndex, {}) as object, let parsedVariables;
operationName: this.getNodeParameter('operationName', itemIndex) as string, if (typeof variables === 'string') {
}; try {
if (typeof jsonBody.variables === 'string') { parsedVariables = JSON.parse(variables || '{}');
try { } catch (error) {
jsonBody.variables = JSON.parse(jsonBody.variables || '{}'); throw new NodeOperationError(
} catch (error) { this.getNode(),
throw new NodeOperationError( `Using variables failed:\n${variables}\n\nWith error message:\n${error}`,
this.getNode(), { itemIndex },
'Using variables failed:\n' + );
(jsonBody.variables as string) +
'\n\nWith error message:\n' +
(error as string),
{ itemIndex },
);
}
} }
if (jsonBody.operationName === '') { } else if (typeof variables === 'object' && variables !== null) {
jsonBody.operationName = null; parsedVariables = variables;
}
requestOptions.json = true;
requestOptions.body = jsonBody;
} else { } else {
requestOptions.body = gqlQuery; throw new NodeOperationError(
this.getNode(),
`Using variables failed:\n${variables}\n\nGraphQL variables should be either an object or a string.`,
{ itemIndex },
);
} }
const jsonBody = {
...requestOptions.body,
query: gqlQuery,
variables: parsedVariables,
operationName: this.getNodeParameter('operationName', itemIndex) as string,
};
if (jsonBody.operationName === '') {
jsonBody.operationName = null;
}
requestOptions.json = true;
requestOptions.body = jsonBody;
} else {
requestOptions.body = gqlQuery;
} }
let response; let response;
@ -509,22 +518,19 @@ export class GraphQL implements INodeType {
throw new NodeApiError(this.getNode(), response.errors as JsonObject, { message }); throw new NodeApiError(this.getNode(), response.errors as JsonObject, { message });
} }
} catch (error) { } catch (error) {
if (this.continueOnFail()) { if (!this.continueOnFail()) {
const errorData = this.helpers.returnJsonArray({ throw error;
$error: error,
json: this.getInputData(itemIndex),
itemIndex,
});
const exectionErrorWithMetaData = this.helpers.constructExecutionMetaData(errorData, {
itemData: { item: itemIndex },
});
returnItems.push(...exectionErrorWithMetaData);
continue;
} }
throw error;
const errorData = this.helpers.returnJsonArray({
error: error.message,
});
const exectionErrorWithMetaData = this.helpers.constructExecutionMetaData(errorData, {
itemData: { item: itemIndex },
});
returnItems.push(...exectionErrorWithMetaData);
} }
} }
return [returnItems]; return [returnItems];
} }
} }

View file

@ -1,54 +1,83 @@
import type { WorkflowTestData } from '@test/nodes/types'; /* eslint-disable n8n-nodes-base/node-filename-against-convention */
import { executeWorkflow } from '@test/nodes/ExecuteWorkflow'; import nock from 'nock';
import * as Helpers from '@test/nodes/Helpers';
import {
equalityTest,
getWorkflowFilenames,
initBinaryDataService,
setup,
workflowToTests,
} from '@test/nodes/Helpers';
describe('GraphQL Node', () => { describe('GraphQL Node', () => {
const mockResponse = { const workflows = getWorkflowFilenames(__dirname);
data: { const workflowTests = workflowToTests(workflows);
nodes: {},
},
};
const tests: WorkflowTestData[] = [ const baseUrl = 'https://api.n8n.io/';
{
description: 'should run Request Format JSON', beforeAll(async () => {
input: { await initBinaryDataService();
workflowData: Helpers.readJsonFileSync('nodes/GraphQL/test/workflow.json'), nock.disableNetConnect();
},
output: { nock(baseUrl)
nodeExecutionOrder: ['Start'], .matchHeader('accept', 'application/json')
nodeData: { .matchHeader('content-type', 'application/json')
'Fetch Request Format JSON': [ .matchHeader('user-agent', 'axios/1.7.4')
[ .matchHeader('content-length', '263')
.matchHeader('accept-encoding', 'gzip, compress, deflate, br')
.post(
'/graphql',
'{"query":"query {\\n nodes(pagination: { limit: 1 }) {\\n data {\\n id\\n attributes {\\n name\\n displayName\\n description\\n group\\n codex\\n createdAt\\n }\\n }\\n }\\n}","variables":{},"operationName":null}',
)
.reply(200, {
data: {
nodes: {
data: [
{ {
json: mockResponse, id: '1',
attributes: {
name: 'n8n-nodes-base.activeCampaign',
displayName: 'ActiveCampaign',
description: 'Create and edit data in ActiveCampaign',
group: '["transform"]',
codex: {
data: {
details:
'ActiveCampaign is a cloud software platform that allows customer experience automation, which combines email marketing, marketing automation, sales automation, and CRM categories. Use this node when you want to interact with your ActiveCampaign account.',
resources: {
primaryDocumentation: [
{
url: 'https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.activecampaign/',
},
],
credentialDocumentation: [
{
url: 'https://docs.n8n.io/integrations/builtin/credentials/activeCampaign/',
},
],
},
categories: ['Marketing'],
nodeVersion: '1.0',
codexVersion: '1.0',
},
},
createdAt: '2019-08-30T22:54:39.934Z',
},
}, },
], ],
],
},
},
nock: {
baseUrl: 'https://api.n8n.io',
mocks: [
{
method: 'post',
path: '/graphql',
statusCode: 200,
responseBody: mockResponse,
}, },
], },
}, });
},
];
const nodeTypes = Helpers.setup(tests);
test.each(tests)('$description', async (testData) => {
const { result } = await executeWorkflow(testData, nodeTypes);
const resultNodeData = Helpers.getResultNodeData(result, testData);
resultNodeData.forEach(({ nodeName, resultData }) =>
expect(resultData).toEqual(testData.output.nodeData[nodeName]),
);
expect(result.finished).toEqual(true);
}); });
afterAll(() => {
nock.restore();
});
const nodeTypes = setup(workflowTests);
for (const workflow of workflowTests) {
test(workflow.description, async () => await equalityTest(workflow, nodeTypes));
}
}); });

View file

@ -0,0 +1,32 @@
{
"meta": {
"templateId": "216",
"instanceId": "ee90fdf8d57662f949e6c691dc07fa0fd2f66e1eee28ed82ef06658223e67255"
},
"nodes": [
{
"parameters": {
"endpoint": "https://graphql-teas-endpoint.netlify.app/",
"requestFormat": "json",
"query": "query getAllTeas($name: String) {\n teas(name: $name) {\n name,\n id\n }\n}",
"variables": "={{ 1 }}"
},
"id": "7aece03f-e0d9-4f49-832c-fc6465613ca7",
"name": "Test: Errors on unsuccessful Expression validation",
"type": "n8n-nodes-base.graphql",
"typeVersion": 1,
"position": [660, 200],
"onError": "continueRegularOutput"
}
],
"connections": {},
"pinData": {
"Test: Errors on unsuccessful Expression validation": [
{
"json": {
"error": "Using variables failed:\n1\n\nGraphQL variables should be either an object or a string."
}
}
]
}
}

View file

@ -1,16 +1,16 @@
{ {
"meta": { "meta": {
"templateCredsSetupCompleted": true, "templateId": "216",
"instanceId": "104a4d08d8897b8bdeb38aaca515021075e0bd8544c983c2bb8c86e6a8e6081c" "instanceId": "ee90fdf8d57662f949e6c691dc07fa0fd2f66e1eee28ed82ef06658223e67255"
}, },
"nodes": [ "nodes": [
{ {
"parameters": {}, "parameters": {},
"id": "fb826323-2e48-4f11-bb0e-e12de32e22ee", "id": "5e2ef15b-2c6c-412f-a9da-515b5211386e",
"name": "When clicking Test workflow", "name": "When clicking Test workflow",
"type": "n8n-nodes-base.manualTrigger", "type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1, "typeVersion": 1,
"position": [180, 160] "position": [420, 100]
}, },
{ {
"parameters": { "parameters": {
@ -21,8 +21,8 @@
"name": "Fetch Request Format JSON", "name": "Fetch Request Format JSON",
"type": "n8n-nodes-base.graphql", "type": "n8n-nodes-base.graphql",
"typeVersion": 1, "typeVersion": 1,
"position": [420, 160], "position": [700, 140],
"id": "7f8ceaf4-b82f-48d5-be0b-9fe3bfb35ee4" "id": "e1c750a0-8d6c-4e81-8111-3218e1e6e69f"
} }
], ],
"connections": { "connections": {
@ -38,5 +38,48 @@
] ]
} }
}, },
"pinData": {} "pinData": {
"Fetch Request Format JSON": [
{
"json": {
"data": {
"nodes": {
"data": [
{
"id": "1",
"attributes": {
"name": "n8n-nodes-base.activeCampaign",
"displayName": "ActiveCampaign",
"description": "Create and edit data in ActiveCampaign",
"group": "[\"transform\"]",
"codex": {
"data": {
"details": "ActiveCampaign is a cloud software platform that allows customer experience automation, which combines email marketing, marketing automation, sales automation, and CRM categories. Use this node when you want to interact with your ActiveCampaign account.",
"resources": {
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.activecampaign/"
}
],
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/credentials/activeCampaign/"
}
]
},
"categories": ["Marketing"],
"nodeVersion": "1.0",
"codexVersion": "1.0"
}
},
"createdAt": "2019-08-30T22:54:39.934Z"
}
}
]
}
}
}
}
]
}
} }

View file

@ -196,7 +196,7 @@ export class HttpRequestV1 implements INodeType {
required: true, required: true,
}, },
{ {
displayName: 'Ignore SSL Issues', displayName: 'Ignore SSL Issues (Insecure)',
name: 'allowUnauthorizedCerts', name: 'allowUnauthorizedCerts',
type: 'boolean', type: 'boolean',
default: false, default: false,

View file

@ -211,7 +211,7 @@ export class HttpRequestV2 implements INodeType {
required: true, required: true,
}, },
{ {
displayName: 'Ignore SSL Issues', displayName: 'Ignore SSL Issues (Insecure)',
name: 'allowUnauthorizedCerts', name: 'allowUnauthorizedCerts',
type: 'boolean', type: 'boolean',
default: false, default: false,

View file

@ -695,7 +695,7 @@ export const mainProperties: INodeProperties[] = [
], ],
}, },
{ {
displayName: 'Ignore SSL Issues', displayName: 'Ignore SSL Issues (Insecure)',
name: 'allowUnauthorizedCerts', name: 'allowUnauthorizedCerts',
type: 'boolean', type: 'boolean',
noDataExpression: true, noDataExpression: true,

View file

@ -0,0 +1,40 @@
import nock from 'nock';
import {
setup,
equalityTest,
workflowToTests,
getWorkflowFilenames,
initBinaryDataService,
} from '@test/nodes/Helpers';
describe('Test Quoted Response Encoding', () => {
const workflows = getWorkflowFilenames(__dirname);
const tests = workflowToTests(workflows);
const baseUrl = 'https://dummy.domain';
const payload = Buffer.from(
'El rápido zorro marrón salta sobre el perro perezoso. ¡Qué bello día en París! Árbol, cañón, façade.',
'latin1',
);
beforeAll(async () => {
await initBinaryDataService();
nock.disableNetConnect();
nock(baseUrl)
.persist()
.get('/index.html')
.reply(200, payload, { 'content-type': 'text/plain; charset="latin1"' });
});
afterAll(() => {
nock.restore();
});
const nodeTypes = setup(tests);
for (const testData of tests) {
test(testData.description, async () => await equalityTest(testData, nodeTypes));
}
});

View file

@ -0,0 +1,69 @@
{
"name": "Response Encoding Test",
"nodes": [
{
"parameters": {},
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [180, 820],
"id": "635fb102-a760-4b9e-836c-82e71bba7974"
},
{
"parameters": {
"url": "https://dummy.domain/index.html",
"options": {}
},
"name": "HTTP Request (v3)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 3,
"position": [520, 720],
"id": "eb243cfd-fbd6-41ef-935d-4ea98617355f"
},
{
"parameters": {
"url": "https://dummy.domain/index.html",
"options": {}
},
"name": "HTTP Request (v4)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [520, 920],
"id": "cc2f185d-df6a-4fa3-b7f4-29f0dbad0f9b"
}
],
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "HTTP Request (v3)",
"type": "main",
"index": 0
},
{
"node": "HTTP Request (v4)",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {
"HTTP Request (v3)": [
{
"json": {
"data": "El rápido zorro marrón salta sobre el perro perezoso. ¡Qué bello día en París! Árbol, cañón, façade."
}
}
],
"HTTP Request (v4)": [
{
"json": {
"data": "El rápido zorro marrón salta sobre el perro perezoso. ¡Qué bello día en París! Árbol, cañón, façade."
}
}
]
}
}

View file

@ -182,7 +182,7 @@ describe('HttpRequestV3', () => {
]; ];
it.each(authenticationTypes)( it.each(authenticationTypes)(
'should handle %s authentication', 'should handle $genericCredentialType authentication',
async ({ genericCredentialType, credentials, authField, authValue }) => { async ({ genericCredentialType, credentials, authField, authValue }) => {
(executeFunctions.getInputData as jest.Mock).mockReturnValue([{ json: {} }]); (executeFunctions.getInputData as jest.Mock).mockReturnValue([{ json: {} }]);
(executeFunctions.getNodeParameter as jest.Mock).mockImplementation((paramName: string) => { (executeFunctions.getNodeParameter as jest.Mock).mockImplementation((paramName: string) => {

Some files were not shown because too many files have changed in this diff Show more