diff --git a/.github/workflows/check-documentation-urls.yml b/.github/workflows/check-documentation-urls.yml index f32fc6f1654ac..c8e405457ddf2 100644 --- a/.github/workflows/check-documentation-urls.yml +++ b/.github/workflows/check-documentation-urls.yml @@ -4,10 +4,12 @@ on: push: tags: - n8n@* + schedule: + - cron: '0 0 * * *' workflow_dispatch: jobs: - build: + check-docs-urls: runs-on: ubuntu-latest timeout-minutes: 5 @@ -23,7 +25,7 @@ jobs: cache: 'pnpm' - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install - name: Build nodes-base run: pnpm --filter n8n-workflow --filter=n8n-core --filter=n8n-nodes-base build diff --git a/CHANGELOG.md b/CHANGELOG.md index 80510a9f0893e..98a1860bba853 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## [0.215.2](https://github.com/n8n-io/n8n/compare/n8n@0.215.1...n8n@0.215.2) (2023-02-14) + + +### Bug Fixes + +* **core:** Fix the issue with test webhooks getting removed incorrectly ([#5466](https://github.com/n8n-io/n8n/issues/5466)) ([4dc458e](https://github.com/n8n-io/n8n/commit/4dc458eca5587cf7765bed6fd384d47a31e66e2c)), closes [/github.com/n8n-io/n8n/pull/5443/files#diff-b386248ff00977749c873ed85821c241b773e9740d7e7adf94e05b73b350ed74L152](https://github.com//github.com/n8n-io/n8n/pull/5443/files/issues/diff-b386248ff00977749c873ed85821c241b773e9740d7e7adf94e05b73b350ed74L152) + + + ## [0.215.1](https://github.com/n8n-io/n8n/compare/n8n@0.215.0...n8n@0.215.1) (2023-02-11) diff --git a/README.md b/README.md index 336ea39e741fb..980a0c665dbe4 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ The official n8n documentation can be found on our [documentation website](https Additional information and example workflows on the [n8n.io website](https://n8n.io) -The release notes can be found [here](https://docs.n8n.io/reference/release-notes/) and the list of breaking +The release notes can be found [here](https://docs.n8n.io/release-notes/) and the list of breaking changes [here](https://github.com/n8n-io/n8n/blob/master/packages/cli/BREAKING-CHANGES.md). ## Usage diff --git a/cypress/e2e/17-sharing.cy.ts b/cypress/e2e/17-sharing.cy.ts new file mode 100644 index 0000000000000..9bb11293f449c --- /dev/null +++ b/cypress/e2e/17-sharing.cy.ts @@ -0,0 +1,168 @@ +import { DEFAULT_USER_EMAIL, DEFAULT_USER_PASSWORD } from '../constants'; +import { + CredentialsModal, + CredentialsPage, + NDV, + WorkflowPage, + WorkflowSharingModal, + WorkflowsPage, +} from '../pages'; + +/** + * User U1 - Instance owner + * User U2 - User, owns C1, W1, W2 + * User U3 - User, owns C2 + * + * W1 - Workflow owned by User U2, shared with User U3 + * W2 - Workflow owned by User U2 + * + * C1 - Credential owned by User U2 + * C2 - Credential owned by User U3, shared with User U1 and User U2 + */ + +const credentialsPage = new CredentialsPage(); +const credentialsModal = new CredentialsModal(); + +const workflowsPage = new WorkflowsPage(); +const workflowPage = new WorkflowPage(); +const workflowSharingModal = new WorkflowSharingModal(); +const ndv = new NDV(); + +const instanceOwner = { + email: `${DEFAULT_USER_EMAIL}one`, + password: DEFAULT_USER_PASSWORD, + firstName: 'User', + lastName: 'U1', +}; + +const users = [ + { + email: `${DEFAULT_USER_EMAIL}two`, + password: DEFAULT_USER_PASSWORD, + firstName: 'User', + lastName: 'U2', + }, + { + email: `${DEFAULT_USER_EMAIL}three`, + password: DEFAULT_USER_PASSWORD, + firstName: 'User', + lastName: 'U3', + }, +]; + +describe('Sharing', () => { + before(() => { + cy.resetAll(); + cy.setupOwner(instanceOwner); + }); + + beforeEach(() => { + cy.on('uncaught:exception', (err, runnable) => { + expect(err.message).to.include('Not logged in'); + return false; + }); + }); + + it('should invite User U2 and User U3 to instance', () => { + cy.inviteUsers({ instanceOwner, users }); + }); + + let workflowW2Url = ''; + it('should create C1, W1, W2, share W1 with U3, as U2', () => { + cy.signin(users[0]); + + cy.visit(credentialsPage.url); + credentialsPage.getters.emptyListCreateCredentialButton().click(); + credentialsModal.getters.newCredentialTypeOption('Notion API').click(); + credentialsModal.getters.newCredentialTypeButton().click(); + credentialsModal.getters.connectionParameter('API Key').type('1234567890'); + credentialsModal.actions.setName('Credential C1'); + credentialsModal.actions.save(); + credentialsModal.actions.close(); + + cy.visit(workflowsPage.url); + workflowsPage.getters.newWorkflowButtonCard().click(); + workflowPage.actions.setWorkflowName('Workflow W1'); + workflowPage.actions.addInitialNodeToCanvas('Manual Trigger'); + workflowPage.actions.addNodeToCanvas('Notion', true, true); + ndv.getters.credentialInput().should('contain', 'Credential C1'); + ndv.actions.close(); + + workflowPage.actions.openShareModal(); + workflowSharingModal.actions.addUser(users[1].email); + workflowSharingModal.actions.save(); + workflowPage.actions.saveWorkflowOnButtonClick(); + + cy.visit(workflowsPage.url); + workflowsPage.getters.createWorkflowButton().click(); + cy.createFixtureWorkflow('Test_workflow_1.json', 'Workflow W2'); + cy.url().then((url) => { + workflowW2Url = url; + }); + }); + + it('should create C2, share C2 with U1 and U2, as U3', () => { + cy.signin(users[1]); + + cy.visit(credentialsPage.url); + credentialsPage.getters.emptyListCreateCredentialButton().click(); + credentialsModal.getters.newCredentialTypeOption('Airtable API').click(); + credentialsModal.getters.newCredentialTypeButton().click(); + credentialsModal.getters.connectionParameter('API Key').type('1234567890'); + credentialsModal.actions.setName('Credential C2'); + credentialsModal.actions.changeTab('Sharing'); + credentialsModal.actions.addUser(instanceOwner.email); + credentialsModal.actions.addUser(users[0].email); + credentialsModal.actions.save(); + credentialsModal.actions.close(); + }); + + it('should open W1, add node using C2 as U3', () => { + cy.signin(users[1]); + + cy.visit(workflowsPage.url); + workflowsPage.getters.workflowCards().should('have.length', 1); + workflowsPage.getters.workflowCard('Workflow W1').click(); + workflowPage.actions.addNodeToCanvas('Airtable', true, true); + ndv.getters.credentialInput().should('contain', 'Credential C2'); + ndv.actions.close(); + workflowPage.actions.saveWorkflowOnButtonClick(); + + workflowPage.actions.openNode('Notion'); + ndv.getters + .credentialInput() + .find('input') + .should('have.value', 'Credential C1') + .should('be.disabled'); + ndv.actions.close(); + }); + + it('should not have access to W2, as U3', () => { + cy.signin(users[1]); + + cy.visit(workflowW2Url); + cy.waitForLoad(); + cy.wait(1000); + cy.get('.el-notification').contains('Could not find workflow').should('be.visible'); + }); + + it('should have access to W1, W2, as U1', () => { + cy.signin(instanceOwner); + + cy.visit(workflowsPage.url); + workflowsPage.getters.workflowCards().should('have.length', 2); + workflowsPage.getters.workflowCard('Workflow W1').click(); + workflowPage.actions.openNode('Notion'); + ndv.getters + .credentialInput() + .find('input') + .should('have.value', 'Credential C1') + .should('be.disabled'); + ndv.actions.close(); + + cy.waitForLoad(); + cy.visit(workflowsPage.url); + workflowsPage.getters.workflowCard('Workflow W2').click(); + workflowPage.actions.executeWorkflow(); + }); +}); diff --git a/cypress/e2e/20-workflow-executions.cy.ts b/cypress/e2e/20-workflow-executions.cy.ts new file mode 100644 index 0000000000000..bd7dc0a2108b1 --- /dev/null +++ b/cypress/e2e/20-workflow-executions.cy.ts @@ -0,0 +1,40 @@ +import { WorkflowPage } from "../pages"; +import { WorkflowExecutionsTab } from "../pages/workflow-executions-tab"; + +const workflowPage = new WorkflowPage(); +const executionsTab = new WorkflowExecutionsTab(); + +// Test suite for executions tab +describe('Current Workflow Executions', () => { + before(() => { + cy.resetAll(); + cy.skipSetup(); + workflowPage.actions.visit(); + cy.waitForLoad(); + cy.createFixtureWorkflow('Test_workflow_4_executions_view.json', `My test workflow`); + createMockExecutions(); + }); + + it('should render executions tab correctly', () => { + cy.waitForLoad(); + executionsTab.getters.executionListItems().should('have.length', 11); + executionsTab.getters.successfulExecutionListItems().should('have.length', 9); + executionsTab.getters.failedExecutionListItems().should('have.length', 2); + executionsTab.getters.executionListItems().first().invoke('attr','class').should('match', /_active_/); + }); + +}); + + +const createMockExecutions = () => { + workflowPage.actions.turnOnManualExecutionSaving(); + executionsTab.actions.createManualExecutions(5); + // Make some failed executions by enabling Code node with syntax error + executionsTab.actions.toggleNodeEnabled('Error'); + executionsTab.actions.createManualExecutions(2); + // Then add some more successful ones + executionsTab.actions.toggleNodeEnabled('Error'); + executionsTab.actions.createManualExecutions(4); + executionsTab.actions.switchToExecutionsTab(); + cy.waitForLoad(); +} diff --git a/cypress/fixtures/Test_workflow_4_executions_view.json b/cypress/fixtures/Test_workflow_4_executions_view.json new file mode 100644 index 0000000000000..a0be9eae35066 --- /dev/null +++ b/cypress/fixtures/Test_workflow_4_executions_view.json @@ -0,0 +1,69 @@ +{ + "meta": { + "instanceId": "6b85439d79c07750ea49eced4bc2a12b283cfcba0ab2917cd4f3fee36080e869" + }, + "nodes": [ + { + "parameters": { + "jsCode": "// Loop over input items and add a new field\n// called 'myNewField' to the JSON of each one\nfor (const item of $input.all()) {\n item.json.myNewField = 1;\n error\n}\n\nreturn $input.all();" + }, + "id": "d0ab7e12-0e1b-4c08-8081-83107794f37d", + "name": "Error", + "type": "n8n-nodes-base.code", + "typeVersion": 1, + "position": [ + 680, + 460 + ], + "disabled": true + }, + { + "parameters": {}, + "id": "f5026145-66c1-463c-8ac8-46a1309a6632", + "name": "On clicking 'execute'", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 460, + 460 + ] + }, + { + "parameters": { + "jsCode": "// Loop over input items and add a new field\n// called 'myNewField' to the JSON of each one\nfor (const item of $input.all()) {\n item.json.myNewField = 1;\n}\n\nreturn $input.all();" + }, + "id": "9926f884-348a-4af0-872e-dd7c8b3da811", + "name": "Code", + "type": "n8n-nodes-base.code", + "typeVersion": 1, + "position": [ + 900, + 460 + ] + } + ], + "connections": { + "Error": { + "main": [ + [ + { + "node": "Code", + "type": "main", + "index": 0 + } + ] + ] + }, + "On clicking 'execute'": { + "main": [ + [ + { + "node": "Error", + "type": "main", + "index": 0 + } + ] + ] + } + } +} diff --git a/cypress/pages/modals/credentials-modal.ts b/cypress/pages/modals/credentials-modal.ts index e6ae20079f1ea..02b267bf9fa08 100644 --- a/cypress/pages/modals/credentials-modal.ts +++ b/cypress/pages/modals/credentials-modal.ts @@ -26,8 +26,19 @@ export class CredentialsModal extends BasePage { credentialAuthTypeRadioButtons: () => this.getters.credentialsAuthTypeSelector().find('label[role=radio]'), credentialInputs: () => cy.getByTestId('credential-connection-parameter'), + menu: () => this.getters.editCredentialModal().get('.menu-container'), + menuItem: (name: string) => this.getters.menu().get('.n8n-menu-item').contains(name), + usersSelect: () => cy.getByTestId('credential-sharing-modal-users-select'), }; actions = { + addUser: (email: string) => { + this.getters.usersSelect().click(); + this.getters + .usersSelect() + .get('.el-select-dropdown__item') + .contains(email.toLowerCase()) + .click(); + }, setName: (name: string) => { this.getters.name().click(); this.getters.nameInput().clear().type(name); @@ -64,5 +75,8 @@ export class CredentialsModal extends BasePage { this.getters.nameInput().type(newName); this.getters.nameInput().type('{enter}'); }, + changeTab: (tabName: string) => { + this.getters.menuItem(tabName).click(); + }, }; } diff --git a/cypress/pages/modals/index.ts b/cypress/pages/modals/index.ts index 24f5101aed2df..3d1981d027ad2 100644 --- a/cypress/pages/modals/index.ts +++ b/cypress/pages/modals/index.ts @@ -1,2 +1,3 @@ export * from './credentials-modal'; export * from './message-box'; +export * from './workflow-sharing-modal'; diff --git a/cypress/pages/modals/workflow-sharing-modal.ts b/cypress/pages/modals/workflow-sharing-modal.ts new file mode 100644 index 0000000000000..c01309328675a --- /dev/null +++ b/cypress/pages/modals/workflow-sharing-modal.ts @@ -0,0 +1,26 @@ +import { BasePage } from '../base'; + +export class WorkflowSharingModal extends BasePage { + getters = { + modal: () => cy.getByTestId('workflowShare-modal', { timeout: 5000 }), + usersSelect: () => cy.getByTestId('workflow-sharing-modal-users-select'), + saveButton: () => cy.getByTestId('workflow-sharing-modal-save-button'), + closeButton: () => this.getters.modal().find('.el-dialog__close').first(), + }; + actions = { + addUser: (email: string) => { + this.getters.usersSelect().click(); + this.getters + .usersSelect() + .get('.el-select-dropdown__item') + .contains(email.toLowerCase()) + .click(); + }, + save: () => { + this.getters.saveButton().click(); + }, + closeModal: () => { + this.getters.closeButton().click(); + }, + }; +} diff --git a/cypress/pages/ndv.ts b/cypress/pages/ndv.ts index 9d15a836c3a9d..9cc0285a4fbf6 100644 --- a/cypress/pages/ndv.ts +++ b/cypress/pages/ndv.ts @@ -5,6 +5,7 @@ export class NDV extends BasePage { container: () => cy.getByTestId('ndv'), backToCanvas: () => cy.getByTestId('back-to-canvas'), copyInput: () => cy.getByTestId('copy-input'), + credentialInput: (eq = 0) => cy.getByTestId('node-credentials-select').eq(eq), nodeExecuteButton: () => cy.getByTestId('node-execute-button'), inputSelect: () => cy.getByTestId('ndv-input-select'), inputOption: () => cy.getByTestId('ndv-input-option'), @@ -24,15 +25,22 @@ export class NDV extends BasePage { outputTableRows: () => this.getters.outputDataContainer().find('table tr'), outputTableHeaders: () => this.getters.outputDataContainer().find('table thead th'), outputTableRow: (row: number) => this.getters.outputTableRows().eq(row), - outputTbodyCell: (row: number, col: number) => this.getters.outputTableRow(row).find('td').eq(col), + outputTbodyCell: (row: number, col: number) => + this.getters.outputTableRow(row).find('td').eq(col), inputTableRows: () => this.getters.inputDataContainer().find('table tr'), inputTableHeaders: () => this.getters.inputDataContainer().find('table thead th'), inputTableRow: (row: number) => this.getters.inputTableRows().eq(row), - inputTbodyCell: (row: number, col: number) => this.getters.inputTableRow(row).find('td').eq(col), + inputTbodyCell: (row: number, col: number) => + this.getters.inputTableRow(row).find('td').eq(col), inlineExpressionEditorInput: () => cy.getByTestId('inline-expression-editor-input'), nodeParameters: () => cy.getByTestId('node-parameters'), parameterInput: (parameterName: string) => cy.getByTestId(`parameter-input-${parameterName}`), - parameterExpressionPreview: (parameterName: string) => this.getters.nodeParameters().find(`[data-test-id="parameter-input-${parameterName}"] + [data-test-id="parameter-expression-preview"]`), + parameterExpressionPreview: (parameterName: string) => + this.getters + .nodeParameters() + .find( + `[data-test-id="parameter-input-${parameterName}"] + [data-test-id="parameter-expression-preview"]`, + ), nodeNameContainer: () => cy.getByTestId('node-title-container'), nodeRenameInput: () => cy.getByTestId('node-rename-input'), executePrevious: () => cy.getByTestId('execute-previous-node'), @@ -77,18 +85,11 @@ export class NDV extends BasePage { this.getters.parameterInput(parameterName).type(content); }, selectOptionInParameterDropdown: (parameterName: string, content: string) => { - this.getters - .parameterInput(parameterName) - .find('.option-headline') - .contains(content) - .click(); + this.getters.parameterInput(parameterName).find('.option-headline').contains(content).click(); }, rename: (newName: string) => { this.getters.nodeNameContainer().click(); - this.getters.nodeRenameInput() - .should('be.visible') - .type('{selectall}') - .type(newName); + this.getters.nodeRenameInput().should('be.visible').type('{selectall}').type(newName); cy.get('body').type('{enter}'); }, executePrevious: () => { @@ -104,10 +105,10 @@ export class NDV extends BasePage { cy.draganddrop('', droppable); }, switchInputMode: (type: 'Schema' | 'Table' | 'JSON' | 'Binary') => { - this.getters.inputDisplayMode().find('label').contains(type).click({force: true}); + this.getters.inputDisplayMode().find('label').contains(type).click({ force: true }); }, switchOutputMode: (type: 'Schema' | 'Table' | 'JSON' | 'Binary') => { - this.getters.outputDisplayMode().find('label').contains(type).click({force: true}); + this.getters.outputDisplayMode().find('label').contains(type).click({ force: true }); }, selectInputNode: (nodeName: string) => { this.getters.inputSelect().find('.el-select').click(); diff --git a/cypress/pages/workflow-executions-tab.ts b/cypress/pages/workflow-executions-tab.ts new file mode 100644 index 0000000000000..ea6c8b4fd5b7c --- /dev/null +++ b/cypress/pages/workflow-executions-tab.ts @@ -0,0 +1,40 @@ +import { BasePage } from "./base"; +import { WorkflowPage } from "./workflow"; + +const workflowPage = new WorkflowPage(); + +export class WorkflowExecutionsTab extends BasePage { + getters = { + executionsTabButton: () => cy.getByTestId('radio-button-executions'), + executionsSidebar: () => cy.getByTestId('executions-sidebar'), + autoRefreshCheckBox: () => cy.getByTestId('auto-refresh-checkbox'), + executionsList: () => cy.getByTestId('current-executions-list'), + executionListItems: () => this.getters.executionsList().find('div.execution-card'), + successfulExecutionListItems: () => cy.get('[data-test-execution-status="success"]'), + failedExecutionListItems: () => cy.get('[data-test-execution-status="error"]'), + executionCard: (executionId: string) => cy.getByTestId(`execution-details-${executionId}`), + executionPreviewDetails: () => cy.get('[data-test-id^="execution-preview-details-"]'), + executionPreviewDetailsById: (executionId: string) => cy.getByTestId(`execution-preview-details-${executionId}`), + executionPreviewTime: () => this.getters.executionPreviewDetails().find('[data-test-id="execution-time"]'), + executionPreviewStatus: () => this.getters.executionPreviewDetails().find('[data-test-id="execution-preview-label"]'), + executionPreviewId: () => this.getters.executionPreviewDetails().find('[data-test-id="execution-preview-id"]'), + }; + actions = { + toggleNodeEnabled: (nodeName: string) => { + workflowPage.getters.canvasNodeByName(nodeName).click(); + cy.get('body').type('d', { force: true }); + }, + createManualExecutions: (count: number) => { + for (let i=0; i { + this.getters.executionsTabButton().click(); + }, + switchToEditorTab: () => { + workflowPage.getters.editorTabButton().click(); + } + }; +}; diff --git a/cypress/pages/workflow.ts b/cypress/pages/workflow.ts index 8a6a78c3a7575..f09bda9f1fc89 100644 --- a/cypress/pages/workflow.ts +++ b/cypress/pages/workflow.ts @@ -26,7 +26,7 @@ export class WorkflowPage extends BasePage { canvasNodeByName: (nodeName: string) => this.getters.canvasNodes().filter(`:contains("${nodeName}")`), getEndpointSelector: (type: 'input' | 'output' | 'plus', nodeName: string, index = 0) => { - return `[data-endpoint-name='${nodeName}'][data-endpoint-type='${type}'][data-input-index='${index}']` + return `[data-endpoint-name='${nodeName}'][data-endpoint-type='${type}'][data-input-index='${index}']`; }, canvasNodeInputEndpointByName: (nodeName: string, index = 0) => { return cy.get(this.getters.getEndpointSelector('input', nodeName, index)); @@ -79,7 +79,7 @@ export class WorkflowPage extends BasePage { workflowSettingsSaveButton: () => cy.getByTestId('workflow-settings-save-button').find('button'), - shareButton: () => cy.getByTestId('workflow-share-button').find('button'), + shareButton: () => cy.getByTestId('workflow-share-button'), duplicateWorkflowModal: () => cy.getByTestId('duplicate-modal'), nodeViewBackground: () => cy.getByTestId('node-view-background'), @@ -103,6 +103,7 @@ export class WorkflowPage extends BasePage { cy.get( `.connection-actions[data-source-node="${sourceNodeName}"][data-target-node="${targetNodeName}"]`, ), + editorTabButton: () => cy.getByTestId('radio-button-workflow'), }; actions = { visit: () => { @@ -154,11 +155,17 @@ export class WorkflowPage extends BasePage { saveWorkflowOnButtonClick: () => { this.getters.saveButton().should('contain', 'Save'); this.getters.saveButton().click(); - this.getters.saveButton().should('contain', 'Saved') + this.getters.saveButton().should('contain', 'Saved'); }, saveWorkflowUsingKeyboardShortcut: () => { cy.get('body').type('{meta}', { release: false }).type('s'); }, + setWorkflowName: (name: string) => { + this.getters.workflowNameInput().should('be.disabled'); + this.getters.workflowNameInput().parent().click(); + this.getters.workflowNameInput().should('be.enabled'); + this.getters.workflowNameInput().clear().type(name).type('{enter}'); + }, activateWorkflow: () => { this.getters.activatorSwitch().find('input').first().should('be.enabled'); this.getters.activatorSwitch().click(); @@ -230,5 +237,15 @@ export class WorkflowPage extends BasePage { .first() .click({ force: true }); }, + turnOnManualExecutionSaving: () => { + this.getters.workflowMenu().click(); + this.getters.workflowMenuItemSettings().click(); + this.getters + .workflowSettingsSaveManualExecutionsSelect() + .find('li:contains("Yes")') + .click({ force: true }); + this.getters.workflowSettingsSaveButton().click(); + this.getters.successToast().should('exist'); + }, }; } diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index ff64f69599c3b..445645975c730 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -40,10 +40,7 @@ Cypress.Commands.add('createFixtureWorkflow', (fixtureKey, workflowName) => { WorkflowPage.getters .workflowImportInput() .selectFile(`cypress/fixtures/${fixtureKey}`, { force: true }); - WorkflowPage.getters.workflowNameInput().should('be.disabled'); - WorkflowPage.getters.workflowNameInput().parent().click(); - WorkflowPage.getters.workflowNameInput().should('be.enabled'); - WorkflowPage.getters.workflowNameInput().clear().type(workflowName).type('{enter}'); + WorkflowPage.actions.setWorkflowName(workflowName); WorkflowPage.getters.saveButton().should('contain', 'Saved'); }); diff --git a/package.json b/package.json index 4aad853282689..8c642daef97c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.215.1", + "version": "0.215.2", "private": true, "homepage": "https://n8n.io", "engines": { @@ -47,8 +47,10 @@ "jest": "^29.4.2", "jest-environment-jsdom": "^29.4.2", "jest-mock": "^29.4.2", + "jest-mock-extended": "^3.0.1", "nock": "^13.2.9", "node-fetch": "^2.6.7", + "p-limit": "^3.1.0", "prettier": "^2.8.3", "rimraf": "^3.0.2", "run-script-os": "^1.0.7", diff --git a/packages/cli/package.json b/packages/cli/package.json index 886227b057b36..5c51f15773b83 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.215.1", + "version": "0.215.2", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/cli/src/TestWebhooks.ts b/packages/cli/src/TestWebhooks.ts index aee337ec74b90..12ec4a6827072 100644 --- a/packages/cli/src/TestWebhooks.ts +++ b/packages/cli/src/TestWebhooks.ts @@ -141,13 +141,13 @@ class TestWebhooks { if (sessionId !== undefined) { push.send('testWebhookReceived', { workflowId, executionId }, sessionId); } - } finally { - // Delete webhook also if an error is thrown - if (timeout) clearTimeout(timeout); - delete testWebhookData[webhookKey]; + } catch {} - await activeWebhooks.removeWorkflow(workflow); - } + // Delete webhook also if an error is thrown + if (timeout) clearTimeout(timeout); + delete testWebhookData[webhookKey]; + + await activeWebhooks.removeWorkflow(workflow); }); } diff --git a/packages/cli/src/commands/export/credentials.ts b/packages/cli/src/commands/export/credentials.ts index d19dd80953425..110febe25e8d1 100644 --- a/packages/cli/src/commands/export/credentials.ts +++ b/packages/cli/src/commands/export/credentials.ts @@ -1,10 +1,10 @@ import { flags } from '@oclif/command'; import fs from 'fs'; import path from 'path'; +import type { FindOptionsWhere } from 'typeorm'; import { Credentials, UserSettings } from 'n8n-core'; -import type { IDataObject } from 'n8n-workflow'; import * as Db from '@/Db'; -import type { ICredentialsDecryptedDb } from '@/Interfaces'; +import type { ICredentialsDb, ICredentialsDecryptedDb } from '@/Interfaces'; import { BaseCommand } from '../BaseCommand'; export class ExportCredentialsCommand extends BaseCommand { @@ -105,13 +105,12 @@ export class ExportCredentialsCommand extends BaseCommand { } } - const findQuery: IDataObject = {}; + const findQuery: FindOptionsWhere = {}; if (flags.id) { findQuery.id = flags.id; } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const credentials = await Db.collections.Credentials.find(findQuery); + const credentials = await Db.collections.Credentials.findBy(findQuery); if (flags.decrypted) { const encryptionKey = await UserSettings.getEncryptionKey(); diff --git a/packages/cli/src/commands/export/workflow.ts b/packages/cli/src/commands/export/workflow.ts index 6a3d3cdf52d55..69f795e5bc998 100644 --- a/packages/cli/src/commands/export/workflow.ts +++ b/packages/cli/src/commands/export/workflow.ts @@ -1,8 +1,9 @@ import { flags } from '@oclif/command'; import fs from 'fs'; import path from 'path'; -import type { IDataObject } from 'n8n-workflow'; +import type { FindOptionsWhere } from 'typeorm'; import * as Db from '@/Db'; +import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { BaseCommand } from '../BaseCommand'; export class ExportWorkflowsCommand extends BaseCommand { @@ -98,7 +99,7 @@ export class ExportWorkflowsCommand extends BaseCommand { } } - const findQuery: IDataObject = {}; + const findQuery: FindOptionsWhere = {}; if (flags.id) { findQuery.id = flags.id; } diff --git a/packages/cli/src/commands/list/workflow.ts b/packages/cli/src/commands/list/workflow.ts index 240e56d4fdedd..da9d37f81c18f 100644 --- a/packages/cli/src/commands/list/workflow.ts +++ b/packages/cli/src/commands/list/workflow.ts @@ -1,6 +1,7 @@ import { flags } from '@oclif/command'; -import type { IDataObject } from 'n8n-workflow'; +import type { FindOptionsWhere } from 'typeorm'; import * as Db from '@/Db'; +import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { BaseCommand } from '../BaseCommand'; export class ListWorkflowCommand extends BaseCommand { @@ -30,12 +31,12 @@ export class ListWorkflowCommand extends BaseCommand { this.error('The --active flag has to be passed using true or false'); } - const findQuery: IDataObject = {}; + const findQuery: FindOptionsWhere = {}; if (flags.active !== undefined) { findQuery.active = flags.active === 'true'; } - const workflows = await Db.collections.Workflow.find(findQuery); + const workflows = await Db.collections.Workflow.findBy(findQuery); if (flags.onlyId) { workflows.forEach((workflow) => this.logger.info(workflow.id)); } else { diff --git a/packages/cli/src/commands/update/workflow.ts b/packages/cli/src/commands/update/workflow.ts index a26fd80516560..a13721ce66a09 100644 --- a/packages/cli/src/commands/update/workflow.ts +++ b/packages/cli/src/commands/update/workflow.ts @@ -2,8 +2,10 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable no-console */ import { flags } from '@oclif/command'; -import type { IDataObject } from 'n8n-workflow'; +import type { FindOptionsWhere } from 'typeorm'; +import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; import * as Db from '@/Db'; +import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { BaseCommand } from '../BaseCommand'; export class UpdateWorkflowCommand extends BaseCommand { @@ -43,7 +45,7 @@ export class UpdateWorkflowCommand extends BaseCommand { return; } - const updateQuery: IDataObject = {}; + const updateQuery: QueryDeepPartialEntity = {}; if (flags.active === undefined) { console.info('No update flag like "--active=true" has been set!'); return; @@ -56,7 +58,7 @@ export class UpdateWorkflowCommand extends BaseCommand { updateQuery.active = flags.active === 'true'; - const findQuery: IDataObject = {}; + const findQuery: FindOptionsWhere = {}; if (flags.id) { this.logger.info(`Deactivating workflow with ID: ${flags.id}`); findQuery.id = flags.id; diff --git a/packages/cli/test/unit/controllers/me.controller.test.ts b/packages/cli/test/unit/controllers/me.controller.test.ts new file mode 100644 index 0000000000000..1606915398c57 --- /dev/null +++ b/packages/cli/test/unit/controllers/me.controller.test.ts @@ -0,0 +1,166 @@ +import { CookieOptions, Response } from 'express'; +import type { Repository } from 'typeorm'; +import jwt from 'jsonwebtoken'; +import { mock, anyObject, captor } from 'jest-mock-extended'; +import type { ILogger } from 'n8n-workflow'; +import type { IExternalHooksClass, IInternalHooksClass } from '@/Interfaces'; +import type { User } from '@db/entities/User'; +import { MeController } from '@/controllers'; +import { AUTH_COOKIE_NAME } from '@/constants'; +import { BadRequestError } from '@/ResponseHelper'; +import type { AuthenticatedRequest, MeRequest } from '@/requests'; + +describe('MeController', () => { + const logger = mock(); + const externalHooks = mock(); + const internalHooks = mock(); + const userRepository = mock>(); + + let controller: MeController; + beforeAll(() => { + controller = new MeController({ + logger, + externalHooks, + internalHooks, + repositories: { User: userRepository }, + }); + }); + + describe('updateCurrentUser', () => { + it('should throw BadRequestError if email is missing in the payload', async () => { + const req = mock({}); + expect(controller.updateCurrentUser(req, mock())).rejects.toThrowError( + new BadRequestError('Email is mandatory'), + ); + }); + + it('should throw BadRequestError if email is invalid', async () => { + const req = mock({ body: { email: 'invalid-email' } }); + expect(controller.updateCurrentUser(req, mock())).rejects.toThrowError( + new BadRequestError('Invalid email address'), + ); + }); + + it('should update the user in the DB, and issue a new cookie', async () => { + const req = mock({ + user: mock({ id: '123', password: 'password', authIdentities: [] }), + body: { email: 'valid@email.com', firstName: 'John', lastName: 'Potato' }, + }); + const res = mock(); + userRepository.save.calledWith(anyObject()).mockResolvedValue(req.user); + jest.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token'); + + await controller.updateCurrentUser(req, res); + + const cookieOptions = captor(); + expect(res.cookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME, 'signed-token', cookieOptions); + expect(cookieOptions.value.httpOnly).toBe(true); + expect(cookieOptions.value.sameSite).toBe('lax'); + + expect(externalHooks.run).toHaveBeenCalledWith('user.profile.update', [ + req.user.email, + anyObject(), + ]); + }); + }); + + describe('updatePassword', () => { + const passwordHash = '$2a$10$ffitcKrHT.Ls.m9FfWrMrOod76aaI0ogKbc3S96Q320impWpCbgj6'; // Hashed 'old_password' + + it('should throw if the user does not have a password set', async () => { + const req = mock({ + user: mock({ password: undefined }), + body: { currentPassword: '', newPassword: '' }, + }); + expect(controller.updatePassword(req, mock())).rejects.toThrowError( + new BadRequestError('Requesting user not set up.'), + ); + }); + + it("should throw if currentPassword does not match the user's password", async () => { + const req = mock({ + user: mock({ password: passwordHash }), + body: { currentPassword: 'not_old_password', newPassword: '' }, + }); + expect(controller.updatePassword(req, mock())).rejects.toThrowError( + new BadRequestError('Provided current password is incorrect.'), + ); + }); + + describe('should throw if newPassword is not valid', () => { + Object.entries({ + pass: 'Password must be 8 to 64 characters long. Password must contain at least 1 number. Password must contain at least 1 uppercase letter.', + password: + 'Password must contain at least 1 number. Password must contain at least 1 uppercase letter.', + password1: 'Password must contain at least 1 uppercase letter.', + }).forEach(([newPassword, errorMessage]) => { + it(newPassword, async () => { + const req = mock({ + user: mock({ password: passwordHash }), + body: { currentPassword: 'old_password', newPassword }, + }); + expect(controller.updatePassword(req, mock())).rejects.toThrowError( + new BadRequestError(errorMessage), + ); + }); + }); + }); + + it('should update the password in the DB, and issue a new cookie', async () => { + const req = mock({ + user: mock({ password: passwordHash }), + body: { currentPassword: 'old_password', newPassword: 'NewPassword123' }, + }); + const res = mock(); + userRepository.save.calledWith(req.user).mockResolvedValue(req.user); + jest.spyOn(jwt, 'sign').mockImplementation(() => 'new-signed-token'); + + await controller.updatePassword(req, res); + + expect(req.user.password).not.toBe(passwordHash); + + const cookieOptions = captor(); + expect(res.cookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME, 'new-signed-token', cookieOptions); + expect(cookieOptions.value.httpOnly).toBe(true); + expect(cookieOptions.value.sameSite).toBe('lax'); + + expect(externalHooks.run).toHaveBeenCalledWith('user.password.update', [ + req.user.email, + req.user.password, + ]); + + expect(internalHooks.onUserUpdate).toHaveBeenCalledWith({ + user: req.user, + fields_changed: ['password'], + }); + }); + }); + + describe('API Key methods', () => { + let req: AuthenticatedRequest; + beforeAll(() => { + req = mock({ user: mock>({ id: '123', apiKey: 'test-key' }) }); + }); + + describe('createAPIKey', () => { + it('should create and save an API key', async () => { + const { apiKey } = await controller.createAPIKey(req); + expect(userRepository.update).toHaveBeenCalledWith(req.user.id, { apiKey }); + }); + }); + + describe('getAPIKey', () => { + it('should return the users api key', async () => { + const { apiKey } = await controller.getAPIKey(req); + expect(apiKey).toEqual(req.user.apiKey); + }); + }); + + describe('deleteAPIKey', () => { + it('should delete the API key', async () => { + await controller.deleteAPIKey(req); + expect(userRepository.update).toHaveBeenCalledWith(req.user.id, { apiKey: null }); + }); + }); + }); +}); diff --git a/packages/design-system/.storybook/main.js b/packages/design-system/.storybook/main.js index 3e512d7a6d1ee..2a72dca1e6e55 100644 --- a/packages/design-system/.storybook/main.js +++ b/packages/design-system/.storybook/main.js @@ -1,9 +1,13 @@ const path = require('path'); /** - * @type {import('@storybook/core-common').StorybookConfig} + * @type {import('@storybook/types').StorybookConfig} */ module.exports = { + framework: { + name: '@storybook/vue-webpack5', + options: {}, + }, stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.{ts,js}'], addons: [ '@storybook/addon-links', diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 4adb3517a7506..24e1853ecee33 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -19,8 +19,8 @@ "test": "vitest run", "test:ci": "vitest run --coverage", "test:dev": "vitest", - "build:storybook": "build-storybook", - "storybook": "start-storybook -p 6006", + "build:storybook": "storybook build", + "storybook": "storybook dev -p 6006", "format": "prettier --write . --ignore-path ../../.prettierignore", "lint": "eslint --quiet --ext .js,.ts,.vue src", "lintfix": "eslint --ext .js,.ts,.vue src --fix" @@ -42,11 +42,12 @@ "@fortawesome/fontawesome-svg-core": "^1.2.36", "@fortawesome/free-solid-svg-icons": "^5.15.4", "@fortawesome/vue-fontawesome": "^2.0.9", - "@storybook/addon-actions": "^6.5.15", - "@storybook/addon-essentials": "^6.5.15", - "@storybook/addon-links": "^6.5.15", - "@storybook/addon-postcss": "^2.0.0", - "@storybook/vue": "^6.5.15", + "@storybook/addon-actions": "^7.0.0-beta.46", + "@storybook/addon-essentials": "^7.0.0-beta.46", + "@storybook/addon-links": "^7.0.0-beta.46", + "@storybook/addon-postcss": "^3.0.0-alpha.1", + "@storybook/vue": "^7.0.0-beta.46", + "@storybook/vue-webpack5": "^7.0.0-beta.46", "@testing-library/jest-dom": "^5.16.5", "@testing-library/vue": "^5.8.3", "@types/markdown-it": "^12.2.3", @@ -54,12 +55,14 @@ "@types/markdown-it-link-attributes": "^3.0.1", "@types/sanitize-html": "^2.8.0", "@vitejs/plugin-vue2": "^2.2.0", + "autoprefixer": "^10.4.13", "c8": "7.12.0", - "core-js": "^3.27.1", - "jsdom": "21.0.0", + "core-js": "^3.27.2", + "jsdom": "21.1.0", "node-notifier": "^10.0.1", - "sass": "^1.57.1", - "sass-loader": "^10.3.1", + "sass": "^1.58.0", + "sass-loader": "^13.2.0", + "storybook": "^7.0.0-beta.46", "storybook-addon-designs": "^6.3.1", "storybook-addon-themes": "^6.1.0", "trim": "^1.0.1", @@ -69,8 +72,7 @@ "vue-loader": "^15.10.1", "vue-property-decorator": "^9.1.2", "vue-template-compiler": "^2.7.14", - "vue-tsc": "^1.0.24", - "webpack": "^4.46.0" + "vue-tsc": "^1.0.24" }, "dependencies": { "element-ui": "~2.15.12", @@ -78,7 +80,7 @@ "markdown-it-emoji": "^2.0.2", "markdown-it-link-attributes": "^4.0.1", "markdown-it-task-lists": "^2.1.1", - "sanitize-html": "2.7.3", + "sanitize-html": "2.9.0", "vue": "^2.7.14", "vue-typed-mixins": "^0.2.0", "vue2-boring-avatars": "^0.3.8", diff --git a/packages/design-system/src/components/N8nRadioButtons/RadioButton.vue b/packages/design-system/src/components/N8nRadioButtons/RadioButton.vue index 4d35e6b1103d7..7f729df7cb340 100644 --- a/packages/design-system/src/components/N8nRadioButtons/RadioButton.vue +++ b/packages/design-system/src/components/N8nRadioButtons/RadioButton.vue @@ -17,6 +17,7 @@ [$style[size]]: true, [$style.disabled]: disabled, }" + :data-test-id="`radio-button-${value}`" @click="$emit('click')" > {{ label }} diff --git a/packages/editor-ui/src/components/CredentialEdit/CredentialConfig.vue b/packages/editor-ui/src/components/CredentialEdit/CredentialConfig.vue index d3f132c4eba24..e2255bd90012e 100644 --- a/packages/editor-ui/src/components/CredentialEdit/CredentialConfig.vue +++ b/packages/editor-ui/src/components/CredentialEdit/CredentialConfig.vue @@ -266,7 +266,7 @@ export default mixins(restApi).extend({ return (this.credentialType as ICredentialType).name; }, credentialOwnerName(): string { - return this.credentialsStore.getCredentialOwnerName(`${this.credentialId}`); + return this.credentialsStore.getCredentialOwnerNameById(`${this.credentialId}`); }, documentationUrl(): string { const type = this.credentialType as ICredentialType; diff --git a/packages/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue b/packages/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue index 2012311bdca71..10094bb05b716 100644 --- a/packages/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue +++ b/packages/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue @@ -61,6 +61,7 @@ :users="usersList" :currentUserId="usersStore.currentUser.id" :placeholder="$locale.baseText('credentialEdit.credentialSharing.select.placeholder')" + data-test-id="credential-sharing-modal-users-select" @input="onAddSharee" >