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:
|
||||
- cron: '0 2 * * *'
|
||||
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:
|
||||
run-test-workflows:
|
||||
build:
|
||||
name: Install & Build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
timeout-minutes: 30
|
||||
|
||||
if: github.event_name != 'pull_request_review' || startsWith(github.event.pull_request.base.ref, 'release/')
|
||||
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:
|
||||
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
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
@ -23,52 +75,24 @@ jobs:
|
|||
repository: n8n-io/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
|
||||
run: n8n/packages/cli/bin/n8n import:credentials --input=test-workflows/credentials.json
|
||||
shell: bash
|
||||
run: packages/cli/bin/n8n import:credentials --input=test-workflows/credentials.json
|
||||
env:
|
||||
N8N_ENCRYPTION_KEY: ${{secrets.ENCRYPTION_KEY}}
|
||||
|
||||
- name: Import workflows
|
||||
run: n8n/packages/cli/bin/n8n import:workflow --separate --input=test-workflows/workflows
|
||||
shell: bash
|
||||
run: packages/cli/bin/n8n import:workflow --separate --input=test-workflows/workflows
|
||||
env:
|
||||
N8N_ENCRYPTION_KEY: ${{secrets.ENCRYPTION_KEY}}
|
||||
|
||||
- name: Copy static assets
|
||||
run: |
|
||||
cp n8n/assets/n8n-logo.png /tmp/n8n-logo.png
|
||||
cp n8n/assets/n8n-screenshot.png /tmp/n8n-screenshot.png
|
||||
cp assets/n8n-logo.png /tmp/n8n-logo.png
|
||||
cp assets/n8n-screenshot.png /tmp/n8n-screenshot.png
|
||||
cp test-workflows/testData/pdfs/*.pdf /tmp/
|
||||
shell: bash
|
||||
|
||||
- name: Run tests
|
||||
run: n8n/packages/cli/bin/n8n executeBatch --shallow --skipList=test-workflows/skipList.txt --githubWorkflow --shortOutput --concurrency=16 --compare=test-workflows/snapshots
|
||||
shell: bash
|
||||
run: packages/cli/bin/n8n executeBatch --shallow --skipList=test-workflows/skipList.txt --githubWorkflow --shortOutput --concurrency=16 --compare=test-workflows/snapshots
|
||||
id: tests
|
||||
env:
|
||||
N8N_ENCRYPTION_KEY: ${{secrets.ENCRYPTION_KEY}}
|
||||
|
@ -76,23 +100,6 @@ jobs:
|
|||
DB_SQLITE_POOL_SIZE: 4
|
||||
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
|
||||
uses: act10ns/slack@v2.0.0
|
||||
if: failure() && github.ref == 'refs/heads/master'
|
||||
|
|
|
@ -40,6 +40,7 @@ export function saveCredential() {
|
|||
.within(() => {
|
||||
cy.get('button').should('not.exist');
|
||||
});
|
||||
getCredentialSaveButton().should('have.text', 'Saved');
|
||||
}
|
||||
|
||||
export function closeCredentialModal() {
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { saveCredential } from '../composables/modals/credential-modal';
|
||||
import * as projects from '../composables/projects';
|
||||
import { INSTANCE_MEMBERS, INSTANCE_OWNER, INSTANCE_ADMIN, NOTION_NODE_NAME } from '../constants';
|
||||
import {
|
||||
|
@ -225,8 +226,7 @@ describe('Sharing', { disableAutoLogin: true }, () => {
|
|||
.filter(':contains("Development")')
|
||||
.should('have.length', 1)
|
||||
.click();
|
||||
credentialsModal.getters.saveButton().click();
|
||||
credentialsModal.getters.saveButton().should('have.text', 'Saved');
|
||||
saveCredential();
|
||||
credentialsModal.actions.close();
|
||||
|
||||
projects.getProjectTabWorkflows().click();
|
||||
|
@ -252,8 +252,7 @@ describe('Sharing', { disableAutoLogin: true }, () => {
|
|||
credentialsModal.actions.changeTab('Sharing');
|
||||
credentialsModal.getters.usersSelect().click();
|
||||
getVisibleSelect().find('li').should('have.length', 4).first().click();
|
||||
credentialsModal.getters.saveButton().click();
|
||||
credentialsModal.getters.saveButton().should('have.text', 'Saved');
|
||||
saveCredential();
|
||||
credentialsModal.actions.close();
|
||||
|
||||
credentialsPage.getters
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { type ICredentialType } from 'n8n-workflow';
|
||||
|
||||
import { getCredentialSaveButton, saveCredential } from '../composables/modals/credential-modal';
|
||||
import {
|
||||
AGENT_NODE_NAME,
|
||||
AI_TOOL_HTTP_NODE_NAME,
|
||||
|
@ -194,7 +195,7 @@ describe('Credentials', () => {
|
|||
credentialsModal.getters.credentialsEditModal().should('be.visible');
|
||||
credentialsModal.getters.name().click();
|
||||
credentialsModal.actions.renameCredential(NEW_CREDENTIAL_NAME);
|
||||
credentialsModal.getters.saveButton().click();
|
||||
saveCredential();
|
||||
credentialsModal.getters.closeButton().click();
|
||||
workflowPage.getters
|
||||
.nodeCredentialsSelect()
|
||||
|
@ -212,7 +213,7 @@ describe('Credentials', () => {
|
|||
credentialsModal.getters.credentialsEditModal().should('be.visible');
|
||||
credentialsModal.getters.name().click();
|
||||
credentialsModal.actions.renameCredential(NEW_CREDENTIAL_NAME2);
|
||||
credentialsModal.getters.saveButton().click();
|
||||
saveCredential();
|
||||
credentialsModal.getters.closeButton().click();
|
||||
workflowPage.getters
|
||||
.nodeCredentialsSelect()
|
||||
|
@ -237,7 +238,7 @@ describe('Credentials', () => {
|
|||
credentialsModal.getters.credentialsEditModal().should('be.visible');
|
||||
credentialsModal.getters.name().click();
|
||||
credentialsModal.actions.renameCredential(NEW_CREDENTIAL_NAME);
|
||||
credentialsModal.getters.saveButton().click();
|
||||
saveCredential();
|
||||
credentialsModal.getters.closeButton().click();
|
||||
workflowPage.getters
|
||||
.nodeCredentialsSelect()
|
||||
|
@ -342,7 +343,8 @@ describe('Credentials', () => {
|
|||
credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890');
|
||||
|
||||
credentialsModal.actions.setName('My awesome Notion account');
|
||||
credentialsModal.getters.saveButton().click({ force: true });
|
||||
getCredentialSaveButton().click();
|
||||
|
||||
errorToast().should('have.length', 1);
|
||||
errorToast().should('be.visible');
|
||||
|
||||
|
|
|
@ -49,33 +49,35 @@ describe('Two-factor authentication', { disableAutoLogin: true }, () => {
|
|||
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;
|
||||
signinPage.actions.loginWithEmailAndPassword(email, password);
|
||||
personalSettingsPage.actions.enableMfa();
|
||||
mainSidebar.actions.signout();
|
||||
const token = generateOTPToken(user.mfaSecret);
|
||||
mfaLoginPage.actions.loginWithMfaToken(email, password, token);
|
||||
const mfaCode = generateOTPToken(user.mfaSecret);
|
||||
mfaLoginPage.actions.loginWithMfaCode(email, password, mfaCode);
|
||||
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;
|
||||
signinPage.actions.loginWithEmailAndPassword(email, password);
|
||||
personalSettingsPage.actions.enableMfa();
|
||||
mainSidebar.actions.signout();
|
||||
mfaLoginPage.actions.loginWithRecoveryCode(email, password, user.mfaRecoveryCodes[0]);
|
||||
mfaLoginPage.actions.loginWithMfaRecoveryCode(email, password, user.mfaRecoveryCodes[0]);
|
||||
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;
|
||||
signinPage.actions.loginWithEmailAndPassword(email, password);
|
||||
personalSettingsPage.actions.enableMfa();
|
||||
mainSidebar.actions.signout();
|
||||
const token = generateOTPToken(user.mfaSecret);
|
||||
mfaLoginPage.actions.loginWithMfaToken(email, password, token);
|
||||
personalSettingsPage.actions.disableMfa();
|
||||
const loginToken = generateOTPToken(user.mfaSecret);
|
||||
mfaLoginPage.actions.loginWithMfaCode(email, password, loginToken);
|
||||
const disableToken = generateOTPToken(user.mfaSecret);
|
||||
personalSettingsPage.actions.disableMfa(disableToken);
|
||||
personalSettingsPage.getters.enableMfaButton().should('exist');
|
||||
mainSidebar.actions.signout();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { getCredentialSaveButton } from '../composables/modals/credential-modal';
|
||||
import { CredentialsPage, CredentialsModal } from '../pages';
|
||||
|
||||
const credentialsPage = new CredentialsPage();
|
||||
|
@ -40,7 +41,7 @@ describe('Credentials', () => {
|
|||
});
|
||||
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,18 +8,18 @@ export class MfaLoginPage extends BasePage {
|
|||
|
||||
getters = {
|
||||
form: () => cy.getByTestId('mfa-login-form'),
|
||||
token: () => cy.getByTestId('token'),
|
||||
recoveryCode: () => cy.getByTestId('recoveryCode'),
|
||||
mfaCode: () => cy.getByTestId('mfaCode'),
|
||||
mfaRecoveryCode: () => cy.getByTestId('mfaRecoveryCode'),
|
||||
enterRecoveryCodeButton: () => cy.getByTestId('mfa-enter-recovery-code-button'),
|
||||
};
|
||||
|
||||
actions = {
|
||||
loginWithMfaToken: (email: string, password: string, mfaToken: string) => {
|
||||
loginWithMfaCode: (email: string, password: string, mfaCode: string) => {
|
||||
const signinPage = new SigninPage();
|
||||
const workflowsPage = new WorkflowsPage();
|
||||
|
||||
cy.session(
|
||||
[mfaToken],
|
||||
[mfaCode],
|
||||
() => {
|
||||
cy.visit(signinPage.url);
|
||||
|
||||
|
@ -30,7 +30,7 @@ export class MfaLoginPage extends BasePage {
|
|||
});
|
||||
|
||||
this.getters.form().within(() => {
|
||||
this.getters.token().type(mfaToken);
|
||||
this.getters.mfaCode().type(mfaCode);
|
||||
});
|
||||
|
||||
// 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 workflowsPage = new WorkflowsPage();
|
||||
|
||||
cy.session(
|
||||
[recoveryCode],
|
||||
[mfaRecoveryCode],
|
||||
() => {
|
||||
cy.visit(signinPage.url);
|
||||
|
||||
|
@ -61,7 +61,7 @@ export class MfaLoginPage extends BasePage {
|
|||
this.getters.enterRecoveryCodeButton().click();
|
||||
|
||||
this.getters.form().within(() => {
|
||||
this.getters.recoveryCode().type(recoveryCode);
|
||||
this.getters.mfaRecoveryCode().type(mfaRecoveryCode);
|
||||
});
|
||||
|
||||
// we should be redirected to /workflows
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { getCredentialSaveButton, saveCredential } from '../../composables/modals/credential-modal';
|
||||
import { getVisibleSelect } from '../../utils';
|
||||
import { BasePage } from '../base';
|
||||
|
||||
|
@ -13,8 +14,6 @@ export class CredentialsModal extends BasePage {
|
|||
this.getters.credentialInputs().find(`:contains('${fieldName}') .n8n-input input`),
|
||||
name: () => cy.getByTestId('credential-name'),
|
||||
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'),
|
||||
closeButton: () => this.getters.editCredentialModal().find('.el-dialog__close').first(),
|
||||
oauthConnectButton: () => cy.getByTestId('oauth-connect-button'),
|
||||
|
@ -41,17 +40,17 @@ export class CredentialsModal extends BasePage {
|
|||
},
|
||||
save: (test = false) => {
|
||||
cy.intercept('POST', '/rest/credentials').as('saveCredential');
|
||||
this.getters.saveButton().click({ force: true });
|
||||
saveCredential();
|
||||
|
||||
cy.wait('@saveCredential');
|
||||
if (test) cy.wait('@testCredential');
|
||||
this.getters.saveButton().should('contain.text', 'Saved');
|
||||
getCredentialSaveButton().should('contain.text', 'Saved');
|
||||
},
|
||||
saveSharing: () => {
|
||||
cy.intercept('PUT', '/rest/credentials/*/share').as('shareCredential');
|
||||
this.getters.saveButton().click({ force: true });
|
||||
saveCredential();
|
||||
cy.wait('@shareCredential');
|
||||
this.getters.saveButton().should('contain.text', 'Saved');
|
||||
getCredentialSaveButton().should('contain.text', 'Saved');
|
||||
},
|
||||
close: () => {
|
||||
this.getters.closeButton().click();
|
||||
|
@ -65,7 +64,7 @@ export class CredentialsModal extends BasePage {
|
|||
.each(($el) => {
|
||||
cy.wrap($el).type('test');
|
||||
});
|
||||
this.getters.saveButton().click();
|
||||
saveCredential();
|
||||
if (closeModal) {
|
||||
this.getters.closeButton().click();
|
||||
}
|
||||
|
|
|
@ -22,6 +22,8 @@ export class PersonalSettingsPage extends BasePage {
|
|||
saveSettingsButton: () => cy.getByTestId('save-settings-button'),
|
||||
enableMfaButton: () => cy.getByTestId('enable-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'),
|
||||
selectOptionsVisible: () => cy.get('.el-select-dropdown:visible .el-select-dropdown__item'),
|
||||
};
|
||||
|
@ -83,9 +85,11 @@ export class PersonalSettingsPage extends BasePage {
|
|||
mfaSetupModal.getters.saveButton().click();
|
||||
});
|
||||
},
|
||||
disableMfa: () => {
|
||||
disableMfa: (mfaCodeOrRecoveryCode: string) => {
|
||||
cy.visit(this.url);
|
||||
this.getters.disableMfaButton().click();
|
||||
this.getters.mfaCodeOrMfaRecoveryCodeInput().type(mfaCodeOrRecoveryCode);
|
||||
this.getters.mfaSaveButton().click();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -6,10 +6,6 @@ export class WorkflowsConfig {
|
|||
@Env('WORKFLOWS_DEFAULT_NAME')
|
||||
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 */
|
||||
@Env('N8N_WORKFLOW_CALLER_POLICY_DEFAULT_OPTION')
|
||||
callerPolicyDefaultOption: 'any' | 'none' | 'workflowsFromAList' | 'workflowsFromSameOwner' =
|
||||
|
|
|
@ -150,7 +150,6 @@ describe('GlobalConfig', () => {
|
|||
},
|
||||
workflows: {
|
||||
defaultName: 'My workflow',
|
||||
onboardingFlowDisabled: false,
|
||||
callerPolicyDefaultOption: 'workflowsFromSameOwner',
|
||||
},
|
||||
endpoints: {
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
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 { DynamicStructuredTool, Tool } from 'langchain/tools';
|
||||
import { NodeOperationError, type IExecuteFunctions, type INode } from 'n8n-workflow';
|
||||
|
||||
type ZodObjectAny = z.ZodObject<any, any, any, any>;
|
||||
|
||||
export async function extractParsedOutput(
|
||||
ctx: IExecuteFunctions,
|
||||
outputParser: BaseOutputParser<unknown>,
|
||||
|
|
|
@ -135,47 +135,47 @@
|
|||
"@getzep/zep-js": "0.9.0",
|
||||
"@google-ai/generativelanguage": "2.6.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",
|
||||
"@langchain/anthropic": "0.3.7",
|
||||
"@langchain/aws": "0.1.1",
|
||||
"@langchain/anthropic": "0.3.8",
|
||||
"@langchain/aws": "0.1.2",
|
||||
"@langchain/cohere": "0.3.1",
|
||||
"@langchain/community": "0.3.11",
|
||||
"@langchain/community": "0.3.15",
|
||||
"@langchain/core": "catalog:",
|
||||
"@langchain/google-genai": "0.1.2",
|
||||
"@langchain/google-vertexai": "0.1.0",
|
||||
"@langchain/google-genai": "0.1.4",
|
||||
"@langchain/google-vertexai": "0.1.3",
|
||||
"@langchain/groq": "0.1.2",
|
||||
"@langchain/mistralai": "0.1.1",
|
||||
"@langchain/ollama": "0.1.1",
|
||||
"@langchain/openai": "0.3.11",
|
||||
"@langchain/pinecone": "0.1.1",
|
||||
"@langchain/qdrant": "0.1.0",
|
||||
"@langchain/mistralai": "0.2.0",
|
||||
"@langchain/ollama": "0.1.2",
|
||||
"@langchain/openai": "0.3.14",
|
||||
"@langchain/pinecone": "0.1.3",
|
||||
"@langchain/qdrant": "0.1.1",
|
||||
"@langchain/redis": "0.1.0",
|
||||
"@langchain/textsplitters": "0.1.0",
|
||||
"@mozilla/readability": "0.5.0",
|
||||
"@n8n/json-schema-to-zod": "workspace:*",
|
||||
"@n8n/typeorm": "0.3.20-12",
|
||||
"@n8n/vm2": "3.9.25",
|
||||
"@pinecone-database/pinecone": "3.0.3",
|
||||
"@pinecone-database/pinecone": "4.0.0",
|
||||
"@qdrant/js-client-rest": "1.11.0",
|
||||
"@supabase/supabase-js": "2.45.4",
|
||||
"@xata.io/client": "0.28.4",
|
||||
"basic-auth": "catalog:",
|
||||
"cheerio": "1.0.0",
|
||||
"cohere-ai": "7.13.2",
|
||||
"cohere-ai": "7.14.0",
|
||||
"d3-dsv": "2.0.0",
|
||||
"epub2": "3.0.2",
|
||||
"form-data": "catalog:",
|
||||
"generate-schema": "2.6.0",
|
||||
"html-to-text": "9.0.5",
|
||||
"jsdom": "23.0.1",
|
||||
"langchain": "0.3.5",
|
||||
"langchain": "0.3.6",
|
||||
"lodash": "catalog:",
|
||||
"mammoth": "1.7.2",
|
||||
"mime-types": "2.1.35",
|
||||
"n8n-nodes-base": "workspace:*",
|
||||
"n8n-workflow": "workspace:*",
|
||||
"openai": "4.69.0",
|
||||
"openai": "4.73.1",
|
||||
"pdf-parse": "1.1.1",
|
||||
"pg": "8.12.0",
|
||||
"redis": "4.6.12",
|
||||
|
|
|
@ -32,7 +32,9 @@ export class N8nStructuredOutputParser extends StructuredOutputParser<
|
|||
[{ json: { action: 'parse', text } }],
|
||||
]);
|
||||
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]) ??
|
||||
get(parsed, [STRUCTURED_OUTPUT_KEY, STRUCTURED_OUTPUT_ARRAY_KEY]) ??
|
||||
|
|
|
@ -94,7 +94,7 @@
|
|||
"@n8n/permissions": "workspace:*",
|
||||
"@n8n/task-runner": "workspace:*",
|
||||
"@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",
|
||||
"@oclif/core": "4.0.7",
|
||||
"@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 { Response } from 'express';
|
||||
import { ErrorReporterProxy } from 'n8n-workflow';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { WritableStream } from 'node:stream/web';
|
||||
|
||||
|
@ -33,8 +32,7 @@ export class AiController {
|
|||
}
|
||||
} catch (e) {
|
||||
assert(e instanceof Error);
|
||||
ErrorReporterProxy.error(e);
|
||||
throw new InternalServerError(`Something went wrong: ${e.message}`);
|
||||
throw new InternalServerError(e.message, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -46,8 +44,7 @@ export class AiController {
|
|||
return await this.aiService.applySuggestion(req.body, req.user);
|
||||
} catch (e) {
|
||||
assert(e instanceof Error);
|
||||
ErrorReporterProxy.error(e);
|
||||
throw new InternalServerError(`Something went wrong: ${e.message}`);
|
||||
throw new InternalServerError(e.message, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -57,8 +54,7 @@ export class AiController {
|
|||
return await this.aiService.askAi(req.body, req.user);
|
||||
} catch (e) {
|
||||
assert(e instanceof Error);
|
||||
ErrorReporterProxy.error(e);
|
||||
throw new InternalServerError(`Something went wrong: ${e.message}`);
|
||||
throw new InternalServerError(e.message, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,7 +41,7 @@ export class AuthController {
|
|||
/** Log in a user */
|
||||
@Post('/login', { skipAuth: true, rateLimit: true })
|
||||
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 (!password) throw new ApplicationError('Password is required to log in');
|
||||
|
||||
|
@ -75,16 +75,16 @@ export class AuthController {
|
|||
|
||||
if (user) {
|
||||
if (user.mfaEnabled) {
|
||||
if (!mfaToken && !mfaRecoveryCode) {
|
||||
if (!mfaCode && !mfaRecoveryCode) {
|
||||
throw new AuthError('MFA Error', 998);
|
||||
}
|
||||
|
||||
const isMFATokenValid = await this.mfaService.validateMfa(
|
||||
const isMfaCodeOrMfaRecoveryCodeValid = await this.mfaService.validateMfa(
|
||||
user.id,
|
||||
mfaToken,
|
||||
mfaCode,
|
||||
mfaRecoveryCode,
|
||||
);
|
||||
if (!isMFATokenValid) {
|
||||
if (!isMfaCodeOrMfaRecoveryCodeValid) {
|
||||
throw new AuthError('Invalid mfa token or recovery code');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -201,7 +201,7 @@ export class CommunityPackagesController {
|
|||
error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON,
|
||||
].join(':');
|
||||
|
||||
throw new InternalServerError(message);
|
||||
throw new InternalServerError(message, error);
|
||||
}
|
||||
|
||||
// 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,
|
||||
].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');
|
||||
}
|
||||
|
||||
const isMfaTokenValid = await this.mfaService.validateMfa(userId, payload.mfaCode, undefined);
|
||||
if (!isMfaTokenValid) {
|
||||
const isMfaCodeValid = await this.mfaService.validateMfa(userId, payload.mfaCode, undefined);
|
||||
if (!isMfaCodeValid) {
|
||||
throw new InvalidMfaCodeError();
|
||||
}
|
||||
}
|
||||
|
@ -142,8 +142,8 @@ export class MeController {
|
|||
throw new BadRequestError('Two-factor code is required to change password.');
|
||||
}
|
||||
|
||||
const isMfaTokenValid = await this.mfaService.validateMfa(user.id, mfaCode, undefined);
|
||||
if (!isMfaTokenValid) {
|
||||
const isMfaCodeValid = await this.mfaService.validateMfa(user.id, mfaCode, undefined);
|
||||
if (!isMfaCodeValid) {
|
||||
throw new InvalidMfaCodeError();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -59,7 +59,7 @@ export class MFAController {
|
|||
|
||||
@Post('/enable', { rateLimit: true })
|
||||
async activateMFA(req: MFA.Activate) {
|
||||
const { token = null } = req.body;
|
||||
const { mfaCode = null } = req.body;
|
||||
const { id, mfaEnabled } = req.user;
|
||||
|
||||
await this.externalHooks.run('mfa.beforeSetup', [req.user]);
|
||||
|
@ -67,7 +67,7 @@ export class MFAController {
|
|||
const { decryptedSecret: secret, decryptedRecoveryCodes: recoveryCodes } =
|
||||
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');
|
||||
|
||||
|
@ -75,10 +75,10 @@ export class MFAController {
|
|||
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)
|
||||
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);
|
||||
}
|
||||
|
@ -86,27 +86,27 @@ export class MFAController {
|
|||
@Post('/disable', { rateLimit: true })
|
||||
async disableMFA(req: MFA.Disable) {
|
||||
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');
|
||||
}
|
||||
|
||||
await this.mfaService.disableMfa(userId, token);
|
||||
await this.mfaService.disableMfa(userId, mfaCode);
|
||||
}
|
||||
|
||||
@Post('/verify', { rateLimit: true })
|
||||
async verifyMFA(req: MFA.Verify) {
|
||||
const { id } = req.user;
|
||||
const { token } = req.body;
|
||||
const { mfaCode } = req.body;
|
||||
|
||||
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');
|
||||
|
||||
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');
|
||||
}
|
||||
|
|
|
@ -120,7 +120,7 @@ export class PasswordResetController {
|
|||
publicApi: false,
|
||||
});
|
||||
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 })
|
||||
async changePassword(req: PasswordResetRequest.NewPassword, res: Response) {
|
||||
const { token, password, mfaToken } = req.body;
|
||||
const { token, password, mfaCode } = req.body;
|
||||
|
||||
if (!token || !password) {
|
||||
this.logger.debug(
|
||||
|
@ -189,11 +189,11 @@ export class PasswordResetController {
|
|||
if (!user) throw new NotFoundError('');
|
||||
|
||||
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 validToken = this.mfaService.totp.verifySecret({ secret, token: mfaToken });
|
||||
const validToken = this.mfaService.totp.verifySecret({ secret, mfaCode });
|
||||
|
||||
if (!validToken) throw new BadRequestError('Invalid MFA token.');
|
||||
}
|
||||
|
|
|
@ -54,7 +54,7 @@ export class TranslationController {
|
|||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return require(NODE_HEADERS_PATH);
|
||||
} 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 (
|
||||
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) {
|
||||
const eventHash = createHash('sha1').update(originalException.stack).digest('base64');
|
||||
if (seenErrors.has(eventHash)) return null;
|
||||
|
|
|
@ -16,8 +16,9 @@ export abstract class ResponseError extends ApplicationError {
|
|||
readonly errorCode: number = httpStatusCode,
|
||||
// The error hint the response
|
||||
readonly hint: string | undefined = undefined,
|
||||
cause?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
super(message, { cause });
|
||||
this.name = 'ResponseError';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { ResponseError } from './abstract/response.error';
|
||||
|
||||
export class InternalServerError extends ResponseError {
|
||||
constructor(message: string, errorCode = 500) {
|
||||
super(message, 500, errorCode);
|
||||
constructor(message: string, cause?: unknown) {
|
||||
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",
|
||||
"value": true,
|
||||
"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 { readFileSync } from 'fs';
|
||||
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 type { ActiveExecutions } from '@/active-executions';
|
||||
import type { ExecutionEntity } from '@/databases/entities/execution-entity';
|
||||
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 { User } from '@/databases/entities/user';
|
||||
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 { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||
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', () => {
|
||||
const executionRepository = mock<ExecutionRepository>();
|
||||
const workflowRepository = mock<WorkflowRepository>();
|
||||
const workflowRunner = mock<WorkflowRunner>();
|
||||
const activeExecutions = mock<ActiveExecutions>();
|
||||
const testRunRepository = mock<TestRunRepository>();
|
||||
const testMetricRepository = mock<TestMetricRepository>();
|
||||
|
||||
beforeEach(() => {
|
||||
const executionsQbMock = mockDeep<SelectQueryBuilder<ExecutionEntity>>({
|
||||
|
@ -80,6 +108,11 @@ describe('TestRunnerService', () => {
|
|||
.mockResolvedValueOnce(executionMocks[1]);
|
||||
|
||||
testRunRepository.createTestRun.mockResolvedValue(mock<TestRun>({ id: 'test-run-id' }));
|
||||
|
||||
testMetricRepository.find.mockResolvedValue([
|
||||
mock<TestMetric>({ name: 'metric1' }),
|
||||
mock<TestMetric>({ name: 'metric2' }),
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -97,6 +130,7 @@ describe('TestRunnerService', () => {
|
|||
executionRepository,
|
||||
activeExecutions,
|
||||
testRunRepository,
|
||||
testMetricRepository,
|
||||
);
|
||||
|
||||
expect(testRunnerService).toBeInstanceOf(TestRunnerService);
|
||||
|
@ -109,6 +143,7 @@ describe('TestRunnerService', () => {
|
|||
executionRepository,
|
||||
activeExecutions,
|
||||
testRunRepository,
|
||||
testMetricRepository,
|
||||
);
|
||||
|
||||
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
||||
|
@ -143,6 +178,7 @@ describe('TestRunnerService', () => {
|
|||
executionRepository,
|
||||
activeExecutions,
|
||||
testRunRepository,
|
||||
testMetricRepository,
|
||||
);
|
||||
|
||||
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
||||
|
@ -166,17 +202,17 @@ describe('TestRunnerService', () => {
|
|||
.mockResolvedValue(mockExecutionData());
|
||||
|
||||
activeExecutions.getPostExecutePromise
|
||||
.calledWith('some-execution-id-2')
|
||||
.calledWith('some-execution-id-3')
|
||||
.mockResolvedValue(mockExecutionData());
|
||||
|
||||
// Mock executions of evaluation workflow
|
||||
activeExecutions.getPostExecutePromise
|
||||
.calledWith('some-execution-id-3')
|
||||
.mockResolvedValue(mockExecutionData());
|
||||
.calledWith('some-execution-id-2')
|
||||
.mockResolvedValue(mockEvaluationExecutionData({ metric1: 1, metric2: 0 }));
|
||||
|
||||
activeExecutions.getPostExecutePromise
|
||||
.calledWith('some-execution-id-4')
|
||||
.mockResolvedValue(mockExecutionData());
|
||||
.mockResolvedValue(mockEvaluationExecutionData({ metric1: 0.5 }));
|
||||
|
||||
await testRunnerService.runTest(
|
||||
mock<User>(),
|
||||
|
@ -225,7 +261,8 @@ describe('TestRunnerService', () => {
|
|||
expect(testRunRepository.markAsRunning).toHaveBeenCalledWith('test-run-id');
|
||||
expect(testRunRepository.markAsCompleted).toHaveBeenCalledTimes(1);
|
||||
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 { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||
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 { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||
import { getRunData } from '@/workflow-execute-additional-data';
|
||||
import { WorkflowRunner } from '@/workflow-runner';
|
||||
|
||||
import { EvaluationMetrics } from './evaluation-metrics.ee';
|
||||
import { createPinData, getPastExecutionStartNode } from './utils.ee';
|
||||
|
||||
/**
|
||||
|
@ -40,6 +42,7 @@ export class TestRunnerService {
|
|||
private readonly executionRepository: ExecutionRepository,
|
||||
private readonly activeExecutions: ActiveExecutions,
|
||||
private readonly testRunRepository: TestRunRepository,
|
||||
private readonly testMetricRepository: TestMetricRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
@ -113,6 +116,11 @@ export class TestRunnerService {
|
|||
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 {
|
||||
const lastNodeExecuted = execution.data.resultData.lastNodeExecuted;
|
||||
assert(lastNodeExecuted, 'Could not find the last node executed in evaluation workflow');
|
||||
|
@ -124,6 +132,21 @@ export class TestRunnerService {
|
|||
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.
|
||||
*/
|
||||
|
@ -152,11 +175,15 @@ export class TestRunnerService {
|
|||
.andWhere('execution.workflowId = :workflowId', { workflowId: test.workflowId })
|
||||
.getMany();
|
||||
|
||||
// Get the metrics to collect from the evaluation workflow
|
||||
const testMetricNames = await this.getTestMetricNames(test.id);
|
||||
|
||||
// 2. Run over all the test cases
|
||||
|
||||
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) {
|
||||
// Fetch past execution with data
|
||||
|
@ -192,12 +219,10 @@ export class TestRunnerService {
|
|||
assert(evalExecution);
|
||||
|
||||
// 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
|
||||
// Now we just set success to true if all the test cases passed
|
||||
const aggregatedMetrics = { success: metrics.every((metric) => metric.success) };
|
||||
const aggregatedMetrics = metrics.getAggregatedMetrics();
|
||||
|
||||
await this.testRunRepository.markAsCompleted(testRun.id, aggregatedMetrics);
|
||||
}
|
||||
|
|
|
@ -251,7 +251,7 @@ export class ExecutionService {
|
|||
requestFilters = requestFiltersRaw as IGetExecutionsQueryFilter;
|
||||
}
|
||||
} 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(
|
||||
userId: string,
|
||||
mfaToken: string | undefined,
|
||||
mfaCode: string | undefined,
|
||||
mfaRecoveryCode: string | undefined,
|
||||
) {
|
||||
const user = await this.authUserRepository.findOneByOrFail({ id: userId });
|
||||
if (mfaToken) {
|
||||
if (mfaCode) {
|
||||
const decryptedSecret = this.cipher.decrypt(user.mfaSecret!);
|
||||
return this.totp.verifySecret({ secret: decryptedSecret, token: mfaToken });
|
||||
return this.totp.verifySecret({ secret: decryptedSecret, mfaCode });
|
||||
}
|
||||
|
||||
if (mfaRecoveryCode) {
|
||||
|
@ -85,8 +85,8 @@ export class MfaService {
|
|||
return await this.authUserRepository.save(user);
|
||||
}
|
||||
|
||||
async disableMfa(userId: string, mfaToken: string) {
|
||||
const isValidToken = await this.validateMfa(userId, mfaToken, undefined);
|
||||
async disableMfa(userId: string, mfaCode: string) {
|
||||
const isValidToken = await this.validateMfa(userId, mfaCode, undefined);
|
||||
if (!isValidToken) {
|
||||
throw new InvalidMfaCodeError();
|
||||
}
|
||||
|
|
|
@ -23,10 +23,14 @@ export class TOTPService {
|
|||
}).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({
|
||||
secret: OTPAuth.Secret.fromBase32(secret),
|
||||
}).validate({ token, window }) === null
|
||||
}).validate({ token: mfaCode, window }) === null
|
||||
? false
|
||||
: true;
|
||||
}
|
||||
|
|
|
@ -228,7 +228,7 @@ export declare namespace PasswordResetRequest {
|
|||
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;
|
||||
password: string;
|
||||
mfaToken?: string;
|
||||
mfaCode?: string;
|
||||
mfaRecoveryCode?: string;
|
||||
}
|
||||
>;
|
||||
|
@ -316,9 +316,9 @@ export type LoginRequest = AuthlessRequest<
|
|||
// ----------------------------------
|
||||
|
||||
export declare namespace MFA {
|
||||
type Verify = AuthenticatedRequest<{}, {}, { token: string }, {}>;
|
||||
type Activate = AuthenticatedRequest<{}, {}, { token: string }, {}>;
|
||||
type Disable = AuthenticatedRequest<{}, {}, { token: string }, {}>;
|
||||
type Verify = AuthenticatedRequest<{}, {}, { mfaCode: string }, {}>;
|
||||
type Activate = AuthenticatedRequest<{}, {}, { mfaCode: string }, {}>;
|
||||
type Disable = AuthenticatedRequest<{}, {}, { mfaCode: string }, {}>;
|
||||
type Config = AuthenticatedRequest<{}, {}, { login: { enabled: boolean } }, {}>;
|
||||
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 { ApplicationError, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow';
|
||||
import { ApplicationError } from 'n8n-workflow';
|
||||
import { Service } from 'typedi';
|
||||
|
||||
import type { User, AssignableRole } from '@/databases/entities/user';
|
||||
|
@ -213,9 +213,8 @@ export class UserService {
|
|||
),
|
||||
);
|
||||
} catch (error) {
|
||||
ErrorReporter.error(error);
|
||||
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));
|
||||
|
|
|
@ -125,7 +125,7 @@ export class UserManagementMailer {
|
|||
|
||||
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);
|
||||
|
||||
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';
|
||||
throw internalServerError;
|
||||
});
|
||||
|
|
|
@ -33,7 +33,6 @@ import * as ResponseHelper from '@/response-helper';
|
|||
import { NamingService } from '@/services/naming.service';
|
||||
import { ProjectService } from '@/services/project.service';
|
||||
import { TagService } from '@/services/tag.service';
|
||||
import { UserOnboardingService } from '@/services/user-onboarding.service';
|
||||
import { UserManagementMailer } from '@/user-management/email';
|
||||
import * as utils from '@/utils';
|
||||
import * as WorkflowHelpers from '@/workflow-helpers';
|
||||
|
@ -55,7 +54,6 @@ export class WorkflowsController {
|
|||
private readonly workflowHistoryService: WorkflowHistoryService,
|
||||
private readonly tagService: TagService,
|
||||
private readonly namingService: NamingService,
|
||||
private readonly userOnboardingService: UserOnboardingService,
|
||||
private readonly workflowRepository: WorkflowRepository,
|
||||
private readonly workflowService: WorkflowService,
|
||||
private readonly workflowExecutionService: WorkflowExecutionService,
|
||||
|
@ -213,13 +211,7 @@ export class WorkflowsController {
|
|||
const requestedName = req.query.name ?? this.globalConfig.workflows.defaultName;
|
||||
|
||||
const name = await this.namingService.getUniqueWorkflowName(requestedName);
|
||||
|
||||
const onboardingFlowEnabled =
|
||||
!this.globalConfig.workflows.onboardingFlowDisabled &&
|
||||
!req.user.settings?.isOnboarded &&
|
||||
(await this.userOnboardingService.isBelowThreshold(req.user));
|
||||
|
||||
return { name, onboardingFlowEnabled };
|
||||
return { name };
|
||||
}
|
||||
|
||||
@Get('/from-url')
|
||||
|
|
|
@ -89,7 +89,7 @@ describe('POST /login', () => {
|
|||
const response = await testServer.authlessAgent.post('/login').send({
|
||||
email: owner.email,
|
||||
password: ownerPassword,
|
||||
mfaToken: mfaService.totp.generateTOTP(secret),
|
||||
mfaCode: mfaService.totp.generateTOTP(secret),
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
|
|
@ -55,8 +55,8 @@ describe('Enable MFA setup', () => {
|
|||
secondCall.body.data.recoveryCodes.join(''),
|
||||
);
|
||||
|
||||
const token = new TOTPService().generateTOTP(firstCall.body.data.secret);
|
||||
await testServer.authAgentFor(owner).post('/mfa/disable').send({ token }).expect(200);
|
||||
const mfaCode = new TOTPService().generateTOTP(firstCall.body.data.secret);
|
||||
await testServer.authAgentFor(owner).post('/mfa/disable').send({ mfaCode }).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);
|
||||
});
|
||||
|
||||
test('POST /verify should fail due to invalid MFA token', async () => {
|
||||
await testServer.authAgentFor(owner).post('/mfa/verify').send({ token: '123' }).expect(400);
|
||||
test('POST /verify should fail due to invalid MFA code', async () => {
|
||||
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).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 { 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);
|
||||
});
|
||||
|
||||
test('POST /verify should fail due to missing token parameter', async () => {
|
||||
await testServer.authAgentFor(owner).post('/mfa/verify').send({ token: '' }).expect(400);
|
||||
test('POST /verify should fail due to missing mfaCode parameter', async () => {
|
||||
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).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 () => {
|
||||
|
@ -125,10 +125,10 @@ describe('Enable MFA setup', () => {
|
|||
const response = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
|
||||
|
||||
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/enable').send({ token }).expect(200);
|
||||
await testServer.authAgentFor(owner).post('/mfa/verify').send({ mfaCode }).expect(200);
|
||||
await testServer.authAgentFor(owner).post('/mfa/enable').send({ mfaCode }).expect(200);
|
||||
|
||||
const user = await Container.get(AuthUserRepository).findOneOrFail({
|
||||
where: {},
|
||||
|
@ -145,13 +145,13 @@ describe('Enable MFA setup', () => {
|
|||
const response = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
|
||||
|
||||
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'));
|
||||
|
||||
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({
|
||||
where: {},
|
||||
|
@ -165,13 +165,13 @@ describe('Enable MFA setup', () => {
|
|||
describe('Disable MFA setup', () => {
|
||||
test('POST /disable should disable login with MFA', async () => {
|
||||
const { user, rawSecret } = await createUserWithMfaEnabled();
|
||||
const token = new TOTPService().generateTOTP(rawSecret);
|
||||
const mfaCode = new TOTPService().generateTOTP(rawSecret);
|
||||
|
||||
await testServer
|
||||
.authAgentFor(user)
|
||||
.post('/mfa/disable')
|
||||
.send({
|
||||
token,
|
||||
mfaCode,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
|
@ -184,21 +184,21 @@ describe('Disable MFA setup', () => {
|
|||
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();
|
||||
|
||||
await testServer
|
||||
.authAgentFor(user)
|
||||
.post('/mfa/disable')
|
||||
.send({
|
||||
token: 'invalid token',
|
||||
mfaCode: 'invalid token',
|
||||
})
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
const newPassword = randomValidPassword();
|
||||
|
@ -210,7 +210,7 @@ describe('Change password with MFA enabled', () => {
|
|||
.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();
|
||||
|
||||
const newPassword = randomValidPassword();
|
||||
|
@ -221,7 +221,7 @@ describe('Change password with MFA enabled', () => {
|
|||
.send({
|
||||
password: newPassword,
|
||||
token: resetPasswordToken,
|
||||
mfaToken: randomInt(10),
|
||||
mfaCode: randomInt(10),
|
||||
})
|
||||
.expect(404);
|
||||
});
|
||||
|
@ -235,14 +235,14 @@ describe('Change password with MFA enabled', () => {
|
|||
|
||||
const resetPasswordToken = Container.get(AuthService).generatePasswordResetToken(user);
|
||||
|
||||
const mfaToken = new TOTPService().generateTOTP(rawSecret);
|
||||
const mfaCode = new TOTPService().generateTOTP(rawSecret);
|
||||
|
||||
await testServer.authlessAgent
|
||||
.post('/change-password')
|
||||
.send({
|
||||
password: newPassword,
|
||||
token: resetPasswordToken,
|
||||
mfaToken,
|
||||
mfaCode,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
|
@ -252,7 +252,7 @@ describe('Change password with MFA enabled', () => {
|
|||
.send({
|
||||
email: user.email,
|
||||
password: newPassword,
|
||||
mfaToken: new TOTPService().generateTOTP(rawSecret),
|
||||
mfaCode: new TOTPService().generateTOTP(rawSecret),
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
|
@ -315,7 +315,7 @@ describe('Login', () => {
|
|||
|
||||
await testServer.authlessAgent
|
||||
.post('/login')
|
||||
.send({ email: user.email, password: rawPassword, mfaToken: 'wrongvalue' })
|
||||
.send({ email: user.email, password: rawPassword, mfaCode: 'wrongvalue' })
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
|
@ -337,7 +337,7 @@ describe('Login', () => {
|
|||
|
||||
const response = await testServer.authlessAgent
|
||||
.post('/login')
|
||||
.send({ email: user.email, password: rawPassword, mfaToken: token })
|
||||
.send({ email: user.email, password: rawPassword, mfaCode: token })
|
||||
.expect(200);
|
||||
|
||||
const data = response.body.data;
|
||||
|
|
|
@ -1194,7 +1194,7 @@ export class WorkflowExecute {
|
|||
}
|
||||
|
||||
if (nodeSuccessData instanceof NodeExecutionOutput) {
|
||||
const hints: NodeExecutionHint[] = nodeSuccessData.getHints();
|
||||
const hints = (nodeSuccessData as NodeExecutionOutput).getHints();
|
||||
|
||||
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 {
|
||||
name: string;
|
||||
onboardingFlowEnabled?: boolean;
|
||||
defaultSettings: IWorkflowSettings;
|
||||
}
|
||||
|
||||
|
@ -277,7 +276,6 @@ export interface IWorkflowTemplate {
|
|||
|
||||
export interface INewWorkflowData {
|
||||
name: string;
|
||||
onboardingFlowEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface WorkflowMetadata {
|
||||
|
|
|
@ -11,19 +11,22 @@ export async function getMfaQR(
|
|||
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);
|
||||
}
|
||||
|
||||
export async function verifyMfaToken(
|
||||
export async function verifyMfaCode(
|
||||
context: IRestApiContext,
|
||||
data: { token: string },
|
||||
data: { mfaCode: string },
|
||||
): Promise<void> {
|
||||
return await makeRestApiRequest(context, 'POST', '/mfa/verify', data);
|
||||
}
|
||||
|
||||
export type DisableMfaParams = {
|
||||
token: string;
|
||||
mfaCode: string;
|
||||
};
|
||||
|
||||
export async function disableMfa(context: IRestApiContext, data: DisableMfaParams): Promise<void> {
|
||||
|
|
|
@ -21,7 +21,7 @@ export async function loginCurrentUser(
|
|||
|
||||
export async function login(
|
||||
context: IRestApiContext,
|
||||
params: { email: string; password: string; mfaToken?: string; mfaRecoveryToken?: string },
|
||||
params: { email: string; password: string; mfaCode?: string; mfaRecoveryToken?: string },
|
||||
): Promise<CurrentUserResponse> {
|
||||
return await makeRestApiRequest(context, 'POST', '/login', params);
|
||||
}
|
||||
|
@ -84,7 +84,7 @@ export async function validatePasswordToken(
|
|||
|
||||
export async function changePassword(
|
||||
context: IRestApiContext,
|
||||
params: { token: string; password: string; mfaToken?: string },
|
||||
params: { token: string; password: string; mfaCode?: string },
|
||||
): Promise<void> {
|
||||
await makeRestApiRequest(context, 'POST', '/change-password', params);
|
||||
}
|
||||
|
|
|
@ -22,7 +22,6 @@ export async function getNewWorkflow(context: IRestApiContext, data?: IDataObjec
|
|||
);
|
||||
return {
|
||||
name: response.name,
|
||||
onboardingFlowEnabled: response.onboardingFlowEnabled === true,
|
||||
settings: response.defaultSettings,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -283,7 +283,7 @@ watchEffect(() => {
|
|||
:style="rootStyles"
|
||||
@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">
|
||||
<n8n-resize-wrapper
|
||||
v-if="isChatOpen"
|
||||
|
|
|
@ -177,7 +177,7 @@ function copySessionId() {
|
|||
</div>
|
||||
</header>
|
||||
<main :class="$style.chatBody">
|
||||
<MessagesList :messages="messages" :class="[$style.messages, 'ignore-key-press-canvas']">
|
||||
<MessagesList :messages="messages" :class="$style.messages">
|
||||
<template #beforeMessage="{ message }">
|
||||
<MessageOptionTooltip
|
||||
v-if="message.sender === 'bot' && !message.id.includes('preload')"
|
||||
|
|
|
@ -219,7 +219,7 @@ const onUserActionToggle = (action: string) => {
|
|||
onLogout();
|
||||
break;
|
||||
case 'settings':
|
||||
void router.push({ name: VIEWS.PERSONAL_SETTINGS });
|
||||
void router.push({ name: VIEWS.SETTINGS });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import Modal from './Modal.vue';
|
||||
import {
|
||||
MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH,
|
||||
MFA_AUTHENTICATION_TOKEN_WINDOW_EXPIRED,
|
||||
MFA_AUTHENTICATION_CODE_INPUT_MAX_LENGTH,
|
||||
MFA_AUTHENTICATION_CODE_WINDOW_EXPIRED,
|
||||
MFA_SETUP_MODAL_KEY,
|
||||
} from '../constants';
|
||||
import { ref, onMounted } from 'vue';
|
||||
|
@ -53,12 +53,12 @@ const closeDialog = () => {
|
|||
};
|
||||
|
||||
const onInput = (value: string) => {
|
||||
if (value.length !== MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH) {
|
||||
if (value.length !== MFA_AUTHENTICATION_CODE_INPUT_MAX_LENGTH) {
|
||||
infoTextErrorMessage.value = '';
|
||||
return;
|
||||
}
|
||||
userStore
|
||||
.verifyMfaToken({ token: value })
|
||||
.verifyMfaCode({ mfaCode: value })
|
||||
.then(() => {
|
||||
showRecoveryCodes.value = true;
|
||||
authenticatorCode.value = value;
|
||||
|
@ -98,14 +98,14 @@ const onDownloadClick = () => {
|
|||
|
||||
const onSetupClick = async () => {
|
||||
try {
|
||||
await userStore.enableMfa({ token: authenticatorCode.value });
|
||||
await userStore.enableMfa({ mfaCode: authenticatorCode.value });
|
||||
closeDialog();
|
||||
toast.showMessage({
|
||||
type: 'success',
|
||||
title: i18n.baseText('mfa.setup.step2.toast.setupFinished.message'),
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.errorCode === MFA_AUTHENTICATION_TOKEN_WINDOW_EXPIRED) {
|
||||
if (e.errorCode === MFA_AUTHENTICATION_CODE_WINDOW_EXPIRED) {
|
||||
toast.showMessage({
|
||||
type: 'error',
|
||||
title: i18n.baseText('mfa.setup.step2.toast.tokenExpired.error.message'),
|
||||
|
|
|
@ -54,7 +54,7 @@ function onFormReady(isReady: boolean) {
|
|||
<template #content>
|
||||
<div :class="[$style.formContainer]">
|
||||
<n8n-form-inputs
|
||||
data-test-id="mfa-code-form"
|
||||
data-test-id="mfa-code-or-recovery-code-input"
|
||||
:inputs="formFields"
|
||||
:event-bus="formBus"
|
||||
@submit="onSubmit"
|
||||
|
|
|
@ -258,7 +258,7 @@ export const webhookModalDescription = [
|
|||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Ignore SSL Issues',
|
||||
displayName: 'Ignore SSL Issues (Insecure)',
|
||||
name: 'allowUnauthorizedCerts',
|
||||
type: 'boolean',
|
||||
noDataExpression: true,
|
||||
|
|
|
@ -7,7 +7,6 @@ import type { Workflow } from 'n8n-workflow';
|
|||
import { isNumber, isString } from '@/utils/typeGuards';
|
||||
import type { INodeUi, XYPosition } from '@/Interface';
|
||||
|
||||
import { QUICKSTART_NOTE_NAME } from '@/constants';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
|
@ -205,16 +204,7 @@ const onEdit = (edit: boolean) => {
|
|||
|
||||
const onMarkdownClick = (link: HTMLAnchorElement) => {
|
||||
if (link) {
|
||||
const isOnboardingNote = props.name === QUICKSTART_NOTE_NAME;
|
||||
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 });
|
||||
telemetry.track('User clicked note link', { type: 'other' });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -25,7 +25,6 @@ import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
|||
import {
|
||||
EnterpriseEditionFeature,
|
||||
FORM_TRIGGER_NODE_TYPE,
|
||||
QUICKSTART_NOTE_NAME,
|
||||
STICKY_NODE_TYPE,
|
||||
UPDATE_WEBHOOK_ID_NODE_TYPES,
|
||||
WEBHOOK_NODE_TYPE,
|
||||
|
@ -365,7 +364,6 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
|||
if (node.type === STICKY_NODE_TYPE) {
|
||||
telemetry.track('User deleted workflow note', {
|
||||
workflow_id: workflowsStore.workflowId,
|
||||
is_welcome_note: node.name === QUICKSTART_NOTE_NAME,
|
||||
});
|
||||
} else {
|
||||
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 DUPLICATE_POSTFFIX = ' copy';
|
||||
export const NODE_OUTPUT_DEFAULT_KEY = '_NODE_OUTPUT_DEFAULT_KEY_';
|
||||
export const QUICKSTART_NOTE_NAME = '_QUICKSTART_NOTE_';
|
||||
|
||||
// tags
|
||||
export const MAX_TAG_NAME_LENGTH = 24;
|
||||
|
@ -487,6 +486,7 @@ export const enum VIEWS {
|
|||
SETUP = 'SetupView',
|
||||
FORGOT_PASSWORD = 'ForgotMyPasswordView',
|
||||
CHANGE_PASSWORD = 'ChangePasswordView',
|
||||
SETTINGS = 'Settings',
|
||||
USERS_SETTINGS = 'UsersSettings',
|
||||
LDAP_SETTINGS = 'LdapSettings',
|
||||
PERSONAL_SETTINGS = 'PersonalSettings',
|
||||
|
@ -723,9 +723,9 @@ export const MFA_FORM = {
|
|||
|
||||
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;
|
||||
|
||||
|
|
|
@ -1381,7 +1381,6 @@
|
|||
"nodeWebhooks.webhookUrls": "Webhook URLs",
|
||||
"nodeWebhooks.webhookUrls.formTrigger": "Form URLs",
|
||||
"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.workflowNotFoundError": "Could not find workflow",
|
||||
"parameterInput.expressionResult": "e.g. {result}",
|
||||
|
|
|
@ -480,8 +480,10 @@ export const routes: RouteRecordRaw[] = [
|
|||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: VIEWS.SETTINGS,
|
||||
component: SettingsView,
|
||||
props: true,
|
||||
redirect: { name: VIEWS.USAGE },
|
||||
children: [
|
||||
{
|
||||
path: 'usage',
|
||||
|
|
|
@ -172,7 +172,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
|||
const loginWithCreds = async (params: {
|
||||
email: string;
|
||||
password: string;
|
||||
mfaToken?: string;
|
||||
mfaCode?: string;
|
||||
mfaRecoveryCode?: string;
|
||||
}) => {
|
||||
const user = await usersApi.login(rootStore.restApiContext, params);
|
||||
|
@ -232,7 +232,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
|||
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);
|
||||
};
|
||||
|
||||
|
@ -316,15 +316,15 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
|||
return await mfaApi.getMfaQR(rootStore.restApiContext);
|
||||
};
|
||||
|
||||
const verifyMfaToken = async (data: { token: string }) => {
|
||||
return await mfaApi.verifyMfaToken(rootStore.restApiContext, data);
|
||||
const verifyMfaCode = async (data: { mfaCode: string }) => {
|
||||
return await mfaApi.verifyMfaCode(rootStore.restApiContext, data);
|
||||
};
|
||||
|
||||
const canEnableMFA = async () => {
|
||||
return await mfaApi.canEnableMFA(rootStore.restApiContext);
|
||||
};
|
||||
|
||||
const enableMfa = async (data: { token: string }) => {
|
||||
const enableMfa = async (data: { mfaCode: string }) => {
|
||||
await mfaApi.enableMfa(rootStore.restApiContext, data);
|
||||
if (currentUser.value) {
|
||||
currentUser.value.mfaEnabled = true;
|
||||
|
@ -333,7 +333,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
|||
|
||||
const disableMfa = async (mfaCode: string) => {
|
||||
await mfaApi.disableMfa(rootStore.restApiContext, {
|
||||
token: mfaCode,
|
||||
mfaCode,
|
||||
});
|
||||
|
||||
if (currentUser.value) {
|
||||
|
@ -404,7 +404,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
|||
submitPersonalizationSurvey,
|
||||
showPersonalizationSurvey,
|
||||
fetchMfaQR,
|
||||
verifyMfaToken,
|
||||
verifyMfaCode,
|
||||
enableMfa,
|
||||
disableMfa,
|
||||
canEnableMFA,
|
||||
|
|
|
@ -468,7 +468,6 @@ describe('useWorkflowsStore', () => {
|
|||
const expectedName = `${name}${DUPLICATE_POSTFFIX}`;
|
||||
vi.mocked(workflowsApi).getNewWorkflow.mockResolvedValue({
|
||||
name: expectedName,
|
||||
onboardingFlowEnabled: false,
|
||||
settings: {} as IWorkflowSettings,
|
||||
});
|
||||
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> {
|
||||
let workflowData = {
|
||||
name: '',
|
||||
onboardingFlowEnabled: false,
|
||||
settings: { ...defaults.settings },
|
||||
};
|
||||
try {
|
||||
|
|
|
@ -9,7 +9,7 @@ import { useToast } from '@/composables/useToast';
|
|||
import { useUsersStore } from '@/stores/users.store';
|
||||
|
||||
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();
|
||||
|
||||
|
@ -56,7 +56,7 @@ const onSubmit = async (values: { [key: string]: string }) => {
|
|||
const changePasswordParameters = {
|
||||
token,
|
||||
password: password.value,
|
||||
...(values.mfaToken && { mfaToken: values.mfaToken }),
|
||||
...(values.mfaCode && { mfaCode: values.mfaCode }),
|
||||
};
|
||||
|
||||
await usersStore.changePassword(changePasswordParameters);
|
||||
|
@ -129,13 +129,13 @@ onMounted(async () => {
|
|||
|
||||
if (mfaEnabled) {
|
||||
form.inputs.push({
|
||||
name: 'mfaToken',
|
||||
name: 'mfaCode',
|
||||
initialValue: '',
|
||||
properties: {
|
||||
required: true,
|
||||
label: locale.baseText('mfa.code.input.label'),
|
||||
placeholder: locale.baseText('mfa.code.input.placeholder'),
|
||||
maxlength: MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH,
|
||||
maxlength: MFA_AUTHENTICATION_CODE_INPUT_MAX_LENGTH,
|
||||
capitalize: true,
|
||||
validateOnBlur: true,
|
||||
},
|
||||
|
|
|
@ -3,7 +3,7 @@ import type { IFormInputs } from '@/Interface';
|
|||
import Logo from '../components/Logo.vue';
|
||||
import {
|
||||
MFA_AUTHENTICATION_RECOVERY_CODE_INPUT_MAX_LENGTH,
|
||||
MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH,
|
||||
MFA_AUTHENTICATION_CODE_INPUT_MAX_LENGTH,
|
||||
MFA_FORM,
|
||||
} from '@/constants';
|
||||
import { mfaEventBus } from '@/event-bus';
|
||||
|
@ -29,7 +29,7 @@ const hasAnyChanges = ref(false);
|
|||
const formBus = ref(mfaEventBus);
|
||||
const formInputs = ref<null | IFormInputs>(null);
|
||||
const showRecoveryCodeForm = ref(false);
|
||||
const verifyingMfaToken = ref(false);
|
||||
const verifyingMfaCode = ref(false);
|
||||
const formError = ref('');
|
||||
const { reportError } = toRefs(props);
|
||||
|
||||
|
@ -48,7 +48,7 @@ const i18 = useI18n();
|
|||
const emit = defineEmits<{
|
||||
onFormChanged: [formField: string];
|
||||
onBackClick: [formField: string];
|
||||
submit: [{ token: string; recoveryCode: string }];
|
||||
submit: [{ mfaCode: string; mfaRecoveryCode: string }];
|
||||
}>();
|
||||
|
||||
// #endregion
|
||||
|
@ -94,11 +94,11 @@ const onBackClick = () => {
|
|||
|
||||
showRecoveryCodeForm.value = false;
|
||||
hasAnyChanges.value = true;
|
||||
formInputs.value = [mfaTokenFieldWithDefaults()];
|
||||
formInputs.value = [mfaCodeFieldWithDefaults()];
|
||||
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
|
||||
? i18.baseText('mfa.code.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 isSubmittingMfaToken = name === 'token';
|
||||
const inputValidLength = isSubmittingMfaToken
|
||||
? MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH
|
||||
const isSubmittingMfaCode = name === 'mfaCode';
|
||||
const inputValidLength = isSubmittingMfaCode
|
||||
? MFA_AUTHENTICATION_CODE_INPUT_MAX_LENGTH
|
||||
: MFA_AUTHENTICATION_RECOVERY_CODE_INPUT_MAX_LENGTH;
|
||||
|
||||
if (value.length !== inputValidLength) {
|
||||
|
@ -116,33 +116,33 @@ const onInput = ({ target: { value, name } }: { target: { value: string; name: s
|
|||
return;
|
||||
}
|
||||
|
||||
verifyingMfaToken.value = true;
|
||||
verifyingMfaCode.value = true;
|
||||
hasAnyChanges.value = true;
|
||||
|
||||
const dataToSubmit = isSubmittingMfaToken
|
||||
? { token: value, recoveryCode: '' }
|
||||
: { token: '', recoveryCode: value };
|
||||
const dataToSubmit = isSubmittingMfaCode
|
||||
? { mfaCode: value, mfaRecoveryCode: '' }
|
||||
: { mfaCode: '', mfaRecoveryCode: value };
|
||||
|
||||
onSubmit(dataToSubmit)
|
||||
.catch(() => {})
|
||||
.finally(() => (verifyingMfaToken.value = false));
|
||||
.finally(() => (verifyingMfaCode.value = false));
|
||||
};
|
||||
|
||||
const mfaRecoveryCodeFieldWithDefaults = () => {
|
||||
return formField(
|
||||
'recoveryCode',
|
||||
'mfaRecoveryCode',
|
||||
i18.baseText('mfa.recovery.input.label'),
|
||||
i18.baseText('mfa.recovery.input.placeholder'),
|
||||
MFA_AUTHENTICATION_RECOVERY_CODE_INPUT_MAX_LENGTH,
|
||||
);
|
||||
};
|
||||
|
||||
const mfaTokenFieldWithDefaults = () => {
|
||||
const mfaCodeFieldWithDefaults = () => {
|
||||
return formField(
|
||||
'token',
|
||||
'mfaCode',
|
||||
i18.baseText('mfa.code.input.label'),
|
||||
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(() => {
|
||||
formInputs.value = [mfaTokenFieldWithDefaults()];
|
||||
formInputs.value = [mfaCodeFieldWithDefaults()];
|
||||
});
|
||||
|
||||
// #endregion
|
||||
|
@ -211,7 +211,7 @@ onMounted(() => {
|
|||
<div>
|
||||
<n8n-button
|
||||
float="right"
|
||||
:loading="verifyingMfaToken"
|
||||
:loading="verifyingMfaCode"
|
||||
:label="
|
||||
showRecoveryCodeForm
|
||||
? i18.baseText('mfa.recovery.button.verify')
|
||||
|
|
|
@ -24,7 +24,6 @@ import {
|
|||
MODAL_CANCEL,
|
||||
MODAL_CONFIRM,
|
||||
PLACEHOLDER_EMPTY_WORKFLOW_ID,
|
||||
QUICKSTART_NOTE_NAME,
|
||||
START_NODE_TYPE,
|
||||
STICKY_NODE_TYPE,
|
||||
VIEWS,
|
||||
|
@ -3581,7 +3580,6 @@ export default defineComponent({
|
|||
if (node.type === STICKY_NODE_TYPE) {
|
||||
this.$telemetry.track('User deleted workflow note', {
|
||||
workflow_id: this.workflowsStore.workflowId,
|
||||
is_welcome_note: node.name === QUICKSTART_NOTE_NAME,
|
||||
});
|
||||
} else {
|
||||
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>();
|
||||
|
||||
function onReturn() {
|
||||
void router.push(
|
||||
isRouteLocationRaw(previousRoute.value) ? previousRoute.value : { name: VIEWS.HOMEPAGE },
|
||||
);
|
||||
const resolvedSettingsRoute = router.resolve({ name: VIEWS.SETTINGS });
|
||||
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(() => {
|
||||
|
|
|
@ -87,7 +87,7 @@ describe('SigninView', () => {
|
|||
expect(usersStore.loginWithCreds).toHaveBeenCalledWith({
|
||||
email: 'test@n8n.io',
|
||||
password: 'password',
|
||||
mfaToken: undefined,
|
||||
mfaCode: 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({
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
token: form.token,
|
||||
recoveryCode: form.recoveryCode,
|
||||
mfaCode: form.mfaCode,
|
||||
mfaRecoveryCode: form.mfaRecoveryCode,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -114,16 +114,16 @@ const getRedirectQueryParameter = () => {
|
|||
const login = async (form: {
|
||||
email: string;
|
||||
password: string;
|
||||
token?: string;
|
||||
recoveryCode?: string;
|
||||
mfaCode?: string;
|
||||
mfaRecoveryCode?: string;
|
||||
}) => {
|
||||
try {
|
||||
loading.value = true;
|
||||
await usersStore.loginWithCreds({
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
mfaToken: form.token,
|
||||
mfaRecoveryCode: form.recoveryCode,
|
||||
mfaCode: form.mfaCode,
|
||||
mfaRecoveryCode: form.mfaRecoveryCode,
|
||||
});
|
||||
loading.value = false;
|
||||
if (settingsStore.isCloudDeployment) {
|
||||
|
|
|
@ -35,7 +35,7 @@ export class CrowdDevApi implements ICredentialType {
|
|||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Ignore SSL Issues',
|
||||
displayName: 'Ignore SSL Issues (Insecure)',
|
||||
name: 'allowUnauthorizedCerts',
|
||||
type: 'boolean',
|
||||
description: 'Whether to connect even if SSL certificate validation is not possible',
|
||||
|
|
|
@ -40,7 +40,7 @@ export class DfirIrisApi implements ICredentialType {
|
|||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Ignore SSL Issues',
|
||||
displayName: 'Ignore SSL Issues (Insecure)',
|
||||
name: 'skipSslCertificateValidation',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
|
|
|
@ -93,7 +93,7 @@ export class ERPNextApi implements ICredentialType {
|
|||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Ignore SSL Issues',
|
||||
displayName: 'Ignore SSL Issues (Insecure)',
|
||||
name: 'allowUnauthorizedCerts',
|
||||
type: 'boolean',
|
||||
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",
|
||||
},
|
||||
{
|
||||
displayName: 'Ignore SSL Issues',
|
||||
displayName: 'Ignore SSL Issues (Insecure)',
|
||||
name: 'ignoreSSLIssues',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
|
|
|
@ -36,7 +36,7 @@ export class GotifyApi implements ICredentialType {
|
|||
description: 'The URL of the Gotify host',
|
||||
},
|
||||
{
|
||||
displayName: 'Ignore SSL Issues',
|
||||
displayName: 'Ignore SSL Issues (Insecure)',
|
||||
name: 'ignoreSSLIssues',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
|
|
|
@ -27,7 +27,7 @@ export class MattermostApi implements ICredentialType {
|
|||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Ignore SSL Issues',
|
||||
displayName: 'Ignore SSL Issues (Insecure)',
|
||||
name: 'allowUnauthorizedCerts',
|
||||
type: 'boolean',
|
||||
description: 'Whether to connect even if SSL certificate validation is not possible',
|
||||
|
|
|
@ -54,7 +54,7 @@ export class MicrosoftSql implements ICredentialType {
|
|||
default: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Ignore SSL Issues',
|
||||
displayName: 'Ignore SSL Issues (Insecure)',
|
||||
name: 'allowUnauthorizedCerts',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
|
|
|
@ -108,7 +108,7 @@ export class OAuth2Api implements ICredentialType {
|
|||
default: 'header',
|
||||
},
|
||||
{
|
||||
displayName: 'Ignore SSL Issues',
|
||||
displayName: 'Ignore SSL Issues (Insecure)',
|
||||
name: 'ignoreSSLIssues',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
|
|
|
@ -37,7 +37,7 @@ export class Postgres implements ICredentialType {
|
|||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Ignore SSL Issues',
|
||||
displayName: 'Ignore SSL Issues (Insecure)',
|
||||
name: 'allowUnauthorizedCerts',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
|
|
|
@ -42,7 +42,7 @@ export class S3 implements ICredentialType {
|
|||
default: false,
|
||||
},
|
||||
{
|
||||
displayName: 'Ignore SSL Issues',
|
||||
displayName: 'Ignore SSL Issues (Insecure)',
|
||||
name: 'ignoreSSLIssues',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
|
|
|
@ -49,7 +49,7 @@ export class TheHiveApi implements ICredentialType {
|
|||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Ignore SSL Issues',
|
||||
displayName: 'Ignore SSL Issues (Insecure)',
|
||||
name: 'allowUnauthorizedCerts',
|
||||
type: 'boolean',
|
||||
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',
|
||||
},
|
||||
{
|
||||
displayName: 'Ignore SSL Issues',
|
||||
displayName: 'Ignore SSL Issues (Insecure)',
|
||||
name: 'allowUnauthorizedCerts',
|
||||
type: 'boolean',
|
||||
description: 'Whether to connect even if SSL certificate validation is not possible',
|
||||
|
|
|
@ -36,7 +36,7 @@ export class TimescaleDb implements ICredentialType {
|
|||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Ignore SSL Issues',
|
||||
displayName: 'Ignore SSL Issues (Insecure)',
|
||||
name: 'allowUnauthorizedCerts',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
|
|
|
@ -36,7 +36,7 @@ export class WordpressApi implements ICredentialType {
|
|||
placeholder: 'https://example.com',
|
||||
},
|
||||
{
|
||||
displayName: 'Ignore SSL Issues',
|
||||
displayName: 'Ignore SSL Issues (Insecure)',
|
||||
name: 'allowUnauthorizedCerts',
|
||||
type: 'boolean',
|
||||
description: 'Whether to connect even if SSL certificate validation is not possible',
|
||||
|
|
|
@ -35,7 +35,7 @@ export class ZammadBasicAuthApi implements ICredentialType {
|
|||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Ignore SSL Issues',
|
||||
displayName: 'Ignore SSL Issues (Insecure)',
|
||||
name: 'allowUnauthorizedCerts',
|
||||
type: 'boolean',
|
||||
description: 'Whether to connect even if SSL certificate validation is not possible',
|
||||
|
|
|
@ -27,7 +27,7 @@ export class ZammadTokenAuthApi implements ICredentialType {
|
|||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Ignore SSL Issues',
|
||||
displayName: 'Ignore SSL Issues (Insecure)',
|
||||
name: 'allowUnauthorizedCerts',
|
||||
type: 'boolean',
|
||||
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.',
|
||||
},
|
||||
{
|
||||
displayName: 'Ignore SSL Issues',
|
||||
displayName: 'Ignore SSL Issues (Insecure)',
|
||||
name: 'allowUnauthorizedCerts',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
|
|
|
@ -110,7 +110,7 @@ const versionDescription: INodeTypeDescription = {
|
|||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Ignore SSL Issues',
|
||||
displayName: 'Ignore SSL Issues (Insecure)',
|
||||
name: 'allowUnauthorizedCerts',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
|
|
|
@ -167,7 +167,7 @@ const properties: INodeProperties[] = [
|
|||
description: 'Email address of BCC recipient',
|
||||
},
|
||||
{
|
||||
displayName: 'Ignore SSL Issues',
|
||||
displayName: 'Ignore SSL Issues (Insecure)',
|
||||
name: 'allowUnauthorizedCerts',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
|
|
|
@ -189,7 +189,7 @@ export class FacebookGraphApi implements INodeType {
|
|||
placeholder: 'videos',
|
||||
},
|
||||
{
|
||||
displayName: 'Ignore SSL Issues',
|
||||
displayName: 'Ignore SSL Issues (Insecure)',
|
||||
name: 'allowUnauthorizedCerts',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
|
|
|
@ -160,7 +160,7 @@ export class GraphQL implements INodeType {
|
|||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Ignore SSL Issues',
|
||||
displayName: 'Ignore SSL Issues (Insecure)',
|
||||
name: 'allowUnauthorizedCerts',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
|
@ -418,40 +418,49 @@ export class GraphQL implements INodeType {
|
|||
|
||||
const gqlQuery = this.getNodeParameter('query', itemIndex, '') as string;
|
||||
if (requestMethod === 'GET') {
|
||||
if (!requestOptions.qs) {
|
||||
requestOptions.qs = {};
|
||||
}
|
||||
requestOptions.qs = requestOptions.qs ?? {};
|
||||
requestOptions.qs.query = gqlQuery;
|
||||
} else {
|
||||
if (requestFormat === 'json') {
|
||||
const jsonBody = {
|
||||
...requestOptions.body,
|
||||
query: gqlQuery,
|
||||
variables: this.getNodeParameter('variables', itemIndex, {}) as object,
|
||||
operationName: this.getNodeParameter('operationName', itemIndex) as string,
|
||||
};
|
||||
if (typeof jsonBody.variables === 'string') {
|
||||
try {
|
||||
jsonBody.variables = JSON.parse(jsonBody.variables || '{}');
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'Using variables failed:\n' +
|
||||
(jsonBody.variables as string) +
|
||||
'\n\nWith error message:\n' +
|
||||
(error as string),
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (requestFormat === 'json') {
|
||||
const variables = this.getNodeParameter('variables', itemIndex, {});
|
||||
|
||||
let parsedVariables;
|
||||
if (typeof variables === 'string') {
|
||||
try {
|
||||
parsedVariables = JSON.parse(variables || '{}');
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Using variables failed:\n${variables}\n\nWith error message:\n${error}`,
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
if (jsonBody.operationName === '') {
|
||||
jsonBody.operationName = null;
|
||||
}
|
||||
requestOptions.json = true;
|
||||
requestOptions.body = jsonBody;
|
||||
} else if (typeof variables === 'object' && variables !== null) {
|
||||
parsedVariables = variables;
|
||||
} 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;
|
||||
|
@ -509,22 +518,19 @@ export class GraphQL implements INodeType {
|
|||
throw new NodeApiError(this.getNode(), response.errors as JsonObject, { message });
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
const errorData = this.helpers.returnJsonArray({
|
||||
$error: error,
|
||||
json: this.getInputData(itemIndex),
|
||||
itemIndex,
|
||||
});
|
||||
const exectionErrorWithMetaData = this.helpers.constructExecutionMetaData(errorData, {
|
||||
itemData: { item: itemIndex },
|
||||
});
|
||||
returnItems.push(...exectionErrorWithMetaData);
|
||||
continue;
|
||||
if (!this.continueOnFail()) {
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
|
||||
const errorData = this.helpers.returnJsonArray({
|
||||
error: error.message,
|
||||
});
|
||||
const exectionErrorWithMetaData = this.helpers.constructExecutionMetaData(errorData, {
|
||||
itemData: { item: itemIndex },
|
||||
});
|
||||
returnItems.push(...exectionErrorWithMetaData);
|
||||
}
|
||||
}
|
||||
|
||||
return [returnItems];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,54 +1,83 @@
|
|||
import type { WorkflowTestData } from '@test/nodes/types';
|
||||
import { executeWorkflow } from '@test/nodes/ExecuteWorkflow';
|
||||
import * as Helpers from '@test/nodes/Helpers';
|
||||
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
||||
import nock from 'nock';
|
||||
|
||||
import {
|
||||
equalityTest,
|
||||
getWorkflowFilenames,
|
||||
initBinaryDataService,
|
||||
setup,
|
||||
workflowToTests,
|
||||
} from '@test/nodes/Helpers';
|
||||
|
||||
describe('GraphQL Node', () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
nodes: {},
|
||||
},
|
||||
};
|
||||
const workflows = getWorkflowFilenames(__dirname);
|
||||
const workflowTests = workflowToTests(workflows);
|
||||
|
||||
const tests: WorkflowTestData[] = [
|
||||
{
|
||||
description: 'should run Request Format JSON',
|
||||
input: {
|
||||
workflowData: Helpers.readJsonFileSync('nodes/GraphQL/test/workflow.json'),
|
||||
},
|
||||
output: {
|
||||
nodeExecutionOrder: ['Start'],
|
||||
nodeData: {
|
||||
'Fetch Request Format JSON': [
|
||||
[
|
||||
const baseUrl = 'https://api.n8n.io/';
|
||||
|
||||
beforeAll(async () => {
|
||||
await initBinaryDataService();
|
||||
nock.disableNetConnect();
|
||||
|
||||
nock(baseUrl)
|
||||
.matchHeader('accept', 'application/json')
|
||||
.matchHeader('content-type', 'application/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": {
|
||||
"templateCredsSetupCompleted": true,
|
||||
"instanceId": "104a4d08d8897b8bdeb38aaca515021075e0bd8544c983c2bb8c86e6a8e6081c"
|
||||
"templateId": "216",
|
||||
"instanceId": "ee90fdf8d57662f949e6c691dc07fa0fd2f66e1eee28ed82ef06658223e67255"
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "fb826323-2e48-4f11-bb0e-e12de32e22ee",
|
||||
"id": "5e2ef15b-2c6c-412f-a9da-515b5211386e",
|
||||
"name": "When clicking ‘Test workflow’",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [180, 160]
|
||||
"position": [420, 100]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
|
@ -21,8 +21,8 @@
|
|||
"name": "Fetch Request Format JSON",
|
||||
"type": "n8n-nodes-base.graphql",
|
||||
"typeVersion": 1,
|
||||
"position": [420, 160],
|
||||
"id": "7f8ceaf4-b82f-48d5-be0b-9fe3bfb35ee4"
|
||||
"position": [700, 140],
|
||||
"id": "e1c750a0-8d6c-4e81-8111-3218e1e6e69f"
|
||||
}
|
||||
],
|
||||
"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,
|
||||
},
|
||||
{
|
||||
displayName: 'Ignore SSL Issues',
|
||||
displayName: 'Ignore SSL Issues (Insecure)',
|
||||
name: 'allowUnauthorizedCerts',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
|
|
|
@ -211,7 +211,7 @@ export class HttpRequestV2 implements INodeType {
|
|||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Ignore SSL Issues',
|
||||
displayName: 'Ignore SSL Issues (Insecure)',
|
||||
name: 'allowUnauthorizedCerts',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
|
|
|
@ -695,7 +695,7 @@ export const mainProperties: INodeProperties[] = [
|
|||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Ignore SSL Issues',
|
||||
displayName: 'Ignore SSL Issues (Insecure)',
|
||||
name: 'allowUnauthorizedCerts',
|
||||
type: 'boolean',
|
||||
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)(
|
||||
'should handle %s authentication',
|
||||
'should handle $genericCredentialType authentication',
|
||||
async ({ genericCredentialType, credentials, authField, authValue }) => {
|
||||
(executeFunctions.getInputData as jest.Mock).mockReturnValue([{ json: {} }]);
|
||||
(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