mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
Merge remote-tracking branch 'origin/master' into fix-CAT-337-put-parent-workflow-to-wait
This commit is contained in:
commit
e4a870d3fc
121
.github/workflows/test-workflows.yml
vendored
121
.github/workflows/test-workflows.yml
vendored
|
@ -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'
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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');
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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' =
|
||||||
|
|
|
@ -150,7 +150,6 @@ describe('GlobalConfig', () => {
|
||||||
},
|
},
|
||||||
workflows: {
|
workflows: {
|
||||||
defaultName: 'My workflow',
|
defaultName: 'My workflow',
|
||||||
onboardingFlowDisabled: false,
|
|
||||||
callerPolicyDefaultOption: 'workflowsFromSameOwner',
|
callerPolicyDefaultOption: 'workflowsFromSameOwner',
|
||||||
},
|
},
|
||||||
endpoints: {
|
endpoints: {
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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>,
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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]) ??
|
||||||
|
|
|
@ -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",
|
||||||
|
|
61
packages/cli/src/__tests__/error-reporting.test.ts
Normal file
61
packages/cli/src/__tests__/error-reporting.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.');
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 });
|
||||||
|
});
|
||||||
|
});
|
|
@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<
|
||||||
{},
|
{},
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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));
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 |
|
@ -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 {
|
||||||
|
|
|
@ -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> {
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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')"
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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'),
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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 });
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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[](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}",
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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 });
|
||||||
|
|
122
packages/editor-ui/src/views/SettingsView.test.ts
Normal file
122
packages/editor-ui/src/views/SettingsView.test.ts
Normal 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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
|
@ -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(() => {
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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));
|
||||||
|
}
|
||||||
|
});
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
Loading…
Reference in a new issue