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

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

View file

@ -4,18 +4,70 @@ on:
schedule:
- 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'

View file

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

View file

@ -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

View file

@ -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');

View file

@ -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();
});
});

View file

@ -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');
});
});

View file

@ -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

View file

@ -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();
}

View file

@ -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();
},
};
}

View file

@ -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' =

View file

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

View file

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

View file

@ -1,8 +1,10 @@
import type { ZodObjectAny } from '@langchain/core/dist/types/zod';
import type { z } from 'zod';
import type { BaseOutputParser } from '@langchain/core/output_parsers';
import type { 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>,

View file

@ -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",

View file

@ -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]) ??

View file

@ -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",

View file

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

View file

@ -1,6 +1,5 @@
import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk';
import type { 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);
}
}
}

View file

@ -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');
}
}

View file

@ -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);
}
}
}

View file

@ -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();
}
}

View file

@ -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');
}

View file

@ -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.');
}

View file

@ -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);
}
}
}

View file

@ -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;

View file

@ -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';
}
}

View file

@ -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);
}
}

View file

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

View file

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

View file

@ -2,15 +2,17 @@ import type { SelectQueryBuilder } from '@n8n/typeorm';
import { stringify } from 'flatted';
import { 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,
});
});
});

View file

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

View file

@ -15,11 +15,13 @@ import type { TestDefinition } from '@/databases/entities/test-definition.ee';
import type { User } from '@/databases/entities/user';
import type { 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);
}

View file

@ -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);
}
}

View file

@ -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();
}

View file

@ -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;
}

View file

@ -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<
{},

View file

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

View file

@ -1,5 +1,5 @@
import type { IUserSettings } from 'n8n-workflow';
import { 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));

View file

@ -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);
}
}

View file

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

View file

@ -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')

View file

@ -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);

View file

@ -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;

View file

@ -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

View file

@ -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 {

View file

@ -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> {

View file

@ -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);
}

View file

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

View file

@ -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"

View file

@ -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')"

View file

@ -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;

View file

@ -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'),

View file

@ -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"

View file

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

View file

@ -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' });
}
};

View file

@ -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 });

View file

@ -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;

View file

@ -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[![n8n quickstart video](/static/quickstart_thumbnail.png#full-width)](https://www.youtube.com/watch?v=1MwSoB0gnM4)",
"openWorkflow.workflowImportError": "Could not import workflow",
"openWorkflow.workflowNotFoundError": "Could not find workflow",
"parameterInput.expressionResult": "e.g. {result}",

View file

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

View file

@ -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,

View file

@ -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);

View file

@ -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 {

View file

@ -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,
},

View file

@ -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')

View file

@ -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 });

View file

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

View file

@ -11,9 +11,16 @@ const router = useRouter();
const previousRoute = ref<HistoryState[string] | undefined>();
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(() => {

View file

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

View file

@ -78,12 +78,12 @@ const formConfig: IFormBoxConfig = reactive({
],
});
const onMFASubmitted = async (form: { token?: string; recoveryCode?: string }) => {
const onMFASubmitted = async (form: { mfaCode?: string; mfaRecoveryCode?: string }) => {
await login({
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) {

View file

@ -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',

View file

@ -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,

View file

@ -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',

View file

@ -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,

View file

@ -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,

View file

@ -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',

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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',

View file

@ -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',

View file

@ -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,

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

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

View file

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

View file

@ -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,

View file

@ -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,

View file

@ -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];
}
}

View file

@ -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));
}
});

View file

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

View file

@ -1,16 +1,16 @@
{
"meta": {
"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"
}
}
]
}
}
}
}
]
}
}

View file

@ -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,

View file

@ -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,

View file

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

View file

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

View file

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

View file

@ -182,7 +182,7 @@ describe('HttpRequestV3', () => {
];
it.each(authenticationTypes)(
'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