diff --git a/cypress/composables/versions.ts b/cypress/composables/versions.ts new file mode 100644 index 0000000000000..f96ea8152fd31 --- /dev/null +++ b/cypress/composables/versions.ts @@ -0,0 +1,32 @@ +/** + * Getters + */ + +export function getVersionUpdatesPanelOpenButton() { + return cy.getByTestId('version-updates-panel-button'); +} + +export function getVersionUpdatesPanel() { + return cy.getByTestId('version-updates-panel'); +} + +export function getVersionUpdatesPanelCloseButton() { + return getVersionUpdatesPanel().get('.el-drawer__close-btn').first(); +} + +export function getVersionCard() { + return cy.getByTestId('version-card'); +} + +/** + * Actions + */ + +export function openVersionUpdatesPanel() { + getVersionUpdatesPanelOpenButton().click(); + getVersionUpdatesPanel().should('be.visible'); +} + +export function closeVersionUpdatesPanel() { + getVersionUpdatesPanelCloseButton().click(); +} diff --git a/cypress/e2e/10-undo-redo.cy.ts b/cypress/e2e/10-undo-redo.cy.ts index 6d3337456d0c8..e904d891b1a53 100644 --- a/cypress/e2e/10-undo-redo.cy.ts +++ b/cypress/e2e/10-undo-redo.cy.ts @@ -325,4 +325,33 @@ describe('Undo/Redo', () => { WorkflowPage.getters.nodeConnections().should('have.length', 2); }); }); + + it('should be able to copy and paste pinned data nodes in workflows with dynamic Switch node', () => { + cy.fixture('Test_workflow_form_switch.json').then((data) => { + cy.get('body').paste(JSON.stringify(data)); + }); + WorkflowPage.actions.zoomToFit(); + + WorkflowPage.getters.canvasNodes().should('have.length', 2); + WorkflowPage.getters.nodeConnections().should('have.length', 1); + cy.get(WorkflowPage.getters.getEndpointSelector('input', 'Switch')).should('have.length', 1); + cy.get(WorkflowPage.getters.getEndpointSelector('input', 'Switch')) + .should('have.css', 'left', `637px`) + .should('have.css', 'top', `501px`); + + cy.fixture('Test_workflow_form_switch.json').then((data) => { + cy.get('body').paste(JSON.stringify(data)); + }); + WorkflowPage.getters.canvasNodes().should('have.length', 4); + WorkflowPage.getters.nodeConnections().should('have.length', 2); + + WorkflowPage.actions.hitUndo(); + + WorkflowPage.getters.canvasNodes().should('have.length', 2); + WorkflowPage.getters.nodeConnections().should('have.length', 1); + cy.get(WorkflowPage.getters.getEndpointSelector('input', 'Switch')).should('have.length', 1); + cy.get(WorkflowPage.getters.getEndpointSelector('input', 'Switch')) + .should('have.css', 'left', `637px`) + .should('have.css', 'top', `501px`); + }); }); diff --git a/cypress/e2e/25-stickies.cy.ts b/cypress/e2e/25-stickies.cy.ts index 0bf52d5581e7f..dea3fa4fde225 100644 --- a/cypress/e2e/25-stickies.cy.ts +++ b/cypress/e2e/25-stickies.cy.ts @@ -1,3 +1,4 @@ +import { META_KEY } from '../constants'; import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; import { getPopper } from '../utils'; import { Interception } from 'cypress/types/net-stubbing'; @@ -35,6 +36,14 @@ describe('Canvas Actions', () => { workflowPage.actions.addStickyFromContextMenu(); workflowPage.actions.hitAddStickyShortcut(); + workflowPage.getters.stickies().should('have.length', 3); + + // Should not add a sticky for ctrl+shift+s + cy.get('body') + .type(META_KEY, { delay: 500, release: false }) + .type('{shift}', { release: false }) + .type('s'); + workflowPage.getters.stickies().should('have.length', 3); workflowPage.getters .stickies() diff --git a/cypress/e2e/36-suggested-templates.cy.ts b/cypress/e2e/36-suggested-templates.cy.ts new file mode 100644 index 0000000000000..897714a835534 --- /dev/null +++ b/cypress/e2e/36-suggested-templates.cy.ts @@ -0,0 +1,143 @@ +import { WorkflowsPage as WorkflowsPageClass } from '../pages/workflows'; +import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; + +type SuggestedTemplatesStub = { + sections: SuggestedTemplatesSectionStub[]; +} + +type SuggestedTemplatesSectionStub = { + name: string; + title: string; + description: string; + workflows: Array; +}; + +const WorkflowsListPage = new WorkflowsPageClass(); +const WorkflowPage = new WorkflowPageClass(); + +let fixtureSections: SuggestedTemplatesStub = { sections: [] };; + +describe('Suggested templates - Should render', () => { + + before(() => { + cy.fixture('Suggested_Templates.json').then((data) => { + fixtureSections = data; + }); + }); + + beforeEach(() => { + localStorage.removeItem('SHOW_N8N_SUGGESTED_TEMPLATES'); + cy.intercept('GET', '/rest/settings', (req) => { + req.on('response', (res) => { + res.send({ + data: { ...res.body.data, deployment: { type: 'cloud' } }, + }); + }); + }).as('loadSettings'); + cy.intercept('GET', '/rest/cloud/proxy/templates', { + fixture: 'Suggested_Templates.json', + }); + cy.visit(WorkflowsListPage.url); + cy.wait('@loadSettings'); + }); + + it('should render suggested templates page in empty workflow list', () => { + WorkflowsListPage.getters.suggestedTemplatesPageContainer().should('exist'); + WorkflowsListPage.getters.suggestedTemplatesCards().should('have.length', fixtureSections.sections[0].workflows.length); + WorkflowsListPage.getters.suggestedTemplatesSectionDescription().should('contain', fixtureSections.sections[0].description); + }); + + it('should render suggested templates when there are workflows in the list', () => { + WorkflowsListPage.getters.suggestedTemplatesNewWorkflowButton().click(); + cy.createFixtureWorkflow('Test_workflow_1.json', 'Test Workflow'); + cy.visit(WorkflowsListPage.url); + WorkflowsListPage.getters.suggestedTemplatesSectionContainer().should('exist'); + cy.contains(`Explore ${fixtureSections.sections[0].name.toLocaleLowerCase()} workflow templates`).should('exist'); + WorkflowsListPage.getters.suggestedTemplatesCards().should('have.length', fixtureSections.sections[0].workflows.length); + }); + + it('should enable users to signup for suggested templates templates', () => { + // Test the whole flow + WorkflowsListPage.getters.suggestedTemplatesCards().first().click(); + WorkflowsListPage.getters.suggestedTemplatesPreviewModal().should('exist'); + WorkflowsListPage.getters.suggestedTemplatesUseTemplateButton().click(); + cy.url().should('include', '/workflow/new'); + WorkflowPage.getters.infoToast().should('contain', 'Template coming soon!'); + WorkflowPage.getters.infoToast().contains('Notify me when it\'s available').click(); + WorkflowPage.getters.successToast().should('contain', 'We will contact you via email once this template is released.'); + cy.visit(WorkflowsListPage.url); + // Once users have signed up for a template, suggestions should not be shown again + WorkflowsListPage.getters.suggestedTemplatesSectionContainer().should('not.exist'); + }); + +}); + +describe('Suggested templates - Should not render', () => { + beforeEach(() => { + localStorage.removeItem('SHOW_N8N_SUGGESTED_TEMPLATES'); + cy.visit(WorkflowsListPage.url); + }); + + it('should not render suggested templates templates if not in cloud deployment', () => { + cy.intercept('GET', '/rest/settings', (req) => { + req.on('response', (res) => { + res.send({ + data: { ...res.body.data, deployment: { type: 'notCloud' } }, + }); + }); + }); + WorkflowsListPage.getters.suggestedTemplatesPageContainer().should('not.exist'); + WorkflowsListPage.getters.suggestedTemplatesSectionContainer().should('not.exist'); + }); + + it('should not render suggested templates templates if endpoint throws error', () => { + cy.intercept('GET', '/rest/settings', (req) => { + req.on('response', (res) => { + res.send({ + data: { ...res.body.data, deployment: { type: 'cloud' } }, + }); + }); + }); + cy.intercept('GET', '/rest/cloud/proxy/templates', { statusCode: 500 }).as('loadTemplates'); + WorkflowsListPage.getters.suggestedTemplatesPageContainer().should('not.exist'); + WorkflowsListPage.getters.suggestedTemplatesSectionContainer().should('not.exist'); + }); + + it('should not render suggested templates templates if endpoint returns empty list', () => { + cy.intercept('GET', '/rest/settings', (req) => { + req.on('response', (res) => { + res.send({ + data: { ...res.body.data, deployment: { type: 'cloud' } }, + }); + }); + }); + cy.intercept('GET', '/rest/cloud/proxy/templates', (req) => { + req.on('response', (res) => { + res.send({ + data: { collections: [] }, + }); + }); + }); + WorkflowsListPage.getters.suggestedTemplatesPageContainer().should('not.exist'); + WorkflowsListPage.getters.suggestedTemplatesSectionContainer().should('not.exist'); + }); + + it('should not render suggested templates templates if endpoint returns invalid response', () => { + cy.intercept('GET', '/rest/settings', (req) => { + req.on('response', (res) => { + res.send({ + data: { ...res.body.data, deployment: { type: 'cloud' } }, + }); + }); + }); + cy.intercept('GET', '/rest/cloud/proxy/templates', (req) => { + req.on('response', (res) => { + res.send({ + data: { somethingElse: [] }, + }); + }); + }); + WorkflowsListPage.getters.suggestedTemplatesPageContainer().should('not.exist'); + WorkflowsListPage.getters.suggestedTemplatesSectionContainer().should('not.exist'); + }); +}); diff --git a/cypress/e2e/36-versions.cy.ts b/cypress/e2e/36-versions.cy.ts new file mode 100644 index 0000000000000..2d93223ebb81f --- /dev/null +++ b/cypress/e2e/36-versions.cy.ts @@ -0,0 +1,66 @@ +import { INSTANCE_OWNER } from '../constants'; +import { WorkflowsPage } from '../pages/workflows'; +import { + closeVersionUpdatesPanel, + getVersionCard, + getVersionUpdatesPanelOpenButton, + openVersionUpdatesPanel, +} from '../composables/versions'; + +const workflowsPage = new WorkflowsPage(); + +describe('Versions', () => { + it('should open updates panel', () => { + cy.intercept('GET', '/rest/settings', (req) => { + req.continue((res) => { + if (res.body.hasOwnProperty('data')) { + res.body.data = { + ...res.body.data, + releaseChannel: 'stable', + versionCli: '1.0.0', + versionNotifications: { + enabled: true, + endpoint: 'https://api.n8n.io/api/versions/', + infoUrl: 'https://docs.n8n.io/getting-started/installation/updating.html', + }, + }; + } + }); + }).as('settings'); + + cy.intercept('GET', 'https://api.n8n.io/api/versions/1.0.0', [ + { + name: '1.3.1', + createdAt: '2023-08-18T11:53:12.857Z', + hasSecurityIssue: null, + hasSecurityFix: null, + securityIssueFixVersion: null, + hasBreakingChange: null, + documentationUrl: 'https://docs.n8n.io/release-notes/#n8n131', + nodes: [], + description: 'Includes bug fixes', + }, + { + name: '1.0.5', + createdAt: '2023-07-24T10:54:56.097Z', + hasSecurityIssue: false, + hasSecurityFix: null, + securityIssueFixVersion: null, + hasBreakingChange: true, + documentationUrl: 'https://docs.n8n.io/release-notes/#n8n104', + nodes: [], + description: 'Includes core functionality and bug fixes', + }, + ]); + + cy.signin(INSTANCE_OWNER); + + cy.visit(workflowsPage.url); + cy.wait('@settings'); + + getVersionUpdatesPanelOpenButton().should('contain', '2 updates'); + openVersionUpdatesPanel(); + getVersionCard().should('have.length', 2); + closeVersionUpdatesPanel(); + }); +}); diff --git a/cypress/fixtures/Suggested_Templates.json b/cypress/fixtures/Suggested_Templates.json new file mode 100644 index 0000000000000..3d891ccb53997 --- /dev/null +++ b/cypress/fixtures/Suggested_Templates.json @@ -0,0 +1,655 @@ +{ + "sections": [ + { + "name": "Lead enrichment", + "description": "Explore curated lead enrichment workflows or start fresh with a blank canvas", + "workflows": [ + { + "title": "Score new leads with AI from Facebook Lead Ads with AI and get notifications for high scores on Slack", + "description": "This workflow will help you save tons of time and will notify you fully automatically about the most important incoming leads from Facebook Lead Ads. The workflow will automatically fire for every submission. It will then take the name, company, and email information, enrich the submitter via AI, and score it based on metrics that you can easily set.", + "preview": { + "nodes": [ + { + "parameters": { + "operation": "create", + "base": { + "__rl": true, + "mode": "list", + "value": "" + }, + "table": { + "__rl": true, + "mode": "list", + "value": "" + }, + "columns": { + "mappingMode": "defineBelow", + "value": {}, + "matchingColumns": [], + "schema": [] + }, + "options": {} + }, + "id": "b09d4f4d-19fa-43de-8148-2d430a04956f", + "name": "Airtable", + "type": "n8n-nodes-base.airtable", + "typeVersion": 2, + "position": [ + 1800, + 740 + ] + }, + { + "parameters": {}, + "id": "551313bb-1e01-4133-9956-e6f09968f2ce", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 920, + 740 + ] + }, + { + "parameters": { + "options": {} + }, + "id": "b4c089ee-2adb-435e-8d48-47012c981a11", + "name": "Get image", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.1, + "position": [ + 1140, + 740 + ] + }, + { + "parameters": { + "operation": "extractHtmlContent", + "options": {} + }, + "id": "04ca2f61-b930-4fbc-b467-3470c0d93d64", + "name": "Extract Information", + "type": "n8n-nodes-base.html", + "typeVersion": 1, + "position": [ + 1360, + 740 + ] + }, + { + "parameters": { + "options": {} + }, + "id": "d1a77493-c579-4ac4-b6a7-708eea2bf8ce", + "name": "Set Information", + "type": "n8n-nodes-base.set", + "typeVersion": 3.2, + "position": [ + 1580, + 740 + ] + } + ], + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Get image", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get image": { + "main": [ + [ + { + "node": "Extract Information", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Information": { + "main": [ + [ + { + "node": "Set Information", + "type": "main", + "index": 0 + } + ] + ] + }, + "Set Information": { + "main": [ + [ + { + "node": "Airtable", + "type": "main", + "index": 0 + } + ] + ] + } + } + }, + "nodes": [ + { + "id": 24, + "icon": "fa:code-branch", + "defaults": { + "color": "#00bbcc" + }, + "iconData": { + "icon": "code-branch", + "type": "icon" + }, + "displayName": "Merge" + } + ] + }, + { + "title": "Verify the email address every time a contact is created in HubSpot", + "description": "This workflow will help you save tons of time and will notify you fully automatically about the most important incoming leads from Facebook Lead Ads. The workflow will automatically fire for every submission. It will then take the name, company, and email information, enrich the submitter via AI, and score it based on metrics that you can easily set.", + "preview": { + "nodes": [ + { + "parameters": { + "operation": "create", + "base": { + "__rl": true, + "mode": "list", + "value": "" + }, + "table": { + "__rl": true, + "mode": "list", + "value": "" + }, + "columns": { + "mappingMode": "defineBelow", + "value": {}, + "matchingColumns": [], + "schema": [] + }, + "options": {} + }, + "id": "b09d4f4d-19fa-43de-8148-2d430a04956f", + "name": "Airtable", + "type": "n8n-nodes-base.airtable", + "typeVersion": 2, + "position": [ + 1800, + 740 + ] + }, + { + "parameters": {}, + "id": "551313bb-1e01-4133-9956-e6f09968f2ce", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 920, + 740 + ] + }, + { + "parameters": { + "options": {} + }, + "id": "b4c089ee-2adb-435e-8d48-47012c981a11", + "name": "Get image", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.1, + "position": [ + 1140, + 740 + ] + }, + { + "parameters": { + "operation": "extractHtmlContent", + "options": {} + }, + "id": "04ca2f61-b930-4fbc-b467-3470c0d93d64", + "name": "Extract Information", + "type": "n8n-nodes-base.html", + "typeVersion": 1, + "position": [ + 1360, + 740 + ] + }, + { + "parameters": { + "options": {} + }, + "id": "d1a77493-c579-4ac4-b6a7-708eea2bf8ce", + "name": "Set Information", + "type": "n8n-nodes-base.set", + "typeVersion": 3.2, + "position": [ + 1580, + 740 + ] + } + ], + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Get image", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get image": { + "main": [ + [ + { + "node": "Extract Information", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Information": { + "main": [ + [ + { + "node": "Set Information", + "type": "main", + "index": 0 + } + ] + ] + }, + "Set Information": { + "main": [ + [ + { + "node": "Airtable", + "type": "main", + "index": 0 + } + ] + ] + } + } + }, + "nodes": [ + { + "id": 14, + "icon": "fa:code", + "name": "n8n-nodes-base.function", + "defaults": { + "name": "Function", + "color": "#FF9922" + }, + "iconData": { + "icon": "code", + "type": "icon" + }, + "categories": [ + { + "id": 5, + "name": "Development" + }, + { + "id": 9, + "name": "Core Nodes" + } + ], + "displayName": "Function", + "typeVersion": 1 + }, + { + "id": 24, + "icon": "fa:code-branch", + "name": "n8n-nodes-base.merge", + "defaults": { + "name": "Merge", + "color": "#00bbcc" + }, + "iconData": { + "icon": "code-branch", + "type": "icon" + }, + "categories": [ + { + "id": 9, + "name": "Core Nodes" + } + ], + "displayName": "Merge", + "typeVersion": 2 + } + ] + }, + { + "title": "Enrich leads from HubSpot with company information via OpenAi", + "description": "This workflow will help you save tons of time and will notify you fully automatically about the most important incoming leads from Facebook Lead Ads. The workflow will automatically fire for every submission. It will then take the name, company, and email information, enrich the submitter via AI, and score it based on metrics that you can easily set.", + "preview": { + "nodes": [ + { + "parameters": { + "operation": "create", + "base": { + "__rl": true, + "mode": "list", + "value": "" + }, + "table": { + "__rl": true, + "mode": "list", + "value": "" + }, + "columns": { + "mappingMode": "defineBelow", + "value": {}, + "matchingColumns": [], + "schema": [] + }, + "options": {} + }, + "id": "b09d4f4d-19fa-43de-8148-2d430a04956f", + "name": "Airtable", + "type": "n8n-nodes-base.airtable", + "typeVersion": 2, + "position": [ + 1800, + 740 + ] + }, + { + "parameters": {}, + "id": "551313bb-1e01-4133-9956-e6f09968f2ce", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 920, + 740 + ] + }, + { + "parameters": { + "options": {} + }, + "id": "b4c089ee-2adb-435e-8d48-47012c981a11", + "name": "Get image", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.1, + "position": [ + 1140, + 740 + ] + }, + { + "parameters": { + "operation": "extractHtmlContent", + "options": {} + }, + "id": "04ca2f61-b930-4fbc-b467-3470c0d93d64", + "name": "Extract Information", + "type": "n8n-nodes-base.html", + "typeVersion": 1, + "position": [ + 1360, + 740 + ] + }, + { + "parameters": { + "options": {} + }, + "id": "d1a77493-c579-4ac4-b6a7-708eea2bf8ce", + "name": "Set Information", + "type": "n8n-nodes-base.set", + "typeVersion": 3.2, + "position": [ + 1580, + 740 + ] + } + ], + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Get image", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get image": { + "main": [ + [ + { + "node": "Extract Information", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Information": { + "main": [ + [ + { + "node": "Set Information", + "type": "main", + "index": 0 + } + ] + ] + }, + "Set Information": { + "main": [ + [ + { + "node": "Airtable", + "type": "main", + "index": 0 + } + ] + ] + } + } + }, + "nodes": [ + { + "id": 14, + "icon": "fa:code", + "defaults": { + "name": "Function", + "color": "#FF9922" + }, + "iconData": { + "icon": "code", + "type": "icon" + }, + "displayName": "Function" + } + ] + }, + { + "title": "Score new lead submissions from Facebook Lead Ads with AI and notify me on Slack when it is a high score lead", + "description": "This workflow will help you save tons of time and will notify you fully automatically about the most important incoming leads from Facebook Lead Ads. The workflow will automatically fire for every submission. It will then take the name, company, and email information, enrich the submitter via AI, and score it based on metrics that you can easily set.", + "preview": { + "nodes": [ + { + "parameters": { + "operation": "create", + "base": { + "__rl": true, + "mode": "list", + "value": "" + }, + "table": { + "__rl": true, + "mode": "list", + "value": "" + }, + "columns": { + "mappingMode": "defineBelow", + "value": {}, + "matchingColumns": [], + "schema": [] + }, + "options": {} + }, + "id": "b09d4f4d-19fa-43de-8148-2d430a04956f", + "name": "Airtable", + "type": "n8n-nodes-base.airtable", + "typeVersion": 2, + "position": [ + 1800, + 740 + ] + }, + { + "parameters": {}, + "id": "551313bb-1e01-4133-9956-e6f09968f2ce", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 920, + 740 + ] + }, + { + "parameters": { + "options": {} + }, + "id": "b4c089ee-2adb-435e-8d48-47012c981a11", + "name": "Get image", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.1, + "position": [ + 1140, + 740 + ] + }, + { + "parameters": { + "operation": "extractHtmlContent", + "options": {} + }, + "id": "04ca2f61-b930-4fbc-b467-3470c0d93d64", + "name": "Extract Information", + "type": "n8n-nodes-base.html", + "typeVersion": 1, + "position": [ + 1360, + 740 + ] + }, + { + "parameters": { + "options": {} + }, + "id": "d1a77493-c579-4ac4-b6a7-708eea2bf8ce", + "name": "Set Information", + "type": "n8n-nodes-base.set", + "typeVersion": 3.2, + "position": [ + 1580, + 740 + ] + } + ], + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Get image", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get image": { + "main": [ + [ + { + "node": "Extract Information", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Information": { + "main": [ + [ + { + "node": "Set Information", + "type": "main", + "index": 0 + } + ] + ] + }, + "Set Information": { + "main": [ + [ + { + "node": "Airtable", + "type": "main", + "index": 0 + } + ] + ] + } + } + }, + "nodes": [ + { + "id": 14, + "icon": "fa:code", + "defaults": { + "name": "Function", + "color": "#FF9922" + }, + "iconData": { + "icon": "code", + "type": "icon" + }, + "displayName": "Function" + }, + { + "id": 24, + "icon": "fa:code-branch", + "defaults": { + "name": "Merge", + "color": "#00bbcc" + }, + "iconData": { + "icon": "code-branch", + "type": "icon" + }, + "displayName": "Merge" + } + ] + } + ] + } + ] +} diff --git a/cypress/fixtures/Test_workflow_form_switch.json b/cypress/fixtures/Test_workflow_form_switch.json new file mode 100644 index 0000000000000..78349c3ae5554 --- /dev/null +++ b/cypress/fixtures/Test_workflow_form_switch.json @@ -0,0 +1,78 @@ +{ + "name": "My workflow 8", + "nodes": [ + { + "parameters": { + "path": "d1cba915-ca18-4425-bcfb-133205fc815a", + "formTitle": "test", + "formFields": { + "values": [ + { + "fieldLabel": "test" + } + ] + }, + "options": {} + }, + "id": "9e685367-fb94-4376-a9a4-7f311d9f7e2d", + "name": "n8n Form Trigger", + "type": "n8n-nodes-base.formTrigger", + "typeVersion": 2, + "position": [ + 620, + 580 + ], + "webhookId": "d1cba915-ca18-4425-bcfb-133205fc815a" + }, + { + "parameters": {}, + "id": "0f4dfe66-51c0-4378-9eab-680f8140a572", + "name": "Switch", + "type": "n8n-nodes-base.switch", + "typeVersion": 2, + "position": [ + 800, + 580 + ] + } + ], + "pinData": { + "n8n Form Trigger": [ + { + "json": { + "name": "First item", + "code": 1 + } + }, + { + "json": { + "name": "Second item", + "code": 2 + } + } + ] + }, + "connections": { + "n8n Form Trigger": { + "main": [ + [ + { + "node": "Switch", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "d6c14bc8-a69f-47bb-b5ba-fe6e9db0a3a4", + "id": "UQSimcMQJGbTeTLG", + "meta": { + "instanceId": "a786b722078489c1fa382391a9f3476c2784761624deb2dfb4634827256d51a0" + }, + "tags": [] +} \ No newline at end of file diff --git a/cypress/pages/workflow.ts b/cypress/pages/workflow.ts index 7613167f5e93e..853877ed0fb25 100644 --- a/cypress/pages/workflow.ts +++ b/cypress/pages/workflow.ts @@ -49,6 +49,7 @@ export class WorkflowPage extends BasePage { successToast: () => cy.get('.el-notification:has(.el-notification--success)'), warningToast: () => cy.get('.el-notification:has(.el-notification--warning)'), errorToast: () => cy.get('.el-notification:has(.el-notification--error)'), + infoToast: () => cy.get('.el-notification:has(.el-notification--info)'), activatorSwitch: () => cy.getByTestId('workflow-activate-switch'), workflowMenu: () => cy.getByTestId('workflow-menu'), firstStepButton: () => cy.getByTestId('canvas-add-button'), @@ -350,9 +351,6 @@ export class WorkflowPage extends BasePage { hitCopy: () => { cy.get('body').type(META_KEY, { delay: 500, release: false }).type('c'); }, - hitPaste: () => { - cy.get('body').type(META_KEY, { delay: 500, release: false }).type('P'); - }, hitPinNodeShortcut: () => { cy.get('body').type('p'); }, diff --git a/cypress/pages/workflows.ts b/cypress/pages/workflows.ts index 5f0b491d5fac6..4c18c21859a5e 100644 --- a/cypress/pages/workflows.ts +++ b/cypress/pages/workflows.ts @@ -34,6 +34,13 @@ export class WorkflowsPage extends BasePage { // Not yet implemented // myWorkflows: () => cy.getByTestId('my-workflows'), // allWorkflows: () => cy.getByTestId('all-workflows'), + suggestedTemplatesPageContainer: () => cy.getByTestId('suggested-templates-page-container'), + suggestedTemplatesCards: () => cy.getByTestId('templates-info-card').filter(':visible'), + suggestedTemplatesNewWorkflowButton: () => cy.getByTestId('suggested-templates-new-workflow-button'), + suggestedTemplatesSectionContainer: () => cy.getByTestId('suggested-templates-section-container'), + suggestedTemplatesPreviewModal: () => cy.getByTestId('suggested-templates-preview-modal'), + suggestedTemplatesUseTemplateButton: () => cy.getByTestId('use-template-button'), + suggestedTemplatesSectionDescription: () => cy.getByTestId('suggested-template-section-description'), }; actions = { diff --git a/package.json b/package.json index a79cfe883b1e3..3bf6fa6fd5cc5 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "@ngneat/falso": "^6.4.0", "@types/jest": "^29.5.3", "@types/supertest": "^2.0.12", - "@vitest/coverage-v8": "^0.33.0", + "@vitest/coverage-v8": "^1.1.0", "cross-env": "^7.0.3", "cypress": "^12.17.2", "cypress-otp": "^1.0.3", @@ -68,9 +68,9 @@ "tsc-watch": "^6.0.4", "turbo": "1.10.12", "typescript": "*", - "vite": "^5.0.2", - "vitest": "^0.33.0", - "vue-tsc": "^1.8.8" + "vite": "^5.0.10", + "vitest": "^1.1.0", + "vue-tsc": "^1.8.25" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/packages/@n8n/chat/package.json b/packages/@n8n/chat/package.json index fc4b7c93a191b..932ff3363bba0 100644 --- a/packages/@n8n/chat/package.json +++ b/packages/@n8n/chat/package.json @@ -3,7 +3,7 @@ "version": "0.4.0", "scripts": { "dev": "pnpm run storybook", - "build": "run-p type-check build:vite && npm run build:prepare", + "build": "pnpm type-check && pnpm build:vite && pnpm build:prepare", "build:vite": "vite build && npm run build:vite:full", "build:vite:full": "INCLUDE_VUE=true vite build", "build:prepare": "node scripts/postbuild.js", @@ -39,40 +39,10 @@ }, "devDependencies": { "@iconify-json/mdi": "^1.1.54", - "@rushstack/eslint-patch": "^1.3.2", - "@storybook/addon-essentials": "^7.4.0", - "@storybook/addon-interactions": "^7.4.0", - "@storybook/addon-links": "^7.4.0", - "@storybook/blocks": "^7.4.0", - "@storybook/testing-library": "^0.2.0", - "@storybook/vue3": "^7.4.0", - "@storybook/vue3-vite": "^7.4.0", - "@testing-library/jest-dom": "^5.17.0", - "@testing-library/vue": "^7.0.0", - "@tsconfig/node18": "^18.2.0", - "@types/jsdom": "^21.1.1", - "@types/markdown-it": "^12.2.3", - "@types/node": "^18.17.0", - "@vitejs/plugin-vue": "^4.2.3", - "@vue/eslint-config-prettier": "^8.0.0", - "@vue/eslint-config-typescript": "^11.0.3", - "@vue/test-utils": "^2.4.1", - "@vue/tsconfig": "^0.4.0", - "eslint": "^8.45.0", - "eslint-plugin-vue": "^9.15.1", - "jsdom": "^22.1.0", - "npm-run-all": "^4.1.5", - "prettier": "^3.0.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "n8n-design-system": "workspace:*", "shelljs": "^0.8.5", - "storybook": "^7.4.0", - "typescript": "~5.1.6", "unplugin-icons": "^0.17.0", - "vite": "^4.4.6", - "vite-plugin-dts": "^3.6.0", - "vitest": "^0.33.0", - "vue-tsc": "^1.8.6" + "vite-plugin-dts": "^3.6.4" }, "repository": { "type": "git", diff --git a/packages/@n8n/client-oauth2/package.json b/packages/@n8n/client-oauth2/package.json index 1f27d8632d9c9..a75c1bafbefdd 100644 --- a/packages/@n8n/client-oauth2/package.json +++ b/packages/@n8n/client-oauth2/package.json @@ -20,6 +20,6 @@ "dist/**/*" ], "dependencies": { - "axios": "0.21.4" + "axios": "1.6.2" } } diff --git a/packages/@n8n/permissions/src/types.ts b/packages/@n8n/permissions/src/types.ts index 5896ca52cbec3..bbca21c954a1c 100644 --- a/packages/@n8n/permissions/src/types.ts +++ b/packages/@n8n/permissions/src/types.ts @@ -8,6 +8,7 @@ export type Resource = | 'eventBusEvent' | 'eventBusDestination' | 'ldap' + | 'license' | 'logStreaming' | 'orchestration' | 'sourceControl' @@ -41,6 +42,7 @@ export type EventBusDestinationScope = ResourceScope< >; export type EventBusEventScope = ResourceScope<'eventBusEvent', DefaultOperations | 'query'>; export type LdapScope = ResourceScope<'ldap', 'manage' | 'sync'>; +export type LicenseScope = ResourceScope<'license', 'manage'>; export type LogStreamingScope = ResourceScope<'logStreaming', 'manage'>; export type OrchestrationScope = ResourceScope<'orchestration', 'read' | 'list'>; export type SamlScope = ResourceScope<'saml', 'manage'>; @@ -59,6 +61,7 @@ export type Scope = | EventBusEventScope | EventBusDestinationScope | LdapScope + | LicenseScope | LogStreamingScope | OrchestrationScope | SamlScope diff --git a/packages/@n8n_io/eslint-config/base.js b/packages/@n8n_io/eslint-config/base.js index e10f90372bd66..c3b0b54460393 100644 --- a/packages/@n8n_io/eslint-config/base.js +++ b/packages/@n8n_io/eslint-config/base.js @@ -153,6 +153,9 @@ const config = (module.exports = { */ '@typescript-eslint/array-type': ['error', { default: 'array-simple' }], + /** https://typescript-eslint.io/rules/await-thenable/ */ + '@typescript-eslint/await-thenable': 'error', + /** * https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/ban-ts-comment.md */ @@ -477,7 +480,6 @@ const config = (module.exports = { process.env.NODE_ENV === 'development' ? 'warn' : 'error', // TODO: Remove these - '@typescript-eslint/await-thenable': 'off', '@typescript-eslint/ban-ts-comment': 'off', '@typescript-eslint/naming-convention': 'off', 'import/no-duplicates': 'off', diff --git a/packages/@n8n_io/eslint-config/package.json b/packages/@n8n_io/eslint-config/package.json index 5bbecc1115ba0..333c198156f9c 100644 --- a/packages/@n8n_io/eslint-config/package.json +++ b/packages/@n8n_io/eslint-config/package.json @@ -6,6 +6,7 @@ "@types/eslint": "^8.44.7", "@typescript-eslint/eslint-plugin": "^6.12.0", "@typescript-eslint/parser": "^6.12.0", + "@vue/eslint-config-prettier": "^8.0.0", "@vue/eslint-config-typescript": "^12.0.0", "eslint": "^8.54.0", "eslint-config-airbnb-typescript": "^17.1.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index 968b80a49e143..62785cd5c2ad8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -76,7 +76,7 @@ "@types/formidable": "^3.4.0", "@types/json-diff": "^1.0.0", "@types/jsonwebtoken": "^9.0.1", - "@types/localtunnel": "^1.9.0", + "@types/localtunnel": "^2.0.4", "@types/lodash": "^4.14.195", "@types/passport-jwt": "^3.0.6", "@types/psl": "^1.1.0", @@ -108,7 +108,7 @@ "@rudderstack/rudder-sdk-node": "1.0.6", "@sentry/integrations": "7.87.0", "@sentry/node": "7.87.0", - "axios": "0.21.4", + "axios": "1.6.2", "basic-auth": "2.0.1", "bcryptjs": "2.4.3", "bull": "4.10.2", diff --git a/packages/cli/src/ActiveWebhooks.ts b/packages/cli/src/ActiveWebhooks.ts deleted file mode 100644 index 73eecd03791aa..0000000000000 --- a/packages/cli/src/ActiveWebhooks.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { Service } from 'typedi'; -import type { - IWebhookData, - IHttpRequestMethods, - Workflow, - WorkflowActivateMode, - WorkflowExecuteMode, -} from 'n8n-workflow'; -import { ApplicationError, WebhookPathTakenError } from 'n8n-workflow'; -import * as NodeExecuteFunctions from 'n8n-core'; - -@Service() -export class ActiveWebhooks { - private workflowWebhooks: { - [key: string]: IWebhookData[]; - } = {}; - - private webhookUrls: { - [key: string]: IWebhookData[]; - } = {}; - - testWebhooks = false; - - /** - * Adds a new webhook - * - */ - async add( - workflow: Workflow, - webhookData: IWebhookData, - mode: WorkflowExecuteMode, - activation: WorkflowActivateMode, - ): Promise { - if (workflow.id === undefined) { - throw new ApplicationError( - 'Webhooks can only be added for saved workflows as an ID is needed', - ); - } - if (webhookData.path.endsWith('/')) { - webhookData.path = webhookData.path.slice(0, -1); - } - - const webhookKey = this.getWebhookKey( - webhookData.httpMethod, - webhookData.path, - webhookData.webhookId, - ); - - // check that there is not a webhook already registered with that path/method - if (this.webhookUrls[webhookKey] && !webhookData.webhookId) { - throw new WebhookPathTakenError(webhookData.node); - } - - if (this.workflowWebhooks[webhookData.workflowId] === undefined) { - this.workflowWebhooks[webhookData.workflowId] = []; - } - - // Make the webhook available directly because sometimes to create it successfully - // it gets called - if (!this.webhookUrls[webhookKey]) { - this.webhookUrls[webhookKey] = []; - } - this.webhookUrls[webhookKey].push(webhookData); - - try { - await workflow.createWebhookIfNotExists( - webhookData, - NodeExecuteFunctions, - mode, - activation, - this.testWebhooks, - ); - } catch (error) { - // If there was a problem unregister the webhook again - if (this.webhookUrls[webhookKey].length <= 1) { - delete this.webhookUrls[webhookKey]; - } else { - this.webhookUrls[webhookKey] = this.webhookUrls[webhookKey].filter( - (webhook) => webhook.path !== webhookData.path, - ); - } - - throw error; - } - this.workflowWebhooks[webhookData.workflowId].push(webhookData); - } - - /** - * Returns webhookData if a webhook with matches is currently registered - * - * @param {(string | undefined)} webhookId - */ - get(httpMethod: IHttpRequestMethods, path: string, webhookId?: string): IWebhookData | undefined { - const webhookKey = this.getWebhookKey(httpMethod, path, webhookId); - if (this.webhookUrls[webhookKey] === undefined) { - return undefined; - } - - let webhook: IWebhookData | undefined; - let maxMatches = 0; - const pathElementsSet = new Set(path.split('/')); - // check if static elements match in path - // if more results have been returned choose the one with the most static-route matches - this.webhookUrls[webhookKey].forEach((dynamicWebhook) => { - const staticElements = dynamicWebhook.path.split('/').filter((ele) => !ele.startsWith(':')); - const allStaticExist = staticElements.every((staticEle) => pathElementsSet.has(staticEle)); - - if (allStaticExist && staticElements.length > maxMatches) { - maxMatches = staticElements.length; - webhook = dynamicWebhook; - } - // handle routes with no static elements - else if (staticElements.length === 0 && !webhook) { - webhook = dynamicWebhook; - } - }); - - return webhook; - } - - /** - * Gets all request methods associated with a single webhook - */ - getWebhookMethods(path: string): IHttpRequestMethods[] { - return Object.keys(this.webhookUrls) - .filter((key) => key.includes(path)) - .map((key) => key.split('|')[0] as IHttpRequestMethods); - } - - /** - * Returns the ids of all the workflows which have active webhooks - * - */ - getWorkflowIds(): string[] { - return Object.keys(this.workflowWebhooks); - } - - /** - * Returns key to uniquely identify a webhook - * - * @param {(string | undefined)} webhookId - */ - getWebhookKey(httpMethod: IHttpRequestMethods, path: string, webhookId?: string): string { - if (webhookId) { - if (path.startsWith(webhookId)) { - const cutFromIndex = path.indexOf('/') + 1; - - path = path.slice(cutFromIndex); - } - return `${httpMethod}|${webhookId}|${path.split('/').length}`; - } - return `${httpMethod}|${path}`; - } - - /** - * Removes all webhooks of a workflow - * - */ - async removeWorkflow(workflow: Workflow): Promise { - const workflowId = workflow.id; - - if (this.workflowWebhooks[workflowId] === undefined) { - // If it did not exist then there is nothing to remove - return false; - } - - const webhooks = this.workflowWebhooks[workflowId]; - - const mode = 'internal'; - - // Go through all the registered webhooks of the workflow and remove them - - for (const webhookData of webhooks) { - await workflow.deleteWebhook( - webhookData, - NodeExecuteFunctions, - mode, - 'update', - this.testWebhooks, - ); - - delete this.webhookUrls[ - this.getWebhookKey(webhookData.httpMethod, webhookData.path, webhookData.webhookId) - ]; - } - - // Remove also the workflow-webhook entry - delete this.workflowWebhooks[workflowId]; - - return true; - } - - /** - * Removes all the webhooks of the given workflows - */ - async removeAll(workflows: Workflow[]): Promise { - const removePromises = []; - - for (const workflow of workflows) { - removePromises.push(this.removeWorkflow(workflow)); - } - - await Promise.all(removePromises); - } -} diff --git a/packages/cli/src/ActiveWorkflowRunner.ts b/packages/cli/src/ActiveWorkflowRunner.ts index 72ec73da36683..5b4a15f40c39a 100644 --- a/packages/cli/src/ActiveWorkflowRunner.ts +++ b/packages/cli/src/ActiveWorkflowRunner.ts @@ -60,7 +60,7 @@ import { WorkflowRunner } from '@/WorkflowRunner'; import { ExternalHooks } from '@/ExternalHooks'; import { whereClause } from './UserManagement/UserManagementHelper'; import { WorkflowService } from './workflows/workflow.service'; -import { webhookNotFoundErrorMessage } from './utils'; +import { WebhookNotFoundError } from './errors/response-errors/webhook-not-found.error'; import { In } from 'typeorm'; import { WebhookService } from './services/webhook.service'; import { Logger } from './Logger'; @@ -71,9 +71,6 @@ import { ActivationErrorsService } from '@/ActivationErrors.service'; import type { Scope } from '@n8n/permissions'; import { NotFoundError } from './errors/response-errors/not-found.error'; -const WEBHOOK_PROD_UNREGISTERED_HINT = - "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"; - @Service() export class ActiveWorkflowRunner implements IWebhookManager { activeWorkflows = new ActiveWorkflows(); @@ -256,10 +253,7 @@ export class ActiveWorkflowRunner implements IWebhookManager { const webhook = await this.webhookService.findWebhook(httpMethod, path); if (webhook === null) { - throw new NotFoundError( - webhookNotFoundErrorMessage(path, httpMethod), - WEBHOOK_PROD_UNREGISTERED_HINT, - ); + throw new WebhookNotFoundError({ path, httpMethod }, { hint: 'production' }); } return webhook; @@ -276,7 +270,7 @@ export class ActiveWorkflowRunner implements IWebhookManager { * Get the IDs of active workflows from storage. */ async allActiveInStorage(options?: { user: User; scope: Scope | Scope[] }) { - const isFullAccess = !options?.user || (await options.user.hasGlobalScope(options.scope)); + const isFullAccess = !options?.user || options.user.hasGlobalScope(options.scope); const activationErrors = await this.activationErrorsService.getAll(); @@ -291,7 +285,7 @@ export class ActiveWorkflowRunner implements IWebhookManager { .filter((workflowId) => !activationErrors[workflowId]); } - const where = await whereClause({ + const where = whereClause({ user: options.user, globalScope: 'workflow:list', entityType: 'workflow', @@ -383,7 +377,6 @@ export class ActiveWorkflowRunner implements IWebhookManager { NodeExecuteFunctions, mode, activation, - false, ); } catch (error) { if (activation === 'init' && error.name === 'QueryFailedError') { @@ -455,7 +448,7 @@ export class ActiveWorkflowRunner implements IWebhookManager { const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData, undefined, true); for (const webhookData of webhooks) { - await workflow.deleteWebhook(webhookData, NodeExecuteFunctions, mode, 'update', false); + await workflow.deleteWebhook(webhookData, NodeExecuteFunctions, mode, 'update'); } await Container.get(WorkflowService).saveStaticData(workflow); diff --git a/packages/cli/src/CredentialsHelper.ts b/packages/cli/src/CredentialsHelper.ts index 63547554380a8..5920986de9378 100644 --- a/packages/cli/src/CredentialsHelper.ts +++ b/packages/cli/src/CredentialsHelper.ts @@ -579,7 +579,7 @@ export class CredentialsHelper extends ICredentialsHelper { credentialType, 'internal' as WorkflowExecuteMode, undefined, - await user.hasGlobalScope('externalSecret:use'), + user.hasGlobalScope('externalSecret:use'), ); } catch (error) { this.logger.debug('Credential test failed', error); diff --git a/packages/cli/src/ExternalSecrets/providers/vault.ts b/packages/cli/src/ExternalSecrets/providers/vault.ts index 6156d762bdb07..6b1046402ade3 100644 --- a/packages/cli/src/ExternalSecrets/providers/vault.ts +++ b/packages/cli/src/ExternalSecrets/providers/vault.ts @@ -251,19 +251,15 @@ export class VaultProvider extends SecretsProvider { this.#http = axios.create({ baseURL: baseURL.toString() }); if (this.settings.namespace) { this.#http.interceptors.request.use((config) => { - return { - ...config, - // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-unsafe-assignment - headers: { ...config.headers, 'X-Vault-Namespace': this.settings.namespace }, - }; + config.headers['X-Vault-Namespace'] = this.settings.namespace; + return config; }); } this.#http.interceptors.request.use((config) => { - if (!this.#currentToken) { - return config; + if (this.#currentToken) { + config.headers['X-Vault-Token'] = this.#currentToken; } - // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-unsafe-assignment - return { ...config, headers: { ...config.headers, 'X-Vault-Token': this.#currentToken } }; + return config; }); } diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index b57836d3979e4..05231828e8a9f 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -261,7 +261,10 @@ export interface IExternalHooksClass { export type WebhookCORSRequest = Request & { method: 'OPTIONS' }; -export type WebhookRequest = Request<{ path: string }> & { method: IHttpRequestMethods }; +export type WebhookRequest = Request<{ path: string }> & { + method: IHttpRequestMethods; + params: Record; +}; export type WaitingWebhookRequest = WebhookRequest & { params: WebhookRequest['path'] & { suffix?: string }; @@ -874,3 +877,11 @@ export abstract class SecretsProvider { } export type N8nInstanceType = 'main' | 'webhook' | 'worker'; + +export type RegisteredWebhook = { + sessionId?: string; + timeout: NodeJS.Timeout; + workflowEntity: IWorkflowDb; + workflow: Workflow; + destinationNode?: string; +}; diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 6884e05706f22..e672ec9a49c94 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -27,7 +27,7 @@ import type { IExecutionsSummary, IN8nUISettings, } from 'n8n-workflow'; -import { ApplicationError, jsonParse } from 'n8n-workflow'; +import { jsonParse } from 'n8n-workflow'; // @ts-ignore import timezones from 'google-timezones-json'; @@ -67,7 +67,6 @@ import { ExternalSecretsController } from '@/ExternalSecrets/ExternalSecrets.con import { executionsController } from '@/executions/executions.controller'; import { isApiEnabled, loadPublicApiVersions } from '@/PublicApi'; import { whereClause } from '@/UserManagement/UserManagementHelper'; -import { UserManagementMailer } from '@/UserManagement/email'; import type { ICredentialsOverwrite, IDiagnosticInfo, IExecutionsStopData } from '@/Interfaces'; import { ActiveExecutions } from '@/ActiveExecutions'; import { CredentialsOverwrites } from '@/CredentialsOverwrites'; @@ -79,7 +78,7 @@ import { WaitTracker } from '@/WaitTracker'; import { toHttpNodeParameters } from '@/CurlConverterHelper'; import { EventBusController } from '@/eventbus/eventBus.controller'; import { EventBusControllerEE } from '@/eventbus/eventBus.controller.ee'; -import { licenseController } from './license/license.controller'; +import { LicenseController } from '@/license/license.controller'; import { setupPushServer, setupPushHandler } from '@/push'; import { setupAuthMiddlewares } from './middlewares'; import { handleLdapInit, isLdapEnabled } from './Ldap/helpers'; @@ -249,7 +248,6 @@ export class Server extends AbstractServer { setupAuthMiddlewares(app, ignoredEndpoints, this.restEndpoint); const internalHooks = Container.get(InternalHooks); - const mailer = Container.get(UserManagementMailer); const userService = Container.get(UserService); const postHog = this.postHog; const mfaService = Container.get(MfaService); @@ -258,6 +256,7 @@ export class Server extends AbstractServer { new EventBusController(), new EventBusControllerEE(), Container.get(AuthController), + Container.get(LicenseController), Container.get(OAuth1CredentialController), Container.get(OAuth2CredentialController), new OwnerController( @@ -423,11 +422,6 @@ export class Server extends AbstractServer { // ---------------------------------------- this.app.use(`/${this.restEndpoint}/workflows`, workflowsController); - // ---------------------------------------- - // License - // ---------------------------------------- - this.app.use(`/${this.restEndpoint}/license`, licenseController); - // ---------------------------------------- // SAML // ---------------------------------------- @@ -472,7 +466,7 @@ export class Server extends AbstractServer { const shared = await Container.get(SharedWorkflowRepository).findOne({ relations: ['workflow'], - where: await whereClause({ + where: whereClause({ user: req.user, globalScope: 'workflow:read', entityType: 'workflow', @@ -687,8 +681,8 @@ export class Server extends AbstractServer { const job = currentJobs.find((job) => job.data.executionId === req.params.id); if (!job) { - throw new ApplicationError('Could not stop job because it is no longer in queue.', { - extra: { jobId: req.params.id }, + this.logger.debug('Could not stop job because it is no longer in queue', { + jobId: req.params.id, }); } else { await queue.stopJob(job); diff --git a/packages/cli/src/TestWebhooks.ts b/packages/cli/src/TestWebhooks.ts index a889bfbd30211..a4da7e7ad48ae 100644 --- a/packages/cli/src/TestWebhooks.ts +++ b/packages/cli/src/TestWebhooks.ts @@ -8,45 +8,38 @@ import { type Workflow, type WorkflowActivateMode, type WorkflowExecuteMode, - ApplicationError, + WebhookPathTakenError, } from 'n8n-workflow'; -import { ActiveWebhooks } from '@/ActiveWebhooks'; import type { IResponseCallbackData, IWebhookManager, IWorkflowDb, + RegisteredWebhook, WebhookAccessControlOptions, WebhookRequest, } from '@/Interfaces'; import { Push } from '@/push'; import { NodeTypes } from '@/NodeTypes'; import * as WebhookHelpers from '@/WebhookHelpers'; -import { webhookNotFoundErrorMessage } from './utils'; import { NotFoundError } from './errors/response-errors/not-found.error'; - -const WEBHOOK_TEST_UNREGISTERED_HINT = - "Click the 'Execute workflow' button on the canvas, then try again. (In test mode, the webhook only works for one call after you click this button)"; +import { TIME } from './constants'; +import { WorkflowMissingIdError } from './errors/workflow-missing-id.error'; +import { WebhookNotFoundError } from './errors/response-errors/webhook-not-found.error'; +import * as NodeExecuteFunctions from 'n8n-core'; @Service() export class TestWebhooks implements IWebhookManager { - private testWebhookData: { - [key: string]: { - sessionId?: string; - timeout: NodeJS.Timeout; - workflowData: IWorkflowDb; - workflow: Workflow; - destinationNode?: string; - }; - } = {}; - constructor( - private readonly activeWebhooks: ActiveWebhooks, private readonly push: Push, private readonly nodeTypes: NodeTypes, - ) { - activeWebhooks.testWebhooks = true; - } + ) {} + + private registeredWebhooks: { [webhookKey: string]: RegisteredWebhook } = {}; + + private workflowWebhooks: { [workflowId: string]: IWebhookData[] } = {}; + + private webhookUrls: { [webhookUrl: string]: IWebhookData[] } = {}; /** * Executes a test-webhook and returns the data. It also makes sure that the @@ -58,69 +51,57 @@ export class TestWebhooks implements IWebhookManager { response: express.Response, ): Promise { const httpMethod = request.method; - let path = request.params.path; - // Reset request parameters + let path = request.params.path.endsWith('/') + ? request.params.path.slice(0, -1) + : request.params.path; + request.params = {} as WebhookRequest['params']; - // Remove trailing slash - if (path.endsWith('/')) { - path = path.slice(0, -1); - } + let webhook = this.getActiveWebhook(httpMethod, path); - const { activeWebhooks, push, testWebhookData } = this; + if (!webhook) { + // no static webhook, so check if dynamic + // e.g. `/webhook-test//user/:id/create` - let webhookData: IWebhookData | undefined = activeWebhooks.get(httpMethod, path); + const [webhookId, ...segments] = path.split('/'); - // check if path is dynamic - if (webhookData === undefined) { - const pathElements = path.split('/'); - const webhookId = pathElements.shift(); + webhook = this.getActiveWebhook(httpMethod, segments.join('/'), webhookId); - webhookData = activeWebhooks.get(httpMethod, pathElements.join('/'), webhookId); - if (webhookData === undefined) { - // The requested webhook is not registered - const methods = await this.getWebhookMethods(path); - throw new NotFoundError( - webhookNotFoundErrorMessage(path, httpMethod, methods), - WEBHOOK_TEST_UNREGISTERED_HINT, - ); - } + if (!webhook) + throw new WebhookNotFoundError({ + path, + httpMethod, + webhookMethods: await this.getWebhookMethods(path), + }); - path = webhookData.path; - // extracting params from path - path.split('/').forEach((ele, index) => { - if (ele.startsWith(':')) { - // write params to req.params - // @ts-ignore - request.params[ele.slice(1)] = pathElements[index]; + path = webhook.path; + + path.split('/').forEach((segment, index) => { + if (segment.startsWith(':')) { + request.params[segment.slice(1)] = segments[index]; } }); } - const { workflowId } = webhookData; - const webhookKey = `${activeWebhooks.getWebhookKey( - webhookData.httpMethod, - webhookData.path, - webhookData.webhookId, - )}|${workflowId}`; - - // TODO: Clean that duplication up one day and improve code generally - if (testWebhookData[webhookKey] === undefined) { - // The requested webhook is not registered - const methods = await this.getWebhookMethods(path); - throw new NotFoundError( - webhookNotFoundErrorMessage(path, httpMethod, methods), - WEBHOOK_TEST_UNREGISTERED_HINT, - ); - } + const key = [ + this.toWebhookKey(webhook.httpMethod, webhook.path, webhook.webhookId), + webhook.workflowId, + ].join('|'); - const { destinationNode, sessionId, workflow, workflowData, timeout } = - testWebhookData[webhookKey]; + if (!(key in this.registeredWebhooks)) + throw new WebhookNotFoundError({ + path, + httpMethod, + webhookMethods: await this.getWebhookMethods(path), + }); + + const { destinationNode, sessionId, workflow, workflowEntity, timeout } = + this.registeredWebhooks[key]; // Get the node which has the webhook defined to know where to start from and to // get additional data - const workflowStartNode = workflow.getNode(webhookData.node); + const workflowStartNode = workflow.getNode(webhook.node); if (workflowStartNode === null) { throw new NotFoundError('Could not find node to process webhook.'); } @@ -130,8 +111,8 @@ export class TestWebhooks implements IWebhookManager { const executionMode = 'manual'; const executionId = await WebhookHelpers.executeWebhook( workflow, - webhookData!, - workflowData, + webhook!, + workflowEntity, workflowStartNode, executionMode, sessionId, @@ -153,107 +134,101 @@ export class TestWebhooks implements IWebhookManager { // Inform editor-ui that webhook got received if (sessionId !== undefined) { - push.send('testWebhookReceived', { workflowId, executionId }, sessionId); + this.push.send( + 'testWebhookReceived', + { workflowId: webhook?.workflowId, executionId }, + sessionId, + ); } } catch {} // Delete webhook also if an error is thrown if (timeout) clearTimeout(timeout); - delete testWebhookData[webhookKey]; + delete this.registeredWebhooks[key]; - await activeWebhooks.removeWorkflow(workflow); + await this.deactivateWebhooksFor(workflow); }); } - async getWebhookMethods(path: string): Promise { - const webhookMethods = this.activeWebhooks.getWebhookMethods(path); - if (!webhookMethods.length) { - // The requested webhook is not registered - throw new NotFoundError(webhookNotFoundErrorMessage(path), WEBHOOK_TEST_UNREGISTERED_HINT); - } + async getWebhookMethods(path: string) { + const webhookMethods = Object.keys(this.webhookUrls) + .filter((key) => key.includes(path)) + .map((key) => key.split('|')[0] as IHttpRequestMethods); + + if (!webhookMethods.length) throw new WebhookNotFoundError({ path }); return webhookMethods; } async findAccessControlOptions(path: string, httpMethod: IHttpRequestMethods) { - const webhookKey = Object.keys(this.testWebhookData).find( + const webhookKey = Object.keys(this.registeredWebhooks).find( (key) => key.includes(path) && key.startsWith(httpMethod), ); + if (!webhookKey) return; - const { workflow } = this.testWebhookData[webhookKey]; + const { workflow } = this.registeredWebhooks[webhookKey]; const webhookNode = Object.values(workflow.nodes).find( ({ type, parameters, typeVersion }) => parameters?.path === path && (parameters?.httpMethod ?? 'GET') === httpMethod && 'webhook' in this.nodeTypes.getByNameAndVersion(type, typeVersion), ); + return webhookNode?.parameters?.options as WebhookAccessControlOptions; } - /** - * Checks if it has to wait for webhook data to execute the workflow. - * If yes it waits for it and resolves with the result of the workflow if not it simply resolves with undefined - */ - async needsWebhookData( - workflowData: IWorkflowDb, + async needsWebhook( + workflowEntity: IWorkflowDb, workflow: Workflow, additionalData: IWorkflowExecuteAdditionalData, - mode: WorkflowExecuteMode, - activation: WorkflowActivateMode, + executionMode: WorkflowExecuteMode, + activationMode: WorkflowActivateMode, sessionId?: string, destinationNode?: string, - ): Promise { + ) { + if (!workflow.id) throw new WorkflowMissingIdError(workflow); + const webhooks = WebhookHelpers.getWorkflowWebhooks( workflow, additionalData, destinationNode, true, ); - if (!webhooks.find((webhook) => webhook.webhookDescription.restartWebhook !== true)) { - // No webhooks found to start a workflow - return false; - } - if (workflow.id === undefined) { - throw new ApplicationError( - 'Webhooks can only be added for saved workflows as an ID is needed', - ); + if (!webhooks.find((w) => w.webhookDescription.restartWebhook !== true)) { + return false; // no webhooks found to start a workflow } - // Remove test-webhooks automatically if they do not get called (after 120 seconds) const timeout = setTimeout(() => { - this.cancelTestWebhook(workflowData.id); - }, 120000); + this.cancelTestWebhook(workflowEntity.id); + }, 2 * TIME.MINUTE); - const { activeWebhooks, testWebhookData } = this; + const activatedKeys: string[] = []; - let key: string; - const activatedKey: string[] = []; + for (const webhook of webhooks) { + const key = [ + this.toWebhookKey(webhook.httpMethod, webhook.path, webhook.webhookId), + workflowEntity.id, + ].join('|'); - for (const webhookData of webhooks) { - key = `${activeWebhooks.getWebhookKey( - webhookData.httpMethod, - webhookData.path, - webhookData.webhookId, - )}|${workflowData.id}`; + activatedKeys.push(key); - activatedKey.push(key); - - testWebhookData[key] = { + this.registeredWebhooks[key] = { sessionId, timeout, workflow, - workflowData, + workflowEntity, destinationNode, }; try { - await activeWebhooks.add(workflow, webhookData, mode, activation); + await this.activateWebhook(workflow, webhook, executionMode, activationMode); } catch (error) { - activatedKey.forEach((deleteKey) => delete testWebhookData[deleteKey]); + activatedKeys.forEach((ak) => delete this.registeredWebhooks[ak]); + + await this.deactivateWebhooksFor(workflow); - await activeWebhooks.removeWorkflow(workflow); throw error; } } @@ -261,38 +236,29 @@ export class TestWebhooks implements IWebhookManager { return true; } - /** - * Removes a test webhook of the workflow with the given id - * - */ - cancelTestWebhook(workflowId: string): boolean { + cancelTestWebhook(workflowId: string) { let foundWebhook = false; - const { activeWebhooks, push, testWebhookData } = this; - for (const webhookKey of Object.keys(testWebhookData)) { - const { sessionId, timeout, workflow, workflowData } = testWebhookData[webhookKey]; + for (const key of Object.keys(this.registeredWebhooks)) { + const { sessionId, timeout, workflow, workflowEntity } = this.registeredWebhooks[key]; - if (workflowData.id !== workflowId) { - continue; - } + if (workflowEntity.id !== workflowId) continue; clearTimeout(timeout); - // Inform editor-ui that webhook got received if (sessionId !== undefined) { try { - push.send('testWebhookDeleted', { workflowId }, sessionId); + this.push.send('testWebhookDeleted', { workflowId }, sessionId); } catch { // Could not inform editor, probably is not connected anymore. So simply go on. } } - // Remove the webhook - delete testWebhookData[webhookKey]; + delete this.registeredWebhooks[key]; if (!foundWebhook) { // As it removes all webhooks of the workflow execute only once - void activeWebhooks.removeWorkflow(workflow); + void this.deactivateWebhooksFor(workflow); } foundWebhook = true; @@ -300,4 +266,127 @@ export class TestWebhooks implements IWebhookManager { return foundWebhook; } + + async activateWebhook( + workflow: Workflow, + webhook: IWebhookData, + executionMode: WorkflowExecuteMode, + activationMode: WorkflowActivateMode, + ) { + if (!workflow.id) throw new WorkflowMissingIdError(workflow); + + if (webhook.path.endsWith('/')) { + webhook.path = webhook.path.slice(0, -1); + } + + const key = this.toWebhookKey(webhook.httpMethod, webhook.path, webhook.webhookId); + + // check that there is not a webhook already registered with that path/method + if (this.webhookUrls[key] && !webhook.webhookId) { + throw new WebhookPathTakenError(webhook.node); + } + + if (this.workflowWebhooks[webhook.workflowId] === undefined) { + this.workflowWebhooks[webhook.workflowId] = []; + } + + // Make the webhook available directly because sometimes to create it successfully + // it gets called + if (!this.webhookUrls[key]) { + this.webhookUrls[key] = []; + } + webhook.isTest = true; + this.webhookUrls[key].push(webhook); + + try { + await workflow.createWebhookIfNotExists( + webhook, + NodeExecuteFunctions, + executionMode, + activationMode, + ); + } catch (error) { + // If there was a problem unregister the webhook again + if (this.webhookUrls[key].length <= 1) { + delete this.webhookUrls[key]; + } else { + this.webhookUrls[key] = this.webhookUrls[key].filter((w) => w.path !== w.path); + } + + throw error; + } + this.workflowWebhooks[webhook.workflowId].push(webhook); + } + + getActiveWebhook(httpMethod: IHttpRequestMethods, path: string, webhookId?: string) { + const webhookKey = this.toWebhookKey(httpMethod, path, webhookId); + if (this.webhookUrls[webhookKey] === undefined) { + return undefined; + } + + let webhook: IWebhookData | undefined; + let maxMatches = 0; + const pathElementsSet = new Set(path.split('/')); + // check if static elements match in path + // if more results have been returned choose the one with the most static-route matches + this.webhookUrls[webhookKey].forEach((dynamicWebhook) => { + const staticElements = dynamicWebhook.path.split('/').filter((ele) => !ele.startsWith(':')); + const allStaticExist = staticElements.every((staticEle) => pathElementsSet.has(staticEle)); + + if (allStaticExist && staticElements.length > maxMatches) { + maxMatches = staticElements.length; + webhook = dynamicWebhook; + } + // handle routes with no static elements + else if (staticElements.length === 0 && !webhook) { + webhook = dynamicWebhook; + } + }); + + return webhook; + } + + toWebhookKey(httpMethod: IHttpRequestMethods, path: string, webhookId?: string) { + if (!webhookId) return `${httpMethod}|${path}`; + + if (path.startsWith(webhookId)) { + const cutFromIndex = path.indexOf('/') + 1; + + path = path.slice(cutFromIndex); + } + + return `${httpMethod}|${webhookId}|${path.split('/').length}`; + } + + async deactivateWebhooksFor(workflow: Workflow) { + const workflowId = workflow.id; + + if (this.workflowWebhooks[workflowId] === undefined) { + // If it did not exist then there is nothing to remove + return false; + } + + const webhooks = this.workflowWebhooks[workflowId]; + + const mode = 'internal'; + + // Go through all the registered webhooks of the workflow and remove them + + for (const webhookData of webhooks) { + await workflow.deleteWebhook(webhookData, NodeExecuteFunctions, mode, 'update'); + + const key = this.toWebhookKey( + webhookData.httpMethod, + webhookData.path, + webhookData.webhookId, + ); + + delete this.webhookUrls[key]; + } + + // Remove also the workflow-webhook entry + delete this.workflowWebhooks[workflowId]; + + return true; + } } diff --git a/packages/cli/src/UserManagement/PermissionChecker.ts b/packages/cli/src/UserManagement/PermissionChecker.ts index 6fb85281e8a7e..b9099915e0734 100644 --- a/packages/cli/src/UserManagement/PermissionChecker.ts +++ b/packages/cli/src/UserManagement/PermissionChecker.ts @@ -32,7 +32,7 @@ export class PermissionChecker { relations: ['globalRole'], }); - if (await user.hasGlobalScope('workflow:execute')) return; + if (user.hasGlobalScope('workflow:execute')) return; // allow if all creds used in this workflow are a subset of // all creds accessible to users who have access to this workflow diff --git a/packages/cli/src/UserManagement/UserManagementHelper.ts b/packages/cli/src/UserManagement/UserManagementHelper.ts index dd53a76324346..6b9b1a0eafec6 100644 --- a/packages/cli/src/UserManagement/UserManagementHelper.ts +++ b/packages/cli/src/UserManagement/UserManagementHelper.ts @@ -58,7 +58,7 @@ export function rightDiff( * Build a `where` clause for a TypeORM entity search, * checking for member access if the user is not an owner. */ -export async function whereClause({ +export function whereClause({ user, entityType, globalScope, @@ -70,10 +70,10 @@ export async function whereClause({ globalScope: Scope; entityId?: string; roles?: string[]; -}): Promise { +}): WhereClause { const where: WhereClause = entityId ? { [entityType]: { id: entityId } } : {}; - if (!(await user.hasGlobalScope(globalScope))) { + if (!user.hasGlobalScope(globalScope)) { where.user = { id: user.id }; if (roles?.length) { where.role = { name: In(roles) }; diff --git a/packages/cli/src/WorkflowHelpers.ts b/packages/cli/src/WorkflowHelpers.ts index 477d0565dd0b0..84b06a492f807 100644 --- a/packages/cli/src/WorkflowHelpers.ts +++ b/packages/cli/src/WorkflowHelpers.ts @@ -422,7 +422,7 @@ export async function replaceInvalidCredentials(workflow: WorkflowEntity): Promi */ export async function getSharedWorkflowIds(user: User, roles?: RoleNames[]): Promise { const where: FindOptionsWhere = {}; - if (!(await user.hasGlobalScope('workflow:read'))) { + if (!user.hasGlobalScope('workflow:read')) { where.userId = user.id; } if (roles?.length) { diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 0ea89dc3b61fb..e2eeecc981e17 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/await-thenable */ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { Container } from 'typedi'; @@ -316,7 +315,6 @@ export class Start extends BaseCommand { const port = config.getEnv('port'); - // @ts-ignore const webhookTunnel = await localtunnel(port, tunnelSettings); process.env.WEBHOOK_URL = `${webhookTunnel.url}/`; diff --git a/packages/cli/src/controllers/passwordReset.controller.ts b/packages/cli/src/controllers/passwordReset.controller.ts index 463e46d9ca0a2..06b1dd250c2d7 100644 --- a/packages/cli/src/controllers/passwordReset.controller.ts +++ b/packages/cli/src/controllers/passwordReset.controller.ts @@ -96,7 +96,7 @@ export class PasswordResetController { if ( isSamlCurrentAuthenticationMethod() && !( - (user && (await user.hasGlobalScope('user:resetPassword'))) === true || + (user && user.hasGlobalScope('user:resetPassword')) === true || user?.settings?.allowSSOManualLogin === true ) ) { diff --git a/packages/cli/src/controllers/workflowStatistics.controller.ts b/packages/cli/src/controllers/workflowStatistics.controller.ts index 4a868bdc2e90e..2eb9e0dcac4f8 100644 --- a/packages/cli/src/controllers/workflowStatistics.controller.ts +++ b/packages/cli/src/controllers/workflowStatistics.controller.ts @@ -37,7 +37,7 @@ export class WorkflowStatisticsController { const workflowId = req.params.id; const allowed = await this.sharedWorkflowRepository.exist({ relations: ['workflow'], - where: await whereClause({ + where: whereClause({ user, globalScope: 'workflow:read', entityType: 'workflow', diff --git a/packages/cli/src/credentials/credentials.controller.ee.ts b/packages/cli/src/credentials/credentials.controller.ee.ts index 6de562b9ddae1..c2f23d9b15323 100644 --- a/packages/cli/src/credentials/credentials.controller.ee.ts +++ b/packages/cli/src/credentials/credentials.controller.ee.ts @@ -50,7 +50,7 @@ EECredentialsController.get( const userSharing = credential.shared?.find((shared) => shared.user.id === req.user.id); - if (!userSharing && !(await req.user.hasGlobalScope('credential:read'))) { + if (!userSharing && !req.user.hasGlobalScope('credential:read')) { throw new UnauthorizedError('Forbidden.'); } @@ -130,7 +130,7 @@ EECredentialsController.put( if (!ownsCredential || !credential) { credential = undefined; // Allow owners/admins to share - if (await req.user.hasGlobalScope('credential:share')) { + if (req.user.hasGlobalScope('credential:share')) { const sharedRes = await EECredentials.getSharing(req.user, credentialId, { allowGlobalScope: true, globalScope: 'credential:share', diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index 9223f7a0f3979..82f6bad5273c3 100644 --- a/packages/cli/src/credentials/credentials.controller.ts +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -163,7 +163,7 @@ credentialsController.patch( ); } - if (sharing.role.name !== 'owner' && !(await req.user.hasGlobalScope('credential:update'))) { + if (sharing.role.name !== 'owner' && !req.user.hasGlobalScope('credential:update')) { Container.get(Logger).info( 'Attempt to update credential blocked due to lack of permissions', { @@ -232,7 +232,7 @@ credentialsController.delete( ); } - if (sharing.role.name !== 'owner' && !(await req.user.hasGlobalScope('credential:delete'))) { + if (sharing.role.name !== 'owner' && !req.user.hasGlobalScope('credential:delete')) { Container.get(Logger).info( 'Attempt to delete credential blocked due to lack of permissions', { diff --git a/packages/cli/src/credentials/credentials.service.ee.ts b/packages/cli/src/credentials/credentials.service.ee.ts index 418c674bb8c43..627af5c678345 100644 --- a/packages/cli/src/credentials/credentials.service.ee.ts +++ b/packages/cli/src/credentials/credentials.service.ee.ts @@ -40,7 +40,7 @@ export class EECredentialsService extends CredentialsService { // Omit user from where if the requesting user has relevant // global credential permissions. This allows the user to // access credentials they don't own. - if (!options.allowGlobalScope || !(await user.hasGlobalScope(options.globalScope))) { + if (!options.allowGlobalScope || !user.hasGlobalScope(options.globalScope)) { where.userId = user.id; } diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index e684423922592..ab5e2379770ea 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -88,7 +88,7 @@ export class CredentialsService { ) { const findManyOptions = this.toFindManyOptions(options.listQueryOptions); - const returnAll = (await user.hasGlobalScope('credential:list')) && !options.onlyOwn; + const returnAll = user.hasGlobalScope('credential:list') && !options.onlyOwn; const isDefaultSelect = !options.listQueryOptions?.select; if (returnAll) { @@ -150,7 +150,7 @@ export class CredentialsService { // Omit user from where if the requesting user has relevant // global credential permissions. This allows the user to // access credentials they don't own. - if (!options.allowGlobalScope || !(await user.hasGlobalScope(options.globalScope))) { + if (!options.allowGlobalScope || !user.hasGlobalScope(options.globalScope)) { Object.assign(where, { userId: user.id, role: { name: 'owner' }, diff --git a/packages/cli/src/databases/entities/User.ts b/packages/cli/src/databases/entities/User.ts index d3d8a7122d630..974315cc08270 100644 --- a/packages/cli/src/databases/entities/User.ts +++ b/packages/cli/src/databases/entities/User.ts @@ -134,7 +134,7 @@ export class User extends WithTimestamps implements IUser { return STATIC_SCOPE_MAP[this.globalRole?.name] ?? []; } - async hasGlobalScope(scope: Scope | Scope[], scopeOptions?: ScopeOptions): Promise { + hasGlobalScope(scope: Scope | Scope[], scopeOptions?: ScopeOptions): boolean { return hasScope( scope, { diff --git a/packages/cli/src/databases/entities/WebhookEntity.ts b/packages/cli/src/databases/entities/WebhookEntity.ts index 6527a7be30fae..89b863d427cbd 100644 --- a/packages/cli/src/databases/entities/WebhookEntity.ts +++ b/packages/cli/src/databases/entities/WebhookEntity.ts @@ -23,10 +23,12 @@ export class WebhookEntity { pathLength?: number; /** - * Unique section of production webhook path, appended to `${instanceUrl}/webhook/`. - * - Example for static UUID webhook: `87dd035f-9606-47b7-b443-8b675fe25719` - * - Example for static user-defined webhook: `user/:id/posts` - * - Example for dynamic webhook: `7e0e2b2a-19ba-4a6c-b452-4b46c0e11749/user/:id/posts` + * Unique section of webhook path. + * + * - Static: `${uuid}` or `user/defined/path` + * - Dynamic: `${uuid}/user/:id/posts` + * + * Appended to `${instanceUrl}/webhook/` or `${instanceUrl}/test-webhook/`. */ private get uniquePath() { return this.webhookPath.includes(':') diff --git a/packages/cli/src/databases/repositories/role.repository.ts b/packages/cli/src/databases/repositories/role.repository.ts index a78efadf10d72..8ecee595c1564 100644 --- a/packages/cli/src/databases/repositories/role.repository.ts +++ b/packages/cli/src/databases/repositories/role.repository.ts @@ -12,4 +12,23 @@ export class RoleRepository extends Repository { async findRole(scope: RoleScopes, name: RoleNames) { return this.findOne({ where: { scope, name } }); } + + /** + * Counts the number of users in each role, e.g. `{ admin: 2, member: 6, owner: 1 }` + */ + async countUsersByRole() { + type Row = { role_name: string; count: number }; + + const rows: Row[] = await this.createQueryBuilder('role') + .select('role.name') + .addSelect('COUNT(user.id)', 'count') + .innerJoin('user', 'user', 'role.id = user.globalRoleId') + .groupBy('role.name') + .getRawMany(); + + return rows.reduce>((acc, item) => { + acc[item.role_name] = item.count; + return acc; + }, {}); + } } diff --git a/packages/cli/src/databases/repositories/sharedCredentials.repository.ts b/packages/cli/src/databases/repositories/sharedCredentials.repository.ts index 2ba0af34845de..6467de4dec9c8 100644 --- a/packages/cli/src/databases/repositories/sharedCredentials.repository.ts +++ b/packages/cli/src/databases/repositories/sharedCredentials.repository.ts @@ -15,7 +15,7 @@ export class SharedCredentialsRepository extends Repository { relations: ['credentials'], where: { credentialsId, - ...(!(await user.hasGlobalScope('credential:read')) ? { userId: user.id } : {}), + ...(!user.hasGlobalScope('credential:read') ? { userId: user.id } : {}), }, }); if (!sharedCredential) return null; diff --git a/packages/cli/src/databases/repositories/workflow.repository.ts b/packages/cli/src/databases/repositories/workflow.repository.ts index 20a937f0eb199..d5b193ff2678c 100644 --- a/packages/cli/src/databases/repositories/workflow.repository.ts +++ b/packages/cli/src/databases/repositories/workflow.repository.ts @@ -21,4 +21,11 @@ export class WorkflowRepository extends Repository { relations: ['shared', 'shared.user', 'shared.user.globalRole', 'shared.role'], }); } + + async getActiveTriggerCount() { + const totalTriggerCount = await this.sum('triggerCount', { + active: true, + }); + return totalTriggerCount ?? 0; + } } diff --git a/packages/cli/src/decorators/registerController.ts b/packages/cli/src/decorators/registerController.ts index 1900aa4c7ded4..ccc097dde9c66 100644 --- a/packages/cli/src/decorators/registerController.ts +++ b/packages/cli/src/decorators/registerController.ts @@ -68,7 +68,7 @@ export const createGlobalScopeMiddleware = if (!user) return res.status(401).json({ status: 'error', message: 'Unauthorized' }); - const hasScopes = await user.hasGlobalScope(scopes); + const hasScopes = user.hasGlobalScope(scopes); if (!hasScopes) { return res.status(403).json({ status: 'error', message: 'Unauthorized' }); } diff --git a/packages/cli/src/errors/response-errors/webhook-not-found.error.ts b/packages/cli/src/errors/response-errors/webhook-not-found.error.ts new file mode 100644 index 0000000000000..617c119a1e077 --- /dev/null +++ b/packages/cli/src/errors/response-errors/webhook-not-found.error.ts @@ -0,0 +1,57 @@ +import { NotFoundError } from './not-found.error'; + +export const webhookNotFoundErrorMessage = ({ + path, + httpMethod, + webhookMethods, +}: { + path: string; + httpMethod?: string; + webhookMethods?: string[]; +}) => { + let webhookPath = path; + + if (httpMethod) { + webhookPath = `${httpMethod} ${webhookPath}`; + } + + if (webhookMethods?.length && httpMethod) { + let methods = ''; + + if (webhookMethods.length === 1) { + methods = webhookMethods[0]; + } else { + const lastMethod = webhookMethods.pop(); + + methods = `${webhookMethods.join(', ')} or ${lastMethod as string}`; + } + + return `This webhook is not registered for ${httpMethod} requests. Did you mean to make a ${methods} request?`; + } else { + return `The requested webhook "${webhookPath}" is not registered.`; + } +}; + +export class WebhookNotFoundError extends NotFoundError { + constructor( + { + path, + httpMethod, + webhookMethods, + }: { + path: string; + httpMethod?: string; + webhookMethods?: string[]; + }, + { hint }: { hint: 'default' | 'production' } = { hint: 'default' }, + ) { + const errorMsg = webhookNotFoundErrorMessage({ path, httpMethod, webhookMethods }); + + const hintMsg = + hint === 'default' + ? "Click the 'Execute workflow' button on the canvas, then try again. (In test mode, the webhook only works for one call after you click this button)" + : "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"; + + super(errorMsg, hintMsg); + } +} diff --git a/packages/cli/src/errors/workflow-missing-id.error.ts b/packages/cli/src/errors/workflow-missing-id.error.ts new file mode 100644 index 0000000000000..613d418884623 --- /dev/null +++ b/packages/cli/src/errors/workflow-missing-id.error.ts @@ -0,0 +1,8 @@ +import type { Workflow } from 'n8n-workflow'; +import { ApplicationError } from 'n8n-workflow'; + +export class WorkflowMissingIdError extends ApplicationError { + constructor(workflow: Workflow) { + super('Detected ID-less worklfow', { extra: { workflow } }); + } +} diff --git a/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestinationWebhook.ee.ts b/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestinationWebhook.ee.ts index 78377d623adea..932ac90c04f09 100644 --- a/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestinationWebhook.ee.ts +++ b/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestinationWebhook.ee.ts @@ -324,9 +324,15 @@ export class MessageEventBusDestinationWebhook password: httpBasicAuth.password as string, }; } else if (httpHeaderAuth) { - this.axiosRequestOptions.headers[httpHeaderAuth.name as string] = httpHeaderAuth.value; + this.axiosRequestOptions.headers = { + ...this.axiosRequestOptions.headers, + [httpHeaderAuth.name as string]: httpHeaderAuth.value as string, + }; } else if (httpQueryAuth) { - this.axiosRequestOptions.params[httpQueryAuth.name as string] = httpQueryAuth.value; + this.axiosRequestOptions.params = { + ...this.axiosRequestOptions.params, + [httpQueryAuth.name as string]: httpQueryAuth.value as string, + }; } else if (httpDigestAuth) { this.axiosRequestOptions.auth = { username: httpDigestAuth.user as string, diff --git a/packages/cli/src/executions/executions.service.ts b/packages/cli/src/executions/executions.service.ts index ab9999d81f18d..6790636346ddd 100644 --- a/packages/cli/src/executions/executions.service.ts +++ b/packages/cli/src/executions/executions.service.ts @@ -155,7 +155,7 @@ export class ExecutionsService { filter, sharedWorkflowIds, executingWorkflowIds, - await req.user.hasGlobalScope('workflow:list'), + req.user.hasGlobalScope('workflow:list'), ); const formattedExecutions = await Container.get(ExecutionRepository).searchExecutions( diff --git a/packages/cli/src/license/License.service.ts b/packages/cli/src/license/License.service.ts deleted file mode 100644 index 20437a5a38934..0000000000000 --- a/packages/cli/src/license/License.service.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Container } from 'typedi'; -import { License } from '@/License'; -import type { ILicenseReadResponse } from '@/Interfaces'; -import { WorkflowRepository } from '@db/repositories/workflow.repository'; - -export class LicenseService { - static async getActiveTriggerCount(): Promise { - const totalTriggerCount = await Container.get(WorkflowRepository).sum('triggerCount', { - active: true, - }); - return totalTriggerCount ?? 0; - } - - // Helper for getting the basic license data that we want to return - static async getLicenseData(): Promise { - const triggerCount = await LicenseService.getActiveTriggerCount(); - const license = Container.get(License); - const mainPlan = license.getMainPlan(); - - return { - usage: { - executions: { - value: triggerCount, - limit: license.getTriggerLimit(), - warningThreshold: 0.8, - }, - }, - license: { - planId: mainPlan?.productId ?? '', - planName: license.getPlanName(), - }, - }; - } -} diff --git a/packages/cli/src/license/license.controller.ts b/packages/cli/src/license/license.controller.ts index b907def729d17..0351361648180 100644 --- a/packages/cli/src/license/license.controller.ts +++ b/packages/cli/src/license/license.controller.ts @@ -1,129 +1,37 @@ -import express from 'express'; -import { Container } from 'typedi'; - -import { Logger } from '@/Logger'; -import * as ResponseHelper from '@/ResponseHelper'; -import type { ILicensePostResponse, ILicenseReadResponse } from '@/Interfaces'; -import { LicenseService } from './License.service'; -import { License } from '@/License'; -import type { AuthenticatedRequest, LicenseRequest } from '@/requests'; -import { InternalHooks } from '@/InternalHooks'; -import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; - -export const licenseController = express.Router(); - -const OWNER_ROUTES = ['/activate', '/renew']; - -/** - * Owner checking - */ -licenseController.use((req: AuthenticatedRequest, res, next) => { - if (OWNER_ROUTES.includes(req.path) && req.user) { - if (!req.user.isOwner) { - Container.get(Logger).info('Non-owner attempted to activate or renew a license', { - userId: req.user.id, - }); - ResponseHelper.sendErrorResponse( - res, - new UnauthorizedError('Only an instance owner may activate or renew a license'), - ); - return; - } +import { Service } from 'typedi'; +import { Authorized, Get, Post, RequireGlobalScope, RestController } from '@/decorators'; +import { LicenseRequest } from '@/requests'; +import { LicenseService } from './license.service'; + +@Service() +@Authorized() +@RestController('/license') +export class LicenseController { + constructor(private readonly licenseService: LicenseService) {} + + @Get('/') + async getLicenseData() { + return this.licenseService.getLicenseData(); } - next(); -}); - -/** - * GET /license - * Get the license data, usable by everyone - */ -licenseController.get( - '/', - ResponseHelper.send(async (): Promise => { - return LicenseService.getLicenseData(); - }), -); - -/** - * POST /license/activate - * Only usable by the instance owner, activates a license. - */ -licenseController.post( - '/activate', - ResponseHelper.send(async (req: LicenseRequest.Activate): Promise => { - // Call the license manager activate function and tell it to throw an error - const license = Container.get(License); - try { - await license.activate(req.body.activationKey); - } catch (e) { - const error = e as Error & { errorId?: string }; - - let message = 'Failed to activate license'; - //override specific error messages (to map License Server vocabulary to n8n terms) - switch (error.errorId ?? 'UNSPECIFIED') { - case 'SCHEMA_VALIDATION': - message = 'Activation key is in the wrong format'; - break; - case 'RESERVATION_EXHAUSTED': - message = - 'Activation key has been used too many times. Please contact sales@n8n.io if you would like to extend it'; - break; - case 'RESERVATION_EXPIRED': - message = 'Activation key has expired'; - break; - case 'NOT_FOUND': - case 'RESERVATION_CONFLICT': - message = 'Activation key not found'; - break; - case 'RESERVATION_DUPLICATE': - message = 'Activation key has already been used on this instance'; - break; - default: - message += `: ${error.message}`; - Container.get(Logger).error(message, { stack: error.stack ?? 'n/a' }); - } - - throw new BadRequestError(message); - } - - // Return the read data, plus the management JWT - return { - managementToken: license.getManagementJwt(), - ...(await LicenseService.getLicenseData()), - }; - }), -); - -/** - * POST /license/renew - * Only usable by instance owner, renews a license - */ -licenseController.post( - '/renew', - ResponseHelper.send(async (): Promise => { - // Call the license manager activate function and tell it to throw an error - const license = Container.get(License); - try { - await license.renew(); - } catch (e) { - const error = e as Error & { errorId?: string }; - - // not awaiting so as not to make the endpoint hang - void Container.get(InternalHooks).onLicenseRenewAttempt({ success: false }); - if (error instanceof Error) { - throw new BadRequestError(error.message); - } - } + @Post('/activate') + @RequireGlobalScope('license:manage') + async activateLicense(req: LicenseRequest.Activate) { + const { activationKey } = req.body; + await this.licenseService.activateLicense(activationKey); + return this.getTokenAndData(); + } - // not awaiting so as not to make the endpoint hang - void Container.get(InternalHooks).onLicenseRenewAttempt({ success: true }); + @Post('/renew') + @RequireGlobalScope('license:manage') + async renewLicense() { + await this.licenseService.renewLicense(); + return this.getTokenAndData(); + } - // Return the read data, plus the management JWT - return { - managementToken: license.getManagementJwt(), - ...(await LicenseService.getLicenseData()), - }; - }), -); + private async getTokenAndData() { + const managementToken = this.licenseService.getManagementJwt(); + const data = await this.licenseService.getLicenseData(); + return { ...data, managementToken }; + } +} diff --git a/packages/cli/src/license/license.service.ts b/packages/cli/src/license/license.service.ts new file mode 100644 index 0000000000000..32d30b16c57a1 --- /dev/null +++ b/packages/cli/src/license/license.service.ts @@ -0,0 +1,83 @@ +import { Service } from 'typedi'; +import { Logger } from '@/Logger'; +import { License } from '@/License'; +import { InternalHooks } from '@/InternalHooks'; +import { WorkflowRepository } from '@db/repositories/workflow.repository'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; + +type LicenseError = Error & { errorId?: keyof typeof LicenseErrors }; + +export const LicenseErrors = { + SCHEMA_VALIDATION: 'Activation key is in the wrong format', + RESERVATION_EXHAUSTED: + 'Activation key has been used too many times. Please contact sales@n8n.io if you would like to extend it', + RESERVATION_EXPIRED: 'Activation key has expired', + NOT_FOUND: 'Activation key not found', + RESERVATION_CONFLICT: 'Activation key not found', + RESERVATION_DUPLICATE: 'Activation key has already been used on this instance', +}; + +@Service() +export class LicenseService { + constructor( + private readonly logger: Logger, + private readonly license: License, + private readonly internalHooks: InternalHooks, + private readonly workflowRepository: WorkflowRepository, + ) {} + + async getLicenseData() { + const triggerCount = await this.workflowRepository.getActiveTriggerCount(); + const mainPlan = this.license.getMainPlan(); + + return { + usage: { + executions: { + value: triggerCount, + limit: this.license.getTriggerLimit(), + warningThreshold: 0.8, + }, + }, + license: { + planId: mainPlan?.productId ?? '', + planName: this.license.getPlanName(), + }, + }; + } + + getManagementJwt(): string { + return this.license.getManagementJwt(); + } + + async activateLicense(activationKey: string) { + try { + await this.license.activate(activationKey); + } catch (e) { + const message = this.mapErrorMessage(e as LicenseError, 'activate'); + throw new BadRequestError(message); + } + } + + async renewLicense() { + try { + await this.license.renew(); + } catch (e) { + const message = this.mapErrorMessage(e as LicenseError, 'renew'); + // not awaiting so as not to make the endpoint hang + void this.internalHooks.onLicenseRenewAttempt({ success: false }); + throw new BadRequestError(message); + } + + // not awaiting so as not to make the endpoint hang + void this.internalHooks.onLicenseRenewAttempt({ success: true }); + } + + private mapErrorMessage(error: LicenseError, action: 'activate' | 'renew') { + let message = error.errorId && LicenseErrors[error.errorId]; + if (!message) { + message = `Failed to ${action} license: ${error.message}`; + this.logger.error(message, { stack: error.stack ?? 'n/a' }); + } + return message; + } +} diff --git a/packages/cli/src/permissions/roles.ts b/packages/cli/src/permissions/roles.ts index 9db80b3d2b318..44977e0aaf9ce 100644 --- a/packages/cli/src/permissions/roles.ts +++ b/packages/cli/src/permissions/roles.ts @@ -34,6 +34,7 @@ export const ownerPermissions: Scope[] = [ 'externalSecret:use', 'ldap:manage', 'ldap:sync', + 'license:manage', 'logStreaming:manage', 'orchestration:read', 'orchestration:list', diff --git a/packages/cli/src/security-audit/SecurityAudit.service.ts b/packages/cli/src/security-audit/SecurityAudit.service.ts index 469ea6033298d..3c41df5e39bea 100644 --- a/packages/cli/src/security-audit/SecurityAudit.service.ts +++ b/packages/cli/src/security-audit/SecurityAudit.service.ts @@ -52,7 +52,7 @@ export class SecurityAuditService { const className = category.charAt(0).toUpperCase() + category.slice(1) + 'RiskReporter'; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const RiskReporterModule = await import(`@/security-audit/risk-reporters/${className}`); + const RiskReporterModule = await import(`./risk-reporters/${className}`); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const RiskReporterClass = RiskReporterModule[className] as { new (): RiskReporter }; diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index bbd8cfcbeb8e8..6fa2fdac18191 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -2,14 +2,16 @@ import type RudderStack from '@rudderstack/rudder-sdk-node'; import { PostHogClient } from '@/posthog'; import { Container, Service } from 'typedi'; import type { ITelemetryTrackProperties } from 'n8n-workflow'; +import { InstanceSettings } from 'n8n-core'; + import config from '@/config'; import type { IExecutionTrackProperties } from '@/Interfaces'; import { Logger } from '@/Logger'; import { License } from '@/License'; -import { LicenseService } from '@/license/License.service'; import { N8N_VERSION } from '@/constants'; +import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { SourceControlPreferencesService } from '../environments/sourceControl/sourceControlPreferences.service.ee'; -import { InstanceSettings } from 'n8n-core'; +import { RoleRepository } from '@/databases/repositories/role.repository'; type ExecutionTrackDataKey = 'manual_error' | 'manual_success' | 'prod_error' | 'prod_success'; @@ -41,6 +43,7 @@ export class Telemetry { private postHog: PostHogClient, private license: License, private readonly instanceSettings: InstanceSettings, + private readonly workflowRepository: WorkflowRepository, ) {} async init() { @@ -107,7 +110,8 @@ export class Telemetry { const pulsePacket = { plan_name_current: this.license.getPlanName(), quota: this.license.getTriggerLimit(), - usage: await LicenseService.getActiveTriggerCount(), + usage: await this.workflowRepository.getActiveTriggerCount(), + role_count: await Container.get(RoleRepository).countUsersByRole(), source_control_set_up: Container.get(SourceControlPreferencesService).isSourceControlSetup(), branchName: sourceControlPreferences.branchName, read_only_instance: sourceControlPreferences.branchReadOnly, diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index 5e4f38d01158a..36960f5dbcdfc 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -60,34 +60,6 @@ export const separate = (array: T[], test: (element: T) => boolean) => { return [pass, fail]; }; -export const webhookNotFoundErrorMessage = ( - path: string, - httpMethod?: string, - webhookMethods?: string[], -) => { - let webhookPath = path; - - if (httpMethod) { - webhookPath = `${httpMethod} ${webhookPath}`; - } - - if (webhookMethods?.length && httpMethod) { - let methods = ''; - - if (webhookMethods.length === 1) { - methods = webhookMethods[0]; - } else { - const lastMethod = webhookMethods.pop(); - - methods = `${webhookMethods.join(', ')} or ${lastMethod as string}`; - } - - return `This webhook is not registered for ${httpMethod} requests. Did you mean to make a ${methods} request?`; - } else { - return `The requested webhook "${webhookPath}" is not registered.`; - } -}; - export const toError = (maybeError: unknown) => // eslint-disable-next-line @typescript-eslint/restrict-template-expressions maybeError instanceof Error ? maybeError : new Error(`${maybeError}`); diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index 68af5795d55c7..d4854d123dff0 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -71,7 +71,7 @@ export class WorkflowService { // Omit user from where if the requesting user has relevant // global workflow permissions. This allows the user to // access workflows they don't own. - if (!options.allowGlobalScope || !(await user.hasGlobalScope(options.globalScope))) { + if (!options.allowGlobalScope || !user.hasGlobalScope(options.globalScope)) { where.userId = user.id; } @@ -215,7 +215,7 @@ export class WorkflowService { ): Promise { const shared = await this.sharedWorkflowRepository.findOne({ relations: ['workflow', 'role'], - where: await whereClause({ + where: whereClause({ user, globalScope: 'workflow:update', entityType: 'workflow', @@ -432,7 +432,7 @@ export class WorkflowService { const additionalData = await WorkflowExecuteAdditionalData.getBase(user.id); - const needsWebhook = await this.testWebhooks.needsWebhookData( + const needsWebhook = await this.testWebhooks.needsWebhook( workflowData, workflow, additionalData, @@ -441,11 +441,8 @@ export class WorkflowService { sessionId, destinationNode, ); - if (needsWebhook) { - return { - waitingForWebhook: true, - }; - } + + if (needsWebhook) return { waitingForWebhook: true }; } // For manual testing always set to not active @@ -482,7 +479,7 @@ export class WorkflowService { const sharedWorkflow = await this.sharedWorkflowRepository.findOne({ relations: ['workflow', 'role'], - where: await whereClause({ + where: whereClause({ user, globalScope: 'workflow:delete', entityType: 'workflow', diff --git a/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts b/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts index 2b5852f23b95a..853c7260f5e04 100644 --- a/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts +++ b/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts @@ -21,7 +21,7 @@ export class WorkflowHistoryService { private async getSharedWorkflow(user: User, workflowId: string): Promise { return this.sharedWorkflowRepository.findOne({ where: { - ...(!(await user.hasGlobalScope('workflow:read')) && { userId: user.id }), + ...(!user.hasGlobalScope('workflow:read') && { userId: user.id }), workflowId, }, }); diff --git a/packages/cli/src/workflows/workflows.controller.ee.ts b/packages/cli/src/workflows/workflows.controller.ee.ts index 20cce565069ac..900c8d3660249 100644 --- a/packages/cli/src/workflows/workflows.controller.ee.ts +++ b/packages/cli/src/workflows/workflows.controller.ee.ts @@ -67,7 +67,7 @@ EEWorkflowController.put( if (!ownsWorkflow || !workflow) { workflow = undefined; // Allow owners/admins to share - if (await req.user.hasGlobalScope('workflow:share')) { + if (req.user.hasGlobalScope('workflow:share')) { const sharedRes = await Container.get(WorkflowService).getSharing(req.user, workflowId, { allowGlobalScope: true, globalScope: 'workflow:share', @@ -136,7 +136,7 @@ EEWorkflowController.get( } const userSharing = workflow.shared?.find((shared) => shared.user.id === req.user.id); - if (!userSharing && !(await req.user.hasGlobalScope('workflow:read'))) { + if (!userSharing && !req.user.hasGlobalScope('workflow:read')) { throw new UnauthorizedError( 'You do not have permission to access this workflow. Ask the owner to share it with you', ); diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index de53ed1dfe226..b32ddce0ade9f 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -209,7 +209,7 @@ workflowsController.get( const shared = await Container.get(SharedWorkflowRepository).findOne({ relations, - where: await whereClause({ + where: whereClause({ user: req.user, entityType: 'workflow', globalScope: 'workflow:read', diff --git a/packages/cli/test/integration/license.api.test.ts b/packages/cli/test/integration/license.api.test.ts index 30c64c213a2a9..1430beb064583 100644 --- a/packages/cli/test/integration/license.api.test.ts +++ b/packages/cli/test/integration/license.api.test.ts @@ -60,7 +60,7 @@ describe('POST /license/activate', () => { await authMemberAgent .post('/license/activate') .send({ activationKey: 'abcde' }) - .expect(403, { code: 403, message: NON_OWNER_ACTIVATE_RENEW_MESSAGE }); + .expect(403, UNAUTHORIZED_RESPONSE); }); test('errors out properly', async () => { @@ -82,19 +82,17 @@ describe('POST /license/renew', () => { }); test('does not work for regular users', async () => { - await authMemberAgent - .post('/license/renew') - .expect(403, { code: 403, message: NON_OWNER_ACTIVATE_RENEW_MESSAGE }); + await authMemberAgent.post('/license/renew').expect(403, UNAUTHORIZED_RESPONSE); }); test('errors out properly', async () => { License.prototype.renew = jest.fn().mockImplementation(() => { - throw new Error(RENEW_ERROR_MESSAGE); + throw new Error(GENERIC_ERROR_MESSAGE); }); await authOwnerAgent .post('/license/renew') - .expect(400, { code: 400, message: RENEW_ERROR_MESSAGE }); + .expect(400, { code: 400, message: `Failed to renew license: ${GENERIC_ERROR_MESSAGE}` }); }); }); @@ -131,6 +129,6 @@ const DEFAULT_POST_RESPONSE: { data: ILicensePostResponse } = { }, }; -const NON_OWNER_ACTIVATE_RENEW_MESSAGE = 'Only an instance owner may activate or renew a license'; +const UNAUTHORIZED_RESPONSE = { status: 'error', message: 'Unauthorized' }; const ACTIVATION_FAILED_MESSAGE = 'Failed to activate license'; -const RENEW_ERROR_MESSAGE = 'Something went wrong when trying to renew license'; +const GENERIC_ERROR_MESSAGE = 'Something went wrong'; diff --git a/packages/cli/test/integration/role.repository.test.ts b/packages/cli/test/integration/role.repository.test.ts new file mode 100644 index 0000000000000..04e645928d9bb --- /dev/null +++ b/packages/cli/test/integration/role.repository.test.ts @@ -0,0 +1,37 @@ +import { createAdmin, createMember, createOwner } from './shared/db/users'; +import * as testDb from './shared/testDb'; +import { RoleRepository } from '@/databases/repositories/role.repository'; +import Container from 'typedi'; + +describe('RoleRepository', () => { + let roleRepository: RoleRepository; + + beforeAll(async () => { + await testDb.init(); + + roleRepository = Container.get(RoleRepository); + + await testDb.truncate(['User']); + }); + + afterAll(async () => { + await testDb.terminate(); + }); + + describe('countUsersByRole()', () => { + test('should return the number of users in each role', async () => { + await Promise.all([ + createOwner(), + createAdmin(), + createAdmin(), + createMember(), + createMember(), + createMember(), + ]); + + const usersByRole = await roleRepository.countUsersByRole(); + + expect(usersByRole).toStrictEqual({ admin: 2, member: 3, owner: 1 }); + }); + }); +}); diff --git a/packages/cli/test/integration/shared/utils/testServer.ts b/packages/cli/test/integration/shared/utils/testServer.ts index 351e0413e2e8a..f765f83dac5c1 100644 --- a/packages/cli/test/integration/shared/utils/testServer.ts +++ b/packages/cli/test/integration/shared/utils/testServer.ts @@ -144,8 +144,8 @@ export const setupTestServer = ({ break; case 'license': - const { licenseController } = await import('@/license/license.controller'); - app.use(`/${REST_PATH_SEGMENT}/license`, licenseController); + const { LicenseController } = await import('@/license/license.controller'); + registerController(app, config, Container.get(LicenseController)); break; case 'metrics': diff --git a/packages/cli/test/unit/License.test.ts b/packages/cli/test/unit/License.test.ts index 7238122380f51..3079695d467a5 100644 --- a/packages/cli/test/unit/License.test.ts +++ b/packages/cli/test/unit/License.test.ts @@ -28,7 +28,7 @@ describe('License', () => { let license: License; const logger = mockInstance(Logger); const instanceSettings = mockInstance(InstanceSettings, { instanceId: MOCK_INSTANCE_ID }); - const multiMainSetup = mockInstance(MultiMainSetup); + mockInstance(MultiMainSetup); beforeEach(async () => { license = new License(logger, instanceSettings, mock(), mock(), mock()); @@ -85,20 +85,20 @@ describe('License', () => { expect(LicenseManager.prototype.renew).toHaveBeenCalled(); }); - test('check if feature is enabled', async () => { - await license.isFeatureEnabled(MOCK_FEATURE_FLAG); + test('check if feature is enabled', () => { + license.isFeatureEnabled(MOCK_FEATURE_FLAG); expect(LicenseManager.prototype.hasFeatureEnabled).toHaveBeenCalledWith(MOCK_FEATURE_FLAG); }); - test('check if sharing feature is enabled', async () => { - await license.isFeatureEnabled(MOCK_FEATURE_FLAG); + test('check if sharing feature is enabled', () => { + license.isFeatureEnabled(MOCK_FEATURE_FLAG); expect(LicenseManager.prototype.hasFeatureEnabled).toHaveBeenCalledWith(MOCK_FEATURE_FLAG); }); - test('check fetching entitlements', async () => { - await license.getCurrentEntitlements(); + test('check fetching entitlements', () => { + license.getCurrentEntitlements(); expect(LicenseManager.prototype.getCurrentEntitlements).toHaveBeenCalled(); }); @@ -110,7 +110,7 @@ describe('License', () => { }); test('check management jwt', async () => { - await license.getManagementJwt(); + license.getManagementJwt(); expect(LicenseManager.prototype.getManagementJwt).toHaveBeenCalled(); }); diff --git a/packages/cli/test/unit/Telemetry.test.ts b/packages/cli/test/unit/Telemetry.test.ts index 129df8daebdc2..d943d83e500c9 100644 --- a/packages/cli/test/unit/Telemetry.test.ts +++ b/packages/cli/test/unit/Telemetry.test.ts @@ -8,13 +8,6 @@ import { InstanceSettings } from 'n8n-core'; import { mockInstance } from '../shared/mocking'; jest.unmock('@/telemetry'); -jest.mock('@/license/License.service', () => { - return { - LicenseService: { - getActiveTriggerCount: async () => 0, - }, - }; -}); jest.mock('@/posthog'); describe('Telemetry', () => { diff --git a/packages/cli/test/unit/TestWebhooks.test.ts b/packages/cli/test/unit/TestWebhooks.test.ts new file mode 100644 index 0000000000000..7eb321f52cac6 --- /dev/null +++ b/packages/cli/test/unit/TestWebhooks.test.ts @@ -0,0 +1,154 @@ +import { mockInstance } from '../shared/mocking'; +import { NodeTypes } from '@/NodeTypes'; +import { Push } from '@/push'; +import { TestWebhooks } from '@/TestWebhooks'; +import { WebhookNotFoundError } from '@/errors/response-errors/webhook-not-found.error'; +import { v4 as uuid } from 'uuid'; +import { generateNanoId } from '@/databases/utils/generators'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import * as WebhookHelpers from '@/WebhookHelpers'; + +import type { IWorkflowDb, WebhookRequest } from '@/Interfaces'; +import type express from 'express'; +import type { + IWebhookData, + IWorkflowExecuteAdditionalData, + Workflow, + WorkflowActivateMode, + WorkflowExecuteMode, +} from 'n8n-workflow'; + +describe('TestWebhooks', () => { + jest.useFakeTimers(); + + const push = mockInstance(Push); + const nodeTypes = mockInstance(NodeTypes); + + const testWebhooks = new TestWebhooks(push, nodeTypes); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('needsWebhook()', () => { + const httpMethod = 'GET'; + const path = uuid(); + const workflowId = generateNanoId(); + + const webhook = { + httpMethod, + path, + workflowId, + webhookDescription: {}, + } as IWebhookData; + + const keyPart = [httpMethod, path].join('|'); + + type NeedsWebhookArgs = [ + IWorkflowDb, + Workflow, + IWorkflowExecuteAdditionalData, + WorkflowExecuteMode, + WorkflowActivateMode, + ]; + + const workflow = { + id: workflowId, + createWebhookIfNotExists: () => {}, + deleteWebhook: () => {}, + } as unknown as Workflow; + + const args: NeedsWebhookArgs = [ + { id: workflowId } as unknown as IWorkflowDb, + workflow, + {} as unknown as IWorkflowExecuteAdditionalData, + 'manual', + 'manual', + ]; + + test('should register a webhook as active', async () => { + jest.spyOn(WebhookHelpers, 'getWorkflowWebhooks').mockReturnValue([webhook]); + jest.spyOn(testWebhooks, 'toWebhookKey').mockReturnValue(keyPart); + const activateWebhookSpy = jest.spyOn(testWebhooks, 'activateWebhook'); + + const needsWebhook = await testWebhooks.needsWebhook(...args); + + expect(needsWebhook).toBe(true); + expect(activateWebhookSpy).toHaveBeenCalledWith(workflow, webhook, 'manual', 'manual'); + }); + + test('should remove from active webhooks on failure to add', async () => { + const msg = 'Failed to add webhook to active webhooks'; + + jest.spyOn(WebhookHelpers, 'getWorkflowWebhooks').mockReturnValue([webhook]); + jest.spyOn(testWebhooks, 'toWebhookKey').mockReturnValue(keyPart); + jest.spyOn(testWebhooks, 'activateWebhook').mockRejectedValue(new Error(msg)); + const deactivateSpy = jest.spyOn(testWebhooks, 'deactivateWebhooksFor'); + + const needsWebhook = testWebhooks.needsWebhook(...args); + + await expect(needsWebhook).rejects.toThrowError(msg); + expect(deactivateSpy).toHaveBeenCalledWith(workflow); + }); + + test('should return false if no webhook to start workflow', async () => { + webhook.webhookDescription.restartWebhook = true; + jest.spyOn(WebhookHelpers, 'getWorkflowWebhooks').mockReturnValue([webhook]); + + const result = await testWebhooks.needsWebhook(...args); + + expect(result).toBe(false); + }); + }); + + describe('executeWebhook()', () => { + const httpMethod = 'GET'; + const path = uuid(); + const workflowId = generateNanoId(); + + const webhook = { + httpMethod, + path, + workflowId, + } as IWebhookData; + + const keyPart = [httpMethod, path].join('|'); + + test('should throw if webhook is not registered', async () => { + jest.spyOn(testWebhooks, 'getActiveWebhook').mockReturnValue(webhook); + jest.spyOn(testWebhooks, 'getWebhookMethods').mockResolvedValue([]); + jest.spyOn(testWebhooks, 'toWebhookKey').mockReturnValue(keyPart); + + const request = { params: { path } } as WebhookRequest; + const response = {} as express.Response; + const promise = testWebhooks.executeWebhook(request, response); + + await expect(promise).rejects.toThrowError(WebhookNotFoundError); + }); + + test('should throw if webhook node is registered but missing from workflow', async () => { + jest.spyOn(testWebhooks, 'getActiveWebhook').mockReturnValue(webhook); + jest.spyOn(testWebhooks, 'getWebhookMethods').mockResolvedValue([]); + jest.spyOn(testWebhooks, 'toWebhookKey').mockReturnValue(keyPart); + + // @ts-expect-error Private property + testWebhooks.registeredWebhooks[`${keyPart}|${workflowId}`] = { + sessionId: 'some-session-id', + timeout: setTimeout(() => {}, 0), + workflowEntity: {} as IWorkflowDb, + workflow: { + getNode: () => null, + } as unknown as Workflow, + }; + + const request = { params: { path } } as WebhookRequest; + const response = {} as express.Response; + const promise = testWebhooks.executeWebhook(request, response); + + await expect(promise).rejects.toThrowError(NotFoundError); + + // @ts-expect-error Private property + delete testWebhooks.registeredWebhooks[`${keyPart}|${workflowId}`]; + }); + }); +}); diff --git a/packages/cli/test/unit/license/license.service.test.ts b/packages/cli/test/unit/license/license.service.test.ts new file mode 100644 index 0000000000000..7ac75ba5b3623 --- /dev/null +++ b/packages/cli/test/unit/license/license.service.test.ts @@ -0,0 +1,76 @@ +import { LicenseErrors, LicenseService } from '@/license/license.service'; +import type { License } from '@/License'; +import type { InternalHooks } from '@/InternalHooks'; +import type { WorkflowRepository } from '@db/repositories/workflow.repository'; +import type { TEntitlement } from '@n8n_io/license-sdk'; +import { mock } from 'jest-mock-extended'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; + +describe('LicenseService', () => { + const license = mock(); + const internalHooks = mock(); + const workflowRepository = mock(); + const entitlement = mock({ productId: '123' }); + const licenseService = new LicenseService(mock(), license, internalHooks, workflowRepository); + + license.getMainPlan.mockReturnValue(entitlement); + license.getTriggerLimit.mockReturnValue(400); + license.getPlanName.mockReturnValue('Test Plan'); + workflowRepository.getActiveTriggerCount.mockResolvedValue(7); + + beforeEach(() => jest.clearAllMocks()); + + class LicenseError extends Error { + constructor(readonly errorId: string) { + super(`License error: ${errorId}`); + } + } + + describe('getLicenseData', () => { + it('should return usage and license data', async () => { + const data = await licenseService.getLicenseData(); + expect(data).toEqual({ + usage: { + executions: { + limit: 400, + value: 7, + warningThreshold: 0.8, + }, + }, + license: { + planId: '123', + planName: 'Test Plan', + }, + }); + }); + }); + + describe('activateLicense', () => { + Object.entries(LicenseErrors).forEach(([errorId, message]) => + it(`should handle ${errorId} error`, async () => { + license.activate.mockRejectedValueOnce(new LicenseError(errorId)); + await expect(licenseService.activateLicense('')).rejects.toThrowError( + new BadRequestError(message), + ); + }), + ); + }); + + describe('renewLicense', () => { + test('on success', async () => { + license.renew.mockResolvedValueOnce(); + await licenseService.renewLicense(); + + expect(internalHooks.onLicenseRenewAttempt).toHaveBeenCalledWith({ success: true }); + }); + + test('on failure', async () => { + license.renew.mockRejectedValueOnce(new LicenseError('RESERVATION_EXPIRED')); + await expect(licenseService.renewLicense()).rejects.toThrowError( + new BadRequestError('Activation key has expired'), + ); + + expect(internalHooks.onLicenseRenewAttempt).toHaveBeenCalledWith({ success: false }); + }); + }); +}); diff --git a/packages/cli/test/unit/repositories/sharedCredentials.repository.test.ts b/packages/cli/test/unit/repositories/sharedCredentials.repository.test.ts index 3755fff579dc1..f14c2ee3548b4 100644 --- a/packages/cli/test/unit/repositories/sharedCredentials.repository.test.ts +++ b/packages/cli/test/unit/repositories/sharedCredentials.repository.test.ts @@ -24,20 +24,18 @@ describe('SharedCredentialsRepository', () => { sharedCredential.credentials = mock({ id: credentialsId }); const owner = mock({ isOwner: true, - hasGlobalScope: async (scope) => { - return hasScope(scope, { + hasGlobalScope: (scope) => + hasScope(scope, { global: ownerPermissions, - }); - }, + }), }); const member = mock({ isOwner: false, id: 'test', - hasGlobalScope: async (scope) => { - return hasScope(scope, { + hasGlobalScope: (scope) => + hasScope(scope, { global: memberPermissions, - }); - }, + }), }); beforeEach(() => { diff --git a/packages/cli/test/unit/utils.test.ts b/packages/cli/test/unit/utils.test.ts index cf6de8103b4ec..92a89003d667e 100644 --- a/packages/cli/test/unit/utils.test.ts +++ b/packages/cli/test/unit/utils.test.ts @@ -1,32 +1,44 @@ -import { webhookNotFoundErrorMessage } from '@/utils'; +import { webhookNotFoundErrorMessage } from '@/errors/response-errors/webhook-not-found.error'; describe('utils test webhookNotFoundErrorMessage ', () => { it('should return a message with path and method', () => { - const message = webhookNotFoundErrorMessage('webhook12345', 'GET'); + const message = webhookNotFoundErrorMessage({ path: 'webhook12345', httpMethod: 'GET' }); expect(message).toEqual('The requested webhook "GET webhook12345" is not registered.'); }); it('should return a message with path', () => { - const message = webhookNotFoundErrorMessage('webhook12345'); + const message = webhookNotFoundErrorMessage({ path: 'webhook12345' }); expect(message).toEqual('The requested webhook "webhook12345" is not registered.'); }); it('should return a message with method with tip', () => { - const message = webhookNotFoundErrorMessage('webhook12345', 'POST', ['GET', 'PUT']); + const message = webhookNotFoundErrorMessage({ + path: 'webhook12345', + httpMethod: 'POST', + webhookMethods: ['GET', 'PUT'], + }); expect(message).toEqual( 'This webhook is not registered for POST requests. Did you mean to make a GET or PUT request?', ); }); it('should return a message with method with tip', () => { - const message = webhookNotFoundErrorMessage('webhook12345', 'POST', ['PUT']); + const message = webhookNotFoundErrorMessage({ + path: 'webhook12345', + httpMethod: 'POST', + webhookMethods: ['PUT'], + }); expect(message).toEqual( 'This webhook is not registered for POST requests. Did you mean to make a PUT request?', ); }); it('should return a message with method with tip', () => { - const message = webhookNotFoundErrorMessage('webhook12345', 'POST', ['GET', 'PUT', 'DELETE']); + const message = webhookNotFoundErrorMessage({ + path: 'webhook12345', + httpMethod: 'POST', + webhookMethods: ['GET', 'PUT', 'DELETE'], + }); expect(message).toEqual( 'This webhook is not registered for POST requests. Did you mean to make a GET, PUT or DELETE request?', diff --git a/packages/core/package.json b/packages/core/package.json index 187dc2bc29620..857bf0d9b5818 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -50,7 +50,7 @@ "dependencies": { "@n8n/client-oauth2": "workspace:*", "aws4": "1.11.0", - "axios": "0.21.4", + "axios": "1.6.2", "concat-stream": "2.0.0", "cron": "1.7.2", "fast-glob": "3.2.12", diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index fcf53d7b6eb56..659dbcae8e934 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -16,6 +16,7 @@ import type { import { ClientOAuth2 } from '@n8n/client-oauth2'; import type { AxiosError, + AxiosHeaders, AxiosPromise, AxiosProxyConfig, AxiosRequestConfig, @@ -186,23 +187,24 @@ const createFormDataObject = (data: Record) => { }); return formData; }; -function searchForHeader(headers: IDataObject, headerName: string) { - if (headers === undefined) { + +function searchForHeader(config: AxiosRequestConfig, headerName: string) { + if (config.headers === undefined) { return undefined; } - const headerNames = Object.keys(headers); + const headerNames = Object.keys(config.headers); headerName = headerName.toLowerCase(); return headerNames.find((thisHeader) => thisHeader.toLowerCase() === headerName); } -async function generateContentLengthHeader(formData: FormData, headers: IDataObject) { - if (!formData?.getLength) { +async function generateContentLengthHeader(config: AxiosRequestConfig) { + if (!(config.data instanceof FormData)) { return; } try { - const length = await new Promise((res, rej) => { - formData.getLength((error: Error | null, length: number) => { + const length = await new Promise((res, rej) => { + config.data.getLength((error: Error | null, length: number) => { if (error) { rej(error); return; @@ -210,9 +212,10 @@ async function generateContentLengthHeader(formData: FormData, headers: IDataObj res(length); }); }); - headers = Object.assign(headers, { + config.headers = { + ...config.headers, 'content-length': length, - }); + }; } catch (error) { Logger.error('Unable to calculate form data length', { error }); } @@ -228,7 +231,7 @@ async function parseRequestObject(requestObject: IDataObject) { const axiosConfig: AxiosRequestConfig = {}; if (requestObject.headers !== undefined) { - axiosConfig.headers = requestObject.headers as string; + axiosConfig.headers = requestObject.headers as AxiosHeaders; } // Let's start parsing the hardest part, which is the request body. @@ -246,7 +249,7 @@ async function parseRequestObject(requestObject: IDataObject) { ); const contentType = contentTypeHeaderKeyName && - (axiosConfig.headers[contentTypeHeaderKeyName] as string | undefined); + (axiosConfig.headers?.[contentTypeHeaderKeyName] as string | undefined); if (contentType === 'application/x-www-form-urlencoded' && requestObject.formData === undefined) { // there are nodes incorrectly created, informing the content type header // and also using formData. Request lib takes precedence for the formData. @@ -265,7 +268,7 @@ async function parseRequestObject(requestObject: IDataObject) { axiosConfig.data = stringify(allData); } } - } else if (contentType && contentType.includes('multipart/form-data') !== false) { + } else if (contentType?.includes('multipart/form-data')) { if (requestObject.formData !== undefined && requestObject.formData instanceof FormData) { axiosConfig.data = requestObject.formData; } else { @@ -278,10 +281,10 @@ async function parseRequestObject(requestObject: IDataObject) { } // replace the existing header with a new one that // contains the boundary property. - delete axiosConfig.headers[contentTypeHeaderKeyName]; + delete axiosConfig.headers?.[contentTypeHeaderKeyName!]; const headers = axiosConfig.data.getHeaders(); axiosConfig.headers = Object.assign(axiosConfig.headers || {}, headers); - await generateContentLengthHeader(axiosConfig.data, axiosConfig.headers); + await generateContentLengthHeader(axiosConfig); } else { // When using the `form` property it means the content should be x-www-form-urlencoded. if (requestObject.form !== undefined && requestObject.body === undefined) { @@ -291,7 +294,7 @@ async function parseRequestObject(requestObject: IDataObject) { ? stringify(requestObject.form, { format: 'RFC3986' }) : stringify(requestObject.form).toString(); if (axiosConfig.headers !== undefined) { - const headerName = searchForHeader(axiosConfig.headers, 'content-type'); + const headerName = searchForHeader(axiosConfig, 'content-type'); if (headerName) { delete axiosConfig.headers[headerName]; } @@ -305,9 +308,11 @@ async function parseRequestObject(requestObject: IDataObject) { // remove any "content-type" that might exist. if (axiosConfig.headers !== undefined) { const headers = Object.keys(axiosConfig.headers); - headers.forEach((header) => - header.toLowerCase() === 'content-type' ? delete axiosConfig.headers[header] : null, - ); + headers.forEach((header) => { + if (header.toLowerCase() === 'content-type') { + delete axiosConfig.headers?.[header]; + } + }); } if (requestObject.formData instanceof FormData) { @@ -318,7 +323,7 @@ async function parseRequestObject(requestObject: IDataObject) { // Mix in headers as FormData creates the boundary. const headers = axiosConfig.data.getHeaders(); axiosConfig.headers = Object.assign(axiosConfig.headers || {}, headers); - await generateContentLengthHeader(axiosConfig.data, axiosConfig.headers); + await generateContentLengthHeader(axiosConfig); } else if (requestObject.body !== undefined) { // If we have body and possibly form if (requestObject.form !== undefined && requestObject.body) { @@ -755,7 +760,7 @@ export async function proxyRequestToAxios( return configObject.resolveWithFullResponse ? { body, - headers: response.headers, + headers: { ...response.headers }, statusCode: response.status, statusMessage: response.statusText, request: response.request, @@ -852,7 +857,7 @@ function convertN8nRequestToAxios(n8nRequest: IHttpRequestOptions): AxiosRequest const { body } = n8nRequest; if (body) { // Let's add some useful header standards here. - const existingContentTypeHeaderKey = searchForHeader(axiosRequest.headers, 'content-type'); + const existingContentTypeHeaderKey = searchForHeader(axiosRequest, 'content-type'); if (existingContentTypeHeaderKey === undefined) { axiosRequest.headers = axiosRequest.headers || {}; // We are only setting content type headers if the user did @@ -866,7 +871,7 @@ function convertN8nRequestToAxios(n8nRequest: IHttpRequestOptions): AxiosRequest axiosRequest.headers['Content-Type'] = 'application/x-www-form-urlencoded'; } } else if ( - axiosRequest.headers[existingContentTypeHeaderKey] === 'application/x-www-form-urlencoded' + axiosRequest.headers?.[existingContentTypeHeaderKey] === 'application/x-www-form-urlencoded' ) { axiosRequest.data = new URLSearchParams(n8nRequest.body as Record); } @@ -879,19 +884,25 @@ function convertN8nRequestToAxios(n8nRequest: IHttpRequestOptions): AxiosRequest } if (n8nRequest.json) { - const key = searchForHeader(axiosRequest.headers, 'accept'); + const key = searchForHeader(axiosRequest, 'accept'); // If key exists, then the user has set both accept // header and the json flag. Header should take precedence. if (!key) { - axiosRequest.headers.Accept = 'application/json'; + axiosRequest.headers = { + ...axiosRequest.headers, + Accept: 'application/json', + }; } } - const userAgentHeader = searchForHeader(axiosRequest.headers, 'user-agent'); + const userAgentHeader = searchForHeader(axiosRequest, 'user-agent'); // If key exists, then the user has set both accept // header and the json flag. Header should take precedence. if (!userAgentHeader) { - axiosRequest.headers['User-Agent'] = 'n8n'; + axiosRequest.headers = { + ...axiosRequest.headers, + 'User-Agent': 'n8n', + }; } if (n8nRequest.ignoreHttpStatusErrors) { @@ -3757,7 +3768,6 @@ export function getExecuteHookFunctions( additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode, activation: WorkflowActivateMode, - isTest?: boolean, webhookData?: IWebhookData, ): IHookFunctions { return ((workflow: Workflow, node: INode) => { @@ -3799,7 +3809,7 @@ export function getExecuteHookFunctions( additionalData, mode, getAdditionalKeys(additionalData, mode, null), - isTest, + webhookData?.isTest, ); }, getWebhookName(): string { diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index fb20760f58d34..bf2757b655e84 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -7,17 +7,18 @@ import { sign } from 'aws4'; import { isStream, parseXml, writeBlockedMessage } from './utils'; import { ApplicationError, LoggerProxy as Logger } from 'n8n-workflow'; -import type { AxiosRequestConfig, AxiosResponse, Method } from 'axios'; +import type { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig, Method } from 'axios'; import type { Request as Aws4Options, Credentials as Aws4Credentials } from 'aws4'; import type { Bucket, ConfigSchemaCredentials, ListPage, + MetadataResponseHeaders, RawListPage, RequestOptions, } from './types'; import type { Readable } from 'stream'; -import type { BinaryData } from '..'; +import type { BinaryData } from '../BinaryData/types'; @Service() export class ObjectStoreService { @@ -115,19 +116,11 @@ export class ObjectStoreService { * @doc https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingMetadata.html */ async getMetadata(fileId: string) { - type Response = { - headers: { - 'content-length': string; - 'content-type'?: string; - 'x-amz-meta-filename'?: string; - } & BinaryData.PreWriteMetadata; - }; - const path = `${this.bucket.name}/${fileId}`; - const response: Response = await this.request('HEAD', this.host, path); + const response = await this.request('HEAD', this.host, path); - return response.headers; + return response.headers as MetadataResponseHeaders; } /** @@ -239,10 +232,16 @@ export class ObjectStoreService { this.logger.warn(logMessage); - return { status: 403, statusText: 'Forbidden', data: logMessage, headers: {}, config: {} }; + return { + status: 403, + statusText: 'Forbidden', + data: logMessage, + headers: {}, + config: {} as InternalAxiosRequestConfig, + }; } - private async request( + private async request( method: Method, host: string, rawPath = '', @@ -275,7 +274,7 @@ export class ObjectStoreService { try { this.logger.debug('Sending request to S3', { config }); - return await axios.request(config); + return await axios.request(config); } catch (e) { const error = e instanceof Error ? e : new Error(`${e}`); diff --git a/packages/core/src/ObjectStore/types.ts b/packages/core/src/ObjectStore/types.ts index 444630e924e94..639ae2e6a2f71 100644 --- a/packages/core/src/ObjectStore/types.ts +++ b/packages/core/src/ObjectStore/types.ts @@ -1,4 +1,5 @@ -import type { ResponseType } from 'axios'; +import type { AxiosResponseHeaders, ResponseType } from 'axios'; +import type { BinaryData } from '../BinaryData/types'; export type RawListPage = { listBucketResult: { @@ -31,4 +32,10 @@ export type RequestOptions = { responseType?: ResponseType; }; +export type MetadataResponseHeaders = AxiosResponseHeaders & { + 'content-length': string; + 'content-type'?: string; + 'x-amz-meta-filename'?: string; +} & BinaryData.PreWriteMetadata; + export type ConfigSchemaCredentials = { accessKey: string; accessSecret: string }; diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 01414a5c66d7a..3f3b2097b632b 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -26,42 +26,33 @@ "lint": "eslint src --ext .js,.ts,.vue --quiet", "lintfix": "eslint src --ext .js,.ts,.vue --fix" }, - "peerDependencies": { - "@fortawesome/fontawesome-svg-core": "1.x", - "@fortawesome/free-solid-svg-icons": "5.x", - "@fortawesome/vue-fontawesome": "2.x", - "core-js": "3.x", - "markdown-it": "^12.3.2", - "markdown-it-emoji": "^2.0.0", - "markdown-it-link-attributes": "^4.0.0", - "markdown-it-task-lists": "^2.1.1", - "vue": "^2.7.14", - "xss": "^1.0.14" - }, "devDependencies": { - "@fortawesome/fontawesome-svg-core": "^1.2.36", - "@fortawesome/free-solid-svg-icons": "^5.15.4", - "@fortawesome/vue-fontawesome": "^3.0.3", "@storybook/addon-a11y": "^7.5.2", "@storybook/addon-actions": "^7.5.2", "@storybook/addon-docs": "^7.5.2", "@storybook/addon-essentials": "^7.5.2", + "@storybook/addon-interactions": "^7.5.2", "@storybook/addon-links": "^7.5.2", "@storybook/addon-postcss": "3.0.0-alpha.1", + "@storybook/blocks": "^7.5.2", + "@storybook/testing-library": "^0.2.2", "@storybook/vue3": "^7.5.2", "@storybook/vue3-vite": "^7.5.2", - "@testing-library/jest-dom": "^5.17.0", - "@testing-library/user-event": "^14.4.3", - "@testing-library/vue": "^7.0.0", + "@testing-library/jest-dom": "^6.1.5", + "@testing-library/user-event": "^14.5.1", + "@testing-library/vue": "^8.0.1", + "@tsconfig/node18": "^18.2.2", + "@types/jsdom": "^21.1.6", "@types/markdown-it": "^12.2.3", "@types/markdown-it-emoji": "^2.0.2", "@types/markdown-it-link-attributes": "^3.0.1", "@types/sanitize-html": "^2.9.0", - "@vitejs/plugin-vue": "^4.2.3", - "@vue/test-utils": "^2.4.1", + "@vitejs/plugin-vue": "^4.5.2", + "@vue/test-utils": "^2.4.3", + "@vue/tsconfig": "^0.5.1", "autoprefixer": "^10.4.14", "core-js": "^3.31.0", - "jsdom": "21.1.0", + "jsdom": "^23.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", "sass": "^1.64.1", @@ -71,6 +62,10 @@ "storybook-dark-mode": "^3.0.1" }, "dependencies": { + "@fortawesome/fontawesome-svg-core": "^1.2.36", + "@fortawesome/free-regular-svg-icons": "^6.1.1", + "@fortawesome/free-solid-svg-icons": "^5.15.4", + "@fortawesome/vue-fontawesome": "^3.0.3", "element-plus": "^2.3.6", "markdown-it": "^13.0.1", "markdown-it-emoji": "^2.0.2", diff --git a/packages/design-system/src/components/N8nActionBox/__tests__/__snapshots__/ActionBox.spec.ts.snap b/packages/design-system/src/components/N8nActionBox/__tests__/__snapshots__/ActionBox.spec.ts.snap index f48caf426c7e1..0e8e770f05e94 100644 --- a/packages/design-system/src/components/N8nActionBox/__tests__/__snapshots__/ActionBox.spec.ts.snap +++ b/packages/design-system/src/components/N8nActionBox/__tests__/__snapshots__/ActionBox.spec.ts.snap @@ -1,15 +1,15 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`N8NActionBox > should render correctly 1`] = ` -"
-
😿
-
- +"
+
😿
+
+
-
- +
+
- +
" `; diff --git a/packages/design-system/src/components/N8nActionDropdown/__tests__/__snapshots__/ActionDropdown.spec.ts.snap b/packages/design-system/src/components/N8nActionDropdown/__tests__/__snapshots__/ActionDropdown.spec.ts.snap index 4f9d8d319f2cd..e7f1352c5248c 100644 --- a/packages/design-system/src/components/N8nActionDropdown/__tests__/__snapshots__/ActionDropdown.spec.ts.snap +++ b/packages/design-system/src/components/N8nActionDropdown/__tests__/__snapshots__/ActionDropdown.spec.ts.snap @@ -1,13 +1,13 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`components > N8nActionDropdown > should render custom styling correctly 1`] = ` -"
- +"
+
" `; exports[`components > N8nActionDropdown > should render default styling correctly 1`] = ` -"
- +"
+
" `; diff --git a/packages/design-system/src/components/N8nBadge/__tests__/__snapshots__/Badge.spec.ts.snap b/packages/design-system/src/components/N8nBadge/__tests__/__snapshots__/Badge.spec.ts.snap index 985e4019b90ba..5bae5965234f9 100644 --- a/packages/design-system/src/components/N8nBadge/__tests__/__snapshots__/Badge.spec.ts.snap +++ b/packages/design-system/src/components/N8nBadge/__tests__/__snapshots__/Badge.spec.ts.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`components > N8nBadge > props > should render default theme correctly 1`] = `""`; +exports[`components > N8nBadge > props > should render default theme correctly 1`] = `""`; -exports[`components > N8nBadge > props > should render secondary theme correctly 1`] = `""`; +exports[`components > N8nBadge > props > should render secondary theme correctly 1`] = `""`; -exports[`components > N8nBadge > props > should render with default values correctly 1`] = `""`; +exports[`components > N8nBadge > props > should render with default values correctly 1`] = `""`; diff --git a/packages/design-system/src/components/N8nButton/__tests__/__snapshots__/Button.spec.ts.snap b/packages/design-system/src/components/N8nButton/__tests__/__snapshots__/Button.spec.ts.snap index 162da77b5d108..7b23d39ad89f0 100644 --- a/packages/design-system/src/components/N8nButton/__tests__/__snapshots__/Button.spec.ts.snap +++ b/packages/design-system/src/components/N8nButton/__tests__/__snapshots__/Button.spec.ts.snap @@ -1,17 +1,17 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`components > N8nButton > props > icon > should render icon button 1`] = `""`; +exports[`components > N8nButton > props > icon > should render icon button 1`] = `""`; -exports[`components > N8nButton > props > loading > should render loading spinner 1`] = `""`; +exports[`components > N8nButton > props > loading > should render loading spinner 1`] = `""`; exports[`components > N8nButton > props > square > should render square button 1`] = ` -"" `; exports[`components > N8nButton > should render correctly 1`] = ` -"" `; diff --git a/packages/design-system/src/components/N8nCallout/__tests__/__snapshots__/Callout.spec.ts.snap b/packages/design-system/src/components/N8nCallout/__tests__/__snapshots__/Callout.spec.ts.snap index a2f82a3ef90c6..0ba2bec2174f8 100644 --- a/packages/design-system/src/components/N8nCallout/__tests__/__snapshots__/Callout.spec.ts.snap +++ b/packages/design-system/src/components/N8nCallout/__tests__/__snapshots__/Callout.spec.ts.snap @@ -1,79 +1,79 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`components > N8nCallout > should render additional slots correctly 1`] = ` -"
-
-
- +"" `; exports[`components > N8nCallout > should render custom theme correctly 1`] = ` -"
-
-
- +"" `; exports[`components > N8nCallout > should render danger theme correctly 1`] = ` -"
-
-
- +"" `; exports[`components > N8nCallout > should render info theme correctly 1`] = ` -"
-
-
- +"" `; exports[`components > N8nCallout > should render secondary theme correctly 1`] = ` -"
-
-
- +"" `; exports[`components > N8nCallout > should render success theme correctly 1`] = ` -"
-
-
- +"" `; exports[`components > N8nCallout > should render warning theme correctly 1`] = ` -"
-
-
- +"" `; diff --git a/packages/design-system/src/components/N8nCard/__tests__/__snapshots__/Card.spec.ts.snap b/packages/design-system/src/components/N8nCard/__tests__/__snapshots__/Card.spec.ts.snap index 05cb131a7924a..c093867f14b75 100644 --- a/packages/design-system/src/components/N8nCard/__tests__/__snapshots__/Card.spec.ts.snap +++ b/packages/design-system/src/components/N8nCard/__tests__/__snapshots__/Card.spec.ts.snap @@ -1,11 +1,11 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`components > N8nCard > should render correctly 1`] = ` -"
+"
-
+
-
This is a card.
+
This is a card.
@@ -13,12 +13,12 @@ exports[`components > N8nCard > should render correctly 1`] = ` `; exports[`components > N8nCard > should render correctly with header and footer 1`] = ` -"
+"
-
-
Header
-
This is a card.
-
Footer
+
+
Header
+
This is a card.
+
" diff --git a/packages/design-system/src/components/N8nCircleLoader/__tests__/__snapshots__/CircleLoader.spec.ts.snap b/packages/design-system/src/components/N8nCircleLoader/__tests__/__snapshots__/CircleLoader.spec.ts.snap index 1f208ed67dfe9..3cee7831f86ae 100644 --- a/packages/design-system/src/components/N8nCircleLoader/__tests__/__snapshots__/CircleLoader.spec.ts.snap +++ b/packages/design-system/src/components/N8nCircleLoader/__tests__/__snapshots__/CircleLoader.spec.ts.snap @@ -1,8 +1,8 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`N8NCircleLoader > should render correctly 1`] = ` -"
- - +"
+ +
" `; diff --git a/packages/design-system/src/components/N8nDatatable/__tests__/__snapshots__/Datatable.spec.ts.snap b/packages/design-system/src/components/N8nDatatable/__tests__/__snapshots__/Datatable.spec.ts.snap index 6d712ddeec9e4..1670619393b88 100644 --- a/packages/design-system/src/components/N8nDatatable/__tests__/__snapshots__/Datatable.spec.ts.snap +++ b/packages/design-system/src/components/N8nDatatable/__tests__/__snapshots__/Datatable.spec.ts.snap @@ -1,103 +1,103 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`components > N8nDatatable > should render correctly 1`] = ` -"
- - +"
+
+ - - - - + + + + - - - - + + + - - - - + + + - - - - + + + - - - - + + + - - - - + + + - - - - + + + - - - - + + + - - - - + + + - - - - + + + - - - - + + +
IDNameAgeActionIDNameAgeAction
1Richard Hendricks291Richard Hendricks29
2Bertram Gilfoyle442Bertram Gilfoyle44
3Dinesh Chugtai313Dinesh Chugtai31
4Jared Dunn 384Jared Dunn 38
5Richard Hendricks295Richard Hendricks29
6Bertram Gilfoyle446Bertram Gilfoyle44
7Dinesh Chugtai317Dinesh Chugtai31
8Jared Dunn 388Jared Dunn 38
9Richard Hendricks299Richard Hendricks29
10Bertram Gilfoyle4410Bertram Gilfoyle44
-
- -
- +
" diff --git a/packages/design-system/src/components/N8nFormInputs/FormInputs.vue b/packages/design-system/src/components/N8nFormInputs/FormInputs.vue index 2f41b5900843f..cade81d9d1356 100644 --- a/packages/design-system/src/components/N8nFormInputs/FormInputs.vue +++ b/packages/design-system/src/components/N8nFormInputs/FormInputs.vue @@ -124,6 +124,7 @@ export default defineComponent({ [name]: value, }; this.$emit('update', { name, value }); + this.$emit('update:modelValue', this.values); }, onValidate(name: string, valid: boolean) { this.validity = { diff --git a/packages/design-system/src/components/N8nInfoTip/__tests__/__snapshots__/InfoTip.spec.ts.snap b/packages/design-system/src/components/N8nInfoTip/__tests__/__snapshots__/InfoTip.spec.ts.snap index b4d194cdabb3f..dc755e961d5ee 100644 --- a/packages/design-system/src/components/N8nInfoTip/__tests__/__snapshots__/InfoTip.spec.ts.snap +++ b/packages/design-system/src/components/N8nInfoTip/__tests__/__snapshots__/InfoTip.spec.ts.snap @@ -1,9 +1,9 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`N8nInfoTip > should render correctly as note 1`] = `"
Need help doing something?Open docs
"`; +exports[`N8nInfoTip > should render correctly as note 1`] = `"
Need help doing something?Open docs
"`; exports[`N8nInfoTip > should render correctly as tooltip 1`] = ` -"
- +"
+
" `; diff --git a/packages/design-system/src/components/N8nInput/__tests__/__snapshots__/Input.spec.ts.snap b/packages/design-system/src/components/N8nInput/__tests__/__snapshots__/Input.spec.ts.snap index d0ad14e3fb206..4571133d9a2ca 100644 --- a/packages/design-system/src/components/N8nInput/__tests__/__snapshots__/Input.spec.ts.snap +++ b/packages/design-system/src/components/N8nInput/__tests__/__snapshots__/Input.spec.ts.snap @@ -1,13 +1,13 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`N8nInput > should render correctly 1`] = ` -"
+"
-
+
- +
diff --git a/packages/design-system/src/components/N8nNotice/__tests__/__snapshots__/Notice.spec.ts.snap b/packages/design-system/src/components/N8nNotice/__tests__/__snapshots__/Notice.spec.ts.snap index 461fcac742718..2060ad25119ec 100644 --- a/packages/design-system/src/components/N8nNotice/__tests__/__snapshots__/Notice.spec.ts.snap +++ b/packages/design-system/src/components/N8nNotice/__tests__/__snapshots__/Notice.spec.ts.snap @@ -1,31 +1,31 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`components > N8nNotice > props > content > should render HTML 1`] = ` -"
-
Hello world! This is a notice.
+"" `; exports[`components > N8nNotice > props > content > should render correctly with content prop 1`] = ` -"
-
- +"" `; exports[`components > N8nNotice > props > content > should sanitize rendered HTML 1`] = ` -"
-
- +"" `; exports[`components > N8nNotice > should render correctly 1`] = ` -"
-
- +"" `; diff --git a/packages/design-system/src/components/N8nRecycleScroller/RecycleScroller.vue b/packages/design-system/src/components/N8nRecycleScroller/RecycleScroller.vue index abd34dae1742d..5e67f236605a8 100644 --- a/packages/design-system/src/components/N8nRecycleScroller/RecycleScroller.vue +++ b/packages/design-system/src/components/N8nRecycleScroller/RecycleScroller.vue @@ -236,6 +236,9 @@ export default defineComponent({
+
+ +
@@ -263,4 +266,7 @@ export default defineComponent({ position: relative; width: 100%; } +.post-list-container { + margin-top: var(--spacing-3xl); +} diff --git a/packages/design-system/src/components/N8nRecycleScroller/__tests__/__snapshots__/RecycleScroller.spec.ts.snap b/packages/design-system/src/components/N8nRecycleScroller/__tests__/__snapshots__/RecycleScroller.spec.ts.snap index 09da3dec0cb28..661ee68ca6a4e 100644 --- a/packages/design-system/src/components/N8nRecycleScroller/__tests__/__snapshots__/RecycleScroller.spec.ts.snap +++ b/packages/design-system/src/components/N8nRecycleScroller/__tests__/__snapshots__/RecycleScroller.spec.ts.snap @@ -1,13 +1,14 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`components > N8nRecycleScroller > should render correctly 1`] = ` -"
-
-
-
-
-
+"
+
+
+
+
+
+
" `; diff --git a/packages/design-system/src/components/N8nSelect/__tests__/__snapshots__/Select.spec.ts.snap b/packages/design-system/src/components/N8nSelect/__tests__/__snapshots__/Select.spec.ts.snap index 3e6ae5470fc6b..7277454cf3bf6 100644 --- a/packages/design-system/src/components/N8nSelect/__tests__/__snapshots__/Select.spec.ts.snap +++ b/packages/design-system/src/components/N8nSelect/__tests__/__snapshots__/Select.spec.ts.snap @@ -1,20 +1,20 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`components > N8nSelect > should render correctly 1`] = ` -"
+"
-
-
+
+
-
+
-
+
- +
diff --git a/packages/design-system/src/components/N8nTree/__tests__/__snapshots__/Tree.spec.ts.snap b/packages/design-system/src/components/N8nTree/__tests__/__snapshots__/Tree.spec.ts.snap index d0c0ef1346bdb..96acff732738a 100644 --- a/packages/design-system/src/components/N8nTree/__tests__/__snapshots__/Tree.spec.ts.snap +++ b/packages/design-system/src/components/N8nTree/__tests__/__snapshots__/Tree.spec.ts.snap @@ -1,24 +1,24 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`components > N8nTree > should render each tree with node class 1`] = ` -"
-
+"
+
hello -
-
-
test:world
+
+
+
test:world
-
+
options -
-
-
0:yes
+
+
+
0:yes
-
-
1:no
+
+
1:no
@@ -27,32 +27,32 @@ exports[`components > N8nTree > should render each tree with node class 1`] = ` `; exports[`components > N8nTree > should render simple tree 1`] = ` -"
-
-
hello:world
+"
+
+
hello:world
" `; exports[`components > N8nTree > should render tree 1`] = ` -"
-
+"
+
hello -
-
-
test:world
+
+
+
test:world
-
+
options -
-
-
0:yes
+
+
+
0:yes
-
-
1:no
+
+
1:no
@@ -61,24 +61,24 @@ exports[`components > N8nTree > should render tree 1`] = ` `; exports[`components > N8nTree > should render tree with slots 1`] = ` -"
-
+"
+
label -
-
-
label:value
+
+
+
label:value
-
+
label -
-
-
label:value
+
+
+
label:value
-
-
label:value
+
+
label:value
diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index e9352f25100e7..581ccbcd87831 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -36,10 +36,6 @@ "@codemirror/state": "^6.1.4", "@codemirror/view": "^6.5.1", "@fontsource/open-sans": "^4.5.0", - "@fortawesome/fontawesome-svg-core": "^1.2.35", - "@fortawesome/free-regular-svg-icons": "^6.1.1", - "@fortawesome/free-solid-svg-icons": "^5.15.3", - "@fortawesome/vue-fontawesome": "^3.0.3", "@jsplumb/browser-ui": "^5.13.2", "@jsplumb/common": "^5.13.2", "@jsplumb/connector-bezier": "^5.13.2", @@ -50,7 +46,7 @@ "@n8n/permissions": "workspace:*", "@vueuse/components": "^10.5.0", "@vueuse/core": "^10.5.0", - "axios": "^0.21.1", + "axios": "^1.6.2", "chart.js": "^4.4.0", "codemirror-lang-html-n8n": "^1.0.0", "codemirror-lang-n8n-expression": "^0.2.0", @@ -87,7 +83,6 @@ "@faker-js/faker": "^8.0.2", "@pinia/testing": "^0.1.3", "@sentry/vite-plugin": "^2.5.0", - "@testing-library/vue": "^7.0.0", "@types/dateformat": "^3.0.0", "@types/file-saver": "^2.0.1", "@types/humanize-duration": "^3.27.1", @@ -96,5 +91,11 @@ "@types/luxon": "^3.2.0", "@types/uuid": "^8.3.2", "miragejs": "^0.1.47" + }, + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "*", + "@fortawesome/free-regular-svg-icons": "*", + "@fortawesome/free-solid-svg-icons": "*", + "@fortawesome/vue-fontawesome": "*" } } diff --git a/packages/editor-ui/src/App.vue b/packages/editor-ui/src/App.vue index e8978ae785c64..d16eb6f664f4c 100644 --- a/packages/editor-ui/src/App.vue +++ b/packages/editor-ui/src/App.vue @@ -35,7 +35,6 @@ diff --git a/packages/editor-ui/src/components/Node.vue b/packages/editor-ui/src/components/Node.vue index 07a0b273e2410..785ff3beb826e 100644 --- a/packages/editor-ui/src/components/Node.vue +++ b/packages/editor-ui/src/components/Node.vue @@ -37,7 +37,7 @@ v-if="!data.disabled" :class="{ 'node-info-icon': true, 'shift-icon': shiftOutputCount }" > -
+