diff --git a/.github/workflows/e2e-reusable.yml b/.github/workflows/e2e-reusable.yml index 597653a5a6..aa37b08c8e 100644 --- a/.github/workflows/e2e-reusable.yml +++ b/.github/workflows/e2e-reusable.yml @@ -153,7 +153,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Cypress run - uses: cypress-io/github-action@v5.8.3 + uses: cypress-io/github-action@v6.6.1 with: install: false start: pnpm start @@ -172,6 +172,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} E2E_TESTS: true COMMIT_INFO_MESSAGE: 🌳 ${{ inputs.branch }} 🖥️ ${{ inputs.run-env }} 🤖 ${{ inputs.user }} 🗃️ ${{ inputs.spec }} + SHELL: /bin/sh # Check if all tests passed and set the output variable check_testing_matrix: diff --git a/.gitignore b/.gitignore index e9356134e5..0c3174e84e 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,3 @@ cypress/screenshots/* cypress/downloads/* *.swp CHANGELOG-*.md -packages/cli/oclif.manifest.json diff --git a/CHANGELOG.md b/CHANGELOG.md index bc07f55eb2..beb70c8ae4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,44 @@ +# [1.25.0](https://github.com/n8n-io/n8n/compare/n8n@1.24.0...n8n@1.25.0) (2024-01-17) + + +### Bug Fixes + +* Add fallback resolver for langchain modules ([#8308](https://github.com/n8n-io/n8n/issues/8308)) ([851060d](https://github.com/n8n-io/n8n/commit/851060dd3f38245da6e09c04ec0b12b24b63dca4)) +* **API:** Fix manual chat trigger execution ([#8300](https://github.com/n8n-io/n8n/issues/8300)) ([884396e](https://github.com/n8n-io/n8n/commit/884396ea0d9f4a8d7987daf2b674f080056dd1d1)) +* **AwsS3 Node:** Return confirmation of success after upload ([#8312](https://github.com/n8n-io/n8n/issues/8312)) ([c921665](https://github.com/n8n-io/n8n/commit/c921665f9abe19d9e8831062c1e7673d4d1ea694)) +* **core:** Account for immediate confirmation request during test webhook creation ([#8329](https://github.com/n8n-io/n8n/issues/8329)) ([5fbd797](https://github.com/n8n-io/n8n/commit/5fbd7971e04640be3f877b3aa22d4aee61c1d40a)) +* **core:** Ensure waiting executions account for workflow timezone ([#8340](https://github.com/n8n-io/n8n/issues/8340)) ([3734c89](https://github.com/n8n-io/n8n/commit/3734c89cf64514489831b5339d722c89b300cc54)) +* **core:** Parse any readable stream response instead of only IncomingMessage ([#8359](https://github.com/n8n-io/n8n/issues/8359)) ([eb1320f](https://github.com/n8n-io/n8n/commit/eb1320fd7a4a67cd16de10c4174c7bcf2c177b06)) +* **core:** Prevent invalid compressed responses from making executions stuck forever ([#8315](https://github.com/n8n-io/n8n/issues/8315)) ([0776814](https://github.com/n8n-io/n8n/commit/0776814ed8c520326a6447dcd7b6c53fda933054)) +* **core:** Prevent issues with missing or mismatching encryption key ([#8332](https://github.com/n8n-io/n8n/issues/8332)) ([d4c93b1](https://github.com/n8n-io/n8n/commit/d4c93b16071081002b4bd316be0921bc7867dd82)) +* **core:** Prevent NodeErrors from being wrapped multiple times ([#8301](https://github.com/n8n-io/n8n/issues/8301)) ([b267bf0](https://github.com/n8n-io/n8n/commit/b267bf07e365d8bb82a9847fb3c490437dc1010e)) +* **core:** Replace all `moment` imports with `moment-timezone` ([#8337](https://github.com/n8n-io/n8n/issues/8337)) ([52a2e25](https://github.com/n8n-io/n8n/commit/52a2e25a25e9a009a536d8a371d9404e75d756f4)) +* **core:** Report when waitTill is invalid and handle it ([#8356](https://github.com/n8n-io/n8n/issues/8356)) ([d5455d7](https://github.com/n8n-io/n8n/commit/d5455d7accb193078b05a0f52386cf9303b6a00f)) +* **editor:** Add read only mode to filter component ([#8285](https://github.com/n8n-io/n8n/issues/8285)) ([dcc76f3](https://github.com/n8n-io/n8n/commit/dcc76f348075b6e05e3f38bb9694d25ac9a5646b)) +* **editor:** Capture indexed access expressions when building completions ([#8331](https://github.com/n8n-io/n8n/issues/8331)) ([159b328](https://github.com/n8n-io/n8n/commit/159b328587f3c57c73ae77c2a0c5d5c6ecc330aa)) +* **editor:** Fix issue with synchronization table on LDAP not loading data ([#8327](https://github.com/n8n-io/n8n/issues/8327)) ([6b92d49](https://github.com/n8n-io/n8n/commit/6b92d49ea58b8e5797e4e938444b161a63137638)) +* **editor:** Properly set colors for connections and labels on nodes with pinned data ([#8209](https://github.com/n8n-io/n8n/issues/8209)) ([3b8ccb9](https://github.com/n8n-io/n8n/commit/3b8ccb9fb903036a7d6e4b33f6b5a8933576e9e6)) +* Fix node graph telemetry with default values ([#8297](https://github.com/n8n-io/n8n/issues/8297)) ([93b969a](https://github.com/n8n-io/n8n/commit/93b969a327e0770d9a0e81a95a5185b0fc12ebc6)) +* **Google Drive Node:** Fix issue preventing service account from downloading files ([#7642](https://github.com/n8n-io/n8n/issues/7642)) ([cf7131d](https://github.com/n8n-io/n8n/commit/cf7131d766dfc7aec2c973525653ffec1ced03c1)) +* **HTTP Request Node:** Delete `response.request` only when it's a valid circular references ([#8293](https://github.com/n8n-io/n8n/issues/8293)) ([05c43fa](https://github.com/n8n-io/n8n/commit/05c43faa2d7582a8ce58b9bb3338c00253ad3281)) +* **Microsoft SQL Node:** Fix "Maximum call stack size exceeded" error on too many rows ([#8334](https://github.com/n8n-io/n8n/issues/8334)) ([bb2be8d](https://github.com/n8n-io/n8n/commit/bb2be8d70580896321641a49a3044165763eb9e1)) +* **Ollama Model Node:** Use a simpler credentials test ([#8318](https://github.com/n8n-io/n8n/issues/8318)) ([63b738a](https://github.com/n8n-io/n8n/commit/63b738a542429934b3838bfc814ea2a4c51675c7)) +* **OpenAI Node:** Load correct models for operation ([#8313](https://github.com/n8n-io/n8n/issues/8313)) ([a6a5372](https://github.com/n8n-io/n8n/commit/a6a5372b5f8e48e98788c4e3750ac4b63e91a96f)) +* Properly output saml validation errors ([#8284](https://github.com/n8n-io/n8n/issues/8284)) ([8c7f399](https://github.com/n8n-io/n8n/commit/8c7f39907fa82fa37af4436511d4a2daaff13015)) +* **Salesforce Node:** Upgrade to API version 59 ([#8346](https://github.com/n8n-io/n8n/issues/8346)) ([b51cbb3](https://github.com/n8n-io/n8n/commit/b51cbb325e03fd42be6dca99819d4cc7c4c1574b)) +* **Supabase Node:** Pagination for get all rows ([#8311](https://github.com/n8n-io/n8n/issues/8311)) ([e080476](https://github.com/n8n-io/n8n/commit/e0804768e84aefe9d66ab683080f67bb15a1cb58)) +* **Venafi TLS Protect Cloud Node:** Remove parameter `Application Server Type` ([#8325](https://github.com/n8n-io/n8n/issues/8325)) ([e3cedf7](https://github.com/n8n-io/n8n/commit/e3cedf7db038a70c9d48bb7c665b1be4beb872a9)) +* **Venafi TLS Protect Cloud Trigger Node:** Handle new webhook payload format ([#8326](https://github.com/n8n-io/n8n/issues/8326)) ([057d7d0](https://github.com/n8n-io/n8n/commit/057d7d031828ea8b6e779ca535ccd50d91bfa0cc)) + + +### Features + +* **core:** Implement inter-main communication for test webhooks in multi-main setup ([#8267](https://github.com/n8n-io/n8n/issues/8267)) ([1a0e285](https://github.com/n8n-io/n8n/commit/1a0e28555385f682aa335115c4d72e671c0bdc85)) +* **editor:** Add new `/templates/search` endpoint ([#8227](https://github.com/n8n-io/n8n/issues/8227)) ([4277e92](https://github.com/n8n-io/n8n/commit/4277e92ec07671a679b0d9ab6e691ef9208585bd)) +* Implement Chat Memory Manager node ([#8127](https://github.com/n8n-io/n8n/issues/8127)) ([464be93](https://github.com/n8n-io/n8n/commit/464be9332354620b2f1890136abf95dfdb71fd2e)) + + + # [1.24.0](https://github.com/n8n-io/n8n/compare/n8n@1.23.0...n8n@1.24.0) (2024-01-10) diff --git a/cypress/composables/becomeTemplateCreatorCta.ts b/cypress/composables/becomeTemplateCreatorCta.ts new file mode 100644 index 0000000000..55fc985c74 --- /dev/null +++ b/cypress/composables/becomeTemplateCreatorCta.ts @@ -0,0 +1,18 @@ +//#region Getters + +export const getBecomeTemplateCreatorCta = () => cy.getByTestId('become-template-creator-cta'); + +export const getCloseBecomeTemplateCreatorCtaButton = () => + cy.getByTestId('close-become-template-creator-cta'); + +//#endregion + +//#region Actions + +export const interceptCtaRequestWithResponse = (becomeCreator: boolean) => { + return cy.intercept('GET', `/rest/cta/become-creator`, { + body: becomeCreator, + }); +}; + +//#endregion diff --git a/cypress/constants.ts b/cypress/constants.ts index d37dd37574..9711a7fc02 100644 --- a/cypress/constants.ts +++ b/cypress/constants.ts @@ -35,7 +35,7 @@ export const INSTANCE_MEMBERS = [ ]; export const MANUAL_TRIGGER_NODE_NAME = 'Manual Trigger'; -export const MANUAL_TRIGGER_NODE_DISPLAY_NAME = 'When clicking "Test Workflow"'; +export const MANUAL_TRIGGER_NODE_DISPLAY_NAME = 'When clicking "Test workflow"'; export const MANUAL_CHAT_TRIGGER_NODE_NAME = 'Chat Trigger'; export const SCHEDULE_TRIGGER_NODE_NAME = 'Schedule Trigger'; export const CODE_NODE_NAME = 'Code'; diff --git a/cypress/e2e/19-execution.cy.ts b/cypress/e2e/19-execution.cy.ts index 2598b3f0f9..b2d7830962 100644 --- a/cypress/e2e/19-execution.cy.ts +++ b/cypress/e2e/19-execution.cy.ts @@ -1,5 +1,6 @@ import { v4 as uuid } from 'uuid'; import { NDV, WorkflowExecutionsTab, WorkflowPage as WorkflowPageClass } from '../pages'; +import { SCHEDULE_TRIGGER_NODE_NAME, EDIT_FIELDS_SET_NODE_NAME } from '../constants'; const workflowPage = new WorkflowPageClass(); const executionsTab = new WorkflowExecutionsTab(); @@ -409,5 +410,83 @@ describe('Execution', () => { .should('have.class', 'pinned') .should('have.class', 'has-run'); }); + + it('when connecting pinned node by output drag and drop', () => { + cy.drag( + workflowPage.getters.getEndpointSelector('output', SCHEDULE_TRIGGER_NODE_NAME), + [-200, -300], + ); + workflowPage.getters.nodeCreatorSearchBar().should('be.visible'); + workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, false); + cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [150, 200], { + clickToFinish: true, + }); + + workflowPage.getters + .getConnectionBetweenNodes('Schedule Trigger', 'Edit Fields8') + .should('have.class', 'success') + .should('have.class', 'pinned') + .should('not.have.class', 'has-run'); + + workflowPage.actions.executeWorkflow(); + + workflowPage.getters + .getConnectionBetweenNodes('Schedule Trigger', 'Edit Fields8') + .should('have.class', 'success') + .should('have.class', 'pinned') + .should('have.class', 'has-run'); + + cy.drag(workflowPage.getters.getEndpointSelector('output', 'Edit Fields2'), [-200, -300]); + workflowPage.getters.nodeCreatorSearchBar().should('be.visible'); + workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, false); + cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [150, 200], { + clickToFinish: true, + }); + + workflowPage.getters + .getConnectionBetweenNodes('Edit Fields2', 'Edit Fields11') + .should('have.class', 'success') + .should('have.class', 'pinned') + .should('have.class', 'has-run'); + }); + + it('when connecting pinned node after adding an unconnected node', () => { + workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME); + + cy.draganddrop( + workflowPage.getters.getEndpointSelector('output', SCHEDULE_TRIGGER_NODE_NAME), + workflowPage.getters.getEndpointSelector('input', 'Edit Fields8'), + ); + workflowPage.getters.zoomToFitButton().click(); + + workflowPage.getters + .getConnectionBetweenNodes('Schedule Trigger', 'Edit Fields8') + .should('have.class', 'success') + .should('have.class', 'pinned') + .should('not.have.class', 'has-run'); + + workflowPage.actions.executeWorkflow(); + + workflowPage.getters + .getConnectionBetweenNodes('Schedule Trigger', 'Edit Fields8') + .should('have.class', 'success') + .should('have.class', 'pinned') + .should('have.class', 'has-run'); + + workflowPage.actions.deselectAll(); + workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME); + workflowPage.getters.zoomToFitButton().click(); + + cy.draganddrop( + workflowPage.getters.getEndpointSelector('output', 'Edit Fields7'), + workflowPage.getters.getEndpointSelector('input', 'Edit Fields11'), + ); + + workflowPage.getters + .getConnectionBetweenNodes('Edit Fields7', 'Edit Fields11') + .should('have.class', 'success') + .should('have.class', 'pinned') + .should('have.class', 'has-run'); + }); }); }); diff --git a/cypress/e2e/20-workflow-executions.cy.ts b/cypress/e2e/20-workflow-executions.cy.ts index b44b9337a7..712927de97 100644 --- a/cypress/e2e/20-workflow-executions.cy.ts +++ b/cypress/e2e/20-workflow-executions.cy.ts @@ -16,11 +16,11 @@ describe('Current Workflow Executions', () => { it('should render executions tab correctly', () => { createMockExecutions(); cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions'); - cy.intercept('GET', '/rest/executions-current?filter=*').as('getCurrentExecutions'); + cy.intercept('GET', '/rest/executions/active?filter=*').as('getActiveExecutions'); executionsTab.actions.switchToExecutionsTab(); - cy.wait(['@getExecutions', '@getCurrentExecutions']); + cy.wait(['@getExecutions', '@getActiveExecutions']); executionsTab.getters.executionListItems().should('have.length', 11); executionsTab.getters.successfulExecutionListItems().should('have.length', 9); @@ -34,7 +34,7 @@ describe('Current Workflow Executions', () => { it('should not redirect back to execution tab when request is not done before leaving the page', () => { cy.intercept('GET', '/rest/executions?filter=*'); - cy.intercept('GET', '/rest/executions-current?filter=*'); + cy.intercept('GET', '/rest/executions/active?filter=*'); executionsTab.actions.switchToExecutionsTab(); executionsTab.actions.switchToEditorTab(); @@ -63,7 +63,7 @@ describe('Current Workflow Executions', () => { }; cy.intercept('GET', '/rest/executions?filter=*', throttleResponse); - cy.intercept('GET', '/rest/executions-current?filter=*', throttleResponse); + cy.intercept('GET', '/rest/executions/active?filter=*', throttleResponse); executionsTab.actions.switchToExecutionsTab(); executionsTab.actions.switchToEditorTab(); diff --git a/cypress/e2e/28-debug.cy.ts b/cypress/e2e/28-debug.cy.ts index 699f07d53f..b022ce5ac6 100644 --- a/cypress/e2e/28-debug.cy.ts +++ b/cypress/e2e/28-debug.cy.ts @@ -19,7 +19,7 @@ describe('Debug', () => { it('should be able to debug executions', () => { cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions'); cy.intercept('GET', '/rest/executions/*').as('getExecution'); - cy.intercept('GET', '/rest/executions-current?filter=*').as('getCurrentExecutions'); + cy.intercept('GET', '/rest/executions/active?filter=*').as('getActiveExecutions'); cy.intercept('POST', '/rest/workflows/run').as('postWorkflowRun'); cy.signin({ email: INSTANCE_OWNER.email, password: INSTANCE_OWNER.password }); @@ -41,7 +41,7 @@ describe('Debug', () => { executionsTab.actions.switchToExecutionsTab(); - cy.wait(['@getExecutions', '@getCurrentExecutions']); + cy.wait(['@getExecutions', '@getActiveExecutions']); executionsTab.getters.executionDebugButton().should('have.text', 'Debug in editor').click(); cy.url().should('include', '/debug'); @@ -66,7 +66,7 @@ describe('Debug', () => { executionsTab.actions.switchToExecutionsTab(); - cy.wait(['@getExecutions', '@getCurrentExecutions']); + cy.wait(['@getExecutions', '@getActiveExecutions']); executionsTab.getters.executionListItems().should('have.length', 2).first().click(); cy.wait(['@getExecution']); @@ -77,7 +77,7 @@ describe('Debug', () => { confirmDialog.find('li').should('have.length', 2); confirmDialog.get('.btn--cancel').click(); - cy.wait(['@getExecutions', '@getCurrentExecutions']); + cy.wait(['@getExecutions', '@getActiveExecutions']); executionsTab.getters.executionListItems().should('have.length', 2).first().click(); cy.wait(['@getExecution']); @@ -108,7 +108,7 @@ describe('Debug', () => { cy.url().should('not.include', '/debug'); executionsTab.actions.switchToExecutionsTab(); - cy.wait(['@getExecutions', '@getCurrentExecutions']); + cy.wait(['@getExecutions', '@getActiveExecutions']); executionsTab.getters.executionDebugButton().should('have.text', 'Copy to editor').click(); confirmDialog = cy.get('.matching-pinned-nodes-confirmation').filter(':visible'); @@ -130,7 +130,7 @@ describe('Debug', () => { workflowPage.actions.deleteNode(IF_NODE_NAME); executionsTab.actions.switchToExecutionsTab(); - cy.wait(['@getExecutions', '@getCurrentExecutions']); + cy.wait(['@getExecutions', '@getActiveExecutions']); executionsTab.getters.executionListItems().should('have.length', 3).first().click(); cy.wait(['@getExecution']); executionsTab.getters.executionDebugButton().should('have.text', 'Copy to editor').click(); diff --git a/cypress/e2e/30-editor-after-route-changes.cy.ts b/cypress/e2e/30-editor-after-route-changes.cy.ts index 656d7e9b78..733753314b 100644 --- a/cypress/e2e/30-editor-after-route-changes.cy.ts +++ b/cypress/e2e/30-editor-after-route-changes.cy.ts @@ -136,10 +136,10 @@ describe('Editor actions should work', () => { it('after switching between Editor and Executions', () => { cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions'); - cy.intercept('GET', '/rest/executions-current?filter=*').as('getCurrentExecutions'); + cy.intercept('GET', '/rest/executions/active?filter=*').as('getActiveExecutions'); executionsTab.actions.switchToExecutionsTab(); - cy.wait(['@getExecutions', '@getCurrentExecutions']); + cy.wait(['@getExecutions', '@getActiveExecutions']); cy.wait(500); executionsTab.actions.switchToEditorTab(); editWorkflowAndDeactivate(); @@ -149,7 +149,7 @@ describe('Editor actions should work', () => { it('after switching between Editor and Debug', () => { cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions'); cy.intercept('GET', '/rest/executions/*').as('getExecution'); - cy.intercept('GET', '/rest/executions-current?filter=*').as('getCurrentExecutions'); + cy.intercept('GET', '/rest/executions/active?filter=*').as('getActiveExecutions'); cy.intercept('POST', '/rest/workflows/run').as('postWorkflowRun'); editWorkflowAndDeactivate(); @@ -157,7 +157,7 @@ describe('Editor actions should work', () => { cy.wait(['@postWorkflowRun']); executionsTab.actions.switchToExecutionsTab(); - cy.wait(['@getExecutions', '@getCurrentExecutions']); + cy.wait(['@getExecutions', '@getActiveExecutions']); executionsTab.getters.executionListItems().should('have.length', 1).first().click(); cy.wait(['@getExecution']); diff --git a/cypress/e2e/36-suggested-templates.cy.ts b/cypress/e2e/36-suggested-templates.cy.ts index 897714a835..b788796e45 100644 --- a/cypress/e2e/36-suggested-templates.cy.ts +++ b/cypress/e2e/36-suggested-templates.cy.ts @@ -49,7 +49,7 @@ describe('Suggested templates - Should render', () => { 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.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'); diff --git a/cypress/e2e/37-become-creator-cta.cy.ts b/cypress/e2e/37-become-creator-cta.cy.ts new file mode 100644 index 0000000000..931208e5f3 --- /dev/null +++ b/cypress/e2e/37-become-creator-cta.cy.ts @@ -0,0 +1,32 @@ +import { + getBecomeTemplateCreatorCta, + getCloseBecomeTemplateCreatorCtaButton, + interceptCtaRequestWithResponse, +} from '../composables/becomeTemplateCreatorCta'; +import { WorkflowsPage as WorkflowsPageClass } from '../pages/workflows'; + +const WorkflowsPage = new WorkflowsPageClass(); + +describe('Become creator CTA', () => { + it('should not show the CTA if user is not eligible', () => { + interceptCtaRequestWithResponse(false).as('cta'); + cy.visit(WorkflowsPage.url); + + cy.wait('@cta'); + + getBecomeTemplateCreatorCta().should('not.exist'); + }); + + it('should show the CTA if the user is eligible', () => { + interceptCtaRequestWithResponse(true).as('cta'); + cy.visit(WorkflowsPage.url); + + cy.wait('@cta'); + + getBecomeTemplateCreatorCta().should('be.visible'); + + getCloseBecomeTemplateCreatorCtaButton().click(); + + getBecomeTemplateCreatorCta().should('not.exist'); + }); +}); diff --git a/cypress/e2e/4-node-creator.cy.ts b/cypress/e2e/4-node-creator.cy.ts index 90ea78c1fb..197aefbe21 100644 --- a/cypress/e2e/4-node-creator.cy.ts +++ b/cypress/e2e/4-node-creator.cy.ts @@ -308,7 +308,7 @@ describe('Node Creator', () => { nodeCreatorFeature.getters.getCategoryItem('Actions').click(); nodeCreatorFeature.getters.getCreatorItem('Create a credential').click(); NDVModal.actions.close(); - WorkflowPage.actions.deleteNode('When clicking "Test Workflow"'); + WorkflowPage.actions.deleteNode('When clicking "Test workflow"'); WorkflowPage.getters.canvasNodePlusEndpointByName('n8n').click(); nodeCreatorFeature.getters.searchBar().find('input').clear().type('n8n'); nodeCreatorFeature.getters.getCreatorItem('n8n').click(); diff --git a/cypress/e2e/5-ndv.cy.ts b/cypress/e2e/5-ndv.cy.ts index 4fc69f8fde..0c6d0cc8dd 100644 --- a/cypress/e2e/5-ndv.cy.ts +++ b/cypress/e2e/5-ndv.cy.ts @@ -579,7 +579,7 @@ describe('NDV', () => { ndv.getters.backToCanvas().click(); workflowPage.actions.executeWorkflow(); // Manual tigger node should show success indicator - workflowPage.actions.openNode('When clicking "Test Workflow"'); + workflowPage.actions.openNode('When clicking "Test workflow"'); ndv.getters.nodeRunSuccessIndicator().should('exist'); // Code node should show error ndv.getters.backToCanvas().click(); diff --git a/cypress/e2e/7-workflow-actions.cy.ts b/cypress/e2e/7-workflow-actions.cy.ts index ef15dd97d7..096fae738d 100644 --- a/cypress/e2e/7-workflow-actions.cy.ts +++ b/cypress/e2e/7-workflow-actions.cy.ts @@ -259,7 +259,7 @@ describe('Workflow Actions', () => { it('should keep endpoint click working when switching between execution and editor tab', () => { cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions'); - cy.intercept('GET', '/rest/executions-current?filter=*').as('getCurrentExecutions'); + cy.intercept('GET', '/rest/executions/active?filter=*').as('getActiveExecutions'); WorkflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME); @@ -270,7 +270,7 @@ describe('Workflow Actions', () => { cy.get('body').type('{esc}'); executionsTab.actions.switchToExecutionsTab(); - cy.wait(['@getExecutions', '@getCurrentExecutions']); + cy.wait(['@getExecutions', '@getActiveExecutions']); cy.wait(500); executionsTab.actions.switchToEditorTab(); diff --git a/cypress/fixtures/Floating_Nodes.json b/cypress/fixtures/Floating_Nodes.json index d95675d3c2..2ffc1b3fde 100644 --- a/cypress/fixtures/Floating_Nodes.json +++ b/cypress/fixtures/Floating_Nodes.json @@ -4,7 +4,7 @@ { "parameters": {}, "id": "d0eda550-2526-42a1-aa19-dee411c8acf9", - "name": "When clicking \"Test Workflow\"", + "name": "When clicking \"Test workflow\"", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [ @@ -91,7 +91,7 @@ ], "pinData": {}, "connections": { - "When clicking \"Test Workflow\"": { + "When clicking \"Test workflow\"": { "main": [ [ { diff --git a/cypress/fixtures/Lots_of_nodes.json b/cypress/fixtures/Lots_of_nodes.json index 85a100b94e..7b3ad507c8 100644 --- a/cypress/fixtures/Lots_of_nodes.json +++ b/cypress/fixtures/Lots_of_nodes.json @@ -4,7 +4,7 @@ { "parameters": {}, "id": "369fe424-dd3b-4399-9de3-50bd4ce1f75b", - "name": "When clicking \"Test Workflow\"", + "name": "When clicking \"Test workflow\"", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [ @@ -570,7 +570,7 @@ ], "pinData": {}, "connections": { - "When clicking \"Test Workflow\"": { + "When clicking \"Test workflow\"": { "main": [ [ { @@ -1048,4 +1048,4 @@ "instanceId": "8a47b83b4479b11330fdf21ccc96d4a8117035a968612e452b4c87bfd09c16c7" }, "tags": [] -} \ No newline at end of file +} diff --git a/cypress/fixtures/Node_IO_filter.json b/cypress/fixtures/Node_IO_filter.json index 9bb9ff994f..61be5d58d8 100644 --- a/cypress/fixtures/Node_IO_filter.json +++ b/cypress/fixtures/Node_IO_filter.json @@ -4,7 +4,7 @@ { "parameters": {}, "id": "46770685-44d1-4aad-9107-1d790cf26b50", - "name": "When clicking \"Test Workflow\"", + "name": "When clicking \"Test workflow\"", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [ @@ -74,7 +74,7 @@ } ], "pinData": { - "When clicking \"Test Workflow\"": [ + "When clicking \"Test workflow\"": [ { "json": { "id": "654cfa05fa51480dcb543b1a", @@ -599,7 +599,7 @@ ] }, "connections": { - "When clicking \"Test Workflow\"": { + "When clicking \"Test workflow\"": { "main": [ [ { diff --git a/cypress/fixtures/Suggested_Templates.json b/cypress/fixtures/Suggested_Templates.json index 982b75296d..3f69c4b1a9 100644 --- a/cypress/fixtures/Suggested_Templates.json +++ b/cypress/fixtures/Suggested_Templates.json @@ -42,7 +42,7 @@ { "parameters": {}, "id": "551313bb-1e01-4133-9956-e6f09968f2ce", - "name": "When clicking \"Test Workflow\"", + "name": "When clicking \"Test workflow\"", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [ @@ -92,7 +92,7 @@ } ], "connections": { - "When clicking \"Test Workflow\"": { + "When clicking \"Test workflow\"": { "main": [ [ { @@ -191,7 +191,7 @@ { "parameters": {}, "id": "551313bb-1e01-4133-9956-e6f09968f2ce", - "name": "When clicking \"Test Workflow\"", + "name": "When clicking \"Test workflow\"", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [ @@ -241,7 +241,7 @@ } ], "connections": { - "When clicking \"Test Workflow\"": { + "When clicking \"Test workflow\"": { "main": [ [ { @@ -374,7 +374,7 @@ { "parameters": {}, "id": "551313bb-1e01-4133-9956-e6f09968f2ce", - "name": "When clicking \"Test Workflow\"", + "name": "When clicking \"Test workflow\"", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [ @@ -424,7 +424,7 @@ } ], "connections": { - "When clicking \"Test Workflow\"": { + "When clicking \"Test workflow\"": { "main": [ [ { @@ -524,7 +524,7 @@ { "parameters": {}, "id": "551313bb-1e01-4133-9956-e6f09968f2ce", - "name": "When clicking \"Test Workflow\"", + "name": "When clicking \"Test workflow\"", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [ @@ -574,7 +574,7 @@ } ], "connections": { - "When clicking \"Test Workflow\"": { + "When clicking \"Test workflow\"": { "main": [ [ { diff --git a/cypress/fixtures/Test_workflow_5.json b/cypress/fixtures/Test_workflow_5.json index b94fcd283a..5771e197d9 100644 --- a/cypress/fixtures/Test_workflow_5.json +++ b/cypress/fixtures/Test_workflow_5.json @@ -40,7 +40,7 @@ { "parameters": {}, "id": "ef63cdc5-50bc-4525-9873-7e7f7589a60e", - "name": "When clicking \"Test Workflow\"", + "name": "When clicking \"Test workflow\"", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [ @@ -199,7 +199,7 @@ ] ] }, - "When clicking \"Test Workflow\"": { + "When clicking \"Test workflow\"": { "main": [ [ { diff --git a/cypress/fixtures/Test_workflow_filter.json b/cypress/fixtures/Test_workflow_filter.json index e30a3504ca..e5aad93388 100644 --- a/cypress/fixtures/Test_workflow_filter.json +++ b/cypress/fixtures/Test_workflow_filter.json @@ -4,7 +4,7 @@ { "parameters": {}, "id": "f332a7d1-31b4-4e78-b31e-9e8db945bf3f", - "name": "When clicking \"Test Workflow\"", + "name": "When clicking \"Test workflow\"", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [ @@ -99,7 +99,7 @@ ], "pinData": {}, "connections": { - "When clicking \"Test Workflow\"": { + "When clicking \"Test workflow\"": { "main": [ [ { diff --git a/cypress/fixtures/Test_workflow_ndv_version.json b/cypress/fixtures/Test_workflow_ndv_version.json index 871a526e3a..7d8f6af924 100644 --- a/cypress/fixtures/Test_workflow_ndv_version.json +++ b/cypress/fixtures/Test_workflow_ndv_version.json @@ -4,7 +4,7 @@ { "parameters": {}, "id": "aadaed66-84ed-4cf8-bf21-082e9a65db76", - "name": "When clicking \"Test Workflow\"", + "name": "When clicking \"Test workflow\"", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [ diff --git a/cypress/fixtures/Test_workflow_schema_test.json b/cypress/fixtures/Test_workflow_schema_test.json index f615316c73..8c83c4f20e 100644 --- a/cypress/fixtures/Test_workflow_schema_test.json +++ b/cypress/fixtures/Test_workflow_schema_test.json @@ -47,7 +47,7 @@ { "parameters": {}, "id": "58512a93-dabf-4584-817f-27c608c1bdd5", - "name": "When clicking \"Test Workflow\"", + "name": "When clicking \"Test workflow\"", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [ @@ -69,7 +69,7 @@ ] ] }, - "When clicking \"Test Workflow\"": { + "When clicking \"Test workflow\"": { "main": [ [ { diff --git a/cypress/fixtures/Test_workflow_schema_test_pinned_data.json b/cypress/fixtures/Test_workflow_schema_test_pinned_data.json index 867563ddfd..8bd5ef783d 100644 --- a/cypress/fixtures/Test_workflow_schema_test_pinned_data.json +++ b/cypress/fixtures/Test_workflow_schema_test_pinned_data.json @@ -47,7 +47,7 @@ { "parameters": {}, "id": "3dc7cf26-ff25-4437-b9fd-0e8b127ebec9", - "name": "When clicking \"Test Workflow\"", + "name": "When clicking \"Test workflow\"", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [ @@ -552,7 +552,7 @@ ] ] }, - "When clicking \"Test Workflow\"": { + "When clicking \"Test workflow\"": { "main": [ [ { diff --git a/cypress/fixtures/Test_workflow_webhook_with_pin_data.json b/cypress/fixtures/Test_workflow_webhook_with_pin_data.json index d1bfb17133..fb632bcf36 100644 --- a/cypress/fixtures/Test_workflow_webhook_with_pin_data.json +++ b/cypress/fixtures/Test_workflow_webhook_with_pin_data.json @@ -4,7 +4,7 @@ { "parameters": {}, "id": "0a60e507-7f34-41c0-a0f9-697d852033b6", - "name": "When clicking \"Test Workflow\"", + "name": "When clicking \"Test workflow\"", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [ @@ -93,7 +93,7 @@ ] }, "connections": { - "When clicking \"Test Workflow\"": { + "When clicking \"Test workflow\"": { "main": [ [ { diff --git a/cypress/fixtures/Test_workflow_xml_output.json b/cypress/fixtures/Test_workflow_xml_output.json index 03d09a1735..17449bc56d 100644 --- a/cypress/fixtures/Test_workflow_xml_output.json +++ b/cypress/fixtures/Test_workflow_xml_output.json @@ -6,7 +6,7 @@ { "parameters": {}, "id": "8108d313-8b03-4aa4-963d-cd1c0fe8f85c", - "name": "When clicking \"Test Workflow\"", + "name": "When clicking \"Test workflow\"", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [ @@ -37,7 +37,7 @@ } ], "connections": { - "When clicking \"Test Workflow\"": { + "When clicking \"Test workflow\"": { "main": [ [ { diff --git a/cypress/fixtures/expression_with_paired_item_in_multi_input_node.json b/cypress/fixtures/expression_with_paired_item_in_multi_input_node.json index 8fb17e15b4..ffb7005f4f 100644 --- a/cypress/fixtures/expression_with_paired_item_in_multi_input_node.json +++ b/cypress/fixtures/expression_with_paired_item_in_multi_input_node.json @@ -6,7 +6,7 @@ { "parameters": {}, "id": "bcb6abdf-d34b-4ea7-a8ed-58155b708c43", - "name": "When clicking \"Test Workflow\"", + "name": "When clicking \"Test workflow\"", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [ @@ -90,7 +90,7 @@ } ], "connections": { - "When clicking \"Test Workflow\"": { + "When clicking \"Test workflow\"": { "main": [ [ { diff --git a/cypress/fixtures/workflow-with-unknown-credentials.json b/cypress/fixtures/workflow-with-unknown-credentials.json index 17d355f92e..142422227c 100644 --- a/cypress/fixtures/workflow-with-unknown-credentials.json +++ b/cypress/fixtures/workflow-with-unknown-credentials.json @@ -27,7 +27,7 @@ { "parameters": {}, "id": "acdd1bdc-c642-4ea6-ad67-f4201b640cfa", - "name": "When clicking \"Test Workflow\"", + "name": "When clicking \"Test workflow\"", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [ @@ -37,7 +37,7 @@ } ], "connections": { - "When clicking \"Test Workflow\"": { + "When clicking \"Test workflow\"": { "main": [ [ { diff --git a/cypress/fixtures/workflow-with-unknown-nodes.json b/cypress/fixtures/workflow-with-unknown-nodes.json index c5b5f165b3..5ea0189e50 100644 --- a/cypress/fixtures/workflow-with-unknown-nodes.json +++ b/cypress/fixtures/workflow-with-unknown-nodes.json @@ -6,7 +6,7 @@ { "parameters": {}, "id": "40720511-19b6-4421-bdb0-3fb6efef4bc5", - "name": "When clicking \"Test Workflow\"", + "name": "When clicking \"Test workflow\"", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [ @@ -64,7 +64,7 @@ } ], "connections": { - "When clicking \"Test Workflow\"": { + "When clicking \"Test workflow\"": { "main": [ [ { diff --git a/package.json b/package.json index 2e794cfbf2..82755641b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.24.0", + "version": "1.25.0", "private": true, "homepage": "https://n8n.io", "engines": { @@ -29,7 +29,7 @@ "test:backend": "pnpm --filter=!@n8n/chat --filter=!n8n-design-system --filter=!n8n-editor-ui --filter=!n8n-nodes-base test", "test:nodes": "pnpm --filter=n8n-nodes-base test", "test:frontend": "pnpm --filter=@n8n/chat --filter=n8n-design-system --filter=n8n-editor-ui test", - "watch": "turbo run watch", + "watch": "turbo run watch --parallel", "webhook": "./packages/cli/bin/n8n webhook", "worker": "./packages/cli/bin/n8n worker", "cypress:install": "cypress install", diff --git a/packages/@n8n/chat/src/__tests__/utils/fetch.ts b/packages/@n8n/chat/src/__tests__/utils/fetch.ts index 51d539d773..6cf31c7d0d 100644 --- a/packages/@n8n/chat/src/__tests__/utils/fetch.ts +++ b/packages/@n8n/chat/src/__tests__/utils/fetch.ts @@ -3,7 +3,7 @@ import type { LoadPreviousSessionResponse, SendMessageResponse } from '@n8n/chat export function createFetchResponse(data: T) { return async () => ({ - json: async () => new Promise((resolve) => resolve(data)), + json: async () => await new Promise((resolve) => resolve(data)), }) as Response; } diff --git a/packages/@n8n/chat/src/api/generic.ts b/packages/@n8n/chat/src/api/generic.ts index 98385b90c4..04b6d61b65 100644 --- a/packages/@n8n/chat/src/api/generic.ts +++ b/packages/@n8n/chat/src/api/generic.ts @@ -16,7 +16,7 @@ export async function authenticatedFetch(...args: Parameters): }, }); - return (await response.json()) as Promise; + return (await response.json()) as T; } export async function get(url: string, query: object = {}, options: RequestInit = {}) { @@ -27,11 +27,11 @@ export async function get(url: string, query: object = {}, options: RequestIn ).toString()}`; } - return authenticatedFetch(resolvedUrl, { ...options, method: 'GET' }); + return await authenticatedFetch(resolvedUrl, { ...options, method: 'GET' }); } export async function post(url: string, body: object = {}, options: RequestInit = {}) { - return authenticatedFetch(url, { + return await authenticatedFetch(url, { ...options, method: 'POST', body: JSON.stringify(body), @@ -39,7 +39,7 @@ export async function post(url: string, body: object = {}, options: RequestIn } export async function put(url: string, body: object = {}, options: RequestInit = {}) { - return authenticatedFetch(url, { + return await authenticatedFetch(url, { ...options, method: 'PUT', body: JSON.stringify(body), @@ -47,7 +47,7 @@ export async function put(url: string, body: object = {}, options: RequestIni } export async function patch(url: string, body: object = {}, options: RequestInit = {}) { - return authenticatedFetch(url, { + return await authenticatedFetch(url, { ...options, method: 'PATCH', body: JSON.stringify(body), @@ -55,7 +55,7 @@ export async function patch(url: string, body: object = {}, options: RequestI } export async function del(url: string, body: object = {}, options: RequestInit = {}) { - return authenticatedFetch(url, { + return await authenticatedFetch(url, { ...options, method: 'DELETE', body: JSON.stringify(body), diff --git a/packages/@n8n/chat/src/api/message.ts b/packages/@n8n/chat/src/api/message.ts index ff629bc917..72f8e2fb27 100644 --- a/packages/@n8n/chat/src/api/message.ts +++ b/packages/@n8n/chat/src/api/message.ts @@ -7,7 +7,7 @@ import type { export async function loadPreviousSession(sessionId: string, options: ChatOptions) { const method = options.webhookConfig?.method === 'POST' ? post : get; - return method( + return await method( `${options.webhookUrl}`, { action: 'loadPreviousSession', @@ -22,7 +22,7 @@ export async function loadPreviousSession(sessionId: string, options: ChatOption export async function sendMessage(message: string, sessionId: string, options: ChatOptions) { const method = options.webhookConfig?.method === 'POST' ? post : get; - return method( + return await method( `${options.webhookUrl}`, { action: 'sendMessage', diff --git a/packages/@n8n/client-oauth2/package.json b/packages/@n8n/client-oauth2/package.json index 85d24f69c8..e3b3ee7657 100644 --- a/packages/@n8n/client-oauth2/package.json +++ b/packages/@n8n/client-oauth2/package.json @@ -20,6 +20,6 @@ "dist/**/*" ], "dependencies": { - "axios": "1.6.2" + "axios": "1.6.5" } } diff --git a/packages/@n8n/client-oauth2/test/CredentialsFlow.test.ts b/packages/@n8n/client-oauth2/test/CredentialsFlow.test.ts index 439d66a4b8..9e0749800d 100644 --- a/packages/@n8n/client-oauth2/test/CredentialsFlow.test.ts +++ b/packages/@n8n/client-oauth2/test/CredentialsFlow.test.ts @@ -42,7 +42,7 @@ describe('CredentialsFlow', () => { refresh_token: config.refreshToken, scope: requestedScope, }); - return new Promise<{ headers: Headers; body: unknown }>((resolve) => { + return await new Promise<{ headers: Headers; body: unknown }>((resolve) => { nockScope.once('request', (req) => { resolve({ headers: req.headers, diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts index 49fa322f5d..378fa51c55 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts @@ -252,15 +252,15 @@ export class Agent implements INodeType { const agentType = this.getNodeParameter('agent', 0, '') as string; if (agentType === 'conversationalAgent') { - return conversationalAgentExecute.call(this); + return await conversationalAgentExecute.call(this); } else if (agentType === 'openAiFunctionsAgent') { - return openAiFunctionsAgentExecute.call(this); + return await openAiFunctionsAgentExecute.call(this); } else if (agentType === 'reActAgent') { - return reActAgentAgentExecute.call(this); + return await reActAgentAgentExecute.call(this); } else if (agentType === 'sqlAgent') { - return sqlAgentAgentExecute.call(this); + return await sqlAgentAgentExecute.call(this); } else if (agentType === 'planAndExecuteAgent') { - return planAndExecuteAgentExecute.call(this); + return await planAndExecuteAgentExecute.call(this); } throw new NodeOperationError(this.getNode(), `The agent type "${agentType}" is not supported`); diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/execute.ts index abc820b325..dd0ff5c07b 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/execute.ts @@ -102,5 +102,5 @@ export async function conversationalAgentExecute( returnData.push({ json: response }); } - return this.prepareOutputData(returnData); + return await this.prepareOutputData(returnData); } diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts index e21b5bdf42..285a95f0cf 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts @@ -101,5 +101,5 @@ export async function openAiFunctionsAgentExecute( returnData.push({ json: response }); } - return this.prepareOutputData(returnData); + return await this.prepareOutputData(returnData); } diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/PlanAndExecuteAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/PlanAndExecuteAgent/execute.ts index e1e031b85a..bfa5f533d2 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/PlanAndExecuteAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/PlanAndExecuteAgent/execute.ts @@ -76,5 +76,5 @@ export async function planAndExecuteAgentExecute( returnData.push({ json: response }); } - return this.prepareOutputData(returnData); + return await this.prepareOutputData(returnData); } diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/execute.ts index 0366fedf73..492272f5af 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/execute.ts @@ -94,5 +94,5 @@ export async function reActAgentAgentExecute( returnData.push({ json: response }); } - return this.prepareOutputData(returnData); + return await this.prepareOutputData(returnData); } diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/execute.ts index faa3c4b609..b52c0d1634 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/execute.ts @@ -101,5 +101,5 @@ export async function sqlAgentAgentExecute( returnData.push({ json: response }); } - return this.prepareOutputData(returnData); + return await this.prepareOutputData(returnData); } diff --git a/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts index 22234ae584..67ea020d14 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts @@ -380,6 +380,6 @@ export class OpenAiAssistant implements INodeType { returnData.push({ json: response }); } - return this.prepareOutputData(returnData); + return await this.prepareOutputData(returnData); } } diff --git a/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/ChainLlm.node.ts b/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/ChainLlm.node.ts index d181dfa1f8..d1a5363636 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/ChainLlm.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/ChainLlm.node.ts @@ -166,7 +166,7 @@ async function getChain( // If there are no output parsers, create a simple LLM chain and execute the query if (!outputParsers.length) { - return createSimpleLLMChain(context, llm, query, chatTemplate); + return await createSimpleLLMChain(context, llm, query, chatTemplate); } // If there's only one output parser, use it; otherwise, create a combined output parser diff --git a/packages/@n8n/nodes-langchain/nodes/chains/ChainRetrievalQA/ChainRetrievalQa.node.ts b/packages/@n8n/nodes-langchain/nodes/chains/ChainRetrievalQA/ChainRetrievalQa.node.ts index 4c4637fcd6..bac0a9bbf1 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/ChainRetrievalQA/ChainRetrievalQa.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/ChainRetrievalQA/ChainRetrievalQa.node.ts @@ -126,6 +126,6 @@ export class ChainRetrievalQa implements INodeType { const response = await chain.call({ query }); returnData.push({ json: { response } }); } - return this.prepareOutputData(returnData); + return await this.prepareOutputData(returnData); } } diff --git a/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/V1/ChainSummarizationV1.node.ts b/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/V1/ChainSummarizationV1.node.ts index 79347087af..f97fb4d1ec 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/V1/ChainSummarizationV1.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/V1/ChainSummarizationV1.node.ts @@ -258,6 +258,6 @@ export class ChainSummarizationV1 implements INodeType { returnData.push({ json: { response } }); } - return this.prepareOutputData(returnData); + return await this.prepareOutputData(returnData); } } diff --git a/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/V2/ChainSummarizationV2.node.ts b/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/V2/ChainSummarizationV2.node.ts index 784406b156..d8aaa992a1 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/V2/ChainSummarizationV2.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/ChainSummarization/V2/ChainSummarizationV2.node.ts @@ -415,6 +415,6 @@ export class ChainSummarizationV2 implements INodeType { } } - return this.prepareOutputData(returnData); + return await this.prepareOutputData(returnData); } } diff --git a/packages/@n8n/nodes-langchain/nodes/memory/MemoryChatRetriever/MemoryChatRetriever.node.ts b/packages/@n8n/nodes-langchain/nodes/memory/MemoryChatRetriever/MemoryChatRetriever.node.ts index f3adf3e92b..77d3609027 100644 --- a/packages/@n8n/nodes-langchain/nodes/memory/MemoryChatRetriever/MemoryChatRetriever.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/memory/MemoryChatRetriever/MemoryChatRetriever.node.ts @@ -98,7 +98,7 @@ export class MemoryChatRetriever implements INodeType { const messages = await memory?.chatHistory.getMessages(); if (simplifyOutput && messages) { - return this.prepareOutputData(simplifyMessages(messages)); + return await this.prepareOutputData(simplifyMessages(messages)); } const serializedMessages = @@ -107,6 +107,6 @@ export class MemoryChatRetriever implements INodeType { return { json: serializedMessage as unknown as IDataObject }; }) ?? []; - return this.prepareOutputData(serializedMessages); + return await this.prepareOutputData(serializedMessages); } } diff --git a/packages/@n8n/nodes-langchain/nodes/memory/MemoryManager/MemoryManager.node.ts b/packages/@n8n/nodes-langchain/nodes/memory/MemoryManager/MemoryManager.node.ts index 8c196673b1..de84f62f2f 100644 --- a/packages/@n8n/nodes-langchain/nodes/memory/MemoryManager/MemoryManager.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/memory/MemoryManager/MemoryManager.node.ts @@ -324,6 +324,6 @@ export class MemoryManager implements INodeType { result.push(...executionData); } - return this.prepareOutputData(result); + return await this.prepareOutputData(result); } } diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts index b3087ad12c..945416c3c5 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts @@ -163,7 +163,7 @@ export class ToolCode implements INodeType { const runFunction = async (query: string): Promise => { const sandbox = getSandbox(query, itemIndex); - return sandbox.runCode() as Promise; + return await (sandbox.runCode() as Promise); }; return { diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemory/VectorStoreInMemory.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemory/VectorStoreInMemory.node.ts index b7d58b8411..fecf8d163a 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemory/VectorStoreInMemory.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemory/VectorStoreInMemory.node.ts @@ -46,7 +46,7 @@ export const VectorStoreInMemory = createVectorStoreNode({ const memoryKey = context.getNodeParameter('memoryKey', itemIndex) as string; const vectorStoreSingleton = MemoryVectorStoreManager.getInstance(embeddings); - return vectorStoreSingleton.getVectorStore(`${workflowId}__${memoryKey}`); + return await vectorStoreSingleton.getVectorStore(`${workflowId}__${memoryKey}`); }, async populateVectorStore(context, embeddings, documents, itemIndex) { const memoryKey = context.getNodeParameter('memoryKey', itemIndex) as string; diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemoryInsert/VectorStoreInMemoryInsert.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemoryInsert/VectorStoreInMemoryInsert.node.ts index 51f0a2f7a9..39f89bfce5 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemoryInsert/VectorStoreInMemoryInsert.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemoryInsert/VectorStoreInMemoryInsert.node.ts @@ -108,6 +108,6 @@ export class VectorStoreInMemoryInsert implements INodeType { clearStore, ); - return this.prepareOutputData(serializedDocuments); + return await this.prepareOutputData(serializedDocuments); } } diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePinecone/VectorStorePinecone.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePinecone/VectorStorePinecone.node.ts index a348ffbc40..9c0c9ccbba 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePinecone/VectorStorePinecone.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePinecone/VectorStorePinecone.node.ts @@ -97,7 +97,7 @@ export const VectorStorePinecone = createVectorStoreNode({ filter, }; - return PineconeStore.fromExistingIndex(embeddings, config); + return await PineconeStore.fromExistingIndex(embeddings, config); }, async populateVectorStore(context, embeddings, documents, itemIndex) { const index = context.getNodeParameter('pineconeIndex', itemIndex, '', { diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePineconeInsert/VectorStorePineconeInsert.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePineconeInsert/VectorStorePineconeInsert.node.ts index cb764c5c7b..3f89de79e1 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePineconeInsert/VectorStorePineconeInsert.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePineconeInsert/VectorStorePineconeInsert.node.ts @@ -134,6 +134,6 @@ export class VectorStorePineconeInsert implements INodeType { pineconeIndex, }); - return this.prepareOutputData(serializedDocuments); + return await this.prepareOutputData(serializedDocuments); } } diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreQdrant/VectorStoreQdrant.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreQdrant/VectorStoreQdrant.node.ts index 759330539e..3a08dfdc42 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreQdrant/VectorStoreQdrant.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreQdrant/VectorStoreQdrant.node.ts @@ -59,7 +59,7 @@ export const VectorStoreQdrant = createVectorStoreNode({ collectionName: collection, }; - return QdrantVectorStore.fromExistingCollection(embeddings, config); + return await QdrantVectorStore.fromExistingCollection(embeddings, config); }, async populateVectorStore(context, embeddings, documents, itemIndex) { const collectionName = context.getNodeParameter('qdrantCollection', itemIndex, '', { diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabase/VectorStoreSupabase.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabase/VectorStoreSupabase.node.ts index 931fc22f82..b4ceae0548 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabase/VectorStoreSupabase.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabase/VectorStoreSupabase.node.ts @@ -76,7 +76,7 @@ export const VectorStoreSupabase = createVectorStoreNode({ const credentials = await context.getCredentials('supabaseApi'); const client = createClient(credentials.host as string, credentials.serviceRole as string); - return SupabaseVectorStore.fromExistingIndex(embeddings, { + return await SupabaseVectorStore.fromExistingIndex(embeddings, { client, tableName, queryName: options.queryName ?? 'match_documents', diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabaseInsert/VectorStoreSupabaseInsert.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabaseInsert/VectorStoreSupabaseInsert.node.ts index f199ecd6af..c374b083f9 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabaseInsert/VectorStoreSupabaseInsert.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabaseInsert/VectorStoreSupabaseInsert.node.ts @@ -122,6 +122,6 @@ export class VectorStoreSupabaseInsert implements INodeType { queryName, }); - return this.prepareOutputData(serializedDocuments); + return await this.prepareOutputData(serializedDocuments); } } diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZepInsert/VectorStoreZepInsert.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZepInsert/VectorStoreZepInsert.node.ts index fad1692d37..36cfecf576 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZepInsert/VectorStoreZepInsert.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZepInsert/VectorStoreZepInsert.node.ts @@ -139,6 +139,6 @@ export class VectorStoreZepInsert implements INodeType { await ZepVectorStore.fromDocuments(processedDocuments, embeddings, zepConfig); - return this.prepareOutputData(serializedDocuments); + return await this.prepareOutputData(serializedDocuments); } } diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode.ts index 1bc49c868f..277254204d 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode.ts @@ -239,7 +239,7 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) => resultData.push(...serializedDocs); } - return this.prepareOutputData(resultData); + return await this.prepareOutputData(resultData); } if (mode === 'insert') { @@ -267,7 +267,7 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) => } } - return this.prepareOutputData(resultData); + return await this.prepareOutputData(resultData); } throw new NodeOperationError( diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index 1dcdff0266..09ba700cf0 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/n8n-nodes-langchain", - "version": "0.9.0", + "version": "0.10.0", "description": "", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -148,7 +148,7 @@ "openai": "4.20.0", "pdf-parse": "1.1.1", "pg": "8.11.3", - "redis": "4.6.11", + "redis": "4.6.12", "sqlite3": "5.1.6", "temp": "0.9.4", "typeorm": "0.3.17", diff --git a/packages/@n8n_io/eslint-config/base.js b/packages/@n8n_io/eslint-config/base.js index 7a2ee861d3..f5150ee61a 100644 --- a/packages/@n8n_io/eslint-config/base.js +++ b/packages/@n8n_io/eslint-config/base.js @@ -336,6 +336,11 @@ const config = (module.exports = { }, ], + /** + * https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/docs/rules/return-await.md + */ + '@typescript-eslint/return-await': ['error', 'always'], + // ---------------------------------- // eslint-plugin-import // ---------------------------------- diff --git a/packages/cli/bin/n8n b/packages/cli/bin/n8n index 11e8b772ad..005cd983cf 100755 --- a/packages/cli/bin/n8n +++ b/packages/cli/bin/n8n @@ -44,7 +44,7 @@ if (process.env.NODEJS_PREFER_IPV4 === 'true') { require('dns').setDefaultResultOrder('ipv4first'); } -require('@oclif/command') - .run() - .then(require('@oclif/command/flush')) - .catch(require('@oclif/errors/handle')); +(async () => { + const oclif = await import('@oclif/core'); + await oclif.execute({}); +})(); diff --git a/packages/cli/package.json b/packages/cli/package.json index 0a8b3ba08b..22e82a4e3b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.24.0", + "version": "1.25.0", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -29,12 +29,9 @@ "format": "prettier --write . --ignore-path ../../.prettierignore", "lint": "eslint . --quiet", "lintfix": "eslint . --fix", - "postpack": "rm -f oclif.manifest.json", - "prepack": "OCLIF_TS_NODE=0 oclif-dev manifest", "start": "run-script-os", "start:default": "cd bin && ./n8n", "start:windows": "cd bin && n8n", - "swagger": "swagger-cli", "test": "pnpm test:sqlite", "test:sqlite": "N8N_LOG_LEVEL=silent DB_TYPE=sqlite jest", "test:postgres": "N8N_LOG_LEVEL=silent DB_TYPE=postgresdb DB_POSTGRESDB_SCHEMA=alt_schema DB_TABLE_PREFIX=test_ jest --no-coverage", @@ -60,12 +57,10 @@ "bin", "templates", "dist", - "oclif.manifest.json", "!dist/**/e2e.*" ], "devDependencies": { - "@apidevtools/swagger-cli": "4.0.0", - "@oclif/dev-cli": "^1.22.2", + "@redocly/cli": "^1.6.0", "@types/basic-auth": "^1.1.3", "@types/bcryptjs": "^2.4.2", "@types/compression": "1.0.1", @@ -84,7 +79,7 @@ "@types/shelljs": "^0.8.11", "@types/sshpk": "^1.17.1", "@types/superagent": "4.1.13", - "@types/swagger-ui-express": "^4.1.3", + "@types/swagger-ui-express": "^4.1.6", "@types/syslog-client": "^1.1.2", "@types/uuid": "^8.3.2", "@types/validator": "^13.7.0", @@ -99,19 +94,17 @@ "dependencies": { "@n8n/client-oauth2": "workspace:*", "@n8n/localtunnel": "2.1.0", + "@n8n/n8n-nodes-langchain": "workspace:*", "@n8n/permissions": "workspace:*", - "@n8n_io/license-sdk": "2.7.2", - "@oclif/command": "1.8.18", - "@oclif/config": "1.18.17", - "@oclif/core": "1.16.6", - "@oclif/errors": "1.3.6", + "@n8n_io/license-sdk": "2.9.1", + "@oclif/core": "3.18.1", "@rudderstack/rudder-sdk-node": "1.0.6", "@sentry/integrations": "7.87.0", "@sentry/node": "7.87.0", - "axios": "1.6.2", + "axios": "1.6.5", "basic-auth": "2.0.1", "bcryptjs": "2.4.3", - "bull": "4.10.2", + "bull": "4.12.1", "cache-manager": "5.2.3", "callsites": "3.1.0", "change-case": "4.1.2", @@ -137,7 +130,7 @@ "handlebars": "4.7.7", "infisical-node": "1.3.0", "inquirer": "7.3.3", - "ioredis": "5.2.4", + "ioredis": "5.3.2", "isbot": "3.6.13", "json-diff": "1.0.6", "jsonschema": "1.4.1", @@ -150,7 +143,6 @@ "n8n-core": "workspace:*", "n8n-editor-ui": "workspace:*", "n8n-nodes-base": "workspace:*", - "@n8n/n8n-nodes-langchain": "workspace:*", "n8n-workflow": "workspace:*", "nanoid": "3.3.6", "nodemailer": "6.8.0", @@ -180,7 +172,7 @@ "sqlite3": "5.1.6", "sse-channel": "4.0.0", "sshpk": "1.17.0", - "swagger-ui-express": "4.5.0", + "swagger-ui-express": "5.0.0", "syslog-client": "1.1.1", "typedi": "0.10.0", "typeorm": "0.3.17", diff --git a/packages/cli/scripts/build.mjs b/packages/cli/scripts/build.mjs index 4c2b43e74c..621e36bdff 100644 --- a/packages/cli/scripts/build.mjs +++ b/packages/cli/scripts/build.mjs @@ -46,7 +46,7 @@ function bundleOpenApiSpecs(rootDir = ROOT_DIR, specFileName = SPEC_FILENAME) { }, []) .forEach((specPath) => { const distSpecPath = path.resolve(rootDir, 'dist', specPath); - const command = `npm run swagger -- bundle src/${specPath} --type yaml --outfile ${distSpecPath}`; + const command = `pnpm openapi bundle src/${specPath} --output ${distSpecPath}`; shell.exec(command, { silent: true }); }); } diff --git a/packages/cli/src/AbstractServer.ts b/packages/cli/src/AbstractServer.ts index 71b4c75ea9..b2773c45ea 100644 --- a/packages/cli/src/AbstractServer.ts +++ b/packages/cli/src/AbstractServer.ts @@ -208,7 +208,7 @@ export abstract class AbstractServer { // TODO UM: check if this needs validation with user management. this.app.delete( `/${this.restEndpoint}/test-webhook/:id`, - send(async (req) => testWebhooks.cancelWebhook(req.params.id)), + send(async (req) => await testWebhooks.cancelWebhook(req.params.id)), ); } diff --git a/packages/cli/src/ActiveExecutions.ts b/packages/cli/src/ActiveExecutions.ts index d9526b84c5..94d0aa80b6 100644 --- a/packages/cli/src/ActiveExecutions.ts +++ b/packages/cli/src/ActiveExecutions.ts @@ -178,7 +178,7 @@ export class ActiveExecutions { this.activeExecutions[executionId].workflowExecution!.cancel(); } - return this.getPostExecutePromise(executionId); + return await this.getPostExecutePromise(executionId); } /** @@ -197,7 +197,7 @@ export class ActiveExecutions { this.activeExecutions[executionId].postExecutePromises.push(waitPromise); - return waitPromise.promise(); + return await waitPromise.promise(); } /** diff --git a/packages/cli/src/ActiveWebhooks.ts b/packages/cli/src/ActiveWebhooks.ts index 77102eb247..6b9717341b 100644 --- a/packages/cli/src/ActiveWebhooks.ts +++ b/packages/cli/src/ActiveWebhooks.ts @@ -30,7 +30,7 @@ export class ActiveWebhooks implements IWebhookManager { ) {} async getWebhookMethods(path: string) { - return this.webhookService.getWebhookMethods(path); + return await this.webhookService.getWebhookMethods(path); } async findAccessControlOptions(path: string, httpMethod: IHttpRequestMethods) { @@ -84,7 +84,7 @@ export class ActiveWebhooks implements IWebhookManager { const workflowData = await this.workflowRepository.findOne({ where: { id: webhook.workflowId }, - relations: ['shared', 'shared.user', 'shared.user.globalRole'], + relations: ['shared', 'shared.user'], }); if (workflowData === null) { @@ -120,7 +120,7 @@ export class ActiveWebhooks implements IWebhookManager { throw new NotFoundError('Could not find node to process webhook.'); } - return new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { const executionMode = 'webhook'; void WebhookHelpers.executeWebhook( workflow, diff --git a/packages/cli/src/ActiveWorkflowRunner.ts b/packages/cli/src/ActiveWorkflowRunner.ts index e0e03ad119..ad19b09d18 100644 --- a/packages/cli/src/ActiveWorkflowRunner.ts +++ b/packages/cli/src/ActiveWorkflowRunner.ts @@ -35,7 +35,7 @@ import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData' import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { ActiveExecutions } from '@/ActiveExecutions'; -import { ExecutionsService } from './executions/executions.service'; +import { ExecutionService } from './executions/execution.service'; import { STARTING_NODES, WORKFLOW_REACTIVATE_INITIAL_TIMEOUT, @@ -47,7 +47,7 @@ import { ExternalHooks } from '@/ExternalHooks'; import { WebhookService } from './services/webhook.service'; import { Logger } from './Logger'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; -import { MultiMainSetup } from '@/services/orchestration/main/MultiMainSetup.ee'; +import { OrchestrationService } from '@/services/orchestration.service'; import { ActivationErrorsService } from '@/ActivationErrors.service'; import { ActiveWorkflowsService } from '@/services/activeWorkflows.service'; import { WorkflowStaticDataService } from '@/workflows/workflowStaticData.service'; @@ -72,15 +72,15 @@ export class ActiveWorkflowRunner { private readonly nodeTypes: NodeTypes, private readonly webhookService: WebhookService, private readonly workflowRepository: WorkflowRepository, - private readonly multiMainSetup: MultiMainSetup, + private readonly orchestrationService: OrchestrationService, private readonly activationErrorsService: ActivationErrorsService, - private readonly executionService: ExecutionsService, + private readonly executionService: ExecutionService, private readonly workflowStaticDataService: WorkflowStaticDataService, private readonly activeWorkflowsService: ActiveWorkflowsService, ) {} async init() { - await this.multiMainSetup.init(); + await this.orchestrationService.init(); await this.addActiveWorkflows('init'); @@ -89,7 +89,7 @@ export class ActiveWorkflowRunner { } async getAllWorkflowActivationErrors() { - return this.activationErrorsService.getAll(); + return await this.activationErrorsService.getAll(); } /** @@ -229,7 +229,7 @@ export class ActiveWorkflowRunner { async clearWebhooks(workflowId: string) { const workflowData = await this.workflowRepository.findOne({ where: { id: workflowId }, - relations: ['shared', 'shared.user', 'shared.user.globalRole'], + relations: ['shared', 'shared.user'], }); if (workflowData === null) { @@ -305,7 +305,7 @@ export class ActiveWorkflowRunner { }; const workflowRunner = new WorkflowRunner(); - return workflowRunner.run(runData, true, undefined, undefined, responsePromise); + return await workflowRunner.run(runData, true, undefined, undefined, responsePromise); } /** @@ -470,25 +470,23 @@ export class ActiveWorkflowRunner { if (dbWorkflows.length === 0) return; - this.logger.info(' ================================'); - this.logger.info(' Start Active Workflows:'); - this.logger.info(' ================================'); + if (this.orchestrationService.isLeader) { + this.logger.info(' ================================'); + this.logger.info(' Start Active Workflows:'); + this.logger.info(' ================================'); + } for (const dbWorkflow of dbWorkflows) { - this.logger.info(` - ${dbWorkflow.display()}`); - this.logger.debug(`Initializing active workflow ${dbWorkflow.display()} (startup)`, { - workflowName: dbWorkflow.name, - workflowId: dbWorkflow.id, - }); - try { - await this.add(dbWorkflow.id, activationMode, dbWorkflow); + const wasActivated = await this.add(dbWorkflow.id, activationMode, dbWorkflow); - this.logger.verbose(`Successfully started workflow ${dbWorkflow.display()}`, { - workflowName: dbWorkflow.name, - workflowId: dbWorkflow.id, - }); - this.logger.info(' => Started'); + if (wasActivated) { + this.logger.verbose(`Successfully started workflow ${dbWorkflow.display()}`, { + workflowName: dbWorkflow.name, + workflowId: dbWorkflow.id, + }); + this.logger.info(' => Started'); + } } catch (error) { ErrorReporter.error(error); this.logger.info( @@ -571,16 +569,18 @@ export class ActiveWorkflowRunner { * again, and the new leader should take over the triggers and pollers that stopped * running when the former leader became unresponsive. */ - if (this.multiMainSetup.isEnabled) { + if (this.orchestrationService.isMultiMainSetupEnabled) { if (activationMode !== 'leadershipChange') { - shouldAddWebhooks = this.multiMainSetup.isLeader; - shouldAddTriggersAndPollers = this.multiMainSetup.isLeader; + shouldAddWebhooks = this.orchestrationService.isLeader; + shouldAddTriggersAndPollers = this.orchestrationService.isLeader; } else { shouldAddWebhooks = false; - shouldAddTriggersAndPollers = this.multiMainSetup.isLeader; + shouldAddTriggersAndPollers = this.orchestrationService.isLeader; } } + const shouldActivate = shouldAddWebhooks || shouldAddTriggersAndPollers; + try { const dbWorkflow = existingWorkflow ?? (await this.workflowRepository.findById(workflowId)); @@ -588,6 +588,14 @@ export class ActiveWorkflowRunner { throw new WorkflowActivationError(`Failed to find workflow with ID "${workflowId}"`); } + if (shouldActivate) { + this.logger.info(` - ${dbWorkflow.display()}`); + this.logger.debug(`Initializing active workflow ${dbWorkflow.display()} (startup)`, { + workflowName: dbWorkflow.name, + workflowId: dbWorkflow.id, + }); + } + workflow = new Workflow({ id: dbWorkflow.id, name: dbWorkflow.name, @@ -607,7 +615,7 @@ export class ActiveWorkflowRunner { ); } - const sharing = dbWorkflow.shared.find((shared) => shared.role.name === 'owner'); + const sharing = dbWorkflow.shared.find((shared) => shared.role === 'workflow:owner'); if (!sharing) { throw new WorkflowActivationError(`Workflow ${dbWorkflow.display()} has no owner`); @@ -644,6 +652,8 @@ export class ActiveWorkflowRunner { // If for example webhooks get created it sometimes has to save the // id of them in the static data. So make sure that data gets persisted. await this.workflowStaticDataService.saveStaticData(workflow); + + return shouldActivate; } /** @@ -804,7 +814,7 @@ export class ActiveWorkflowRunner { ); if (workflow.getTriggerNodes().length !== 0 || workflow.getPollNodes().length !== 0) { - this.logger.debug(`Adding triggers and pollers for workflow "${dbWorkflow.display()}"`); + this.logger.debug(`Adding triggers and pollers for workflow ${dbWorkflow.display()}`); await this.activeWorkflows.add( workflow.id, diff --git a/packages/cli/src/CredentialsHelper.ts b/packages/cli/src/CredentialsHelper.ts index dad6f9c3ef..95b5a9a161 100644 --- a/packages/cli/src/CredentialsHelper.ts +++ b/packages/cli/src/CredentialsHelper.ts @@ -121,7 +121,10 @@ export class CredentialsHelper extends ICredentialsHelper { if (typeof credentialType.authenticate === 'function') { // Special authentication function is defined - return credentialType.authenticate(credentials, requestOptions as IHttpRequestOptions); + return await credentialType.authenticate( + credentials, + requestOptions as IHttpRequestOptions, + ); } if (typeof credentialType.authenticate === 'object') { @@ -783,15 +786,9 @@ export class CredentialsHelper extends ICredentialsHelper { const credential = await this.sharedCredentialsRepository.findOne({ where: { - role: { - scope: 'credential', - name: 'owner', - }, + role: 'credential:owner', user: { - globalRole: { - scope: 'global', - name: 'owner', - }, + role: 'global:owner', }, credentials: { id: nodeCredential.id, diff --git a/packages/cli/src/Db.ts b/packages/cli/src/Db.ts index c53419ac76..839a0b187b 100644 --- a/packages/cli/src/Db.ts +++ b/packages/cli/src/Db.ts @@ -54,7 +54,7 @@ if (!inTest) { } export async function transaction(fn: (entityManager: EntityManager) => Promise): Promise { - return connection.transaction(fn); + return await connection.transaction(fn); } export function getConnectionOptions(dbType: DatabaseType): ConnectionOptions { @@ -97,6 +97,16 @@ export function getConnectionOptions(dbType: DatabaseType): ConnectionOptions { } } +export async function setSchema(conn: Connection) { + const schema = config.getEnv('database.postgresdb.schema'); + const searchPath = ['public']; + if (schema !== 'public') { + await conn.query(`CREATE SCHEMA IF NOT EXISTS ${schema}`); + searchPath.unshift(schema); + } + await conn.query(`SET search_path TO ${searchPath.join(',')};`); +} + export async function init(testConnectionOptions?: ConnectionOptions): Promise { if (connectionState.connected) return; @@ -130,13 +140,7 @@ export async function init(testConnectionOptions?: ConnectionOptions): Promise this.updateSecrets(), updateIntervalTime()); + this.updateInterval = setInterval( + async () => await this.updateSecrets(), + updateIntervalTime(), + ); }); } - return this.initializingPromise; + return await this.initializingPromise; } } @@ -76,7 +79,7 @@ export class ExternalSecretsManager { } async broadcastReloadExternalSecretsProviders() { - await Container.get(SingleMainSetup).broadcastReloadExternalSecretsProviders(); + await Container.get(OrchestrationService).publish('reloadExternalSecretsProviders'); } private decryptSecretsSettings(value: string): ExternalSecretsSettings { @@ -107,8 +110,8 @@ export class ExternalSecretsManager { } const providers: Array = ( await Promise.allSettled( - Object.entries(settings).map(async ([name, providerSettings]) => - this.initProvider(name, providerSettings), + Object.entries(settings).map( + async ([name, providerSettings]) => await this.initProvider(name, providerSettings), ), ) ).map((i) => (i.status === 'rejected' ? null : i.value)); diff --git a/packages/cli/src/ExternalSecrets/constants.ts b/packages/cli/src/ExternalSecrets/constants.ts index 3408cfac0b..d62dd1e459 100644 --- a/packages/cli/src/ExternalSecrets/constants.ts +++ b/packages/cli/src/ExternalSecrets/constants.ts @@ -2,4 +2,4 @@ export const EXTERNAL_SECRETS_DB_KEY = 'feature.externalSecrets'; export const EXTERNAL_SECRETS_INITIAL_BACKOFF = 10 * 1000; export const EXTERNAL_SECRETS_MAX_BACKOFF = 5 * 60 * 1000; -export const EXTERNAL_SECRETS_NAME_REGEX = /^[a-zA-Z0-9\_\/]+$/; +export const EXTERNAL_SECRETS_NAME_REGEX = /^[a-zA-Z0-9\-\_\/]+$/; diff --git a/packages/cli/src/ExternalSecrets/providers/vault.ts b/packages/cli/src/ExternalSecrets/providers/vault.ts index 6b1046402a..0bd68f7fb1 100644 --- a/packages/cli/src/ExternalSecrets/providers/vault.ts +++ b/packages/cli/src/ExternalSecrets/providers/vault.ts @@ -436,7 +436,7 @@ export class VaultProvider extends SecretsProvider { await Promise.allSettled( listResp.data.data.keys.map(async (key): Promise<[string, IDataObject] | null> => { if (key.endsWith('/')) { - return this.getKVSecrets(mountPath, kvVersion, path + key); + return await this.getKVSecrets(mountPath, kvVersion, path + key); } let secretPath = mountPath; if (kvVersion === '2') { diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 88a0c2f610..06b4f9ae95 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -18,7 +18,7 @@ import type { Workflow, WorkflowExecuteMode, ExecutionStatus, - IExecutionsSummary, + ExecutionSummary, FeatureFlags, INodeProperties, IUserSettings, @@ -35,10 +35,9 @@ import type { ChildProcess } from 'child_process'; import type { DatabaseType } from '@db/types'; import type { AuthProviderType } from '@db/entities/AuthIdentity'; -import type { Role } from '@db/entities/Role'; import type { SharedCredentials } from '@db/entities/SharedCredentials'; import type { TagEntity } from '@db/entities/TagEntity'; -import type { User } from '@db/entities/User'; +import type { GlobalRole, User } from '@db/entities/User'; import type { CredentialsRepository } from '@db/repositories/credentials.repository'; import type { SettingsRepository } from '@db/repositories/settings.repository'; import type { UserRepository } from '@db/repositories/user.repository'; @@ -170,8 +169,7 @@ export interface IExecutionFlattedResponse extends IExecutionFlatted { export interface IExecutionsListResponse { count: number; - // results: IExecutionShortResponse[]; - results: IExecutionsSummary[]; + results: ExecutionSummary[]; estimated: boolean; } @@ -192,12 +190,6 @@ export interface IExecutionsCurrentSummary { status?: ExecutionStatus; } -export interface IExecutionDeleteFilter { - deleteBefore?: Date; - filters?: IDataObject; - ids?: string[]; -} - export interface IExecutingWorkflowData { executionData: IWorkflowExecutionDataProcess; process?: ChildProcess; @@ -667,6 +659,7 @@ export interface ILicensePostResponse extends ILicenseReadResponse { export interface JwtToken { token: string; + /** The amount of seconds after which the JWT will expire. **/ expiresIn: number; } @@ -687,7 +680,7 @@ export interface PublicUser { createdAt: Date; isPending: boolean; hasRecoveryCodesLeft: boolean; - globalRole?: Role; + role?: GlobalRole; globalScopes?: Scope[]; signInType: AuthProviderType; disabled: boolean; diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index c2829fb061..257be0f4b4 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -22,11 +22,11 @@ import { Telemetry } from '@/telemetry'; import type { AuthProviderType } from '@db/entities/AuthIdentity'; import { eventBus } from './eventbus'; import { EventsService } from '@/services/events.service'; -import type { User } from '@db/entities/User'; +import type { GlobalRole, User } from '@db/entities/User'; import { N8N_VERSION } from '@/constants'; -import { NodeTypes } from './NodeTypes'; +import { NodeTypes } from '@/NodeTypes'; import type { ExecutionMetadata } from '@db/entities/ExecutionMetadata'; -import { RoleService } from './services/role.service'; +import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import type { EventPayloadWorkflow } from './eventbus/EventMessageClasses/EventMessageWorkflow'; import { determineFinalExecutionStatus } from './executionLifecycleHooks/shared/sharedHookFunctions'; import { InstanceSettings } from 'n8n-core'; @@ -36,14 +36,14 @@ function userToPayload(user: User): { _email: string; _firstName: string; _lastName: string; - globalRole?: string; + globalRole: GlobalRole; } { return { userId: user.id, _email: user.email, _firstName: user.firstName, _lastName: user.lastName, - globalRole: user.globalRole?.name, + globalRole: user.role, }; } @@ -52,15 +52,17 @@ export class InternalHooks { constructor( private telemetry: Telemetry, private nodeTypes: NodeTypes, - private roleService: RoleService, + private sharedWorkflowRepository: SharedWorkflowRepository, eventsService: EventsService, private readonly instanceSettings: InstanceSettings, ) { - eventsService.on('telemetry.onFirstProductionWorkflowSuccess', async (metrics) => - this.onFirstProductionWorkflowSuccess(metrics), + eventsService.on( + 'telemetry.onFirstProductionWorkflowSuccess', + async (metrics) => await this.onFirstProductionWorkflowSuccess(metrics), ); - eventsService.on('telemetry.onFirstWorkflowDataLoad', async (metrics) => - this.onFirstWorkflowDataLoad(metrics), + eventsService.on( + 'telemetry.onFirstWorkflowDataLoad', + async (metrics) => await this.onFirstWorkflowDataLoad(metrics), ); } @@ -88,7 +90,7 @@ export class InternalHooks { license_tenant_id: diagnosticInfo.licenseTenantId, }; - return Promise.all([ + return await Promise.all([ this.telemetry.identify(info), this.telemetry.track('Instance started', { ...info, @@ -98,7 +100,7 @@ export class InternalHooks { } async onFrontendSettingsAPI(sessionId?: string): Promise { - return this.telemetry.track('Session started', { session_id: sessionId }); + return await this.telemetry.track('Session started', { session_id: sessionId }); } async onPersonalizationSurveySubmitted( @@ -111,7 +113,7 @@ export class InternalHooks { personalizationSurveyData[snakeCase(camelCaseKey)] = answers[camelCaseKey]; }); - return this.telemetry.track( + return await this.telemetry.track( 'User responded to personalization questions', personalizationSurveyData, ); @@ -164,9 +166,9 @@ export class InternalHooks { let userRole: 'owner' | 'sharee' | undefined = undefined; if (user.id && workflow.id) { - const role = await this.roleService.findRoleByUserAndWorkflow(user.id, workflow.id); + const role = await this.sharedWorkflowRepository.findSharingRole(user.id, workflow.id); if (role) { - userRole = role.name === 'owner' ? 'owner' : 'sharee'; + userRole = role === 'workflow:owner' ? 'owner' : 'sharee'; } } @@ -369,9 +371,9 @@ export class InternalHooks { let userRole: 'owner' | 'sharee' | undefined = undefined; if (userId) { - const role = await this.roleService.findRoleByUserAndWorkflow(userId, workflow.id); + const role = await this.sharedWorkflowRepository.findSharingRole(userId, workflow.id); if (role) { - userRole = role.name === 'owner' ? 'owner' : 'sharee'; + userRole = role === 'workflow:owner' ? 'owner' : 'sharee'; } } @@ -459,7 +461,7 @@ export class InternalHooks { user_id_list: userList, }; - return this.telemetry.track('User updated workflow sharing', properties); + return await this.telemetry.track('User updated workflow sharing', properties); } async onN8nStop(): Promise { @@ -469,7 +471,7 @@ export class InternalHooks { }, 3000); }); - return Promise.race([timeoutPromise, this.telemetry.trackN8nStop()]); + return await Promise.race([timeoutPromise, this.telemetry.trackN8nStop()]); } async onUserDeletion(userDeletionData: { @@ -554,42 +556,42 @@ export class InternalHooks { user_id: string; public_api: boolean; }): Promise { - return this.telemetry.track('User retrieved user', userRetrievedData); + return await this.telemetry.track('User retrieved user', userRetrievedData); } async onUserRetrievedAllUsers(userRetrievedData: { user_id: string; public_api: boolean; }): Promise { - return this.telemetry.track('User retrieved all users', userRetrievedData); + return await this.telemetry.track('User retrieved all users', userRetrievedData); } async onUserRetrievedExecution(userRetrievedData: { user_id: string; public_api: boolean; }): Promise { - return this.telemetry.track('User retrieved execution', userRetrievedData); + return await this.telemetry.track('User retrieved execution', userRetrievedData); } async onUserRetrievedAllExecutions(userRetrievedData: { user_id: string; public_api: boolean; }): Promise { - return this.telemetry.track('User retrieved all executions', userRetrievedData); + return await this.telemetry.track('User retrieved all executions', userRetrievedData); } async onUserRetrievedWorkflow(userRetrievedData: { user_id: string; public_api: boolean; }): Promise { - return this.telemetry.track('User retrieved workflow', userRetrievedData); + return await this.telemetry.track('User retrieved workflow', userRetrievedData); } async onUserRetrievedAllWorkflows(userRetrievedData: { user_id: string; public_api: boolean; }): Promise { - return this.telemetry.track('User retrieved all workflows', userRetrievedData); + return await this.telemetry.track('User retrieved all workflows', userRetrievedData); } async onUserUpdate(userUpdateData: { user: User; fields_changed: string[] }): Promise { @@ -646,10 +648,15 @@ export class InternalHooks { async onUserTransactionalEmail(userTransactionalEmailData: { user_id: string; - message_type: 'Reset password' | 'New user invite' | 'Resend invite'; + message_type: + | 'Reset password' + | 'New user invite' + | 'Resend invite' + | 'Workflow shared' + | 'Credentials shared'; public_api: boolean; }): Promise { - return this.telemetry.track( + return await this.telemetry.track( 'Instance sent transactional email to user', userTransactionalEmailData, ); @@ -661,7 +668,7 @@ export class InternalHooks { method: string; api_version: string; }): Promise { - return this.telemetry.track('User invoked API', userInvokedApiData); + return await this.telemetry.track('User invoked API', userInvokedApiData); } async onApiKeyDeleted(apiKeyDeletedData: { user: User; public_api: boolean }): Promise { @@ -709,7 +716,7 @@ export class InternalHooks { } async onInstanceOwnerSetup(instanceOwnerSetupData: { user_id: string }): Promise { - return this.telemetry.track('Owner finished instance setup', instanceOwnerSetupData); + return await this.telemetry.track('Owner finished instance setup', instanceOwnerSetupData); } async onUserSignup( @@ -735,7 +742,12 @@ export class InternalHooks { async onEmailFailed(failedEmailData: { user: User; - message_type: 'Reset password' | 'New user invite' | 'Resend invite'; + message_type: + | 'Reset password' + | 'New user invite' + | 'Resend invite' + | 'Workflow shared' + | 'Credentials shared'; public_api: boolean; }): Promise { void Promise.all([ @@ -963,7 +975,7 @@ export class InternalHooks { users_synced: number; error: string; }): Promise { - return this.telemetry.track('Ldap general sync finished', data); + return await this.telemetry.track('Ldap general sync finished', data); } async onUserUpdatedLdapSettings(data: { @@ -980,15 +992,15 @@ export class InternalHooks { loginLabel: string; loginEnabled: boolean; }): Promise { - return this.telemetry.track('Ldap general sync finished', data); + return await this.telemetry.track('Ldap general sync finished', data); } async onLdapLoginSyncFailed(data: { error: string }): Promise { - return this.telemetry.track('Ldap login sync failed', data); + return await this.telemetry.track('Ldap login sync failed', data); } async userLoginFailedDueToLdapDisabled(data: { user_id: string }): Promise { - return this.telemetry.track('User login failed since ldap disabled', data); + return await this.telemetry.track('User login failed since ldap disabled', data); } /* @@ -998,7 +1010,7 @@ export class InternalHooks { user_id: string; workflow_id: string; }): Promise { - return this.telemetry.track('Workflow first prod success', data); + return await this.telemetry.track('Workflow first prod success', data); } async onFirstWorkflowDataLoad(data: { @@ -1009,7 +1021,7 @@ export class InternalHooks { credential_type?: string; credential_id?: string; }): Promise { - return this.telemetry.track('Workflow first data fetched', data); + return await this.telemetry.track('Workflow first data fetched', data); } /** @@ -1023,11 +1035,11 @@ export class InternalHooks { * Audit */ async onAuditGeneratedViaCli() { - return this.telemetry.track('Instance generated security audit via CLI command'); + return await this.telemetry.track('Instance generated security audit via CLI command'); } async onVariableCreated(createData: { variable_type: string }): Promise { - return this.telemetry.track('User created variable', createData); + return await this.telemetry.track('User created variable', createData); } async onSourceControlSettingsUpdated(data: { @@ -1036,7 +1048,7 @@ export class InternalHooks { repo_type: 'github' | 'gitlab' | 'other'; connected: boolean; }): Promise { - return this.telemetry.track('User updated source control settings', data); + return await this.telemetry.track('User updated source control settings', data); } async onSourceControlUserStartedPullUI(data: { @@ -1044,11 +1056,11 @@ export class InternalHooks { workflow_conflicts: number; cred_conflicts: number; }): Promise { - return this.telemetry.track('User started pull via UI', data); + return await this.telemetry.track('User started pull via UI', data); } async onSourceControlUserFinishedPullUI(data: { workflow_updates: number }): Promise { - return this.telemetry.track('User finished pull via UI', { + return await this.telemetry.track('User finished pull via UI', { workflow_updates: data.workflow_updates, }); } @@ -1057,7 +1069,7 @@ export class InternalHooks { workflow_updates: number; forced: boolean; }): Promise { - return this.telemetry.track('User pulled via API', data); + return await this.telemetry.track('User pulled via API', data); } async onSourceControlUserStartedPushUI(data: { @@ -1067,7 +1079,7 @@ export class InternalHooks { creds_eligible_with_conflicts: number; variables_eligible: number; }): Promise { - return this.telemetry.track('User started push via UI', data); + return await this.telemetry.track('User started push via UI', data); } async onSourceControlUserFinishedPushUI(data: { @@ -1076,7 +1088,7 @@ export class InternalHooks { creds_pushed: number; variables_pushed: number; }): Promise { - return this.telemetry.track('User finished push via UI', data); + return await this.telemetry.track('User finished push via UI', data); } async onExternalSecretsProviderSettingsSaved(saveData: { @@ -1086,6 +1098,6 @@ export class InternalHooks { is_new: boolean; error_message?: string | undefined; }): Promise { - return this.telemetry.track('User updated external secrets settings', saveData); + return await this.telemetry.track('User updated external secrets settings', saveData); } } diff --git a/packages/cli/src/Ldap/helpers.ts b/packages/cli/src/Ldap/helpers.ts index cd618600ed..0dab8e3982 100644 --- a/packages/cli/src/Ldap/helpers.ts +++ b/packages/cli/src/Ldap/helpers.ts @@ -5,7 +5,6 @@ import { Container } from 'typedi'; import { validate } from 'jsonschema'; import * as Db from '@/Db'; import config from '@/config'; -import type { Role } from '@db/entities/Role'; import { User } from '@db/entities/User'; import { AuthIdentity } from '@db/entities/AuthIdentity'; import type { AuthProviderSyncHistory } from '@db/entities/AuthProviderSyncHistory'; @@ -18,7 +17,6 @@ import { } from './constants'; import type { ConnectionSecurity, LdapConfig } from './types'; import { License } from '@/License'; -import { RoleService } from '@/services/role.service'; import { UserRepository } from '@db/repositories/user.repository'; import { AuthProviderSyncHistoryRepository } from '@db/repositories/authProviderSyncHistory.repository'; import { AuthIdentityRepository } from '@db/repositories/authIdentity.repository'; @@ -47,13 +45,6 @@ export const randomPassword = (): string => { return Math.random().toString(36).slice(-8); }; -/** - * Return the user role to be assigned to LDAP users - */ -export const getLdapUserRole = async (): Promise => { - return Container.get(RoleService).findGlobalMemberRole(); -}; - /** * Validate the structure of the LDAP configuration schema */ @@ -101,8 +92,8 @@ export const escapeFilter = (filter: string): string => { export const getAuthIdentityByLdapId = async ( idAttributeValue: string, ): Promise => { - return Container.get(AuthIdentityRepository).findOne({ - relations: ['user', 'user.globalRole'], + return await Container.get(AuthIdentityRepository).findOne({ + relations: ['user'], where: { providerId: idAttributeValue, providerType: 'ldap', @@ -111,9 +102,8 @@ export const getAuthIdentityByLdapId = async ( }; export const getUserByEmail = async (email: string): Promise => { - return Container.get(UserRepository).findOne({ + return await Container.get(UserRepository).findOne({ where: { email }, - relations: ['globalRole'], }); }; @@ -164,13 +154,13 @@ export const getLdapUsers = async (): Promise => { export const mapLdapUserToDbUser = ( ldapUser: LdapUser, ldapConfig: LdapConfig, - role?: Role, + toCreate = false, ): [string, User] => { const user = new User(); const [ldapId, data] = mapLdapAttributesToUser(ldapUser, ldapConfig); Object.assign(user, data); - if (role) { - user.globalRole = role; + if (toCreate) { + user.role = 'global:member'; user.password = randomPassword(); user.disabled = false; } else { @@ -190,10 +180,10 @@ export const processUsers = async ( toDisableUsers: string[], ): Promise => { await Db.transaction(async (transactionManager) => { - return Promise.all([ + return await Promise.all([ ...toCreateUsers.map(async ([ldapId, user]) => { const authIdentity = AuthIdentity.create(await transactionManager.save(user), ldapId); - return transactionManager.save(authIdentity); + return await transactionManager.save(authIdentity); }), ...toUpdateUsers.map(async ([ldapId, user]) => { const authIdentity = await transactionManager.findOneBy(AuthIdentity, { @@ -240,7 +230,7 @@ export const getLdapSynchronizations = async ( perPage: number, ): Promise => { const _page = Math.abs(page); - return Container.get(AuthProviderSyncHistoryRepository).find({ + return await Container.get(AuthProviderSyncHistoryRepository).find({ where: { providerType: 'ldap' }, order: { id: 'DESC' }, take: perPage, @@ -267,13 +257,13 @@ export const getMappingAttributes = (ldapConfig: LdapConfig): string[] => { }; export const createLdapAuthIdentity = async (user: User, ldapId: string) => { - return Container.get(AuthIdentityRepository).save(AuthIdentity.create(user, ldapId)); + return await Container.get(AuthIdentityRepository).save(AuthIdentity.create(user, ldapId)); }; -export const createLdapUserOnLocalDb = async (role: Role, data: Partial, ldapId: string) => { +export const createLdapUserOnLocalDb = async (data: Partial, ldapId: string) => { const user = await Container.get(UserRepository).save({ password: randomPassword(), - globalRole: role, + role: 'global:member', ...data, }); await createLdapAuthIdentity(user, ldapId); @@ -288,5 +278,5 @@ export const updateLdapUserOnLocalDb = async (identity: AuthIdentity, data: Part }; export const deleteAllLdapIdentities = async () => { - return Container.get(AuthIdentityRepository).delete({ providerType: 'ldap' }); + return await Container.get(AuthIdentityRepository).delete({ providerType: 'ldap' }); }; diff --git a/packages/cli/src/Ldap/ldap.controller.ts b/packages/cli/src/Ldap/ldap.controller.ts index 441c5c993b..a158d81d49 100644 --- a/packages/cli/src/Ldap/ldap.controller.ts +++ b/packages/cli/src/Ldap/ldap.controller.ts @@ -19,7 +19,7 @@ export class LdapController { @Get('/config') @RequireGlobalScope('ldap:manage') async getConfig() { - return this.ldapService.loadConfig(); + return await this.ldapService.loadConfig(); } @Post('/test-connection') @@ -55,7 +55,7 @@ export class LdapController { @RequireGlobalScope('ldap:sync') async getLdapSync(req: LdapConfiguration.GetSync) { const { page = '0', perPage = '20' } = req.query; - return getLdapSynchronizations(parseInt(page, 10), parseInt(perPage, 10)); + return await getLdapSynchronizations(parseInt(page, 10), parseInt(perPage, 10)); } @Post('/sync') diff --git a/packages/cli/src/Ldap/ldap.service.ts b/packages/cli/src/Ldap/ldap.service.ts index 5d3af3fb86..c7eda15e33 100644 --- a/packages/cli/src/Ldap/ldap.service.ts +++ b/packages/cli/src/Ldap/ldap.service.ts @@ -7,7 +7,6 @@ import { ApplicationError, jsonParse } from 'n8n-workflow'; import { Cipher } from 'n8n-core'; import config from '@/config'; -import type { Role } from '@db/entities/Role'; import type { User } from '@db/entities/User'; import type { RunningMode, SyncStatus } from '@db/entities/AuthProviderSyncHistory'; import { SettingsRepository } from '@db/repositories/settings.repository'; @@ -30,7 +29,6 @@ import { escapeFilter, formatUrl, getLdapIds, - getLdapUserRole, getLdapUsers, getMappingAttributes, mapLdapUserToDbUser, @@ -346,12 +344,9 @@ export class LdapService { const localAdUsers = await getLdapIds(); - const role = await getLdapUserRole(); - const { usersToCreate, usersToUpdate, usersToDisable } = this.getUsersToProcess( adUsers, localAdUsers, - role, ); this.logger.debug('LDAP - Users processed', { @@ -407,14 +402,13 @@ export class LdapService { private getUsersToProcess( adUsers: LdapUser[], localAdUsers: string[], - role: Role, ): { usersToCreate: Array<[string, User]>; usersToUpdate: Array<[string, User]>; usersToDisable: string[]; } { return { - usersToCreate: this.getUsersToCreate(adUsers, localAdUsers, role), + usersToCreate: this.getUsersToCreate(adUsers, localAdUsers), usersToUpdate: this.getUsersToUpdate(adUsers, localAdUsers), usersToDisable: this.getUsersToDisable(adUsers, localAdUsers), }; @@ -424,11 +418,10 @@ export class LdapService { private getUsersToCreate( remoteAdUsers: LdapUser[], localLdapIds: string[], - role: Role, ): Array<[string, User]> { return remoteAdUsers .filter((adUser) => !localLdapIds.includes(adUser[this.config.ldapIdAttribute] as string)) - .map((adUser) => mapLdapUserToDbUser(adUser, this.config, role)); + .map((adUser) => mapLdapUserToDbUser(adUser, this.config, true)); } /** Get users in LDAP that are already in the database */ diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index e10bacd77e..ce678939c8 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -12,12 +12,12 @@ import { UNLIMITED_LICENSE_QUOTA, } from './constants'; import { SettingsRepository } from '@db/repositories/settings.repository'; -import { WorkflowRepository } from '@db/repositories/workflow.repository'; import type { BooleanLicenseFeature, N8nInstanceType, NumericLicenseFeature } from './Interfaces'; import type { RedisServicePubSubPublisher } from './services/redis/RedisServicePubSubPublisher'; import { RedisService } from './services/redis.service'; -import { MultiMainSetup } from '@/services/orchestration/main/MultiMainSetup.ee'; +import { OrchestrationService } from '@/services/orchestration.service'; import { OnShutdown } from '@/decorators/OnShutdown'; +import { UsageMetricsService } from './services/usageMetrics.service'; type FeatureReturnType = Partial< { @@ -36,9 +36,9 @@ export class License { constructor( private readonly logger: Logger, private readonly instanceSettings: InstanceSettings, - private readonly multiMainSetup: MultiMainSetup, + private readonly orchestrationService: OrchestrationService, private readonly settingsRepository: SettingsRepository, - private readonly workflowRepository: WorkflowRepository, + private readonly usageMetricsService: UsageMetricsService, ) {} async init(instanceType: N8nInstanceType = 'main') { @@ -51,21 +51,19 @@ export class License { return; } - await this.multiMainSetup.init(); - const isMainInstance = instanceType === 'main'; const server = config.getEnv('license.serverUrl'); const autoRenewEnabled = isMainInstance && config.getEnv('license.autoRenewEnabled'); const offlineMode = !isMainInstance; const autoRenewOffset = config.getEnv('license.autoRenewOffset'); const saveCertStr = isMainInstance - ? async (value: TLicenseBlock) => this.saveCertStr(value) + ? async (value: TLicenseBlock) => await this.saveCertStr(value) : async () => {}; const onFeatureChange = isMainInstance - ? async (features: TFeatures) => this.onFeatureChange(features) + ? async (features: TFeatures) => await this.onFeatureChange(features) : async () => {}; const collectUsageMetrics = isMainInstance - ? async () => this.collectUsageMetrics() + ? async () => await this.usageMetricsService.collectUsageMetrics() : async () => []; try { @@ -78,7 +76,7 @@ export class License { autoRenewOffset, offlineMode, logger: this.logger, - loadCertStr: async () => this.loadCertStr(), + loadCertStr: async () => await this.loadCertStr(), saveCertStr, deviceFingerprint: () => this.instanceSettings.instanceId, collectUsageMetrics, @@ -93,15 +91,6 @@ export class License { } } - async collectUsageMetrics() { - return [ - { - name: 'activeWorkflows', - value: await this.workflowRepository.count({ where: { active: true } }), - }, - ]; - } - async loadCertStr(): Promise { // if we have an ephemeral license, we don't want to load it from the database const ephemeralLicense = config.get('license.cert'); @@ -123,16 +112,19 @@ export class License { | boolean | undefined; - this.multiMainSetup.setLicensed(isMultiMainLicensed ?? false); + this.orchestrationService.setMultiMainSetupLicensed(isMultiMainLicensed ?? false); - if (this.multiMainSetup.isEnabled && this.multiMainSetup.isFollower) { + if ( + this.orchestrationService.isMultiMainSetupEnabled && + this.orchestrationService.isFollower + ) { this.logger.debug( '[Multi-main setup] Instance is follower, skipping sending of "reloadLicense" command...', ); return; } - if (this.multiMainSetup.isEnabled && !isMultiMainLicensed) { + if (this.orchestrationService.isMultiMainSetupEnabled && !isMultiMainLicensed) { this.logger.debug( '[Multi-main setup] License changed with no support for multi-main setup - no new followers will be allowed to init. To restore multi-main setup, please upgrade to a license that supporst this feature.', ); diff --git a/packages/cli/src/LoadNodesAndCredentials.ts b/packages/cli/src/LoadNodesAndCredentials.ts index 7912679e1c..5af72e4765 100644 --- a/packages/cli/src/LoadNodesAndCredentials.ts +++ b/packages/cli/src/LoadNodesAndCredentials.ts @@ -182,7 +182,7 @@ export class LoadNodesAndCredentials { 'node_modules', packageName, ); - return this.runDirectoryLoader(PackageDirectoryLoader, finalNodeUnpackedPath); + return await this.runDirectoryLoader(PackageDirectoryLoader, finalNodeUnpackedPath); } async unloadPackage(packageName: string) { diff --git a/packages/cli/src/Mfa/helpers.ts b/packages/cli/src/Mfa/helpers.ts index 34d117ed5c..54ebad078b 100644 --- a/packages/cli/src/Mfa/helpers.ts +++ b/packages/cli/src/Mfa/helpers.ts @@ -8,7 +8,7 @@ export const isMfaFeatureEnabled = () => config.get(MFA_FEATURE_ENABLED); const isMfaFeatureDisabled = () => !isMfaFeatureEnabled(); const getUsersWithMfaEnabled = async () => - Container.get(UserRepository).count({ where: { mfaEnabled: true } }); + await Container.get(UserRepository).count({ where: { mfaEnabled: true } }); export const handleMfaDisable = async () => { if (isMfaFeatureDisabled()) { diff --git a/packages/cli/src/Mfa/mfa.service.ts b/packages/cli/src/Mfa/mfa.service.ts index 452a3d7071..019005054d 100644 --- a/packages/cli/src/Mfa/mfa.service.ts +++ b/packages/cli/src/Mfa/mfa.service.ts @@ -25,7 +25,7 @@ export class MfaService { secret, recoveryCodes, ); - return this.userRepository.update(userId, { + return await this.userRepository.update(userId, { mfaSecret: encryptedSecret, mfaRecoveryCodes: encryptedRecoveryCodes, }); diff --git a/packages/cli/src/PublicApi/index.ts b/packages/cli/src/PublicApi/index.ts index d4cb53b26a..7248b91e72 100644 --- a/packages/cli/src/PublicApi/index.ts +++ b/packages/cli/src/PublicApi/index.ts @@ -98,7 +98,6 @@ async function createApiRouter( const apiKey = req.headers[schema.name.toLowerCase()] as string; const user = await Container.get(UserRepository).findOne({ where: { apiKey }, - relations: ['globalRole'], }); if (!user) return false; @@ -144,7 +143,7 @@ export const loadPublicApiVersions = async ( const apiRouters = await Promise.all( versions.map(async (version) => { const openApiPath = path.join(__dirname, version, 'openapi.yml'); - return createApiRouter(version, openApiPath, __dirname, publicApiEndpoint); + return await createApiRouter(version, openApiPath, __dirname, publicApiEndpoint); }), ); diff --git a/packages/cli/src/PublicApi/types.ts b/packages/cli/src/PublicApi/types.ts index fe40fdf63a..4933edda55 100644 --- a/packages/cli/src/PublicApi/types.ts +++ b/packages/cli/src/PublicApi/types.ts @@ -3,8 +3,6 @@ import type { IDataObject, ExecutionStatus } from 'n8n-workflow'; import type { User } from '@db/entities/User'; -import type { Role } from '@db/entities/Role'; - import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import type { UserManagementMailer } from '@/UserManagement/email'; @@ -25,7 +23,6 @@ export type AuthenticatedRequest< RequestQuery = {}, > = express.Request & { user: User; - globalMemberRole?: Role; mailer?: UserManagementMailer; }; diff --git a/packages/cli/src/PublicApi/v1/handlers/audit/audit.handler.ts b/packages/cli/src/PublicApi/v1/handlers/audit/audit.handler.ts index 2f9f4892b4..9fd2d028ff 100644 --- a/packages/cli/src/PublicApi/v1/handlers/audit/audit.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/audit/audit.handler.ts @@ -5,7 +5,7 @@ import Container from 'typedi'; export = { generateAudit: [ - authorize(['owner', 'admin']), + authorize(['global:owner', 'global:admin']), async (req: AuditRequest.Generate, res: Response): Promise => { try { const { SecurityAuditService } = await import('@/security-audit/SecurityAudit.service'); diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts index a31656be19..1a4275f949 100644 --- a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts @@ -23,7 +23,7 @@ import { Container } from 'typedi'; export = { createCredential: [ - authorize(['owner', 'admin', 'member']), + authorize(['global:owner', 'global:admin', 'global:member']), validCredentialType, validCredentialsProperties, async ( @@ -47,7 +47,7 @@ export = { }, ], deleteCredential: [ - authorize(['owner', 'admin', 'member']), + authorize(['global:owner', 'global:admin', 'global:member']), async ( req: CredentialRequest.Delete, res: express.Response, @@ -55,13 +55,10 @@ export = { const { id: credentialId } = req.params; let credential: CredentialsEntity | undefined; - if (!['owner', 'admin'].includes(req.user.globalRole.name)) { - const shared = await getSharedCredentials(req.user.id, credentialId, [ - 'credentials', - 'role', - ]); + if (!['global:owner', 'global:admin'].includes(req.user.role)) { + const shared = await getSharedCredentials(req.user.id, credentialId); - if (shared?.role.name === 'owner') { + if (shared?.role === 'credential:owner') { credential = shared.credentials; } } else { @@ -78,7 +75,7 @@ export = { ], getCredentialType: [ - authorize(['owner', 'admin', 'member']), + authorize(['global:owner', 'global:admin', 'global:member']), async (req: CredentialTypeRequest.Get, res: express.Response): Promise => { const { credentialTypeName } = req.params; diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts index 398254be59..81a82616a9 100644 --- a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts @@ -1,5 +1,10 @@ import { Credentials } from 'n8n-core'; -import type { IDataObject, INodeProperties, INodePropertyOptions } from 'n8n-workflow'; +import type { + DisplayCondition, + IDataObject, + INodeProperties, + INodePropertyOptions, +} from 'n8n-workflow'; import * as Db from '@/Db'; import type { ICredentialsDb } from '@/Interfaces'; import { CredentialsEntity } from '@db/entities/CredentialsEntity'; @@ -9,25 +14,23 @@ import { ExternalHooks } from '@/ExternalHooks'; import type { IDependency, IJsonSchema } from '../../../types'; import type { CredentialRequest } from '@/requests'; import { Container } from 'typedi'; -import { RoleService } from '@/services/role.service'; import { CredentialsRepository } from '@db/repositories/credentials.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; export async function getCredentials(credentialId: string): Promise { - return Container.get(CredentialsRepository).findOneBy({ id: credentialId }); + return await Container.get(CredentialsRepository).findOneBy({ id: credentialId }); } export async function getSharedCredentials( userId: string, credentialId: string, - relations?: string[], ): Promise { - return Container.get(SharedCredentialsRepository).findOne({ + return await Container.get(SharedCredentialsRepository).findOne({ where: { userId, credentialsId: credentialId, }, - relations, + relations: ['credentials'], }); } @@ -60,11 +63,9 @@ export async function saveCredential( user: User, encryptedData: ICredentialsDb, ): Promise { - const role = await Container.get(RoleService).findCredentialOwnerRole(); - await Container.get(ExternalHooks).run('credentials.create', [encryptedData]); - return Db.transaction(async (transactionManager) => { + return await Db.transaction(async (transactionManager) => { const savedCredential = await transactionManager.save(credential); savedCredential.data = credential.data; @@ -72,7 +73,7 @@ export async function saveCredential( const newSharedCredential = new SharedCredentials(); Object.assign(newSharedCredential, { - role, + role: 'credential:owner', user, credentials: savedCredential, }); @@ -85,7 +86,7 @@ export async function saveCredential( export async function removeCredential(credentials: CredentialsEntity): Promise { await Container.get(ExternalHooks).run('credentials.delete', [credentials.id]); - return Container.get(CredentialsRepository).remove(credentials); + return await Container.get(CredentialsRepository).remove(credentials); } export async function encryptCredential(credential: CredentialsEntity): Promise { @@ -186,7 +187,7 @@ export function toJsonSchema(properties: INodeProperties[]): IDataObject { if (property.displayOptions?.show) { const dependantName = Object.keys(property.displayOptions?.show)[0] || ''; const displayOptionsValues = property.displayOptions.show[dependantName]; - let dependantValue: string | number | boolean = ''; + let dependantValue: DisplayCondition | string | number | boolean = ''; if (displayOptionsValues && Array.isArray(displayOptionsValues) && displayOptionsValues[0]) { dependantValue = displayOptionsValues[0]; @@ -197,12 +198,75 @@ export function toJsonSchema(properties: INodeProperties[]): IDataObject { } if (!resolveProperties.includes(dependantName)) { + let conditionalValue; + if (typeof dependantValue === 'object' && dependantValue._cnd) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const [key, targetValue] = Object.entries(dependantValue._cnd)[0]; + + if (key === 'eq') { + conditionalValue = { + const: [targetValue], + }; + } else if (key === 'not') { + conditionalValue = { + not: { + const: [targetValue], + }, + }; + } else if (key === 'gt') { + conditionalValue = { + type: 'number', + exclusiveMinimum: [targetValue], + }; + } else if (key === 'gte') { + conditionalValue = { + type: 'number', + minimum: [targetValue], + }; + } else if (key === 'lt') { + conditionalValue = { + type: 'number', + exclusiveMaximum: [targetValue], + }; + } else if (key === 'lte') { + conditionalValue = { + type: 'number', + maximum: [targetValue], + }; + } else if (key === 'startsWith') { + conditionalValue = { + type: 'string', + pattern: `^${targetValue}`, + }; + } else if (key === 'endsWith') { + conditionalValue = { + type: 'string', + pattern: `${targetValue}$`, + }; + } else if (key === 'includes') { + conditionalValue = { + type: 'string', + pattern: `${targetValue}`, + }; + } else if (key === 'regex') { + conditionalValue = { + type: 'string', + pattern: `${targetValue}`, + }; + } else { + conditionalValue = { + enum: [dependantValue], + }; + } + } else { + conditionalValue = { + enum: [dependantValue], + }; + } propertyRequiredDependencies[dependantName] = { if: { properties: { - [dependantName]: { - enum: [dependantValue], - }, + [dependantName]: conditionalValue, }, }, then: { diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts b/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts index 4acad28696..3a63fb9aa0 100644 --- a/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts @@ -2,7 +2,6 @@ import type express from 'express'; import { Container } from 'typedi'; import { replaceCircularReferences } from 'n8n-workflow'; -import { getExecutions, getExecutionInWorkflows, getExecutionsCount } from './executions.service'; import { ActiveExecutions } from '@/ActiveExecutions'; import { authorize, validCursor } from '../../shared/middlewares/global.middleware'; import type { ExecutionRequest } from '../../../types'; @@ -13,7 +12,7 @@ import { ExecutionRepository } from '@db/repositories/execution.repository'; export = { deleteExecution: [ - authorize(['owner', 'admin', 'member']), + authorize(['global:owner', 'global:admin', 'global:member']), async (req: ExecutionRequest.Delete, res: express.Response): Promise => { const sharedWorkflowsIds = await getSharedWorkflowIds(req.user); @@ -26,7 +25,9 @@ export = { const { id } = req.params; // look for the execution on the workflow the user owns - const execution = await getExecutionInWorkflows(id, sharedWorkflowsIds, false); + const execution = await Container.get( + ExecutionRepository, + ).getExecutionInWorkflowsForPublicApi(id, sharedWorkflowsIds, false); if (!execution) { return res.status(404).json({ message: 'Not Found' }); @@ -43,7 +44,7 @@ export = { }, ], getExecution: [ - authorize(['owner', 'admin', 'member']), + authorize(['global:owner', 'global:admin', 'global:member']), async (req: ExecutionRequest.Get, res: express.Response): Promise => { const sharedWorkflowsIds = await getSharedWorkflowIds(req.user); @@ -57,7 +58,9 @@ export = { const { includeData = false } = req.query; // look for the execution on the workflow the user owns - const execution = await getExecutionInWorkflows(id, sharedWorkflowsIds, includeData); + const execution = await Container.get( + ExecutionRepository, + ).getExecutionInWorkflowsForPublicApi(id, sharedWorkflowsIds, includeData); if (!execution) { return res.status(404).json({ message: 'Not Found' }); @@ -72,7 +75,7 @@ export = { }, ], getExecutions: [ - authorize(['owner', 'admin', 'member']), + authorize(['global:owner', 'global:admin', 'global:member']), validCursor, async (req: ExecutionRequest.GetAll, res: express.Response): Promise => { const { @@ -105,13 +108,15 @@ export = { excludedExecutionsIds: runningExecutionsIds, }; - const executions = await getExecutions(filters); + const executions = + await Container.get(ExecutionRepository).getExecutionsForPublicApi(filters); const newLastId = !executions.length ? '0' : executions.slice(-1)[0].id; filters.lastId = newLastId; - const count = await getExecutionsCount(filters); + const count = + await Container.get(ExecutionRepository).getExecutionsCountForPublicApi(filters); void Container.get(InternalHooks).onUserRetrievedAllExecutions({ user_id: req.user.id, diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/executions.service.ts b/packages/cli/src/PublicApi/v1/handlers/executions/executions.service.ts deleted file mode 100644 index b2811bdcda..0000000000 --- a/packages/cli/src/PublicApi/v1/handlers/executions/executions.service.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { FindOptionsWhere } from 'typeorm'; -import { In, Not, Raw, LessThan } from 'typeorm'; -import { Container } from 'typedi'; -import type { ExecutionStatus } from 'n8n-workflow'; - -import type { IExecutionBase, IExecutionFlattedDb } from '@/Interfaces'; -import { ExecutionRepository } from '@db/repositories/execution.repository'; - -function getStatusCondition(status: ExecutionStatus) { - const condition: Pick, 'status'> = {}; - - if (status === 'success') { - condition.status = 'success'; - } else if (status === 'waiting') { - condition.status = 'waiting'; - } else if (status === 'error') { - condition.status = In(['error', 'crashed', 'failed']); - } - - return condition; -} - -export async function getExecutions(params: { - limit: number; - includeData?: boolean; - lastId?: string; - workflowIds?: string[]; - status?: ExecutionStatus; - excludedExecutionsIds?: string[]; -}): Promise { - let where: FindOptionsWhere = {}; - - if (params.lastId && params.excludedExecutionsIds?.length) { - where.id = Raw((id) => `${id} < :lastId AND ${id} NOT IN (:...excludedExecutionsIds)`, { - lastId: params.lastId, - excludedExecutionsIds: params.excludedExecutionsIds, - }); - } else if (params.lastId) { - where.id = LessThan(params.lastId); - } else if (params.excludedExecutionsIds?.length) { - where.id = Not(In(params.excludedExecutionsIds)); - } - - if (params.status) { - where = { ...where, ...getStatusCondition(params.status) }; - } - - if (params.workflowIds) { - where = { ...where, workflowId: In(params.workflowIds) }; - } - - return Container.get(ExecutionRepository).findMultipleExecutions( - { - select: [ - 'id', - 'mode', - 'retryOf', - 'retrySuccessId', - 'startedAt', - 'stoppedAt', - 'workflowId', - 'waitTill', - 'finished', - ], - where, - order: { id: 'DESC' }, - take: params.limit, - relations: ['executionData'], - }, - { - includeData: params.includeData, - unflattenData: true, - }, - ); -} - -export async function getExecutionsCount(data: { - limit: number; - lastId?: string; - workflowIds?: string[]; - status?: ExecutionStatus; - excludedWorkflowIds?: string[]; -}): Promise { - // TODO: Consider moving this to the repository as well - const executions = await Container.get(ExecutionRepository).count({ - where: { - ...(data.lastId && { id: LessThan(data.lastId) }), - ...(data.status && { ...getStatusCondition(data.status) }), - ...(data.workflowIds && { workflowId: In(data.workflowIds) }), - ...(data.excludedWorkflowIds && { workflowId: Not(In(data.excludedWorkflowIds)) }), - }, - take: data.limit, - }); - - return executions; -} - -export async function getExecutionInWorkflows( - id: string, - workflowIds: string[], - includeData?: boolean, -): Promise { - return Container.get(ExecutionRepository).findSingleExecution(id, { - where: { - workflowId: In(workflowIds), - }, - includeData, - unflattenData: true, - }); -} diff --git a/packages/cli/src/PublicApi/v1/handlers/sourceControl/sourceControl.handler.ts b/packages/cli/src/PublicApi/v1/handlers/sourceControl/sourceControl.handler.ts index d19741f82b..66233867de 100644 --- a/packages/cli/src/PublicApi/v1/handlers/sourceControl/sourceControl.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/sourceControl/sourceControl.handler.ts @@ -14,7 +14,7 @@ import { InternalHooks } from '@/InternalHooks'; export = { pull: [ - authorize(['owner', 'admin']), + authorize(['global:owner', 'global:admin']), async ( req: PublicSourceControlRequest.Pull, res: express.Response, diff --git a/packages/cli/src/PublicApi/v1/handlers/users/spec/schemas/user.yml b/packages/cli/src/PublicApi/v1/handlers/users/spec/schemas/user.yml index e0403962ae..532ee98736 100644 --- a/packages/cli/src/PublicApi/v1/handlers/users/spec/schemas/user.yml +++ b/packages/cli/src/PublicApi/v1/handlers/users/spec/schemas/user.yml @@ -36,5 +36,7 @@ properties: description: Last time the user was updated. format: date-time readOnly: true - globalRole: - $ref: './role.yml' + role: + type: string + example: owner + readOnly: true diff --git a/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts b/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts index f9611fa29f..8fd36b1dbb 100644 --- a/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts +++ b/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts @@ -15,7 +15,7 @@ import { InternalHooks } from '@/InternalHooks'; export = { getUser: [ validLicenseWithUserQuota, - authorize(['owner', 'admin']), + authorize(['global:owner', 'global:admin']), async (req: UserRequest.Get, res: express.Response) => { const { includeRole = false } = req.query; const { id } = req.params; @@ -41,7 +41,7 @@ export = { getUsers: [ validLicenseWithUserQuota, validCursor, - authorize(['owner', 'admin']), + authorize(['global:owner', 'global:admin']), async (req: UserRequest.Get, res: express.Response) => { const { offset = 0, limit = 100, includeRole = false } = req.query; diff --git a/packages/cli/src/PublicApi/v1/handlers/users/users.service.ee.ts b/packages/cli/src/PublicApi/v1/handlers/users/users.service.ee.ts index 678727e693..f7bf661816 100644 --- a/packages/cli/src/PublicApi/v1/handlers/users/users.service.ee.ts +++ b/packages/cli/src/PublicApi/v1/handlers/users/users.service.ee.ts @@ -4,24 +4,21 @@ import type { User } from '@db/entities/User'; import pick from 'lodash/pick'; import { validate as uuidValidate } from 'uuid'; -export const getSelectableProperties = (table: 'user' | 'role'): string[] => { - return { - user: ['id', 'email', 'firstName', 'lastName', 'createdAt', 'updatedAt', 'isPending'], - role: ['id', 'name', 'scope', 'createdAt', 'updatedAt'], - }[table]; -}; - export async function getUser(data: { withIdentifier: string; includeRole?: boolean; }): Promise { - return Container.get(UserRepository).findOne({ - where: { - ...(uuidValidate(data.withIdentifier) && { id: data.withIdentifier }), - ...(!uuidValidate(data.withIdentifier) && { email: data.withIdentifier }), - }, - relations: data?.includeRole ? ['globalRole'] : undefined, - }); + return await Container.get(UserRepository) + .findOne({ + where: { + ...(uuidValidate(data.withIdentifier) && { id: data.withIdentifier }), + ...(!uuidValidate(data.withIdentifier) && { email: data.withIdentifier }), + }, + }) + .then((user) => { + if (user && !data?.includeRole) delete (user as Partial).role; + return user; + }); } export async function getAllUsersAndCount(data: { @@ -31,19 +28,29 @@ export async function getAllUsersAndCount(data: { }): Promise<[User[], number]> { const users = await Container.get(UserRepository).find({ where: {}, - relations: data?.includeRole ? ['globalRole'] : undefined, skip: data.offset, take: data.limit, }); + if (!data?.includeRole) { + users.forEach((user) => { + delete (user as Partial).role; + }); + } const count = await Container.get(UserRepository).count(); return [users, count]; } +const userProperties = [ + 'id', + 'email', + 'firstName', + 'lastName', + 'createdAt', + 'updatedAt', + 'isPending', +]; function pickUserSelectableProperties(user: User, options?: { includeRole: boolean }) { - return pick( - user, - getSelectableProperties('user').concat(options?.includeRole ? ['globalRole'] : []), - ); + return pick(user, userProperties.concat(options?.includeRole ? ['role'] : [])); } export function clean(user: User, options?: { includeRole: boolean }): Partial; diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts index 67f9b908a2..bc1dcdc36f 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts @@ -23,7 +23,6 @@ import { } from './workflows.service'; import { WorkflowService } from '@/workflows/workflow.service'; import { InternalHooks } from '@/InternalHooks'; -import { RoleService } from '@/services/role.service'; import { WorkflowHistoryService } from '@/workflows/workflowHistory/workflowHistory.service.ee'; import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; import { TagRepository } from '@/databases/repositories/tag.repository'; @@ -31,7 +30,7 @@ import { WorkflowRepository } from '@/databases/repositories/workflow.repository export = { createWorkflow: [ - authorize(['owner', 'admin', 'member']), + authorize(['global:owner', 'global:admin', 'global:member']), async (req: WorkflowRequest.Create, res: express.Response): Promise => { const workflow = req.body; @@ -42,9 +41,7 @@ export = { addNodeIds(workflow); - const role = await Container.get(RoleService).findWorkflowOwnerRole(); - - const createdWorkflow = await createWorkflow(workflow, req.user, role); + const createdWorkflow = await createWorkflow(workflow, req.user, 'workflow:owner'); await Container.get(WorkflowHistoryService).saveVersion( req.user, @@ -59,7 +56,7 @@ export = { }, ], deleteWorkflow: [ - authorize(['owner', 'admin', 'member']), + authorize(['global:owner', 'global:admin', 'global:member']), async (req: WorkflowRequest.Get, res: express.Response): Promise => { const { id: workflowId } = req.params; @@ -74,7 +71,7 @@ export = { }, ], getWorkflow: [ - authorize(['owner', 'admin', 'member']), + authorize(['global:owner', 'global:admin', 'global:member']), async (req: WorkflowRequest.Get, res: express.Response): Promise => { const { id } = req.params; @@ -95,7 +92,7 @@ export = { }, ], getWorkflows: [ - authorize(['owner', 'admin', 'member']), + authorize(['global:owner', 'global:admin', 'global:member']), validCursor, async (req: WorkflowRequest.GetAll, res: express.Response): Promise => { const { offset = 0, limit = 100, active = undefined, tags = undefined } = req.query; @@ -104,7 +101,7 @@ export = { ...(active !== undefined && { active }), }; - if (['owner', 'admin'].includes(req.user.globalRole.name)) { + if (['global:owner', 'global:admin'].includes(req.user.role)) { if (tags) { const workflowIds = await Container.get(TagRepository).getWorkflowIdsViaTags( parseTagNames(tags), @@ -159,7 +156,7 @@ export = { }, ], updateWorkflow: [ - authorize(['owner', 'admin', 'member']), + authorize(['global:owner', 'global:admin', 'global:member']), async (req: WorkflowRequest.Update, res: express.Response): Promise => { const { id } = req.params; const updateData = new WorkflowEntity(); @@ -221,7 +218,7 @@ export = { }, ], activateWorkflow: [ - authorize(['owner', 'admin', 'member']), + authorize(['global:owner', 'global:admin', 'global:member']), async (req: WorkflowRequest.Activate, res: express.Response): Promise => { const { id } = req.params; @@ -255,7 +252,7 @@ export = { }, ], deactivateWorkflow: [ - authorize(['owner', 'admin', 'member']), + authorize(['global:owner', 'global:admin', 'global:member']), async (req: WorkflowRequest.Activate, res: express.Response): Promise => { const { id } = req.params; diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts index 607aeb31d6..8d53a72ea1 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts @@ -1,10 +1,9 @@ +import { Container } from 'typedi'; import * as Db from '@/Db'; import type { User } from '@db/entities/User'; import { WorkflowEntity } from '@db/entities/WorkflowEntity'; -import { SharedWorkflow } from '@db/entities/SharedWorkflow'; -import type { Role } from '@db/entities/Role'; +import { SharedWorkflow, type WorkflowSharingRole } from '@db/entities/SharedWorkflow'; import config from '@/config'; -import Container from 'typedi'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; @@ -13,7 +12,7 @@ function insertIf(condition: boolean, elements: string[]): string[] { } export async function getSharedWorkflowIds(user: User): Promise { - const where = ['owner', 'admin'].includes(user.globalRole.name) ? {} : { userId: user.id }; + const where = ['global:owner', 'global:admin'].includes(user.role) ? {} : { userId: user.id }; const sharedWorkflows = await Container.get(SharedWorkflowRepository).find({ where, select: ['workflowId'], @@ -25,9 +24,9 @@ export async function getSharedWorkflow( user: User, workflowId?: string | undefined, ): Promise { - return Container.get(SharedWorkflowRepository).findOne({ + return await Container.get(SharedWorkflowRepository).findOne({ where: { - ...(!['owner', 'admin'].includes(user.globalRole.name) && { userId: user.id }), + ...(!['global:owner', 'global:admin'].includes(user.role) && { userId: user.id }), ...(workflowId && { workflowId }), }, relations: [...insertIf(!config.getEnv('workflowTagsDisabled'), ['workflow.tags']), 'workflow'], @@ -35,7 +34,7 @@ export async function getSharedWorkflow( } export async function getWorkflowById(id: string): Promise { - return Container.get(WorkflowRepository).findOne({ + return await Container.get(WorkflowRepository).findOne({ where: { id }, }); } @@ -43,9 +42,9 @@ export async function getWorkflowById(id: string): Promise { - return Db.transaction(async (transactionManager) => { + return await Db.transaction(async (transactionManager) => { const newWorkflow = new WorkflowEntity(); Object.assign(newWorkflow, workflow); const savedWorkflow = await transactionManager.save(newWorkflow); @@ -70,18 +69,18 @@ export async function setWorkflowAsActive(workflow: WorkflowEntity) { } export async function setWorkflowAsInactive(workflow: WorkflowEntity) { - return Container.get(WorkflowRepository).update(workflow.id, { + return await Container.get(WorkflowRepository).update(workflow.id, { active: false, updatedAt: new Date(), }); } export async function deleteWorkflow(workflow: WorkflowEntity): Promise { - return Container.get(WorkflowRepository).remove(workflow); + return await Container.get(WorkflowRepository).remove(workflow); } export async function updateWorkflow(workflowId: string, updateData: WorkflowEntity) { - return Container.get(WorkflowRepository).update(workflowId, updateData); + return await Container.get(WorkflowRepository).update(workflowId, updateData); } export function parseTagNames(tags: string): string[] { diff --git a/packages/cli/src/PublicApi/v1/shared/middlewares/global.middleware.ts b/packages/cli/src/PublicApi/v1/shared/middlewares/global.middleware.ts index 7335ca4cd8..97501f0466 100644 --- a/packages/cli/src/PublicApi/v1/shared/middlewares/global.middleware.ts +++ b/packages/cli/src/PublicApi/v1/shared/middlewares/global.middleware.ts @@ -6,20 +6,18 @@ import { Container } from 'typedi'; import type { AuthenticatedRequest, PaginatedRequest } from '../../../types'; import { decodeCursor } from '../services/pagination.service'; import { License } from '@/License'; -import type { RoleNames } from '@/databases/entities/Role'; +import type { GlobalRole } from '@db/entities/User'; const UNLIMITED_USERS_QUOTA = -1; export const authorize = - (authorizedRoles: readonly RoleNames[]) => + (authorizedRoles: readonly GlobalRole[]) => ( req: AuthenticatedRequest, res: express.Response, next: express.NextFunction, ): express.Response | void => { - const { name } = req.user.globalRole; - - if (!authorizedRoles.includes(name)) { + if (!authorizedRoles.includes(req.user.role)) { return res.status(403).json({ message: 'Forbidden' }); } diff --git a/packages/cli/src/Queue.ts b/packages/cli/src/Queue.ts index 88cad41488..e524aed049 100644 --- a/packages/cli/src/Queue.ts +++ b/packages/cli/src/Queue.ts @@ -74,27 +74,27 @@ export class Queue { } async add(jobData: JobData, jobOptions: object): Promise { - return this.jobQueue.add(jobData, jobOptions); + return await this.jobQueue.add(jobData, jobOptions); } async getJob(jobId: JobId): Promise { - return this.jobQueue.getJob(jobId); + return await this.jobQueue.getJob(jobId); } async getJobs(jobTypes: Bull.JobStatus[]): Promise { - return this.jobQueue.getJobs(jobTypes); + return await this.jobQueue.getJobs(jobTypes); } async process(concurrency: number, fn: Bull.ProcessCallbackFunction): Promise { - return this.jobQueue.process(concurrency, fn); + return await this.jobQueue.process(concurrency, fn); } async ping(): Promise { - return this.jobQueue.client.ping(); + return await this.jobQueue.client.ping(); } async pause(isLocal?: boolean): Promise { - return this.jobQueue.pause(isLocal); + return await this.jobQueue.pause(isLocal); } getBullObjectInstance(): JobQueue { diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 1de23822f3..7bad362226 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -14,13 +14,10 @@ import cookieParser from 'cookie-parser'; import express from 'express'; import { engine as expressHandlebars } from 'express-handlebars'; import type { ServeStaticOptions } from 'serve-static'; -import type { FindManyOptions, FindOptionsWhere } from 'typeorm'; -import { Not, In } from 'typeorm'; import { type Class, InstanceSettings } from 'n8n-core'; -import type { ExecutionStatus, IExecutionsSummary, IN8nUISettings } from 'n8n-workflow'; -import { jsonParse } from 'n8n-workflow'; +import type { IN8nUISettings } from 'n8n-workflow'; // @ts-ignore import timezones from 'google-timezones-json'; @@ -28,7 +25,6 @@ import history from 'connect-history-api-fallback'; import config from '@/config'; import { Queue } from '@/Queue'; -import { getSharedWorkflowIds } from '@/WorkflowHelpers'; import { WorkflowsController } from '@/workflows/workflows.controller'; import { @@ -39,7 +35,7 @@ import { TEMPLATES_DIR, } from '@/constants'; import { credentialsController } from '@/credentials/credentials.controller'; -import type { CurlHelper, ExecutionRequest } from '@/requests'; +import type { CurlHelper } from '@/requests'; import { registerController } from '@/decorators'; import { AuthController } from '@/controllers/auth.controller'; import { BinaryDataController } from '@/controllers/binaryData.controller'; @@ -56,14 +52,12 @@ import { TranslationController } from '@/controllers/translation.controller'; import { UsersController } from '@/controllers/users.controller'; import { WorkflowStatisticsController } from '@/controllers/workflowStatistics.controller'; import { ExternalSecretsController } from '@/ExternalSecrets/ExternalSecrets.controller.ee'; -import { executionsController } from '@/executions/executions.controller'; +import { ExecutionsController } from '@/executions/executions.controller'; import { isApiEnabled, loadPublicApiVersions } from '@/PublicApi'; -import type { ICredentialsOverwrite, IDiagnosticInfo, IExecutionsStopData } from '@/Interfaces'; -import { ActiveExecutions } from '@/ActiveExecutions'; +import type { ICredentialsOverwrite, IDiagnosticInfo } from '@/Interfaces'; import { CredentialsOverwrites } from '@/CredentialsOverwrites'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import * as ResponseHelper from '@/ResponseHelper'; -import { WaitTracker } from '@/WaitTracker'; import { toHttpNodeParameters } from '@/CurlConverterHelper'; import { EventBusController } from '@/eventbus/eventBus.controller'; import { EventBusControllerEE } from '@/eventbus/eventBus.controller.ee'; @@ -76,7 +70,6 @@ import { PostHogClient } from './posthog'; import { eventBus } from './eventbus'; import { InternalHooks } from './InternalHooks'; import { License } from './License'; -import { getStatusUsingPreviousExecutionStatusMethod } from './executions/executionHelpers'; import { SamlController } from './sso/saml/routes/saml.controller.ee'; import { SamlService } from './sso/saml/saml.service.ee'; import { VariablesController } from './environments/variables/variables.controller.ee'; @@ -87,8 +80,6 @@ import { import { SourceControlService } from '@/environments/sourceControl/sourceControl.service.ee'; import { SourceControlController } from '@/environments/sourceControl/sourceControl.controller.ee'; -import type { ExecutionEntity } from '@db/entities/ExecutionEntity'; -import { ExecutionRepository } from '@db/repositories/execution.repository'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { handleMfaDisable, isMfaFeatureEnabled } from './Mfa/helpers'; @@ -98,10 +89,8 @@ import { OrchestrationController } from './controllers/orchestration.controller' import { WorkflowHistoryController } from './workflows/workflowHistory/workflowHistory.controller.ee'; import { InvitationController } from './controllers/invitation.controller'; import { CollaborationService } from './collaboration/collaboration.service'; -import { RoleController } from './controllers/role.controller'; import { BadRequestError } from './errors/response-errors/bad-request.error'; -import { NotFoundError } from './errors/response-errors/not-found.error'; -import { MultiMainSetup } from './services/orchestration/main/MultiMainSetup.ee'; +import { OrchestrationService } from '@/services/orchestration.service'; const exec = promisify(callbackExec); @@ -109,10 +98,6 @@ const exec = promisify(callbackExec); export class Server extends AbstractServer { private endpointPresetCredentials: string; - private waitTracker: WaitTracker; - - private activeExecutionsInstance: ActiveExecutions; - private presetCredentialsLoaded: boolean; private loadNodesAndCredentials: LoadNodesAndCredentials; @@ -138,9 +123,6 @@ export class Server extends AbstractServer { this.frontendService = Container.get(require('@/services/frontend.service').FrontendService); } - this.activeExecutionsInstance = Container.get(ActiveExecutions); - this.waitTracker = Container.get(WaitTracker); - this.presetCredentialsLoaded = false; this.endpointPresetCredentials = config.getEnv('credentials.overwrite.endpoint'); @@ -208,8 +190,9 @@ export class Server extends AbstractServer { order: { createdAt: 'ASC' }, where: {}, }) - .then(async (workflow) => - Container.get(InternalHooks).onServerStarted(diagnosticInfo, workflow?.createdAt), + .then( + async (workflow) => + await Container.get(InternalHooks).onServerStarted(diagnosticInfo, workflow?.createdAt), ); Container.get(CollaborationService); @@ -244,12 +227,15 @@ export class Server extends AbstractServer { VariablesController, InvitationController, VariablesController, - RoleController, ActiveWorkflowsController, WorkflowsController, + ExecutionsController, ]; - if (process.env.NODE_ENV !== 'production' && Container.get(MultiMainSetup).isEnabled) { + if ( + process.env.NODE_ENV !== 'production' && + Container.get(OrchestrationService).isMultiMainSetupEnabled + ) { const { DebugController } = await import('@/controllers/debug.controller'); controllers.push(DebugController); } @@ -277,6 +263,11 @@ export class Server extends AbstractServer { controllers.push(MFAController); } + if (!config.getEnv('endpoints.disableUi')) { + const { CtaController } = await import('@/controllers/cta.controller'); + controllers.push(CtaController); + } + controllers.forEach((controller) => registerController(app, controller)); } @@ -397,219 +388,6 @@ export class Server extends AbstractServer { }), ); - // ---------------------------------------- - // Executions - // ---------------------------------------- - - this.app.use(`/${this.restEndpoint}/executions`, executionsController); - - // ---------------------------------------- - // Executing Workflows - // ---------------------------------------- - - // Returns all the currently working executions - this.app.get( - `/${this.restEndpoint}/executions-current`, - ResponseHelper.send( - async (req: ExecutionRequest.GetAllCurrent): Promise => { - if (config.getEnv('executions.mode') === 'queue') { - const queue = Container.get(Queue); - const currentJobs = await queue.getJobs(['active', 'waiting']); - - const currentlyRunningQueueIds = currentJobs.map((job) => job.data.executionId); - - const currentlyRunningManualExecutions = - this.activeExecutionsInstance.getActiveExecutions(); - const manualExecutionIds = currentlyRunningManualExecutions.map( - (execution) => execution.id, - ); - - const currentlyRunningExecutionIds = - currentlyRunningQueueIds.concat(manualExecutionIds); - - if (!currentlyRunningExecutionIds.length) return []; - - const findOptions: FindManyOptions & { - where: FindOptionsWhere; - } = { - select: ['id', 'workflowId', 'mode', 'retryOf', 'startedAt', 'stoppedAt', 'status'], - order: { id: 'DESC' }, - where: { - id: In(currentlyRunningExecutionIds), - status: Not(In(['finished', 'stopped', 'failed', 'crashed'] as ExecutionStatus[])), - }, - }; - - const sharedWorkflowIds = await getSharedWorkflowIds(req.user); - - if (!sharedWorkflowIds.length) return []; - - if (req.query.filter) { - const { workflowId, status, finished } = jsonParse(req.query.filter); - if (workflowId && sharedWorkflowIds.includes(workflowId)) { - Object.assign(findOptions.where, { workflowId }); - } else { - Object.assign(findOptions.where, { workflowId: In(sharedWorkflowIds) }); - } - if (status) { - Object.assign(findOptions.where, { status: In(status) }); - } - if (finished) { - Object.assign(findOptions.where, { finished }); - } - } else { - Object.assign(findOptions.where, { workflowId: In(sharedWorkflowIds) }); - } - - const executions = - await Container.get(ExecutionRepository).findMultipleExecutions(findOptions); - - if (!executions.length) return []; - - return executions.map((execution) => { - if (!execution.status) { - execution.status = getStatusUsingPreviousExecutionStatusMethod(execution); - } - return { - id: execution.id, - workflowId: execution.workflowId, - mode: execution.mode, - retryOf: execution.retryOf !== null ? execution.retryOf : undefined, - startedAt: new Date(execution.startedAt), - status: execution.status ?? null, - stoppedAt: execution.stoppedAt ?? null, - } as IExecutionsSummary; - }); - } - - const executingWorkflows = this.activeExecutionsInstance.getActiveExecutions(); - - const returnData: IExecutionsSummary[] = []; - - const filter = req.query.filter ? jsonParse(req.query.filter) : {}; - - const sharedWorkflowIds = await getSharedWorkflowIds(req.user); - - for (const data of executingWorkflows) { - if ( - (filter.workflowId !== undefined && filter.workflowId !== data.workflowId) || - (data.workflowId !== undefined && !sharedWorkflowIds.includes(data.workflowId)) - ) { - continue; - } - - returnData.push({ - id: data.id, - workflowId: data.workflowId === undefined ? '' : data.workflowId, - mode: data.mode, - retryOf: data.retryOf, - startedAt: new Date(data.startedAt), - status: data.status, - }); - } - - returnData.sort((a, b) => Number(b.id) - Number(a.id)); - - return returnData; - }, - ), - ); - - // Forces the execution to stop - this.app.post( - `/${this.restEndpoint}/executions-current/:id/stop`, - ResponseHelper.send(async (req: ExecutionRequest.Stop): Promise => { - const { id: executionId } = req.params; - - const sharedWorkflowIds = await getSharedWorkflowIds(req.user); - - if (!sharedWorkflowIds.length) { - throw new NotFoundError('Execution not found'); - } - - const fullExecutionData = await Container.get(ExecutionRepository).findSingleExecution( - executionId, - { - where: { - workflowId: In(sharedWorkflowIds), - }, - }, - ); - - if (!fullExecutionData) { - throw new NotFoundError('Execution not found'); - } - - if (config.getEnv('executions.mode') === 'queue') { - // Manual executions should still be stoppable, so - // try notifying the `activeExecutions` to stop it. - const result = await this.activeExecutionsInstance.stopExecution(req.params.id); - - if (result === undefined) { - // If active execution could not be found check if it is a waiting one - try { - return await this.waitTracker.stopExecution(req.params.id); - } catch (error) { - // Ignore, if it errors as then it is probably a currently running - // execution - } - } else { - return { - mode: result.mode, - startedAt: new Date(result.startedAt), - stoppedAt: result.stoppedAt ? new Date(result.stoppedAt) : undefined, - finished: result.finished, - status: result.status, - } as IExecutionsStopData; - } - - const queue = Container.get(Queue); - const currentJobs = await queue.getJobs(['active', 'waiting']); - - const job = currentJobs.find((job) => job.data.executionId === req.params.id); - - if (!job) { - this.logger.debug('Could not stop job because it is no longer in queue', { - jobId: req.params.id, - }); - } else { - await queue.stopJob(job); - } - - const returnData: IExecutionsStopData = { - mode: fullExecutionData.mode, - startedAt: new Date(fullExecutionData.startedAt), - stoppedAt: fullExecutionData.stoppedAt - ? new Date(fullExecutionData.stoppedAt) - : undefined, - finished: fullExecutionData.finished, - status: fullExecutionData.status, - }; - - return returnData; - } - - // Stop the execution and wait till it is done and we got the data - const result = await this.activeExecutionsInstance.stopExecution(executionId); - - let returnData: IExecutionsStopData; - if (result === undefined) { - // If active execution could not be found check if it is a waiting one - returnData = await this.waitTracker.stopExecution(executionId); - } else { - returnData = { - mode: result.mode, - startedAt: new Date(result.startedAt), - stoppedAt: result.stoppedAt ? new Date(result.stoppedAt) : undefined, - finished: result.finished, - status: result.status, - }; - } - - return returnData; - }), - ); - // ---------------------------------------- // Options // ---------------------------------------- diff --git a/packages/cli/src/TestWebhooks.ts b/packages/cli/src/TestWebhooks.ts index e46be34dde..fef109cb92 100644 --- a/packages/cli/src/TestWebhooks.ts +++ b/packages/cli/src/TestWebhooks.ts @@ -25,7 +25,7 @@ import * as NodeExecuteFunctions from 'n8n-core'; import { removeTrailingSlash } from './utils'; import type { TestWebhookRegistration } from '@/services/test-webhook-registrations.service'; import { TestWebhookRegistrationsService } from '@/services/test-webhook-registrations.service'; -import { MultiMainSetup } from './services/orchestration/main/MultiMainSetup.ee'; +import { OrchestrationService } from '@/services/orchestration.service'; import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; @Service() @@ -34,7 +34,7 @@ export class TestWebhooks implements IWebhookManager { private readonly push: Push, private readonly nodeTypes: NodeTypes, private readonly registrations: TestWebhookRegistrationsService, - private readonly multiMainSetup: MultiMainSetup, + private readonly orchestrationService: OrchestrationService, ) {} private timeouts: { [webhookKey: string]: NodeJS.Timeout } = {}; @@ -101,7 +101,7 @@ export class TestWebhooks implements IWebhookManager { throw new NotFoundError('Could not find node to process webhook.'); } - return new Promise(async (resolve, reject) => { + return await new Promise(async (resolve, reject) => { try { const executionMode = 'manual'; const executionId = await WebhookHelpers.executeWebhook( @@ -144,12 +144,12 @@ export class TestWebhooks implements IWebhookManager { * the handler process commands the creator process to clear its test webhooks. */ if ( - this.multiMainSetup.isEnabled && + this.orchestrationService.isMultiMainSetupEnabled && sessionId && !this.push.getBackend().hasSessionId(sessionId) ) { const payload = { webhookKey: key, workflowEntity, sessionId }; - void this.multiMainSetup.publish('clear-test-webhooks', payload); + void this.orchestrationService.publish('clear-test-webhooks', payload); return; } @@ -229,7 +229,10 @@ export class TestWebhooks implements IWebhookManager { return false; // no webhooks found to start a workflow } - const timeout = setTimeout(async () => this.cancelWebhook(workflow.id), TEST_WEBHOOK_TIMEOUT); + const timeout = setTimeout( + async () => await this.cancelWebhook(workflow.id), + TEST_WEBHOOK_TIMEOUT, + ); for (const webhook of webhooks) { const key = this.registrations.toKey(webhook); diff --git a/packages/cli/src/UserManagement/PermissionChecker.ts b/packages/cli/src/UserManagement/PermissionChecker.ts index a5e1ffdb8a..7b6261c4d6 100644 --- a/packages/cli/src/UserManagement/PermissionChecker.ts +++ b/packages/cli/src/UserManagement/PermissionChecker.ts @@ -1,22 +1,31 @@ +import { Service } from 'typedi'; import type { INode, Workflow } from 'n8n-workflow'; import { NodeOperationError, WorkflowOperationError } from 'n8n-workflow'; + import config from '@/config'; -import { isSharingEnabled } from './UserManagementHelper'; +import { License } from '@/License'; import { OwnershipService } from '@/services/ownership.service'; -import Container from 'typedi'; -import { RoleService } from '@/services/role.service'; import { UserRepository } from '@db/repositories/user.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; +@Service() export class PermissionChecker { + constructor( + private readonly userRepository: UserRepository, + private readonly sharedCredentialsRepository: SharedCredentialsRepository, + private readonly sharedWorkflowRepository: SharedWorkflowRepository, + private readonly ownershipService: OwnershipService, + private readonly license: License, + ) {} + /** * Check if a user is permitted to execute a workflow. */ - static async check(workflow: Workflow, userId: string) { + async check(workflow: Workflow, userId: string) { // allow if no nodes in this workflow use creds - const credIdsToNodes = PermissionChecker.mapCredIdsToNodes(workflow); + const credIdsToNodes = this.mapCredIdsToNodes(workflow); const workflowCredIds = Object.keys(credIdsToNodes); @@ -24,9 +33,8 @@ export class PermissionChecker { // allow if requesting user is instance owner - const user = await Container.get(UserRepository).findOneOrFail({ + const user = await this.userRepository.findOneOrFail({ where: { id: userId }, - relations: ['globalRole'], }); if (user.hasGlobalScope('workflow:execute')) return; @@ -36,8 +44,8 @@ export class PermissionChecker { let workflowUserIds = [userId]; - if (workflow.id && isSharingEnabled()) { - const workflowSharings = await Container.get(SharedWorkflowRepository).find({ + if (workflow.id && this.license.isSharingEnabled()) { + const workflowSharings = await this.sharedWorkflowRepository.find({ relations: ['workflow'], where: { workflowId: workflow.id }, select: ['userId'], @@ -45,12 +53,8 @@ export class PermissionChecker { workflowUserIds = workflowSharings.map((s) => s.userId); } - const roleId = await Container.get(RoleService).findCredentialOwnerRoleId(); - - const credentialSharings = await Container.get(SharedCredentialsRepository).findSharings( - workflowUserIds, - roleId, - ); + const credentialSharings = + await this.sharedCredentialsRepository.findOwnedSharings(workflowUserIds); const accessibleCredIds = credentialSharings.map((s) => s.credentialsId); @@ -68,7 +72,7 @@ export class PermissionChecker { }); } - static async checkSubworkflowExecutePolicy( + async checkSubworkflowExecutePolicy( subworkflow: Workflow, parentWorkflowId: string, node?: INode, @@ -88,17 +92,15 @@ export class PermissionChecker { let policy = subworkflow.settings?.callerPolicy ?? config.getEnv('workflows.callerPolicyDefaultOption'); - if (!isSharingEnabled()) { + if (!this.license.isSharingEnabled()) { // Community version allows only same owner workflows policy = 'workflowsFromSameOwner'; } const parentWorkflowOwner = - await Container.get(OwnershipService).getWorkflowOwnerCached(parentWorkflowId); + await this.ownershipService.getWorkflowOwnerCached(parentWorkflowId); - const subworkflowOwner = await Container.get(OwnershipService).getWorkflowOwnerCached( - subworkflow.id, - ); + const subworkflowOwner = await this.ownershipService.getWorkflowOwnerCached(subworkflow.id); const description = subworkflowOwner.id === parentWorkflowOwner.id @@ -134,7 +136,7 @@ export class PermissionChecker { } } - private static mapCredIdsToNodes(workflow: Workflow) { + private mapCredIdsToNodes(workflow: Workflow) { return Object.values(workflow.nodes).reduce<{ [credentialId: string]: INode[] }>( (map, node) => { if (node.disabled || !node.credentials) return map; diff --git a/packages/cli/src/UserManagement/UserManagementHelper.ts b/packages/cli/src/UserManagement/UserManagementHelper.ts deleted file mode 100644 index 97557c45f9..0000000000 --- a/packages/cli/src/UserManagement/UserManagementHelper.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Container } from 'typedi'; -import { License } from '@/License'; - -export function isSharingEnabled(): boolean { - return Container.get(License).isSharingEnabled(); -} diff --git a/packages/cli/src/UserManagement/email/UserManagementMailer.ts b/packages/cli/src/UserManagement/email/UserManagementMailer.ts index 9aa8efa314..95ad9614e0 100644 --- a/packages/cli/src/UserManagement/email/UserManagementMailer.ts +++ b/packages/cli/src/UserManagement/email/UserManagementMailer.ts @@ -9,7 +9,7 @@ import { NodeMailer } from './NodeMailer'; import { ApplicationError } from 'n8n-workflow'; type Template = HandlebarsTemplateDelegate; -type TemplateName = 'invite' | 'passwordReset'; +type TemplateName = 'invite' | 'passwordReset' | 'workflowShared' | 'credentialsShared'; const templates: Partial> = {}; @@ -53,7 +53,7 @@ export class UserManagementMailer { async verifyConnection(): Promise { if (!this.mailer) throw new ApplicationError('No mailer configured.'); - return this.mailer.verifyConnection(); + return await this.mailer.verifyConnection(); } async invite(inviteEmailData: InviteEmailData): Promise { @@ -81,4 +81,50 @@ export class UserManagementMailer { // No error, just say no email was sent. return result ?? { emailSent: false }; } + + async notifyWorkflowShared({ + recipientEmails, + workflowName, + baseUrl, + workflowId, + sharerFirstName, + }: { + recipientEmails: string[]; + workflowName: string; + baseUrl: string; + workflowId: string; + sharerFirstName: string; + }) { + const populateTemplate = await getTemplate('workflowShared', 'workflowShared.html'); + + const result = await this.mailer?.sendMail({ + emailRecipients: recipientEmails, + subject: `${sharerFirstName} has shared an n8n workflow with you`, + body: populateTemplate({ workflowName, workflowUrl: `${baseUrl}/workflow/${workflowId}` }), + }); + + return result ?? { emailSent: false }; + } + + async notifyCredentialsShared({ + sharerFirstName, + credentialsName, + recipientEmails, + baseUrl, + }: { + sharerFirstName: string; + credentialsName: string; + recipientEmails: string[]; + baseUrl: string; + }) { + const populateTemplate = await getTemplate('credentialsShared', 'credentialsShared.html'); + + const result = await this.mailer?.sendMail({ + emailRecipients: recipientEmails, + subject: `${sharerFirstName} has shared an n8n credential with you`, + body: populateTemplate({ credentialsName, credentialsListUrl: `${baseUrl}/credentials` }), + }); + + return result ?? { emailSent: false }; + } } diff --git a/packages/cli/src/UserManagement/email/templates/credentialsShared.html b/packages/cli/src/UserManagement/email/templates/credentialsShared.html new file mode 100644 index 0000000000..b4634a9ddf --- /dev/null +++ b/packages/cli/src/UserManagement/email/templates/credentialsShared.html @@ -0,0 +1,4 @@ +

Hi there,

+

"{{ credentialsName }}" credential has been shared with you.

+

To view all the credentials you have access to within n8n, click the following link:

+

{{ credentialsListUrl }}

diff --git a/packages/cli/src/UserManagement/email/templates/workflowShared.html b/packages/cli/src/UserManagement/email/templates/workflowShared.html new file mode 100644 index 0000000000..d6fa692759 --- /dev/null +++ b/packages/cli/src/UserManagement/email/templates/workflowShared.html @@ -0,0 +1,4 @@ +

Hi there,

+

"{{ workflowName }}" workflow has been shared with you.

+

To access the workflow, click the following link:

+

{{ workflowUrl }}

diff --git a/packages/cli/src/WaitTracker.ts b/packages/cli/src/WaitTracker.ts index 435b6f5b94..b39222bc63 100644 --- a/packages/cli/src/WaitTracker.ts +++ b/packages/cli/src/WaitTracker.ts @@ -53,6 +53,21 @@ export class WaitTracker { for (const execution of executions) { const executionId = execution.id; if (this.waitingExecutions[executionId] === undefined) { + if (!(execution.waitTill instanceof Date)) { + // n8n expects waitTill to be a date object + // but for some reason it's not being converted + // we are handling this like this since it seems to address the issue + // for some users, as reported by Jon when using a custom image. + // Once we figure out why this it not a Date object, we can remove this. + ErrorReporter.error('Wait Till is not a date object', { + extra: { + variableType: typeof execution.waitTill, + }, + }); + if (typeof execution.waitTill === 'string') { + execution.waitTill = new Date(execution.waitTill); + } + } const triggerTime = execution.waitTill!.getTime() - new Date().getTime(); this.waitingExecutions[executionId] = { executionId, diff --git a/packages/cli/src/WaitingWebhooks.ts b/packages/cli/src/WaitingWebhooks.ts index 223fc92820..c745644dbe 100644 --- a/packages/cli/src/WaitingWebhooks.ts +++ b/packages/cli/src/WaitingWebhooks.ts @@ -122,7 +122,7 @@ export class WaitingWebhooks implements IWebhookManager { const runExecutionData = execution.data; - return new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { const executionMode = 'webhook'; void WebhookHelpers.executeWebhook( workflow, diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index 143ef169a4..0e2d2f313d 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -68,6 +68,7 @@ import { saveExecutionProgress } from './executionLifecycleHooks/saveExecutionPr import { WorkflowStaticDataService } from './workflows/workflowStaticData.service'; import { WorkflowRepository } from './databases/repositories/workflow.repository'; import { UrlService } from './services/url.service'; +import { WorkflowExecutionService } from './workflows/workflowExecution.service'; const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType'); @@ -194,7 +195,11 @@ export function executeErrorWorkflow( Container.get(OwnershipService) .getWorkflowOwnerCached(workflowId) .then((user) => { - void WorkflowHelpers.executeErrorWorkflow(errorWorkflow, workflowErrorData, user); + void Container.get(WorkflowExecutionService).executeErrorWorkflow( + errorWorkflow, + workflowErrorData, + user, + ); }) .catch((error: Error) => { ErrorReporter.error(error); @@ -218,7 +223,11 @@ export function executeErrorWorkflow( void Container.get(OwnershipService) .getWorkflowOwnerCached(workflowId) .then((user) => { - void WorkflowHelpers.executeErrorWorkflow(workflowId, workflowErrorData, user); + void Container.get(WorkflowExecutionService).executeErrorWorkflow( + workflowId, + workflowErrorData, + user, + ); }); } } @@ -795,8 +804,8 @@ async function executeWorkflow( let data; try { - await PermissionChecker.check(workflow, additionalData.userId); - await PermissionChecker.checkSubworkflowExecutePolicy( + await Container.get(PermissionChecker).check(workflow, additionalData.userId); + await Container.get(PermissionChecker).checkSubworkflowExecutePolicy( workflow, options.parentWorkflowId, options.node, diff --git a/packages/cli/src/WorkflowHelpers.ts b/packages/cli/src/WorkflowHelpers.ts index 010dee308a..9ea4e8335d 100644 --- a/packages/cli/src/WorkflowHelpers.ts +++ b/packages/cli/src/WorkflowHelpers.ts @@ -1,52 +1,22 @@ -import type { FindOptionsWhere } from 'typeorm'; -import { In } from 'typeorm'; import { Container } from 'typedi'; - +import { v4 as uuid } from 'uuid'; import type { IDataObject, - IExecuteData, INode, INodeCredentialsDetails, IRun, - IRunExecutionData, ITaskData, NodeApiError, WorkflowExecuteMode, WorkflowOperationError, -} from 'n8n-workflow'; -import { - ErrorReporterProxy as ErrorReporter, - NodeOperationError, - SubworkflowOperationError, Workflow, + NodeOperationError, } from 'n8n-workflow'; -import { v4 as uuid } from 'uuid'; -import omit from 'lodash/omit'; -import type { - ExecutionPayload, - IWorkflowErrorData, - IWorkflowExecutionDataProcess, -} from '@/Interfaces'; -import { NodeTypes } from '@/NodeTypes'; -import { WorkflowRunner } from '@/WorkflowRunner'; -import config from '@/config'; -import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; -import type { User } from '@db/entities/User'; -import { PermissionChecker } from './UserManagement/PermissionChecker'; -import { UserService } from './services/user.service'; -import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; -import type { SharedWorkflow } from '@db/entities/SharedWorkflow'; -import type { RoleNames } from '@db/entities/Role'; -import { CredentialsRepository } from '@db/repositories/credentials.repository'; -import { ExecutionRepository } from '@db/repositories/execution.repository'; -import { RoleRepository } from '@db/repositories/role.repository'; -import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; -import { WorkflowRepository } from '@db/repositories/workflow.repository'; -import { RoleService } from './services/role.service'; -import { VariablesService } from './environments/variables/variables.service.ee'; -import { Logger } from './Logger'; -const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType'); +import type { IWorkflowExecutionDataProcess } from '@/Interfaces'; +import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; +import { CredentialsRepository } from '@db/repositories/credentials.repository'; +import { VariablesService } from '@/environments/variables/variables.service.ee'; export function generateFailedExecutionFromError( mode: WorkflowExecuteMode, @@ -97,7 +67,6 @@ export function generateFailedExecutionFromError( /** * Returns the data of the last executed node - * */ export function getDataLastExecutedNodeData(inputData: IRun): ITaskData | undefined { const { runData, pinData = {} } = inputData.data.resultData; @@ -133,168 +102,6 @@ export function getDataLastExecutedNodeData(inputData: IRun): ITaskData | undefi return lastNodeRunData; } -/** - * Executes the error workflow - * - * @param {string} workflowId The id of the error workflow - * @param {IWorkflowErrorData} workflowErrorData The error data - */ -export async function executeErrorWorkflow( - workflowId: string, - workflowErrorData: IWorkflowErrorData, - runningUser: User, -): Promise { - const logger = Container.get(Logger); - // Wrap everything in try/catch to make sure that no errors bubble up and all get caught here - try { - const workflowData = await Container.get(WorkflowRepository).findOneBy({ id: workflowId }); - - if (workflowData === null) { - // The error workflow could not be found - logger.error( - `Calling Error Workflow for "${workflowErrorData.workflow.id}". Could not find error workflow "${workflowId}"`, - { workflowId }, - ); - return; - } - - const executionMode = 'error'; - const nodeTypes = Container.get(NodeTypes); - - const workflowInstance = new Workflow({ - id: workflowId, - name: workflowData.name, - nodeTypes, - nodes: workflowData.nodes, - connections: workflowData.connections, - active: workflowData.active, - staticData: workflowData.staticData, - settings: workflowData.settings, - }); - - try { - const failedNode = workflowErrorData.execution?.lastNodeExecuted - ? workflowInstance.getNode(workflowErrorData.execution?.lastNodeExecuted) - : undefined; - await PermissionChecker.checkSubworkflowExecutePolicy( - workflowInstance, - workflowErrorData.workflow.id!, - failedNode ?? undefined, - ); - } catch (error) { - const initialNode = workflowInstance.getStartNode(); - if (initialNode) { - const errorWorkflowPermissionError = new SubworkflowOperationError( - `Another workflow: (ID ${workflowErrorData.workflow.id}) tried to invoke this workflow to handle errors.`, - "Unfortunately current permissions do not allow this. Please check that this workflow's settings allow it to be called by others", - ); - - // Create a fake execution and save it to DB. - const fakeExecution = generateFailedExecutionFromError( - 'error', - errorWorkflowPermissionError, - initialNode, - ); - - const fullExecutionData: ExecutionPayload = { - data: fakeExecution.data, - mode: fakeExecution.mode, - finished: false, - startedAt: new Date(), - stoppedAt: new Date(), - workflowData, - waitTill: null, - status: fakeExecution.status, - workflowId: workflowData.id, - }; - - await Container.get(ExecutionRepository).createNewExecution(fullExecutionData); - } - logger.info('Error workflow execution blocked due to subworkflow settings', { - erroredWorkflowId: workflowErrorData.workflow.id, - errorWorkflowId: workflowId, - }); - return; - } - - let node: INode; - let workflowStartNode: INode | undefined; - for (const nodeName of Object.keys(workflowInstance.nodes)) { - node = workflowInstance.nodes[nodeName]; - if (node.type === ERROR_TRIGGER_TYPE) { - workflowStartNode = node; - } - } - - if (workflowStartNode === undefined) { - logger.error( - `Calling Error Workflow for "${workflowErrorData.workflow.id}". Could not find "${ERROR_TRIGGER_TYPE}" in workflow "${workflowId}"`, - ); - return; - } - - // Can execute without webhook so go on - - // Initialize the data of the webhook node - const nodeExecutionStack: IExecuteData[] = []; - nodeExecutionStack.push({ - node: workflowStartNode, - data: { - main: [ - [ - { - json: workflowErrorData, - }, - ], - ], - }, - source: null, - }); - - const runExecutionData: IRunExecutionData = { - startData: {}, - resultData: { - runData: {}, - }, - executionData: { - contextData: {}, - metadata: {}, - nodeExecutionStack, - waitingExecution: {}, - waitingExecutionSource: {}, - }, - }; - - const runData: IWorkflowExecutionDataProcess = { - executionMode, - executionData: runExecutionData, - workflowData, - userId: runningUser.id, - }; - - const workflowRunner = new WorkflowRunner(); - await workflowRunner.run(runData); - } catch (error) { - ErrorReporter.error(error); - logger.error( - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - `Calling Error Workflow for "${workflowErrorData.workflow.id}": "${error.message}"`, - { workflowId: workflowErrorData.workflow.id }, - ); - } -} - -/** - * Returns the static data of workflow - */ -export async function getStaticDataById(workflowId: string) { - const workflowData = await Container.get(WorkflowRepository).findOne({ - select: ['staticData'], - where: { id: workflowId }, - }); - return workflowData?.staticData ?? {}; -} - /** * Set node ids if not already set */ @@ -416,164 +223,6 @@ export async function replaceInvalidCredentials(workflow: WorkflowEntity): Promi return workflow; } -/** - * Get the IDs of the workflows that have been shared with the user. - * Returns all IDs if user has the 'workflow:read' scope. - */ -export async function getSharedWorkflowIds(user: User, roleNames?: RoleNames[]): Promise { - const where: FindOptionsWhere = {}; - if (!user.hasGlobalScope('workflow:read')) { - where.userId = user.id; - } - if (roleNames?.length) { - const roleIds = await Container.get(RoleRepository).getIdsInScopeWorkflowByNames(roleNames); - - where.roleId = In(roleIds); - } - const sharedWorkflows = await Container.get(SharedWorkflowRepository).find({ - where, - select: ['workflowId'], - }); - return sharedWorkflows.map(({ workflowId }) => workflowId); -} -/** - * Check if user owns more than 15 workflows or more than 2 workflows with at least 2 nodes. - * If user does, set flag in its settings. - */ -export async function isBelowOnboardingThreshold(user: User): Promise { - let belowThreshold = true; - const skippedTypes = ['n8n-nodes-base.start', 'n8n-nodes-base.stickyNote']; - - const workflowOwnerRole = await Container.get(RoleService).findWorkflowOwnerRole(); - const ownedWorkflowsIds = await Container.get(SharedWorkflowRepository) - .find({ - where: { - userId: user.id, - roleId: workflowOwnerRole?.id, - }, - select: ['workflowId'], - }) - .then((ownedWorkflows) => ownedWorkflows.map(({ workflowId }) => workflowId)); - - if (ownedWorkflowsIds.length > 15) { - belowThreshold = false; - } else { - // just fetch workflows' nodes to keep memory footprint low - const workflows = await Container.get(WorkflowRepository).find({ - where: { id: In(ownedWorkflowsIds) }, - select: ['nodes'], - }); - - // valid workflow: 2+ nodes without start node - const validWorkflowCount = workflows.reduce((counter, workflow) => { - if (counter <= 2 && workflow.nodes.length > 2) { - const nodes = workflow.nodes.filter((node) => !skippedTypes.includes(node.type)); - if (nodes.length >= 2) { - return counter + 1; - } - } - return counter; - }, 0); - - // more than 2 valid workflows required - belowThreshold = validWorkflowCount <= 2; - } - - // user is above threshold --> set flag in settings - if (!belowThreshold) { - void Container.get(UserService).updateSettings(user.id, { isOnboarded: true }); - } - - return belowThreshold; -} - -/** Get all nodes in a workflow where the node credential is not accessible to the user. */ -export function getNodesWithInaccessibleCreds(workflow: WorkflowEntity, userCredIds: string[]) { - if (!workflow.nodes) { - return []; - } - return workflow.nodes.filter((node) => { - if (!node.credentials) return false; - - const allUsedCredentials = Object.values(node.credentials); - - const allUsedCredentialIds = allUsedCredentials.map((nodeCred) => nodeCred.id?.toString()); - return allUsedCredentialIds.some( - (nodeCredId) => nodeCredId && !userCredIds.includes(nodeCredId), - ); - }); -} - -export function validateWorkflowCredentialUsage( - newWorkflowVersion: WorkflowEntity, - previousWorkflowVersion: WorkflowEntity, - credentialsUserHasAccessTo: CredentialsEntity[], -) { - /** - * We only need to check nodes that use credentials the current user cannot access, - * since these can be 2 possibilities: - * - Same ID already exist: it's a read only node and therefore cannot be changed - * - It's a new node which indicates tampering and therefore must fail saving - */ - - const allowedCredentialIds = credentialsUserHasAccessTo.map((cred) => cred.id); - - const nodesWithCredentialsUserDoesNotHaveAccessTo = getNodesWithInaccessibleCreds( - newWorkflowVersion, - allowedCredentialIds, - ); - - // If there are no nodes with credentials the user does not have access to we can skip the rest - if (nodesWithCredentialsUserDoesNotHaveAccessTo.length === 0) { - return newWorkflowVersion; - } - - const previouslyExistingNodeIds = previousWorkflowVersion.nodes.map((node) => node.id); - - // If it's a new node we can't allow it to be saved - // since it uses creds the node doesn't have access - const isTamperingAttempt = (inaccessibleCredNodeId: string) => - !previouslyExistingNodeIds.includes(inaccessibleCredNodeId); - - const logger = Container.get(Logger); - nodesWithCredentialsUserDoesNotHaveAccessTo.forEach((node) => { - if (isTamperingAttempt(node.id)) { - logger.verbose('Blocked workflow update due to tampering attempt', { - nodeType: node.type, - nodeName: node.name, - nodeId: node.id, - nodeCredentials: node.credentials, - }); - // Node is new, so this is probably a tampering attempt. Throw an error - throw new NodeOperationError( - node, - `You don't have access to the credentials in the '${node.name}' node. Ask the owner to share them with you.`, - ); - } - // Replace the node with the previous version of the node - // Since it cannot be modified (read only node) - const nodeIdx = newWorkflowVersion.nodes.findIndex( - (newWorkflowNode) => newWorkflowNode.id === node.id, - ); - - logger.debug('Replacing node with previous version when saving updated workflow', { - nodeType: node.type, - nodeName: node.name, - nodeId: node.id, - }); - const previousNodeVersion = previousWorkflowVersion.nodes.find( - (previousNode) => previousNode.id === node.id, - ); - // Allow changing only name, position and disabled status for read-only nodes - Object.assign( - newWorkflowVersion.nodes[nodeIdx], - omit(previousNodeVersion, ['name', 'position', 'disabled']), - ); - }); - - return newWorkflowVersion; -} - export function getExecutionStartNode(data: IWorkflowExecutionDataProcess, workflow: Workflow) { let startNode; if ( diff --git a/packages/cli/src/WorkflowRunner.ts b/packages/cli/src/WorkflowRunner.ts index 1242bc876d..00d15cbad9 100644 --- a/packages/cli/src/WorkflowRunner.ts +++ b/packages/cli/src/WorkflowRunner.ts @@ -1,10 +1,8 @@ /* eslint-disable @typescript-eslint/no-unsafe-argument */ - /* eslint-disable @typescript-eslint/no-unsafe-member-access */ - /* eslint-disable @typescript-eslint/no-shadow */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ - +import { Container } from 'typedi'; import type { IProcessMessage } from 'n8n-core'; import { WorkflowExecute } from 'n8n-core'; @@ -29,6 +27,7 @@ import { fork } from 'child_process'; import { ActiveExecutions } from '@/ActiveExecutions'; import config from '@/config'; +import { ExecutionRepository } from '@db/repositories/execution.repository'; import { ExternalHooks } from '@/ExternalHooks'; import type { IExecutionResponse, @@ -38,7 +37,6 @@ import type { } from '@/Interfaces'; import { NodeTypes } from '@/NodeTypes'; import type { Job, JobData, JobResponse } from '@/Queue'; - import { Queue } from '@/Queue'; import { decodeWebhookResponse } from '@/helpers/decodeWebhookResponse'; import * as WorkflowHelpers from '@/WorkflowHelpers'; @@ -47,10 +45,9 @@ import { generateFailedExecutionFromError } from '@/WorkflowHelpers'; import { initErrorHandling } from '@/ErrorReporting'; import { PermissionChecker } from '@/UserManagement/PermissionChecker'; import { Push } from '@/push'; -import { Container } from 'typedi'; -import { InternalHooks } from './InternalHooks'; -import { ExecutionRepository } from '@db/repositories/execution.repository'; -import { Logger } from './Logger'; +import { InternalHooks } from '@/InternalHooks'; +import { Logger } from '@/Logger'; +import { WorkflowStaticDataService } from '@/workflows/workflowStaticData.service'; export class WorkflowRunner { logger: Logger; @@ -269,7 +266,8 @@ export class WorkflowRunner { ): Promise { const workflowId = data.workflowData.id; if (loadStaticData === true && workflowId) { - data.workflowData.staticData = await WorkflowHelpers.getStaticDataById(workflowId); + data.workflowData.staticData = + await Container.get(WorkflowStaticDataService).getStaticDataById(workflowId); } const nodeTypes = Container.get(NodeTypes); @@ -327,7 +325,7 @@ export class WorkflowRunner { ); try { - await PermissionChecker.check(workflow, data.userId); + await Container.get(PermissionChecker).check(workflow, data.userId); } catch (error) { ErrorReporter.error(error); // Create a failed execution with the data for the node @@ -419,14 +417,15 @@ export class WorkflowRunner { fullRunData.status = this.activeExecutions.getStatus(executionId); this.activeExecutions.remove(executionId, fullRunData); }) - .catch(async (error) => - this.processError( - error, - new Date(), - data.executionMode, - executionId, - additionalData.hooks, - ), + .catch( + async (error) => + await this.processError( + error, + new Date(), + data.executionMode, + executionId, + additionalData.hooks, + ), ); } catch (error) { await this.processError( @@ -672,7 +671,8 @@ export class WorkflowRunner { const subprocess = fork(pathJoin(__dirname, 'WorkflowRunnerProcess.js')); if (loadStaticData === true && workflowId) { - data.workflowData.staticData = await WorkflowHelpers.getStaticDataById(workflowId); + data.workflowData.staticData = + await Container.get(WorkflowStaticDataService).getStaticDataById(workflowId); } data.restartExecutionId = restartExecutionId; diff --git a/packages/cli/src/WorkflowRunnerProcess.ts b/packages/cli/src/WorkflowRunnerProcess.ts index 4bb8ba24cc..7ea03f5d7f 100644 --- a/packages/cli/src/WorkflowRunnerProcess.ts +++ b/packages/cli/src/WorkflowRunnerProcess.ts @@ -142,7 +142,7 @@ class WorkflowRunnerProcess { pinData: this.data.pinData, }); try { - await PermissionChecker.check(this.workflow, userId); + await Container.get(PermissionChecker).check(this.workflow, userId); } catch (error) { const caughtError = error as NodeOperationError; const failedExecutionData = generateFailedExecutionFromError( @@ -276,7 +276,7 @@ class WorkflowRunnerProcess { this.data.executionMode, this.data.executionData, ); - return this.workflowExecute.processRunExecutionData(this.workflow); + return await this.workflowExecute.processRunExecutionData(this.workflow); } if ( this.data.runData === undefined || @@ -289,7 +289,7 @@ class WorkflowRunnerProcess { // Can execute without webhook so go on this.workflowExecute = new WorkflowExecute(additionalData, this.data.executionMode); - return this.workflowExecute.run( + return await this.workflowExecute.run( this.workflow, startNode, this.data.destinationNode, @@ -298,7 +298,7 @@ class WorkflowRunnerProcess { } // Execute only the nodes between start and destination nodes this.workflowExecute = new WorkflowExecute(additionalData, this.data.executionMode); - return this.workflowExecute.runPartialWorkflow( + return await this.workflowExecute.runPartialWorkflow( this.workflow, this.data.runData, this.data.startNodes, @@ -383,7 +383,7 @@ class WorkflowRunnerProcess { * @param {*} data The data */ async function sendToParentProcess(type: string, data: any): Promise { - return new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { process.send!( { type, diff --git a/packages/cli/src/auth/jwt.ts b/packages/cli/src/auth/jwt.ts index bbb6386742..affc9ea75f 100644 --- a/packages/cli/src/auth/jwt.ts +++ b/packages/cli/src/auth/jwt.ts @@ -1,6 +1,6 @@ import type { Response } from 'express'; import { createHash } from 'crypto'; -import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES } from '@/constants'; +import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES, Time } from '@/constants'; import type { JwtPayload, JwtToken } from '@/Interfaces'; import type { User } from '@db/entities/User'; import config from '@/config'; @@ -14,7 +14,9 @@ import { ApplicationError } from 'n8n-workflow'; export function issueJWT(user: User): JwtToken { const { id, email, password } = user; - const expiresIn = 7 * 86400000; // 7 days + const expiresInHours = config.getEnv('userManagement.jwtSessionDurationHours'); + const expiresInSeconds = expiresInHours * Time.hours.toSeconds; + const isWithinUsersLimit = Container.get(License).isWithinUsersLimit(); const payload: JwtPayload = { @@ -37,12 +39,12 @@ export function issueJWT(user: User): JwtToken { } const signedToken = Container.get(JwtService).sign(payload, { - expiresIn: expiresIn / 1000 /* in seconds */, + expiresIn: expiresInSeconds, }); return { token: signedToken, - expiresIn, + expiresIn: expiresInSeconds, }; } @@ -54,7 +56,6 @@ export const createPasswordSha = (user: User) => export async function resolveJwtContent(jwtPayload: JwtPayload): Promise { const user = await Container.get(UserRepository).findOne({ where: { id: jwtPayload.id }, - relations: ['globalRole'], }); let passwordHash = null; @@ -80,13 +81,13 @@ export async function resolveJwt(token: string): Promise { const jwtPayload: JwtPayload = Container.get(JwtService).verify(token, { algorithms: ['HS256'], }); - return resolveJwtContent(jwtPayload); + return await resolveJwtContent(jwtPayload); } export async function issueCookie(res: Response, user: User): Promise { const userData = issueJWT(user); res.cookie(AUTH_COOKIE_NAME, userData.token, { - maxAge: userData.expiresIn, + maxAge: userData.expiresIn * Time.seconds.toMilliseconds, httpOnly: true, sameSite: 'lax', }); diff --git a/packages/cli/src/auth/methods/email.ts b/packages/cli/src/auth/methods/email.ts index 9775c1959d..ba839fa4e6 100644 --- a/packages/cli/src/auth/methods/email.ts +++ b/packages/cli/src/auth/methods/email.ts @@ -12,7 +12,7 @@ export const handleEmailLogin = async ( ): Promise => { const user = await Container.get(UserRepository).findOne({ where: { email }, - relations: ['globalRole', 'authIdentities'], + relations: ['authIdentities'], }); if (user?.password && (await Container.get(PasswordUtility).compare(password, user.password))) { diff --git a/packages/cli/src/auth/methods/ldap.ts b/packages/cli/src/auth/methods/ldap.ts index c2e38cff02..964fc9f485 100644 --- a/packages/cli/src/auth/methods/ldap.ts +++ b/packages/cli/src/auth/methods/ldap.ts @@ -4,7 +4,6 @@ import { InternalHooks } from '@/InternalHooks'; import { LdapService } from '@/Ldap/ldap.service'; import { createLdapUserOnLocalDb, - getLdapUserRole, getUserByEmail, getAuthIdentityByLdapId, isLdapEnabled, @@ -50,8 +49,7 @@ export const handleLdapLogin = async ( const identity = await createLdapAuthIdentity(emailUser, ldapId); await updateLdapUserOnLocalDb(identity, ldapAttributesValues); } else { - const role = await getLdapUserRole(); - const user = await createLdapUserOnLocalDb(role, ldapAttributesValues, ldapId); + const user = await createLdapUserOnLocalDb(ldapAttributesValues, ldapId); void Container.get(InternalHooks).onUserSignup(user, { user_type: 'ldap', was_disabled_ldap_user: false, diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index ff664505fd..61aab82702 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -1,7 +1,7 @@ import 'reflect-metadata'; -import { Command } from '@oclif/command'; -import { ExitError } from '@oclif/errors'; import { Container } from 'typedi'; +import { Command } from '@oclif/core'; +import { ExitError } from '@oclif/core/lib/errors'; import { ApplicationError, ErrorReporterProxy as ErrorReporter, sleep } from 'n8n-workflow'; import { BinaryDataService, InstanceSettings, ObjectStoreService } from 'n8n-core'; import type { AbstractServer } from '@/AbstractServer'; @@ -59,8 +59,8 @@ export abstract class BaseCommand extends Command { this.nodeTypes = Container.get(NodeTypes); await Container.get(LoadNodesAndCredentials).init(); - await Db.init().catch(async (error: Error) => - this.exitWithCrash('There was an error initializing DB', error), + await Db.init().catch( + async (error: Error) => await this.exitWithCrash('There was an error initializing DB', error), ); // This needs to happen after DB.init() or otherwise DB Connection is not @@ -71,8 +71,9 @@ export abstract class BaseCommand extends Command { await this.server?.init(); - await Db.migrate().catch(async (error: Error) => - this.exitWithCrash('There was an error running database migrations', error), + await Db.migrate().catch( + async (error: Error) => + await this.exitWithCrash('There was an error running database migrations', error), ); const dbType = config.getEnv('database.type'); diff --git a/packages/cli/src/commands/audit.ts b/packages/cli/src/commands/audit.ts index eb027ae0fa..2414c09fc7 100644 --- a/packages/cli/src/commands/audit.ts +++ b/packages/cli/src/commands/audit.ts @@ -1,12 +1,13 @@ -import { flags } from '@oclif/command'; +import { Container } from 'typedi'; +import { Flags } from '@oclif/core'; +import { ApplicationError } from 'n8n-workflow'; + import { SecurityAuditService } from '@/security-audit/SecurityAudit.service'; import { RISK_CATEGORIES } from '@/security-audit/constants'; import config from '@/config'; import type { Risk } from '@/security-audit/types'; import { BaseCommand } from './BaseCommand'; -import { Container } from 'typedi'; import { InternalHooks } from '@/InternalHooks'; -import { ApplicationError } from 'n8n-workflow'; export class SecurityAudit extends BaseCommand { static description = 'Generate a security audit report for this n8n instance'; @@ -18,20 +19,20 @@ export class SecurityAudit extends BaseCommand { ]; static flags = { - help: flags.help({ char: 'h' }), - categories: flags.string({ + help: Flags.help({ char: 'h' }), + categories: Flags.string({ default: RISK_CATEGORIES.join(','), description: 'Comma-separated list of categories to include in the audit', }), // eslint-disable-next-line @typescript-eslint/naming-convention - 'days-abandoned-workflow': flags.integer({ + 'days-abandoned-workflow': Flags.integer({ default: config.getEnv('security.audit.daysAbandonedWorkflow'), description: 'Days for a workflow to be considered abandoned if not executed', }), }; async run() { - const { flags: auditFlags } = this.parse(SecurityAudit); + const { flags: auditFlags } = await this.parse(SecurityAudit); const categories = auditFlags.categories?.split(',').filter((c): c is Risk.Category => c !== '') ?? diff --git a/packages/cli/src/commands/db/revert.ts b/packages/cli/src/commands/db/revert.ts index 428216e5b2..967d4a8286 100644 --- a/packages/cli/src/commands/db/revert.ts +++ b/packages/cli/src/commands/db/revert.ts @@ -1,9 +1,9 @@ -import { Command, flags } from '@oclif/command'; +import { Command, Flags } from '@oclif/core'; import type { DataSourceOptions as ConnectionOptions } from 'typeorm'; import { DataSource as Connection } from 'typeorm'; import { Container } from 'typedi'; import { Logger } from '@/Logger'; -import { getConnectionOptions } from '@/Db'; +import { getConnectionOptions, setSchema } from '@/Db'; import type { Migration } from '@db/types'; import { wrapMigration } from '@db/utils/migrationHelpers'; import config from '@/config'; @@ -14,7 +14,7 @@ export class DbRevertMigrationCommand extends Command { static examples = ['$ n8n db:revert']; static flags = { - help: flags.help({ char: 'h' }), + help: Flags.help({ char: 'h' }), }; protected logger = Container.get(Logger); @@ -22,7 +22,7 @@ export class DbRevertMigrationCommand extends Command { private connection: Connection; async init() { - this.parse(DbRevertMigrationCommand); + await this.parse(DbRevertMigrationCommand); } async run() { @@ -40,6 +40,7 @@ export class DbRevertMigrationCommand extends Command { this.connection = new Connection(connectionOptions); await this.connection.initialize(); + if (dbType === 'postgresdb') await setSchema(this.connection); await this.connection.undoLastMigration(); await this.connection.destroy(); } diff --git a/packages/cli/src/commands/execute.ts b/packages/cli/src/commands/execute.ts index c298639fbf..329349297f 100644 --- a/packages/cli/src/commands/execute.ts +++ b/packages/cli/src/commands/execute.ts @@ -1,5 +1,6 @@ +import { Container } from 'typedi'; +import { Flags } from '@oclif/core'; import { promises as fs } from 'fs'; -import { flags } from '@oclif/command'; import { PLACEHOLDER_EMPTY_WORKFLOW_ID } from 'n8n-core'; import type { IWorkflowBase } from 'n8n-workflow'; import { ApplicationError, ExecutionBaseError } from 'n8n-workflow'; @@ -9,7 +10,7 @@ import { WorkflowRunner } from '@/WorkflowRunner'; import type { IWorkflowExecutionDataProcess } from '@/Interfaces'; import { findCliWorkflowStart, isWorkflowIdValid } from '@/utils'; import { BaseCommand } from './BaseCommand'; -import { Container } from 'typedi'; + import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { OwnershipService } from '@/services/ownership.service'; @@ -19,14 +20,14 @@ export class Execute extends BaseCommand { static examples = ['$ n8n execute --id=5', '$ n8n execute --file=workflow.json']; static flags = { - help: flags.help({ char: 'h' }), - file: flags.string({ + help: Flags.help({ char: 'h' }), + file: Flags.string({ description: 'path to a workflow file to execute', }), - id: flags.string({ + id: Flags.string({ description: 'id of the workflow to execute', }), - rawOutput: flags.boolean({ + rawOutput: Flags.boolean({ description: 'Outputs only JSON data, with no other text', }), }; @@ -38,8 +39,7 @@ export class Execute extends BaseCommand { } async run() { - // eslint-disable-next-line @typescript-eslint/no-shadow - const { flags } = this.parse(Execute); + const { flags } = await this.parse(Execute); if (!flags.id && !flags.file) { this.logger.info('Either option "--id" or "--file" have to be set!'); diff --git a/packages/cli/src/commands/executeBatch.ts b/packages/cli/src/commands/executeBatch.ts index e931ce8e99..565eb555bd 100644 --- a/packages/cli/src/commands/executeBatch.ts +++ b/packages/cli/src/commands/executeBatch.ts @@ -1,7 +1,8 @@ /* eslint-disable @typescript-eslint/no-loop-func */ +import { Container } from 'typedi'; +import { Flags } from '@oclif/core'; import fs from 'fs'; import os from 'os'; -import { flags } from '@oclif/command'; import type { IRun, ITaskData } from 'n8n-workflow'; import { ApplicationError, jsonParse, sleep } from 'n8n-workflow'; import { sep } from 'path'; @@ -12,9 +13,11 @@ import { ActiveExecutions } from '@/ActiveExecutions'; import { WorkflowRunner } from '@/WorkflowRunner'; import type { IWorkflowDb, IWorkflowExecutionDataProcess } from '@/Interfaces'; import type { User } from '@db/entities/User'; +import { WorkflowRepository } from '@db/repositories/workflow.repository'; +import { OwnershipService } from '@/services/ownership.service'; import { findCliWorkflowStart } from '@/utils'; + import { BaseCommand } from './BaseCommand'; -import { Container } from 'typedi'; import type { IExecutionResult, INodeSpecialCase, @@ -22,8 +25,6 @@ import type { IResult, IWorkflowExecutionProgress, } from '../types/commands.types'; -import { WorkflowRepository } from '@db/repositories/workflow.repository'; -import { OwnershipService } from '@/services/ownership.service'; const re = /\d+/; @@ -60,49 +61,49 @@ export class ExecuteBatch extends BaseCommand { ]; static flags = { - help: flags.help({ char: 'h' }), - debug: flags.boolean({ + help: Flags.help({ char: 'h' }), + debug: Flags.boolean({ description: 'Toggles on displaying all errors and debug messages.', }), - ids: flags.string({ + ids: Flags.string({ description: 'Specifies workflow IDs to get executed, separated by a comma or a file containing the ids', }), - concurrency: flags.integer({ + concurrency: Flags.integer({ default: 1, description: 'How many workflows can run in parallel. Defaults to 1 which means no concurrency.', }), - output: flags.string({ + output: Flags.string({ description: 'Enable execution saving, You must inform an existing folder to save execution via this param', }), - snapshot: flags.string({ + snapshot: Flags.string({ description: 'Enables snapshot saving. You must inform an existing folder to save snapshots via this param.', }), - compare: flags.string({ + compare: Flags.string({ description: 'Compares current execution with an existing snapshot. You must inform an existing folder where the snapshots are saved.', }), - shallow: flags.boolean({ + shallow: Flags.boolean({ description: 'Compares only if attributes output from node are the same, with no regards to nested JSON objects.', }), - githubWorkflow: flags.boolean({ + githubWorkflow: Flags.boolean({ description: 'Enables more lenient comparison for GitHub workflows. This is useful for reducing false positives when comparing Test workflows.', }), - skipList: flags.string({ + skipList: Flags.string({ description: 'File containing a comma separated list of workflow IDs to skip.', }), - retries: flags.integer({ + retries: Flags.integer({ description: 'Retries failed workflows up to N tries. Default is 1. Set 0 to disable.', default: 1, }), - shortOutput: flags.boolean({ + shortOutput: Flags.boolean({ description: 'Omits the full execution information from output, displaying only summary.', }), }; @@ -120,7 +121,7 @@ export class ExecuteBatch extends BaseCommand { const activeExecutionsInstance = Container.get(ActiveExecutions); const stopPromises = activeExecutionsInstance .getActiveExecutions() - .map(async (execution) => activeExecutionsInstance.stopExecution(execution.id)); + .map(async (execution) => await activeExecutionsInstance.stopExecution(execution.id)); await Promise.allSettled(stopPromises); @@ -185,8 +186,7 @@ export class ExecuteBatch extends BaseCommand { } async run() { - // eslint-disable-next-line @typescript-eslint/no-shadow - const { flags } = this.parse(ExecuteBatch); + const { flags } = await this.parse(ExecuteBatch); ExecuteBatch.debug = flags.debug; ExecuteBatch.concurrency = flags.concurrency || 1; @@ -410,7 +410,7 @@ export class ExecuteBatch extends BaseCommand { this.initializeLogs(); } - return new Promise(async (res) => { + return await new Promise(async (res) => { const promisesArray = []; for (let i = 0; i < ExecuteBatch.concurrency; i++) { const promise = new Promise(async (resolve) => { @@ -623,7 +623,7 @@ export class ExecuteBatch extends BaseCommand { } }); - return new Promise(async (resolve) => { + return await new Promise(async (resolve) => { let gotCancel = false; // Timeouts execution after 5 minutes. diff --git a/packages/cli/src/commands/export/credentials.ts b/packages/cli/src/commands/export/credentials.ts index 47008aa734..b0914cc4f5 100644 --- a/packages/cli/src/commands/export/credentials.ts +++ b/packages/cli/src/commands/export/credentials.ts @@ -1,4 +1,4 @@ -import { flags } from '@oclif/command'; +import { Flags } from '@oclif/core'; import fs from 'fs'; import path from 'path'; import { Credentials } from 'n8n-core'; @@ -20,37 +20,36 @@ export class ExportCredentialsCommand extends BaseCommand { ]; static flags = { - help: flags.help({ char: 'h' }), - all: flags.boolean({ + help: Flags.help({ char: 'h' }), + all: Flags.boolean({ description: 'Export all credentials', }), - backup: flags.boolean({ + backup: Flags.boolean({ description: 'Sets --all --pretty --separate for simple backups. Only --output has to be set additionally.', }), - id: flags.string({ + id: Flags.string({ description: 'The ID of the credential to export', }), - output: flags.string({ + output: Flags.string({ char: 'o', description: 'Output file name or directory if using separate files', }), - pretty: flags.boolean({ + pretty: Flags.boolean({ description: 'Format the output in an easier to read fashion', }), - separate: flags.boolean({ + separate: Flags.boolean({ description: 'Exports one file per credential (useful for versioning). Must inform a directory via --output.', }), - decrypted: flags.boolean({ + decrypted: Flags.boolean({ description: 'Exports data decrypted / in plain text. ALL SENSITIVE INFORMATION WILL BE VISIBLE IN THE FILES. Use to migrate from a installation to another that have a different secret key (in the config file).', }), }; async run() { - // eslint-disable-next-line @typescript-eslint/no-shadow - const { flags } = this.parse(ExportCredentialsCommand); + const { flags } = await this.parse(ExportCredentialsCommand); if (flags.backup) { flags.all = true; diff --git a/packages/cli/src/commands/export/workflow.ts b/packages/cli/src/commands/export/workflow.ts index 3aac28ffbb..c747608b1d 100644 --- a/packages/cli/src/commands/export/workflow.ts +++ b/packages/cli/src/commands/export/workflow.ts @@ -1,4 +1,4 @@ -import { flags } from '@oclif/command'; +import { Flags } from '@oclif/core'; import fs from 'fs'; import path from 'path'; import { BaseCommand } from '../BaseCommand'; @@ -17,33 +17,32 @@ export class ExportWorkflowsCommand extends BaseCommand { ]; static flags = { - help: flags.help({ char: 'h' }), - all: flags.boolean({ + help: Flags.help({ char: 'h' }), + all: Flags.boolean({ description: 'Export all workflows', }), - backup: flags.boolean({ + backup: Flags.boolean({ description: 'Sets --all --pretty --separate for simple backups. Only --output has to be set additionally.', }), - id: flags.string({ + id: Flags.string({ description: 'The ID of the workflow to export', }), - output: flags.string({ + output: Flags.string({ char: 'o', description: 'Output file name or directory if using separate files', }), - pretty: flags.boolean({ + pretty: Flags.boolean({ description: 'Format the output in an easier to read fashion', }), - separate: flags.boolean({ + separate: Flags.boolean({ description: 'Exports one file per workflow (useful for versioning). Must inform a directory via --output.', }), }; async run() { - // eslint-disable-next-line @typescript-eslint/no-shadow - const { flags } = this.parse(ExportWorkflowsCommand); + const { flags } = await this.parse(ExportWorkflowsCommand); if (flags.backup) { flags.all = true; diff --git a/packages/cli/src/commands/import/credentials.ts b/packages/cli/src/commands/import/credentials.ts index cb4bdc6a1f..95a85fb90c 100644 --- a/packages/cli/src/commands/import/credentials.ts +++ b/packages/cli/src/commands/import/credentials.ts @@ -1,19 +1,18 @@ -import { flags } from '@oclif/command'; +import { Container } from 'typedi'; +import { Flags } from '@oclif/core'; import { Cipher } from 'n8n-core'; import fs from 'fs'; import glob from 'fast-glob'; -import { Container } from 'typedi'; import type { EntityManager } from 'typeorm'; + import * as Db from '@/Db'; import type { User } from '@db/entities/User'; import { SharedCredentials } from '@db/entities/SharedCredentials'; -import type { Role } from '@db/entities/Role'; import { CredentialsEntity } from '@db/entities/CredentialsEntity'; import { disableAutoGeneratedIds } from '@db/utils/commandHelpers'; import { BaseCommand } from '../BaseCommand'; import type { ICredentialsEncrypted } from 'n8n-workflow'; import { ApplicationError, jsonParse } from 'n8n-workflow'; -import { RoleService } from '@/services/role.service'; import { UM_FIX_INSTRUCTION } from '@/constants'; import { UserRepository } from '@db/repositories/user.repository'; @@ -28,21 +27,19 @@ export class ImportCredentialsCommand extends BaseCommand { ]; static flags = { - help: flags.help({ char: 'h' }), - input: flags.string({ + help: Flags.help({ char: 'h' }), + input: Flags.string({ char: 'i', description: 'Input file name or directory if --separate is used', }), - separate: flags.boolean({ + separate: Flags.boolean({ description: 'Imports *.json files from directory provided by --input', }), - userId: flags.string({ + userId: Flags.string({ description: 'The ID of the user to assign the imported credentials to', }), }; - private ownerCredentialRole: Role; - private transactionManager: EntityManager; async init() { @@ -51,8 +48,7 @@ export class ImportCredentialsCommand extends BaseCommand { } async run(): Promise { - // eslint-disable-next-line @typescript-eslint/no-shadow - const { flags } = this.parse(ImportCredentialsCommand); + const { flags } = await this.parse(ImportCredentialsCommand); if (!flags.input) { this.logger.info('An input file or directory with --input must be provided'); @@ -71,7 +67,6 @@ export class ImportCredentialsCommand extends BaseCommand { let totalImported = 0; const cipher = Container.get(Cipher); - await this.initOwnerCredentialRole(); const user = flags.userId ? await this.getAssignee(flags.userId) : await this.getOwner(); if (flags.separate) { @@ -145,16 +140,6 @@ export class ImportCredentialsCommand extends BaseCommand { ); } - private async initOwnerCredentialRole() { - const ownerCredentialRole = await Container.get(RoleService).findCredentialOwnerRole(); - - if (!ownerCredentialRole) { - throw new ApplicationError(`Failed to find owner credential role. ${UM_FIX_INSTRUCTION}`); - } - - this.ownerCredentialRole = ownerCredentialRole; - } - private async storeCredential(credential: Partial, user: User) { if (!credential.nodesAccess) { credential.nodesAccess = []; @@ -165,19 +150,14 @@ export class ImportCredentialsCommand extends BaseCommand { { credentialsId: result.identifiers[0].id as string, userId: user.id, - roleId: this.ownerCredentialRole.id, + role: 'credential:owner', }, ['credentialsId', 'userId'], ); } private async getOwner() { - const ownerGlobalRole = await Container.get(RoleService).findGlobalOwnerRole(); - - const owner = - ownerGlobalRole && - (await Container.get(UserRepository).findOneBy({ globalRoleId: ownerGlobalRole.id })); - + const owner = await Container.get(UserRepository).findOneBy({ role: 'global:owner' }); if (!owner) { throw new ApplicationError(`Failed to find owner. ${UM_FIX_INSTRUCTION}`); } diff --git a/packages/cli/src/commands/import/workflow.ts b/packages/cli/src/commands/import/workflow.ts index 9f3ccfc682..21c3d82501 100644 --- a/packages/cli/src/commands/import/workflow.ts +++ b/packages/cli/src/commands/import/workflow.ts @@ -1,18 +1,18 @@ -import { flags } from '@oclif/command'; +import { Container } from 'typedi'; +import { Flags } from '@oclif/core'; import { ApplicationError, jsonParse } from 'n8n-workflow'; import fs from 'fs'; import glob from 'fast-glob'; -import { Container } from 'typedi'; + +import { UM_FIX_INSTRUCTION } from '@/constants'; import { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { disableAutoGeneratedIds } from '@db/utils/commandHelpers'; -import type { IWorkflowToImport } from '@/Interfaces'; -import { BaseCommand } from '../BaseCommand'; import { generateNanoId } from '@db/utils/generators'; -import { RoleService } from '@/services/role.service'; -import { UM_FIX_INSTRUCTION } from '@/constants'; import { UserRepository } from '@db/repositories/user.repository'; +import { WorkflowRepository } from '@db/repositories/workflow.repository'; +import type { IWorkflowToImport } from '@/Interfaces'; import { ImportService } from '@/services/import.service'; -import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; +import { BaseCommand } from '../BaseCommand'; function assertHasWorkflowsToImport(workflows: unknown): asserts workflows is IWorkflowToImport[] { if (!Array.isArray(workflows)) { @@ -43,15 +43,15 @@ export class ImportWorkflowsCommand extends BaseCommand { ]; static flags = { - help: flags.help({ char: 'h' }), - input: flags.string({ + help: Flags.help({ char: 'h' }), + input: Flags.string({ char: 'i', description: 'Input file name or directory if --separate is used', }), - separate: flags.boolean({ + separate: Flags.boolean({ description: 'Imports *.json files from directory provided by --input', }), - userId: flags.string({ + userId: Flags.string({ description: 'The ID of the user to assign the imported workflows to', }), }; @@ -62,8 +62,7 @@ export class ImportWorkflowsCommand extends BaseCommand { } async run(): Promise { - // eslint-disable-next-line @typescript-eslint/no-shadow - const { flags } = this.parse(ImportWorkflowsCommand); + const { flags } = await this.parse(ImportWorkflowsCommand); if (!flags.input) { this.logger.info('An input file or directory with --input must be provided'); @@ -138,12 +137,7 @@ export class ImportWorkflowsCommand extends BaseCommand { } private async getOwner() { - const ownerGlobalRole = await Container.get(RoleService).findGlobalOwnerRole(); - - const owner = - ownerGlobalRole && - (await Container.get(UserRepository).findOneBy({ globalRoleId: ownerGlobalRole?.id })); - + const owner = await Container.get(UserRepository).findOneBy({ role: 'global:owner' }); if (!owner) { throw new ApplicationError(`Failed to find owner. ${UM_FIX_INSTRUCTION}`); } diff --git a/packages/cli/src/commands/license/clear.ts b/packages/cli/src/commands/license/clear.ts index e25fb94dc4..0191136caf 100644 --- a/packages/cli/src/commands/license/clear.ts +++ b/packages/cli/src/commands/license/clear.ts @@ -1,7 +1,7 @@ +import { Container } from 'typedi'; import { SETTINGS_LICENSE_CERT_KEY } from '@/constants'; import { BaseCommand } from '../BaseCommand'; import { SettingsRepository } from '@db/repositories/settings.repository'; -import Container from 'typedi'; export class ClearLicenseCommand extends BaseCommand { static description = 'Clear license'; diff --git a/packages/cli/src/commands/license/info.ts b/packages/cli/src/commands/license/info.ts index dee106a581..a4e9e42cf2 100644 --- a/packages/cli/src/commands/license/info.ts +++ b/packages/cli/src/commands/license/info.ts @@ -1,5 +1,5 @@ -import { License } from '@/License'; import { Container } from 'typedi'; +import { License } from '@/License'; import { BaseCommand } from '../BaseCommand'; export class LicenseInfoCommand extends BaseCommand { diff --git a/packages/cli/src/commands/list/workflow.ts b/packages/cli/src/commands/list/workflow.ts index abab56f2d5..2d33de19e3 100644 --- a/packages/cli/src/commands/list/workflow.ts +++ b/packages/cli/src/commands/list/workflow.ts @@ -1,7 +1,7 @@ -import { flags } from '@oclif/command'; -import { BaseCommand } from '../BaseCommand'; -import { WorkflowRepository } from '@db/repositories/workflow.repository'; import Container from 'typedi'; +import { Flags } from '@oclif/core'; +import { WorkflowRepository } from '@db/repositories/workflow.repository'; +import { BaseCommand } from '../BaseCommand'; export class ListWorkflowCommand extends BaseCommand { static description = '\nList workflows'; @@ -13,18 +13,17 @@ export class ListWorkflowCommand extends BaseCommand { ]; static flags = { - help: flags.help({ char: 'h' }), - active: flags.string({ + help: Flags.help({ char: 'h' }), + active: Flags.string({ description: 'Filters workflows by active status. Can be true or false', }), - onlyId: flags.boolean({ + onlyId: Flags.boolean({ description: 'Outputs workflow IDs only, one per line.', }), }; async run() { - // eslint-disable-next-line @typescript-eslint/no-shadow - const { flags } = this.parse(ListWorkflowCommand); + const { flags } = await this.parse(ListWorkflowCommand); if (flags.active !== undefined && !['true', 'false'].includes(flags.active)) { this.error('The --active flag has to be passed using true or false'); diff --git a/packages/cli/src/commands/mfa/disable.ts b/packages/cli/src/commands/mfa/disable.ts index a9a51db5ba..fb39aed795 100644 --- a/packages/cli/src/commands/mfa/disable.ts +++ b/packages/cli/src/commands/mfa/disable.ts @@ -1,7 +1,7 @@ -import { flags } from '@oclif/command'; -import { BaseCommand } from '../BaseCommand'; import Container from 'typedi'; +import { Flags } from '@oclif/core'; import { UserRepository } from '@db/repositories/user.repository'; +import { BaseCommand } from '../BaseCommand'; export class DisableMFACommand extends BaseCommand { static description = 'Disable MFA authentication for a user'; @@ -9,8 +9,8 @@ export class DisableMFACommand extends BaseCommand { static examples = ['$ n8n mfa:disable --email=johndoe@example.com']; static flags = { - help: flags.help({ char: 'h' }), - email: flags.string({ + help: Flags.help({ char: 'h' }), + email: Flags.string({ description: 'The email of the user to disable the MFA authentication', }), }; @@ -20,8 +20,7 @@ export class DisableMFACommand extends BaseCommand { } async run(): Promise { - // eslint-disable-next-line @typescript-eslint/no-shadow - const { flags } = this.parse(DisableMFACommand); + const { flags } = await this.parse(DisableMFACommand); if (!flags.email) { this.logger.info('An email with --email must be provided'); diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index b9c43a9a69..955d429586 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -1,36 +1,33 @@ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { Container } from 'typedi'; +import { Flags, type Config } from '@oclif/core'; import path from 'path'; import { mkdir } from 'fs/promises'; import { createReadStream, createWriteStream, existsSync } from 'fs'; -import { flags } from '@oclif/command'; import stream from 'stream'; import replaceStream from 'replacestream'; import { promisify } from 'util'; import glob from 'fast-glob'; - import { sleep, jsonParse } from 'n8n-workflow'; -import config from '@/config'; +import config from '@/config'; import { ActiveExecutions } from '@/ActiveExecutions'; import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; import { Server } from '@/Server'; import { EDITOR_UI_DIST_DIR, LICENSE_FEATURES } from '@/constants'; import { eventBus } from '@/eventbus'; -import { BaseCommand } from './BaseCommand'; import { InternalHooks } from '@/InternalHooks'; import { License } from '@/License'; -import type { IConfig } from '@oclif/config'; -import { SingleMainSetup } from '@/services/orchestration/main/SingleMainSetup'; +import { OrchestrationService } from '@/services/orchestration.service'; import { OrchestrationHandlerMainService } from '@/services/orchestration/main/orchestration.handler.main.service'; import { PruningService } from '@/services/pruning.service'; -import { MultiMainSetup } from '@/services/orchestration/main/MultiMainSetup.ee'; import { UrlService } from '@/services/url.service'; import { SettingsRepository } from '@db/repositories/settings.repository'; import { ExecutionRepository } from '@db/repositories/execution.repository'; import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; import { WaitTracker } from '@/WaitTracker'; +import { BaseCommand } from './BaseCommand'; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires const open = require('open'); @@ -47,16 +44,16 @@ export class Start extends BaseCommand { ]; static flags = { - help: flags.help({ char: 'h' }), - open: flags.boolean({ + help: Flags.help({ char: 'h' }), + open: Flags.boolean({ char: 'o', description: 'opens the UI automatically in browser', }), - tunnel: flags.boolean({ + tunnel: Flags.boolean({ description: 'runs the webhooks via a hooks.n8n.cloud tunnel server. Use only for testing and development!', }), - reinstallMissingPackages: flags.boolean({ + reinstallMissingPackages: Flags.boolean({ description: 'Attempts to self heal n8n if packages with nodes are missing. Might drastically increase startup times.', }), @@ -68,7 +65,7 @@ export class Start extends BaseCommand { private pruningService: PruningService; - constructor(argv: string[], cmdConfig: IConfig) { + constructor(argv: string[], cmdConfig: Config) { super(argv, cmdConfig); this.setInstanceType('main'); this.setInstanceQueueModeId(); @@ -104,10 +101,10 @@ export class Start extends BaseCommand { await this.externalHooks?.run('n8n.stop', []); - if (Container.get(MultiMainSetup).isEnabled) { + if (Container.get(OrchestrationService).isMultiMainSetupEnabled) { await this.activeWorkflowRunner.removeAllTriggerAndPollerBasedWorkflows(); - await Container.get(MultiMainSetup).shutdown(); + await Container.get(OrchestrationService).shutdown(); } await Container.get(InternalHooks).onN8nStop(); @@ -174,7 +171,7 @@ export class Start extends BaseCommand { ); } streams.push(createWriteStream(destFile, 'utf-8')); - return pipeline(streams); + return await pipeline(streams); } }; @@ -216,48 +213,52 @@ export class Start extends BaseCommand { async initOrchestration() { if (config.getEnv('executions.mode') !== 'queue') return; - // queue mode in single-main scenario - - if (!config.getEnv('multiMainSetup.enabled')) { - await Container.get(SingleMainSetup).init(); - await Container.get(OrchestrationHandlerMainService).init(); - return; - } - - // queue mode in multi-main scenario - - if (!Container.get(License).isMultipleMainInstancesLicensed()) { + if ( + config.getEnv('multiMainSetup.enabled') && + !Container.get(License).isMultipleMainInstancesLicensed() + ) { throw new FeatureNotLicensedError(LICENSE_FEATURES.MULTIPLE_MAIN_INSTANCES); } + const orchestrationService = Container.get(OrchestrationService); + + await orchestrationService.init(); + await Container.get(OrchestrationHandlerMainService).init(); - const multiMainSetup = Container.get(MultiMainSetup); + if (!orchestrationService.isMultiMainSetupEnabled) return; - await multiMainSetup.init(); + orchestrationService.multiMainSetup + .addListener('leadershipChange', async () => { + if (orchestrationService.isLeader) { + this.logger.debug('[Leadership change] Clearing all activation errors...'); - multiMainSetup.on('leadershipChange', async () => { - if (multiMainSetup.isLeader) { - this.logger.debug('[Leadership change] Clearing all activation errors...'); + await this.activeWorkflowRunner.clearAllActivationErrors(); - await this.activeWorkflowRunner.clearAllActivationErrors(); + this.logger.debug( + '[Leadership change] Adding all trigger- and poller-based workflows...', + ); - this.logger.debug('[Leadership change] Adding all trigger- and poller-based workflows...'); + await this.activeWorkflowRunner.addAllTriggerAndPollerBasedWorkflows(); + } else { + this.logger.debug( + '[Leadership change] Removing all trigger- and poller-based workflows...', + ); - await this.activeWorkflowRunner.addAllTriggerAndPollerBasedWorkflows(); - } else { + await this.activeWorkflowRunner.removeAllTriggerAndPollerBasedWorkflows(); + } + }) + .addListener('leadershipVacant', async () => { this.logger.debug( - '[Leadership change] Removing all trigger- and poller-based workflows...', + '[Leadership vacant] Removing all trigger- and poller-based workflows...', ); await this.activeWorkflowRunner.removeAllTriggerAndPollerBasedWorkflows(); - } - }); + }); } async run() { - // eslint-disable-next-line @typescript-eslint/no-shadow - const { flags } = this.parse(Start); + const { flags } = await this.parse(Start); // Load settings from database and set them to config. const databaseSettings = await Container.get(SettingsRepository).findBy({ @@ -361,27 +362,27 @@ export class Start extends BaseCommand { async initPruning() { this.pruningService = Container.get(PruningService); - if (this.pruningService.isPruningEnabled()) { - this.pruningService.startPruning(); - } + this.pruningService.startPruning(); - if (config.getEnv('executions.mode') === 'queue' && config.getEnv('multiMainSetup.enabled')) { - const multiMainSetup = Container.get(MultiMainSetup); + if (config.getEnv('executions.mode') !== 'queue') return; - await multiMainSetup.init(); + const orchestrationService = Container.get(OrchestrationService); - multiMainSetup.on('leadershipChange', async () => { - if (multiMainSetup.isLeader) { - if (this.pruningService.isPruningEnabled()) { - this.pruningService.startPruning(); - } + await orchestrationService.init(); + + if (!orchestrationService.isMultiMainSetupEnabled) return; + + orchestrationService.multiMainSetup + .addListener('leadershipChange', async () => { + if (orchestrationService.isLeader) { + this.pruningService.startPruning(); } else { - if (this.pruningService.isPruningEnabled()) { - this.pruningService.stopPruning(); - } + this.pruningService.stopPruning(); } + }) + .addListener('leadershipVacant', () => { + this.pruningService.stopPruning(); }); - } } async catch(error: Error) { diff --git a/packages/cli/src/commands/update/workflow.ts b/packages/cli/src/commands/update/workflow.ts index 08f9403368..0a8f031461 100644 --- a/packages/cli/src/commands/update/workflow.ts +++ b/packages/cli/src/commands/update/workflow.ts @@ -1,7 +1,7 @@ -import { flags } from '@oclif/command'; -import { BaseCommand } from '../BaseCommand'; +import { Container } from 'typedi'; +import { Flags } from '@oclif/core'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; -import Container from 'typedi'; +import { BaseCommand } from '../BaseCommand'; export class UpdateWorkflowCommand extends BaseCommand { static description = 'Update workflows'; @@ -12,21 +12,20 @@ export class UpdateWorkflowCommand extends BaseCommand { ]; static flags = { - help: flags.help({ char: 'h' }), - active: flags.string({ + help: Flags.help({ char: 'h' }), + active: Flags.string({ description: 'Active state the workflow/s should be set to', }), - all: flags.boolean({ + all: Flags.boolean({ description: 'Operate on all workflows', }), - id: flags.string({ + id: Flags.string({ description: 'The ID of the workflow to operate on', }), }; async run() { - // eslint-disable-next-line @typescript-eslint/no-shadow - const { flags } = this.parse(UpdateWorkflowCommand); + const { flags } = await this.parse(UpdateWorkflowCommand); if (!flags.all && !flags.id) { console.info('Either option "--all" or "--id" have to be set!'); @@ -51,16 +50,22 @@ export class UpdateWorkflowCommand extends BaseCommand { } const newState = flags.active === 'true'; + const action = newState ? 'Activating' : 'Deactivating'; if (flags.id) { - this.logger.info(`Deactivating workflow with ID: ${flags.id}`); + this.logger.info(`${action} workflow with ID: ${flags.id}`); await Container.get(WorkflowRepository).updateActiveState(flags.id, newState); } else { - this.logger.info('Deactivating all workflows'); - await Container.get(WorkflowRepository).deactivateAll(); + this.logger.info(`${action} all workflows`); + if (newState) { + await Container.get(WorkflowRepository).activateAll(); + } else { + await Container.get(WorkflowRepository).deactivateAll(); + } } - this.logger.info('Done'); + this.logger.info('Activation or deactivation will not take effect if n8n is running.'); + this.logger.info('Please restart n8n for changes to take effect if n8n is currently running.'); } async catch(error: Error) { diff --git a/packages/cli/src/commands/user-management/reset.ts b/packages/cli/src/commands/user-management/reset.ts index caa46ecc90..188183e7d4 100644 --- a/packages/cli/src/commands/user-management/reset.ts +++ b/packages/cli/src/commands/user-management/reset.ts @@ -6,7 +6,6 @@ import { SettingsRepository } from '@db/repositories/settings.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { UserRepository } from '@db/repositories/user.repository'; -import { RoleService } from '@/services/role.service'; import { BaseCommand } from '../BaseCommand'; const defaultUserProps = { @@ -14,6 +13,7 @@ const defaultUserProps = { lastName: null, email: null, password: null, + role: 'global:owner', }; export class Reset extends BaseCommand { @@ -24,14 +24,8 @@ export class Reset extends BaseCommand { async run(): Promise { const owner = await this.getInstanceOwner(); - const workflowOwnerRole = await Container.get(RoleService).findWorkflowOwnerRole(); - const credentialOwnerRole = await Container.get(RoleService).findCredentialOwnerRole(); - - await Container.get(SharedWorkflowRepository).makeOwnerOfAllWorkflows(owner, workflowOwnerRole); - await Container.get(SharedCredentialsRepository).makeOwnerOfAllCredentials( - owner, - credentialOwnerRole, - ); + await Container.get(SharedWorkflowRepository).makeOwnerOfAllWorkflows(owner); + await Container.get(SharedCredentialsRepository).makeOwnerOfAllCredentials(owner); await Container.get(UserRepository).deleteAllExcept(owner); await Container.get(UserRepository).save(Object.assign(owner, defaultUserProps)); @@ -45,7 +39,7 @@ export class Reset extends BaseCommand { Container.get(SharedCredentialsRepository).create({ credentials, user: owner, - role: credentialOwnerRole, + role: 'credential:owner', }), ); await Container.get(SharedCredentialsRepository).save(newSharedCredentials); @@ -59,19 +53,17 @@ export class Reset extends BaseCommand { } async getInstanceOwner(): Promise { - const globalRole = await Container.get(RoleService).findGlobalOwnerRole(); - - const owner = await Container.get(UserRepository).findOneBy({ globalRoleId: globalRole.id }); + const owner = await Container.get(UserRepository).findOneBy({ role: 'global:owner' }); if (owner) return owner; const user = new User(); - Object.assign(user, { ...defaultUserProps, globalRole }); + Object.assign(user, defaultUserProps); await Container.get(UserRepository).save(user); - return Container.get(UserRepository).findOneByOrFail({ globalRoleId: globalRole.id }); + return await Container.get(UserRepository).findOneByOrFail({ role: 'global:owner' }); } async catch(error: Error): Promise { diff --git a/packages/cli/src/commands/webhook.ts b/packages/cli/src/commands/webhook.ts index 1ff1dd8bb8..e7184bdfe0 100644 --- a/packages/cli/src/commands/webhook.ts +++ b/packages/cli/src/commands/webhook.ts @@ -1,12 +1,13 @@ -import { flags } from '@oclif/command'; +import { Container } from 'typedi'; +import { Flags, type Config } from '@oclif/core'; import { sleep } from 'n8n-workflow'; + import config from '@/config'; import { ActiveExecutions } from '@/ActiveExecutions'; import { WebhookServer } from '@/WebhookServer'; import { Queue } from '@/Queue'; import { BaseCommand } from './BaseCommand'; -import { Container } from 'typedi'; -import type { IConfig } from '@oclif/config'; + import { OrchestrationWebhookService } from '@/services/orchestration/webhook/orchestration.webhook.service'; import { OrchestrationHandlerWebhookService } from '@/services/orchestration/webhook/orchestration.handler.webhook.service'; @@ -16,12 +17,12 @@ export class Webhook extends BaseCommand { static examples = ['$ n8n webhook']; static flags = { - help: flags.help({ char: 'h' }), + help: Flags.help({ char: 'h' }), }; protected server = Container.get(WebhookServer); - constructor(argv: string[], cmdConfig: IConfig) { + constructor(argv: string[], cmdConfig: Config) { super(argv, cmdConfig); this.setInstanceType('webhook'); if (this.queueModeId) { diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts index 4848ff7d10..99657fc9ae 100644 --- a/packages/cli/src/commands/worker.ts +++ b/packages/cli/src/commands/worker.ts @@ -1,11 +1,9 @@ +import { Container } from 'typedi'; +import { Flags, type Config } from '@oclif/core'; import express from 'express'; import http from 'http'; import type PCancelable from 'p-cancelable'; -import { Container } from 'typedi'; - -import { flags } from '@oclif/command'; import { WorkflowExecute } from 'n8n-core'; - import type { ExecutionError, ExecutionStatus, @@ -20,13 +18,11 @@ import * as ResponseHelper from '@/ResponseHelper'; import * as WebhookHelpers from '@/WebhookHelpers'; import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; import { PermissionChecker } from '@/UserManagement/PermissionChecker'; - import config from '@/config'; import type { Job, JobId, JobResponse, WebhookResponse } from '@/Queue'; import { Queue } from '@/Queue'; import { generateFailedExecutionFromError } from '@/WorkflowHelpers'; import { N8N_VERSION } from '@/constants'; -import { BaseCommand } from './BaseCommand'; import { ExecutionRepository } from '@db/repositories/execution.repository'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { OwnershipService } from '@/services/ownership.service'; @@ -36,11 +32,11 @@ import { rawBodyReader, bodyParser } from '@/middlewares'; import { eventBus } from '@/eventbus'; import type { RedisServicePubSubSubscriber } from '@/services/redis/RedisServicePubSubSubscriber'; import { EventMessageGeneric } from '@/eventbus/EventMessageClasses/EventMessageGeneric'; -import type { IConfig } from '@oclif/config'; import { OrchestrationHandlerWorkerService } from '@/services/orchestration/worker/orchestration.handler.worker.service'; import { OrchestrationWorkerService } from '@/services/orchestration/worker/orchestration.worker.service'; -import type { WorkerJobStatusSummary } from '../services/orchestration/worker/types'; +import type { WorkerJobStatusSummary } from '@/services/orchestration/worker/types'; import { ServiceUnavailableError } from '@/errors/response-errors/service-unavailable.error'; +import { BaseCommand } from './BaseCommand'; export class Worker extends BaseCommand { static description = '\nStarts a n8n worker'; @@ -48,8 +44,8 @@ export class Worker extends BaseCommand { static examples = ['$ n8n worker --concurrency=5']; static flags = { - help: flags.help({ char: 'h' }), - concurrency: flags.integer({ + help: Flags.help({ char: 'h' }), + concurrency: Flags.integer({ default: 10, description: 'How many jobs can run in parallel.', }), @@ -185,7 +181,7 @@ export class Worker extends BaseCommand { ); try { - await PermissionChecker.check(workflow, workflowOwner.id); + await Container.get(PermissionChecker).check(workflow, workflowOwner.id); } catch (error) { if (error instanceof NodeOperationError) { const failedExecution = generateFailedExecutionFromError( @@ -257,8 +253,15 @@ export class Worker extends BaseCommand { }; } - constructor(argv: string[], cmdConfig: IConfig) { + constructor(argv: string[], cmdConfig: Config) { super(argv, cmdConfig); + + if (!process.env.N8N_ENCRYPTION_KEY) { + throw new ApplicationError( + 'Missing encryption key. Worker started without the required N8N_ENCRYPTION_KEY env var. More information: https://docs.n8n.io/hosting/environment-variables/configuration-methods/#encryption-key', + ); + } + this.setInstanceType('worker'); this.setInstanceQueueModeId(); } @@ -326,8 +329,7 @@ export class Worker extends BaseCommand { } async initQueue() { - // eslint-disable-next-line @typescript-eslint/no-shadow - const { flags } = this.parse(Worker); + const { flags } = await this.parse(Worker); const redisConnectionTimeoutLimit = config.getEnv('queue.bull.redis.timeoutThreshold'); @@ -338,8 +340,9 @@ export class Worker extends BaseCommand { Worker.jobQueue = Container.get(Queue); await Worker.jobQueue.init(); this.logger.debug('Queue singleton ready'); - void Worker.jobQueue.process(flags.concurrency, async (job) => - this.runJob(job, this.nodeTypes), + void Worker.jobQueue.process( + flags.concurrency, + async (job) => await this.runJob(job, this.nodeTypes), ); Worker.jobQueue.getBullObjectInstance().on('global:progress', (jobId: JobId, progress) => { @@ -487,8 +490,7 @@ export class Worker extends BaseCommand { } async run() { - // eslint-disable-next-line @typescript-eslint/no-shadow - const { flags } = this.parse(Worker); + const { flags } = await this.parse(Worker); this.logger.info('\nn8n worker is now ready'); this.logger.info(` * Version: ${N8N_VERSION}`); diff --git a/packages/cli/src/config/index.ts b/packages/cli/src/config/index.ts index 44d341afcc..7300b0dcf7 100644 --- a/packages/cli/src/config/index.ts +++ b/packages/cli/src/config/index.ts @@ -14,7 +14,6 @@ if (inE2ETests) { process.env.N8N_AI_ENABLED = 'true'; } else if (inTest) { process.env.N8N_LOG_LEVEL = 'silent'; - process.env.N8N_ENCRYPTION_KEY = 'test-encryption-key'; process.env.N8N_PUBLIC_API_DISABLED = 'true'; process.env.SKIP_STATISTICS_EVENTS = 'true'; } else { @@ -63,9 +62,18 @@ if (!inE2ETests && !inTest) { }); } +// Validate Configuration config.validate({ allowed: 'strict', }); +const userManagement = config.get('userManagement'); +if (userManagement.jwtRefreshTimeoutHours >= userManagement.jwtSessionDurationHours) { + console.warn( + 'N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS needs to smaller than N8N_USER_MANAGEMENT_JWT_DURATION_HOURS. Setting N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS to 0 for now.', + ); + + config.set('userManagement.jwtRefreshTimeoutHours', 0); +} setGlobalState({ defaultTimezone: config.getEnv('generic.timezone'), diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 3a2c3a8ee4..2df8eafb25 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -762,11 +762,17 @@ export const schema = { default: '', env: 'N8N_USER_MANAGEMENT_JWT_SECRET', }, - jwtDuration: { - doc: 'Set a specific JWT secret (optional - n8n can generate one)', // Generated @ start.ts + jwtSessionDurationHours: { + doc: 'Set a specific expiration date for the JWTs in hours.', format: Number, default: 168, - env: 'N8N_USER_MANAGEMENT_JWT_DURATION', + env: 'N8N_USER_MANAGEMENT_JWT_DURATION_HOURS', + }, + jwtRefreshTimeoutHours: { + doc: 'How long before the JWT expires to automatically refresh it. 0 means 25% of N8N_USER_MANAGEMENT_JWT_DURATION_HOURS. -1 means it will never refresh, which forces users to login again after the defined period in N8N_USER_MANAGEMENT_JWT_DURATION_HOURS.', + format: Number, + default: 0, + env: 'N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS', }, isInstanceOwnerSetUp: { // n8n loads this setting from DB on startup @@ -846,6 +852,18 @@ export const schema = { default: '', env: 'N8N_UM_EMAIL_TEMPLATES_PWRESET', }, + workflowShared: { + doc: 'Overrides default HTML template for notifying that a workflow was shared (use full path)', + format: String, + default: '', + env: 'N8N_UM_EMAIL_TEMPLATES_WORKFLOW_SHARED', + }, + credentialsShared: { + doc: 'Overrides default HTML template for notifying that credentials were shared (use full path)', + format: String, + default: '', + env: 'N8N_UM_EMAIL_TEMPLATES_CREDENTIALS_SHARED', + }, }, }, authenticationMethod: { @@ -1143,12 +1161,6 @@ export const schema = { default: 'https://ph.n8n.io', env: 'N8N_DIAGNOSTICS_POSTHOG_API_HOST', }, - disableSessionRecording: { - doc: 'Disable posthog session recording', - format: Boolean, - default: true, - env: 'N8N_DIAGNOSTICS_POSTHOG_DISABLE_RECORDING', - }, }, sentry: { dsn: { diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 8603d29963..9e65e4c342 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -103,6 +103,7 @@ export const UM_FIX_INSTRUCTION = /** * Units of time in milliseconds + * @deprecated Please use constants.Time instead. */ export const TIME = { SECOND: 1000, @@ -111,6 +112,28 @@ export const TIME = { DAY: 24 * 60 * 60 * 1000, } as const; +/** + * Convert time from any unit to any other unit + * + * Please amend conversions as necessary. + * Eventually this will superseed `TIME` above + */ +export const Time = { + seconds: { + toMilliseconds: 1000, + }, + minutes: { + toMilliseconds: 60 * 1000, + }, + hours: { + toMilliseconds: 60 * 60 * 1000, + toSeconds: 60 * 60, + }, + days: { + toSeconds: 24 * 60 * 60, + }, +}; + export const MIN_PASSWORD_CHAR_LENGTH = 8; export const MAX_PASSWORD_CHAR_LENGTH = 64; diff --git a/packages/cli/src/controllers/activeWorkflows.controller.ts b/packages/cli/src/controllers/activeWorkflows.controller.ts index 6e37b40b86..b86c2c4bad 100644 --- a/packages/cli/src/controllers/activeWorkflows.controller.ts +++ b/packages/cli/src/controllers/activeWorkflows.controller.ts @@ -9,7 +9,7 @@ export class ActiveWorkflowsController { @Get('/') async getActiveWorkflows(req: ActiveWorkflowRequest.GetAllActive) { - return this.activeWorkflowsService.getAllActiveIdsFor(req.user); + return await this.activeWorkflowsService.getAllActiveIdsFor(req.user); } @Get('/error/:id') @@ -18,6 +18,6 @@ export class ActiveWorkflowsController { user, params: { id: workflowId }, } = req; - return this.activeWorkflowsService.getActivationError(workflowId, user); + return await this.activeWorkflowsService.getActivationError(workflowId, user); } } diff --git a/packages/cli/src/controllers/auth.controller.ts b/packages/cli/src/controllers/auth.controller.ts index 35bdacd16f..7ba13a2677 100644 --- a/packages/cli/src/controllers/auth.controller.ts +++ b/packages/cli/src/controllers/auth.controller.ts @@ -55,7 +55,7 @@ export class AuthController { const preliminaryUser = await handleEmailLogin(email, password); // if the user is an owner, continue with the login if ( - preliminaryUser?.globalRole?.name === 'owner' || + preliminaryUser?.role === 'global:owner' || preliminaryUser?.settings?.allowSSOManualLogin ) { user = preliminaryUser; @@ -65,7 +65,7 @@ export class AuthController { } } else if (isLdapCurrentAuthenticationMethod()) { const preliminaryUser = await handleEmailLogin(email, password); - if (preliminaryUser?.globalRole?.name === 'owner') { + if (preliminaryUser?.role === 'global:owner') { user = preliminaryUser; usedAuthenticationMethod = 'email'; } else { @@ -102,7 +102,7 @@ export class AuthController { authenticationMethod: usedAuthenticationMethod, }); - return this.userService.toPublic(user, { posthog: this.postHog, withScopes: true }); + return await this.userService.toPublic(user, { posthog: this.postHog, withScopes: true }); } void this.internalHooks.onUserLoginFailed({ user: email, @@ -138,7 +138,7 @@ export class AuthController { } try { - user = await this.userRepository.findOneOrFail({ where: {}, relations: ['globalRole'] }); + user = await this.userRepository.findOneOrFail({ where: {} }); } catch (error) { throw new InternalServerError( 'No users found in database - did you wipe the users table? Create at least one user.', @@ -150,7 +150,7 @@ export class AuthController { } await issueCookie(res, user); - return this.userService.toPublic(user, { posthog: this.postHog, withScopes: true }); + return await this.userService.toPublic(user, { posthog: this.postHog, withScopes: true }); } /** @@ -187,7 +187,7 @@ export class AuthController { } } - const users = await this.userRepository.findManybyIds([inviterId, inviteeId]); + const users = await this.userRepository.findManyByIds([inviterId, inviteeId]); if (users.length !== 2) { this.logger.debug( diff --git a/packages/cli/src/controllers/cta.controller.ts b/packages/cli/src/controllers/cta.controller.ts new file mode 100644 index 0000000000..9d06ff0812 --- /dev/null +++ b/packages/cli/src/controllers/cta.controller.ts @@ -0,0 +1,21 @@ +import express from 'express'; +import { Authorized, Get, RestController } from '@/decorators'; +import { AuthenticatedRequest } from '@/requests'; +import { CtaService } from '@/services/cta.service'; + +/** + * Controller for Call to Action (CTA) endpoints. CTAs are certain + * messages that are shown to users in the UI. + */ +@Authorized() +@RestController('/cta') +export class CtaController { + constructor(private readonly ctaService: CtaService) {} + + @Get('/become-creator') + async getCta(req: AuthenticatedRequest, res: express.Response) { + const becomeCreator = await this.ctaService.getBecomeCreatorCta(req.user.id); + + res.json(becomeCreator); + } +} diff --git a/packages/cli/src/controllers/debug.controller.ts b/packages/cli/src/controllers/debug.controller.ts index 10c45a0618..b74ccd5840 100644 --- a/packages/cli/src/controllers/debug.controller.ts +++ b/packages/cli/src/controllers/debug.controller.ts @@ -1,19 +1,19 @@ import { Get, RestController } from '@/decorators'; import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; -import { MultiMainSetup } from '@/services/orchestration/main/MultiMainSetup.ee'; +import { OrchestrationService } from '@/services/orchestration.service'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; @RestController('/debug') export class DebugController { constructor( - private readonly multiMainSetup: MultiMainSetup, + private readonly orchestrationService: OrchestrationService, private readonly activeWorkflowRunner: ActiveWorkflowRunner, private readonly workflowRepository: WorkflowRepository, ) {} @Get('/multi-main-setup') async getMultiMainSetupDetails() { - const leaderKey = await this.multiMainSetup.fetchLeaderKey(); + const leaderKey = await this.orchestrationService.multiMainSetup.fetchLeaderKey(); const triggersAndPollers = await this.workflowRepository.findIn( this.activeWorkflowRunner.allActiveInMemory(), @@ -24,9 +24,9 @@ export class DebugController { const activationErrors = await this.activeWorkflowRunner.getAllWorkflowActivationErrors(); return { - instanceId: this.multiMainSetup.instanceId, + instanceId: this.orchestrationService.instanceId, leaderKey, - isLeader: this.multiMainSetup.isLeader, + isLeader: this.orchestrationService.isLeader, activeWorkflows: { webhooks, // webhook-based active workflows triggersAndPollers, // poller- and trigger-based active workflows diff --git a/packages/cli/src/controllers/dynamicNodeParameters.controller.ts b/packages/cli/src/controllers/dynamicNodeParameters.controller.ts index d18799341b..8a3fba26bb 100644 --- a/packages/cli/src/controllers/dynamicNodeParameters.controller.ts +++ b/packages/cli/src/controllers/dynamicNodeParameters.controller.ts @@ -57,7 +57,7 @@ export class DynamicNodeParametersController { const additionalData = await getBase(req.user.id, currentNodeParameters); if (methodName) { - return this.service.getOptionsViaMethodName( + return await this.service.getOptionsViaMethodName( methodName, path, additionalData, @@ -68,7 +68,7 @@ export class DynamicNodeParametersController { } if (loadOptions) { - return this.service.getOptionsViaLoadOptions( + return await this.service.getOptionsViaLoadOptions( jsonParse(loadOptions), additionalData, nodeTypeAndVersion, @@ -87,7 +87,7 @@ export class DynamicNodeParametersController { const { path, methodName, filter, paginationToken } = req.query; const { credentials, currentNodeParameters, nodeTypeAndVersion } = req.params; const additionalData = await getBase(req.user.id, currentNodeParameters); - return this.service.getResourceLocatorResults( + return await this.service.getResourceLocatorResults( methodName, path, additionalData, @@ -106,7 +106,7 @@ export class DynamicNodeParametersController { const { path, methodName } = req.query; const { credentials, currentNodeParameters, nodeTypeAndVersion } = req.params; const additionalData = await getBase(req.user.id, currentNodeParameters); - return this.service.getResourceMappingFields( + return await this.service.getResourceMappingFields( methodName, path, additionalData, diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index 1129641c0f..8e93d77726 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -1,8 +1,6 @@ import { Request } from 'express'; import { v4 as uuid } from 'uuid'; import config from '@/config'; -import type { Role } from '@db/entities/Role'; -import { RoleRepository } from '@db/repositories/role.repository'; import { SettingsRepository } from '@db/repositories/settings.repository'; import { UserRepository } from '@db/repositories/user.repository'; import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; @@ -39,7 +37,6 @@ const tablesToTruncate = [ 'installed_packages', 'installed_nodes', 'user', - 'role', 'variables', ]; @@ -87,7 +84,6 @@ export class E2EController { constructor( license: License, - private readonly roleRepo: RoleRepository, private readonly settingsRepo: SettingsRepository, private readonly userRepo: UserRepository, private readonly workflowRunner: ActiveWorkflowRunner, @@ -148,7 +144,7 @@ export class E2EController { private async truncateAll() { for (const table of tablesToTruncate) { try { - const { connection } = this.roleRepo.manager; + const { connection } = this.settingsRepo.manager; await connection.query( `DELETE FROM ${table}; DELETE FROM sqlite_sequence WHERE name=${table};`, ); @@ -163,27 +159,12 @@ export class E2EController { members: UserSetupPayload[], admin: UserSetupPayload, ) { - const roles: Array<[Role['name'], Role['scope']]> = [ - ['owner', 'global'], - ['member', 'global'], - ['admin', 'global'], - ['owner', 'workflow'], - ['owner', 'credential'], - ['user', 'credential'], - ['editor', 'workflow'], - ]; - - const [{ id: globalOwnerRoleId }, { id: globalMemberRoleId }, { id: globalAdminRoleId }] = - await this.roleRepo.save( - roles.map(([name, scope], index) => ({ name, scope, id: (index + 1).toString() })), - ); - - const instanceOwner = { + const instanceOwner = this.userRepo.create({ id: uuid(), ...owner, password: await this.passwordUtility.hash(owner.password), - globalRoleId: globalOwnerRoleId, - }; + role: 'global:owner', + }); if (owner?.mfaSecret && owner.mfaRecoveryCodes?.length) { const { encryptedRecoveryCodes, encryptedSecret } = @@ -192,12 +173,12 @@ export class E2EController { instanceOwner.mfaRecoveryCodes = encryptedRecoveryCodes; } - const adminUser = { + const adminUser = this.userRepo.create({ id: uuid(), ...admin, password: await this.passwordUtility.hash(admin.password), - globalRoleId: globalAdminRoleId, - }; + role: 'global:admin', + }); const users = []; @@ -209,7 +190,7 @@ export class E2EController { id: uuid(), ...payload, password: await this.passwordUtility.hash(password), - globalRoleId: globalMemberRoleId, + role: 'global:member', }), ); } diff --git a/packages/cli/src/controllers/invitation.controller.ts b/packages/cli/src/controllers/invitation.controller.ts index 379a993021..cba846965d 100644 --- a/packages/cli/src/controllers/invitation.controller.ts +++ b/packages/cli/src/controllers/invitation.controller.ts @@ -1,4 +1,5 @@ import { Response } from 'express'; +import validator from 'validator'; import config from '@/config'; import { Authorized, NoAuthRequired, Post, RequireGlobalScope, RestController } from '@/decorators'; @@ -12,12 +13,11 @@ import { isSamlLicensedAndEnabled } from '@/sso/saml/samlHelpers'; import { PasswordUtility } from '@/services/password.utility'; import { PostHogClient } from '@/posthog'; import type { User } from '@/databases/entities/User'; -import validator from 'validator'; +import { UserRepository } from '@db/repositories/user.repository'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; import { InternalHooks } from '@/InternalHooks'; import { ExternalHooks } from '@/ExternalHooks'; -import { UserRepository } from '@/databases/repositories/user.repository'; @Authorized() @RestController('/invitations') @@ -91,13 +91,13 @@ export class InvitationController { ); } - if (invite.role && !['member', 'admin'].includes(invite.role)) { + if (invite.role && !['global:member', 'global:admin'].includes(invite.role)) { throw new BadRequestError( - `Cannot invite user with invalid role: ${invite.role}. Please ensure all invitees' roles are either 'member' or 'admin'.`, + `Cannot invite user with invalid role: ${invite.role}. Please ensure all invitees' roles are either 'global:member' or 'global:admin'.`, ); } - if (invite.role === 'admin' && !this.license.isAdvancedPermissionsLicensed()) { + if (invite.role === 'global:admin' && !this.license.isAdvancedPermissionsLicensed()) { throw new UnauthorizedError( 'Cannot invite admin user without advanced permissions. Please upgrade to a license that includes this feature.', ); @@ -106,7 +106,7 @@ export class InvitationController { const attributes = req.body.map(({ email, role }) => ({ email, - role: role ?? 'member', + role: role ?? 'global:member', })); const { usersInvited, usersCreated } = await this.userService.inviteUsers(req.user, attributes); @@ -136,7 +136,7 @@ export class InvitationController { const validPassword = this.passwordUtility.validate(password); - const users = await this.userRepository.findManybyIds([inviterId, inviteeId]); + const users = await this.userRepository.findManyByIds([inviterId, inviteeId]); if (users.length !== 2) { this.logger.debug( @@ -177,6 +177,9 @@ export class InvitationController { await this.externalHooks.run('user.profile.update', [invitee.email, publicInvitee]); await this.externalHooks.run('user.password.update', [invitee.email, invitee.password]); - return this.userService.toPublic(updatedUser, { posthog: this.postHog, withScopes: true }); + return await this.userService.toPublic(updatedUser, { + posthog: this.postHog, + withScopes: true, + }); } } diff --git a/packages/cli/src/controllers/me.controller.ts b/packages/cli/src/controllers/me.controller.ts index a7b306d5df..bf7337de78 100644 --- a/packages/cli/src/controllers/me.controller.ts +++ b/packages/cli/src/controllers/me.controller.ts @@ -80,7 +80,6 @@ export class MeController { await this.userService.update(userId, payload); const user = await this.userRepository.findOneOrFail({ where: { id: userId }, - relations: ['globalRole'], }); this.logger.info('User updated successfully', { userId }); @@ -235,7 +234,6 @@ export class MeController { const user = await this.userRepository.findOneOrFail({ select: ['settings'], where: { id }, - relations: ['globalRole'], }); return user.settings; diff --git a/packages/cli/src/controllers/nodeTypes.controller.ts b/packages/cli/src/controllers/nodeTypes.controller.ts index 507c7a3c20..ad0b20efbc 100644 --- a/packages/cli/src/controllers/nodeTypes.controller.ts +++ b/packages/cli/src/controllers/nodeTypes.controller.ts @@ -50,8 +50,8 @@ export class NodeTypesController { const nodeTypes: INodeTypeDescription[] = []; - const promises = nodeInfos.map(async ({ name, version }) => - populateTranslation(name, version, nodeTypes), + const promises = nodeInfos.map( + async ({ name, version }) => await populateTranslation(name, version, nodeTypes), ); await Promise.all(promises); diff --git a/packages/cli/src/controllers/oauth/abstractOAuth.controller.ts b/packages/cli/src/controllers/oauth/abstractOAuth.controller.ts index c2e9ded77e..518238db71 100644 --- a/packages/cli/src/controllers/oauth/abstractOAuth.controller.ts +++ b/packages/cli/src/controllers/oauth/abstractOAuth.controller.ts @@ -61,14 +61,14 @@ export abstract class AbstractOAuthController { } protected async getAdditionalData(user: User) { - return WorkflowExecuteAdditionalData.getBase(user.id); + return await WorkflowExecuteAdditionalData.getBase(user.id); } protected async getDecryptedData( credential: ICredentialsDb, additionalData: IWorkflowExecuteAdditionalData, ) { - return this.credentialsHelper.getDecrypted( + return await this.credentialsHelper.getDecrypted( additionalData, credential, credential.type, @@ -105,6 +105,6 @@ export abstract class AbstractOAuthController { /** Get a credential without user check */ protected async getCredentialWithoutUser(credentialId: string): Promise { - return this.credentialsRepository.findOneBy({ id: credentialId }); + return await this.credentialsRepository.findOneBy({ id: credentialId }); } } diff --git a/packages/cli/src/controllers/orchestration.controller.ts b/packages/cli/src/controllers/orchestration.controller.ts index c1c03696fc..fb044014be 100644 --- a/packages/cli/src/controllers/orchestration.controller.ts +++ b/packages/cli/src/controllers/orchestration.controller.ts @@ -1,13 +1,13 @@ import { Authorized, Post, RestController, RequireGlobalScope } from '@/decorators'; import { OrchestrationRequest } from '@/requests'; -import { SingleMainSetup } from '@/services/orchestration/main/SingleMainSetup'; +import { OrchestrationService } from '@/services/orchestration.service'; import { License } from '@/License'; @Authorized() @RestController('/orchestration') export class OrchestrationController { constructor( - private readonly singleMainSetup: SingleMainSetup, + private readonly orchestrationService: OrchestrationService, private readonly licenseService: License, ) {} @@ -20,20 +20,20 @@ export class OrchestrationController { async getWorkersStatus(req: OrchestrationRequest.Get) { if (!this.licenseService.isWorkerViewLicensed()) return; const id = req.params.id; - return this.singleMainSetup.getWorkerStatus(id); + return await this.orchestrationService.getWorkerStatus(id); } @RequireGlobalScope('orchestration:read') @Post('/worker/status') async getWorkersStatusAll() { if (!this.licenseService.isWorkerViewLicensed()) return; - return this.singleMainSetup.getWorkerStatus(); + return await this.orchestrationService.getWorkerStatus(); } @RequireGlobalScope('orchestration:list') @Post('/worker/ids') async getWorkerIdsAll() { if (!this.licenseService.isWorkerViewLicensed()) return; - return this.singleMainSetup.getWorkerIds(); + return await this.orchestrationService.getWorkerIds(); } } diff --git a/packages/cli/src/controllers/owner.controller.ts b/packages/cli/src/controllers/owner.controller.ts index 2caa1e0616..fe0484021f 100644 --- a/packages/cli/src/controllers/owner.controller.ts +++ b/packages/cli/src/controllers/owner.controller.ts @@ -15,7 +15,7 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { InternalHooks } from '@/InternalHooks'; import { UserRepository } from '@/databases/repositories/user.repository'; -@Authorized(['global', 'owner']) +@Authorized('global:owner') @RestController('/owner') export class OwnerController { constructor( @@ -35,7 +35,7 @@ export class OwnerController { @Post('/setup') async setupOwner(req: OwnerRequest.Post, res: Response) { const { email, firstName, lastName, password } = req.body; - const { id: userId, globalRole } = req.user; + const { id: userId } = req.user; if (config.getEnv('userManagement.isInstanceOwnerSetUp')) { this.logger.debug( @@ -65,17 +65,6 @@ export class OwnerController { throw new BadRequestError('First and last names are mandatory'); } - // TODO: This check should be in a middleware outside this class - if (globalRole.scope === 'global' && globalRole.name !== 'owner') { - this.logger.debug( - 'Request to claim instance ownership failed because user shell does not exist or has wrong role!', - { - userId, - }, - ); - throw new BadRequestError('Invalid request'); - } - let owner = req.user; Object.assign(owner, { @@ -104,7 +93,7 @@ export class OwnerController { void this.internalHooks.onInstanceOwnerSetup({ user_id: userId }); - return this.userService.toPublic(owner, { posthog: this.postHog, withScopes: true }); + return await this.userService.toPublic(owner, { posthog: this.postHog, withScopes: true }); } @Post('/dismiss-banner') diff --git a/packages/cli/src/controllers/role.controller.ts b/packages/cli/src/controllers/role.controller.ts deleted file mode 100644 index 4b16ceb771..0000000000 --- a/packages/cli/src/controllers/role.controller.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { License } from '@/License'; -import { Get, RestController } from '@/decorators'; -import { RoleService } from '@/services/role.service'; - -@RestController('/roles') -export class RoleController { - constructor( - private readonly roleService: RoleService, - private readonly license: License, - ) {} - - @Get('/') - async listRoles() { - return this.roleService.listRoles().map((role) => { - if (role.scope === 'global' && role.name === 'admin') { - return { ...role, isAvailable: this.license.isAdvancedPermissionsLicensed() }; - } - - return { ...role, isAvailable: true }; - }); - } -} diff --git a/packages/cli/src/controllers/tags.controller.ts b/packages/cli/src/controllers/tags.controller.ts index 43c06c83e2..a50cb25dcd 100644 --- a/packages/cli/src/controllers/tags.controller.ts +++ b/packages/cli/src/controllers/tags.controller.ts @@ -32,7 +32,7 @@ export class TagsController { @Get('/') @RequireGlobalScope('tag:list') async getAll(req: TagsRequest.GetAll) { - return this.tagService.getAll({ withUsageCount: req.query.withUsageCount === 'true' }); + return await this.tagService.getAll({ withUsageCount: req.query.withUsageCount === 'true' }); } @Post('/') @@ -40,7 +40,7 @@ export class TagsController { async createTag(req: TagsRequest.Create) { const tag = this.tagService.toEntity({ name: req.body.name }); - return this.tagService.save(tag, 'create'); + return await this.tagService.save(tag, 'create'); } @Patch('/:id(\\w+)') @@ -48,7 +48,7 @@ export class TagsController { async updateTag(req: TagsRequest.Update) { const newTag = this.tagService.toEntity({ id: req.params.id, name: req.body.name.trim() }); - return this.tagService.save(newTag, 'update'); + return await this.tagService.save(newTag, 'update'); } @Delete('/:id(\\w+)') diff --git a/packages/cli/src/controllers/users.controller.ts b/packages/cli/src/controllers/users.controller.ts index cd47a7fde6..2ed94e1345 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -23,7 +23,6 @@ import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials. import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { UserRepository } from '@db/repositories/user.repository'; import { plainToInstance } from 'class-transformer'; -import { RoleService } from '@/services/role.service'; import { UserService } from '@/services/user.service'; import { listQueryMiddleware } from '@/middlewares'; import { Logger } from '@/Logger'; @@ -45,7 +44,6 @@ export class UsersController { private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly userRepository: UserRepository, private readonly activeWorkflowRunner: ActiveWorkflowRunner, - private readonly roleService: RoleService, private readonly userService: UserService, ) {} @@ -70,7 +68,7 @@ export class UsersController { } if (filter?.isOwner) { - for (const user of publicUsers) delete user.globalRole; + for (const user of publicUsers) delete user.role; } // remove computed fields (unselectable) @@ -92,18 +90,14 @@ export class UsersController { async listUsers(req: ListQuery.Request) { const { listQueryOptions } = req; - const globalOwner = await this.roleService.findGlobalOwnerRole(); - - const findManyOptions = await this.userRepository.toFindManyOptions( - listQueryOptions, - globalOwner.id, - ); + const findManyOptions = await this.userRepository.toFindManyOptions(listQueryOptions); const users = await this.userRepository.find(findManyOptions); const publicUsers: Array> = await Promise.all( - users.map(async (u) => - this.userService.toPublic(u, { withInviteUrl: true, inviterId: req.user.id }), + users.map( + async (u) => + await this.userService.toPublic(u, { withInviteUrl: true, inviterId: req.user.id }), ), ); @@ -117,7 +111,6 @@ export class UsersController { async getUserPasswordResetLink(req: UserRequest.PasswordResetLink) { const user = await this.userRepository.findOneOrFail({ where: { id: req.params.id }, - relations: ['globalRole'], }); if (!user) { throw new NotFoundError('User not found'); @@ -139,7 +132,6 @@ export class UsersController { const user = await this.userRepository.findOneOrFail({ select: ['settings'], where: { id }, - relations: ['globalRole'], }); return user.settings; @@ -171,7 +163,7 @@ export class UsersController { const userIds = transferId ? [transferId, idToDelete] : [idToDelete]; - const users = await this.userRepository.findManybyIds(userIds); + const users = await this.userRepository.findManyByIds(userIds); if (!users.length || (transferId && users.length !== 2)) { throw new NotFoundError( @@ -193,11 +185,6 @@ export class UsersController { telemetryData.migration_user_id = transferId; } - const [workflowOwnerRole, credentialOwnerRole] = await Promise.all([ - this.roleService.findWorkflowOwnerRole(), - this.roleService.findCredentialOwnerRole(), - ]); - if (transferId) { const transferee = users.find((user) => user.id === transferId); @@ -207,7 +194,7 @@ export class UsersController { .getRepository(SharedWorkflow) .find({ select: ['workflowId'], - where: { userId: userToDelete.id, roleId: workflowOwnerRole?.id }, + where: { userId: userToDelete.id, role: 'workflow:owner' }, }) .then((sharedWorkflows) => sharedWorkflows.map(({ workflowId }) => workflowId)); @@ -222,7 +209,7 @@ export class UsersController { // Transfer ownership of owned workflows await transactionManager.update( SharedWorkflow, - { user: userToDelete, role: workflowOwnerRole }, + { user: userToDelete, role: 'workflow:owner' }, { user: transferee }, ); @@ -233,7 +220,7 @@ export class UsersController { .getRepository(SharedCredentials) .find({ select: ['credentialsId'], - where: { userId: userToDelete.id, roleId: credentialOwnerRole?.id }, + where: { userId: userToDelete.id, role: 'credential:owner' }, }) .then((sharedCredentials) => sharedCredentials.map(({ credentialsId }) => credentialsId)); @@ -248,7 +235,7 @@ export class UsersController { // Transfer ownership of owned credentials await transactionManager.update( SharedCredentials, - { user: userToDelete, role: credentialOwnerRole }, + { user: userToDelete, role: 'credential:owner' }, { user: transferee }, ); @@ -270,11 +257,11 @@ export class UsersController { const [ownedSharedWorkflows, ownedSharedCredentials] = await Promise.all([ this.sharedWorkflowRepository.find({ relations: ['workflow'], - where: { userId: userToDelete.id, roleId: workflowOwnerRole?.id }, + where: { userId: userToDelete.id, role: 'workflow:owner' }, }), this.sharedCredentialsRepository.find({ relations: ['credentials'], - where: { userId: userToDelete.id, roleId: credentialOwnerRole?.id }, + where: { userId: userToDelete.id, role: 'credential:owner' }, }), ]); @@ -317,23 +304,20 @@ export class UsersController { const targetUser = await this.userRepository.findOne({ where: { id: req.params.id }, - relations: ['globalRole'], }); if (targetUser === null) { throw new NotFoundError(NO_USER); } - if (req.user.globalRole.name === 'admin' && targetUser.globalRole.name === 'owner') { + if (req.user.role === 'global:admin' && targetUser.role === 'global:owner') { throw new UnauthorizedError(NO_ADMIN_ON_OWNER); } - if (req.user.globalRole.name === 'owner' && targetUser.globalRole.name === 'owner') { + if (req.user.role === 'global:owner' && targetUser.role === 'global:owner') { throw new UnauthorizedError(NO_OWNER_ON_OWNER); } - const roleToSet = await this.roleService.findCached('global', payload.newRoleName); - - await this.userService.update(targetUser.id, { globalRoleId: roleToSet.id }); + await this.userService.update(targetUser.id, { role: payload.newRoleName }); void this.internalHooks.onUserRoleChange({ user: req.user, diff --git a/packages/cli/src/controllers/workflow-statistics.types.ts b/packages/cli/src/controllers/workflow-statistics.types.ts new file mode 100644 index 0000000000..124f48f252 --- /dev/null +++ b/packages/cli/src/controllers/workflow-statistics.types.ts @@ -0,0 +1,5 @@ +import type { ExecutionRequest } from '@/executions/execution.types'; + +export namespace StatisticsRequest { + export type GetOne = ExecutionRequest.GetOne; +} diff --git a/packages/cli/src/controllers/workflowStatistics.controller.ts b/packages/cli/src/controllers/workflowStatistics.controller.ts index a5ad163823..caa9f3cae3 100644 --- a/packages/cli/src/controllers/workflowStatistics.controller.ts +++ b/packages/cli/src/controllers/workflowStatistics.controller.ts @@ -4,10 +4,10 @@ import type { WorkflowStatistics } from '@db/entities/WorkflowStatistics'; import { StatisticsNames } from '@db/entities/WorkflowStatistics'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { WorkflowStatisticsRepository } from '@db/repositories/workflowStatistics.repository'; -import { ExecutionRequest } from '@/requests'; import type { IWorkflowStatisticsDataLoaded } from '@/Interfaces'; import { Logger } from '@/Logger'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { StatisticsRequest } from './workflow-statistics.types'; interface WorkflowStatisticsData { productionSuccess: T; @@ -29,7 +29,7 @@ export class WorkflowStatisticsController { */ // TODO: move this into a new decorator `@ValidateWorkflowPermission` @Middleware() - async hasWorkflowAccess(req: ExecutionRequest.Get, res: Response, next: NextFunction) { + async hasWorkflowAccess(req: StatisticsRequest.GetOne, res: Response, next: NextFunction) { const { user } = req; const workflowId = req.params.id; @@ -48,17 +48,17 @@ export class WorkflowStatisticsController { } @Get('/:id/counts/') - async getCounts(req: ExecutionRequest.Get): Promise> { - return this.getData(req.params.id, 'count', 0); + async getCounts(req: StatisticsRequest.GetOne): Promise> { + return await this.getData(req.params.id, 'count', 0); } @Get('/:id/times/') - async getTimes(req: ExecutionRequest.Get): Promise> { - return this.getData(req.params.id, 'latestEvent', null); + async getTimes(req: StatisticsRequest.GetOne): Promise> { + return await this.getData(req.params.id, 'latestEvent', null); } @Get('/:id/data-loaded/') - async getDataLoaded(req: ExecutionRequest.Get): Promise { + async getDataLoaded(req: StatisticsRequest.GetOne): Promise { // Get flag const workflowId = req.params.id; diff --git a/packages/cli/src/credentials/credentials.controller.ee.ts b/packages/cli/src/credentials/credentials.controller.ee.ts index eb4fd7ee4a..4a8211caf2 100644 --- a/packages/cli/src/credentials/credentials.controller.ee.ts +++ b/packages/cli/src/credentials/credentials.controller.ee.ts @@ -5,7 +5,7 @@ import * as Db from '@/Db'; import * as ResponseHelper from '@/ResponseHelper'; import type { CredentialRequest } from '@/requests'; -import { isSharingEnabled } from '@/UserManagement/UserManagementHelper'; +import { License } from '@/License'; import { EECredentialsService as EECredentials } from './credentials.service.ee'; import { OwnershipService } from '@/services/ownership.service'; import { Container } from 'typedi'; @@ -15,11 +15,16 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; import * as utils from '@/utils'; +import { UserRepository } from '@/databases/repositories/user.repository'; +import { UserManagementMailer } from '@/UserManagement/email'; +import { UrlService } from '@/services/url.service'; +import { Logger } from '@/Logger'; +import { InternalServerError } from '@/errors/response-errors/internal-server.error'; export const EECredentialsController = express.Router(); EECredentialsController.use((req, res, next) => { - if (!isSharingEnabled()) { + if (!Container.get(License).isSharingEnabled()) { // skip ee router and use free one next('router'); return; @@ -40,7 +45,7 @@ EECredentialsController.get( let credential = await Container.get(CredentialsRepository).findOne({ where: { id: credentialId }, - relations: ['shared', 'shared.role', 'shared.user'], + relations: ['shared', 'shared.user'], }); if (!credential) { @@ -57,7 +62,7 @@ EECredentialsController.get( credential = Container.get(OwnershipService).addOwnedByAndSharedWith(credential); - if (!includeDecryptedData || !userSharing || userSharing.role.name !== 'owner') { + if (!includeDecryptedData || !userSharing || userSharing.role !== 'credential:owner') { const { data: _, ...rest } = credential; return { ...rest }; } @@ -102,7 +107,7 @@ EECredentialsController.post( mergedCredentials.data = EECredentials.unredact(mergedCredentials.data, decryptedData); } - return EECredentials.test(req.user, mergedCredentials); + return await EECredentials.test(req.user, mergedCredentials); }), ); @@ -146,10 +151,9 @@ EECredentialsController.put( const ownerIds = ( await EECredentials.getSharings(Db.getConnection().createEntityManager(), credentialId, [ 'shared', - 'shared.role', ]) ) - .filter((e) => e.role.name === 'owner') + .filter((e) => e.role === 'credential:owner') .map((e) => e.userId); let amountRemoved: number | null = null; @@ -185,5 +189,37 @@ EECredentialsController.put( user_ids_sharees_added: newShareeIds, sharees_removed: amountRemoved, }); + + const recipients = await Container.get(UserRepository).getEmailsByIds(newShareeIds); + + if (recipients.length === 0) return; + + try { + await Container.get(UserManagementMailer).notifyCredentialsShared({ + sharerFirstName: req.user.firstName, + credentialsName: credential.name, + recipientEmails: recipients.map(({ email }) => email), + baseUrl: Container.get(UrlService).getInstanceBaseUrl(), + }); + } catch (error) { + void Container.get(InternalHooks).onEmailFailed({ + user: req.user, + message_type: 'Credentials shared', + public_api: false, + }); + if (error instanceof Error) { + throw new InternalServerError(`Please contact your administrator: ${error.message}`); + } + } + + Container.get(Logger).info('Sent credentials shared email successfully', { + sharerId: req.user.id, + }); + + void Container.get(InternalHooks).onUserTransactionalEmail({ + user_id: req.user.id, + message_type: 'Credentials shared', + public_api: false, + }); }), ); diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index 82f6bad527..d7406d496f 100644 --- a/packages/cli/src/credentials/credentials.controller.ts +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -27,7 +27,7 @@ credentialsController.get( '/', listQueryMiddleware, ResponseHelper.send(async (req: ListQuery.Request) => { - return CredentialsService.getMany(req.user, { listQueryOptions: req.listQueryOptions }); + return await CredentialsService.getMany(req.user, { listQueryOptions: req.listQueryOptions }); }), ); @@ -105,7 +105,7 @@ credentialsController.post( mergedCredentials.data = CredentialsService.unredact(mergedCredentials.data, decryptedData); } - return CredentialsService.test(req.user, mergedCredentials); + return await CredentialsService.test(req.user, mergedCredentials); }), ); @@ -147,7 +147,7 @@ credentialsController.patch( allowGlobalScope: true, globalScope: 'credential:update', }, - ['credentials', 'role'], + ['credentials'], ); if (!sharing) { @@ -163,7 +163,7 @@ credentialsController.patch( ); } - if (sharing.role.name !== 'owner' && !req.user.hasGlobalScope('credential:update')) { + if (sharing.role !== 'credential:owner' && !req.user.hasGlobalScope('credential:update')) { Container.get(Logger).info( 'Attempt to update credential blocked due to lack of permissions', { @@ -216,7 +216,7 @@ credentialsController.delete( allowGlobalScope: true, globalScope: 'credential:delete', }, - ['credentials', 'role'], + ['credentials'], ); if (!sharing) { @@ -232,7 +232,7 @@ credentialsController.delete( ); } - if (sharing.role.name !== 'owner' && !req.user.hasGlobalScope('credential:delete')) { + if (sharing.role !== 'credential: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 88f824b147..9b31d3c4eb 100644 --- a/packages/cli/src/credentials/credentials.service.ee.ts +++ b/packages/cli/src/credentials/credentials.service.ee.ts @@ -1,10 +1,9 @@ +import { Container } from 'typedi'; import type { EntityManager, FindOptionsWhere } from 'typeorm'; import { CredentialsEntity } from '@db/entities/CredentialsEntity'; import type { SharedCredentials } from '@db/entities/SharedCredentials'; import type { User } from '@db/entities/User'; import { CredentialsService, type CredentialsGetSharedOptions } from './credentials.service'; -import { RoleService } from '@/services/role.service'; -import Container from 'typedi'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import { UserRepository } from '@/databases/repositories/user.repository'; @@ -15,10 +14,9 @@ export class EECredentialsService extends CredentialsService { ): Promise<{ ownsCredential: boolean; credential?: CredentialsEntity }> { const sharing = await this.getSharing(user, credentialId, { allowGlobalScope: false }, [ 'credentials', - 'role', ]); - if (!sharing || sharing.role.name !== 'owner') return { ownsCredential: false }; + if (!sharing || sharing.role !== 'credential:owner') return { ownsCredential: false }; const { credentials: credential } = sharing; @@ -43,7 +41,7 @@ export class EECredentialsService extends CredentialsService { where.userId = user.id; } - return Container.get(SharedCredentialsRepository).findOne({ + return await Container.get(SharedCredentialsRepository).findOne({ where, relations, }); @@ -67,7 +65,6 @@ export class EECredentialsService extends CredentialsService { shareWithIds: string[], ): Promise { const users = await Container.get(UserRepository).getByIds(transaction, shareWithIds); - const role = await Container.get(RoleService).findCredentialUserRole(); const newSharedCredentials = users .filter((user) => !user.isPending) @@ -75,10 +72,10 @@ export class EECredentialsService extends CredentialsService { Container.get(SharedCredentialsRepository).create({ credentialsId: credential.id, userId: user.id, - roleId: role?.id, + role: 'credential:user', }), ); - return transaction.save(newSharedCredentials); + return await transaction.save(newSharedCredentials); } } diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index 1d720c6cd9..9533b5a1ad 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -23,7 +23,6 @@ import { ExternalHooks } from '@/ExternalHooks'; import type { User } from '@db/entities/User'; import type { CredentialRequest, ListQuery } from '@/requests'; import { CredentialTypes } from '@/CredentialTypes'; -import { RoleService } from '@/services/role.service'; import { OwnershipService } from '@/services/ownership.service'; import { Logger } from '@/Logger'; import { CredentialsRepository } from '@db/repositories/credentials.repository'; @@ -35,7 +34,7 @@ export type CredentialsGetSharedOptions = export class CredentialsService { static async get(where: FindOptionsWhere, options?: { relations: string[] }) { - return Container.get(CredentialsRepository).findOne({ + return await Container.get(CredentialsRepository).findOne({ relations: options?.relations, where, }); @@ -85,16 +84,11 @@ export class CredentialsService { // global credential permissions. This allows the user to // access credentials they don't own. if (!options.allowGlobalScope || !user.hasGlobalScope(options.globalScope)) { - Object.assign(where, { - userId: user.id, - role: { name: 'owner' }, - }); - if (!relations.includes('role')) { - relations.push('role'); - } + where.userId = user.id; + where.role = 'credential:owner'; } - return Container.get(SharedCredentialsRepository).findOne({ where, relations }); + return await Container.get(SharedCredentialsRepository).findOne({ where, relations }); } static async prepareCreateData( @@ -180,7 +174,7 @@ export class CredentialsService { // We sadly get nothing back from "update". Neither if it updated a record // nor the new value. So query now the updated entry. - return Container.get(CredentialsRepository).findOneBy({ id: credentialId }); + return await Container.get(CredentialsRepository).findOneBy({ id: credentialId }); } static async save( @@ -194,8 +188,6 @@ export class CredentialsService { await Container.get(ExternalHooks).run('credentials.create', [encryptedData]); - const role = await Container.get(RoleService).findCredentialOwnerRole(); - const result = await Db.transaction(async (transactionManager) => { const savedCredential = await transactionManager.save(newCredential); @@ -204,7 +196,7 @@ export class CredentialsService { const newSharedCredential = new SharedCredentials(); Object.assign(newSharedCredential, { - role, + role: 'credential:owner', user, credentials: savedCredential, }); @@ -231,7 +223,7 @@ export class CredentialsService { credentials: ICredentialsDecrypted, ): Promise { const helper = Container.get(CredentialsHelper); - return helper.testCredentials(user, credentials.type, credentials); + return await helper.testCredentials(user, credentials.type, credentials); } // Take data and replace all sensitive values with a sentinel value. diff --git a/packages/cli/src/databases/dsl/Indices.ts b/packages/cli/src/databases/dsl/Indices.ts index 384cbb0b8a..5e5a3346b8 100644 --- a/packages/cli/src/databases/dsl/Indices.ts +++ b/packages/cli/src/databases/dsl/Indices.ts @@ -40,7 +40,7 @@ export class CreateIndex extends IndexOperation { async execute(queryRunner: QueryRunner) { const { columnNames, isUnique } = this; - return queryRunner.createIndex( + return await queryRunner.createIndex( this.fullTableName, new TableIndex({ name: this.customIndexName ?? this.fullIndexName, columnNames, isUnique }), ); @@ -49,6 +49,9 @@ export class CreateIndex extends IndexOperation { export class DropIndex extends IndexOperation { async execute(queryRunner: QueryRunner) { - return queryRunner.dropIndex(this.fullTableName, this.customIndexName ?? this.fullIndexName); + return await queryRunner.dropIndex( + this.fullTableName, + this.customIndexName ?? this.fullIndexName, + ); } } diff --git a/packages/cli/src/databases/dsl/Table.ts b/packages/cli/src/databases/dsl/Table.ts index e132042749..94ed4392d0 100644 --- a/packages/cli/src/databases/dsl/Table.ts +++ b/packages/cli/src/databases/dsl/Table.ts @@ -1,5 +1,5 @@ import type { TableForeignKeyOptions, TableIndexOptions, QueryRunner } from 'typeorm'; -import { Table, TableColumn } from 'typeorm'; +import { Table, TableColumn, TableForeignKey } from 'typeorm'; import LazyPromise from 'p-lazy'; import { Column } from './Column'; import { ApplicationError } from 'n8n-workflow'; @@ -62,7 +62,7 @@ export class CreateTable extends TableOperation { async execute(queryRunner: QueryRunner) { const { driver } = queryRunner.connection; const { columns, tableName: name, prefix, indices, foreignKeys } = this; - return queryRunner.createTable( + return await queryRunner.createTable( new Table({ name: `${prefix}${name}`, columns: columns.map((c) => c.toOptions(driver)), @@ -78,7 +78,7 @@ export class CreateTable extends TableOperation { export class DropTable extends TableOperation { async execute(queryRunner: QueryRunner) { const { tableName: name, prefix } = this; - return queryRunner.dropTable(`${prefix}${name}`, true); + return await queryRunner.dropTable(`${prefix}${name}`, true); } } @@ -95,7 +95,7 @@ export class AddColumns extends TableOperation { async execute(queryRunner: QueryRunner) { const { driver } = queryRunner.connection; const { tableName, prefix, columns } = this; - return queryRunner.addColumns( + return await queryRunner.addColumns( `${prefix}${tableName}`, columns.map((c) => new TableColumn(c.toOptions(driver))), ); @@ -114,7 +114,43 @@ export class DropColumns extends TableOperation { async execute(queryRunner: QueryRunner) { const { tableName, prefix, columnNames } = this; - return queryRunner.dropColumns(`${prefix}${tableName}`, columnNames); + return await queryRunner.dropColumns(`${prefix}${tableName}`, columnNames); + } +} + +abstract class ForeignKeyOperation extends TableOperation { + protected foreignKey: TableForeignKey; + + constructor( + tableName: string, + columnName: string, + [referencedTableName, referencedColumnName]: [string, string], + prefix: string, + queryRunner: QueryRunner, + customConstraintName?: string, + ) { + super(tableName, prefix, queryRunner); + + this.foreignKey = new TableForeignKey({ + name: customConstraintName, + columnNames: [columnName], + referencedTableName: `${prefix}${referencedTableName}`, + referencedColumnNames: [referencedColumnName], + }); + } +} + +export class AddForeignKey extends ForeignKeyOperation { + async execute(queryRunner: QueryRunner) { + const { tableName, prefix } = this; + return await queryRunner.createForeignKey(`${prefix}${tableName}`, this.foreignKey); + } +} + +export class DropForeignKey extends ForeignKeyOperation { + async execute(queryRunner: QueryRunner) { + const { tableName, prefix } = this; + return await queryRunner.dropForeignKey(`${prefix}${tableName}`, this.foreignKey); } } @@ -136,7 +172,7 @@ class ModifyNotNull extends TableOperation { const oldColumn = table.findColumnByName(columnName)!; const newColumn = oldColumn.clone(); newColumn.isNullable = isNullable; - return queryRunner.changeColumn(table, oldColumn, newColumn); + return await queryRunner.changeColumn(table, oldColumn, newColumn); } } diff --git a/packages/cli/src/databases/dsl/index.ts b/packages/cli/src/databases/dsl/index.ts index 6eb6df8bc9..2e108c0ef7 100644 --- a/packages/cli/src/databases/dsl/index.ts +++ b/packages/cli/src/databases/dsl/index.ts @@ -1,6 +1,15 @@ import type { QueryRunner } from 'typeorm'; import { Column } from './Column'; -import { AddColumns, AddNotNull, CreateTable, DropColumns, DropNotNull, DropTable } from './Table'; +import { + AddColumns, + AddForeignKey, + AddNotNull, + CreateTable, + DropColumns, + DropForeignKey, + DropNotNull, + DropTable, +} from './Table'; import { CreateIndex, DropIndex } from './Indices'; export const createSchemaBuilder = (tablePrefix: string, queryRunner: QueryRunner) => ({ @@ -26,6 +35,36 @@ export const createSchemaBuilder = (tablePrefix: string, queryRunner: QueryRunne dropIndex: (tableName: string, columnNames: string[], customIndexName?: string) => new DropIndex(tableName, columnNames, tablePrefix, queryRunner, customIndexName), + addForeignKey: ( + tableName: string, + columnName: string, + reference: [string, string], + customConstraintName?: string, + ) => + new AddForeignKey( + tableName, + columnName, + reference, + tablePrefix, + queryRunner, + customConstraintName, + ), + + dropForeignKey: ( + tableName: string, + columnName: string, + reference: [string, string], + customConstraintName?: string, + ) => + new DropForeignKey( + tableName, + columnName, + reference, + tablePrefix, + queryRunner, + customConstraintName, + ), + addNotNull: (tableName: string, columnName: string) => new AddNotNull(tableName, columnName, tablePrefix, queryRunner), dropNotNull: (tableName: string, columnName: string) => diff --git a/packages/cli/src/databases/entities/Role.ts b/packages/cli/src/databases/entities/Role.ts deleted file mode 100644 index 070e8e6be8..0000000000 --- a/packages/cli/src/databases/entities/Role.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Column, Entity, OneToMany, PrimaryColumn, Unique } from 'typeorm'; -import { IsString, Length } from 'class-validator'; - -import type { User } from './User'; -import type { SharedWorkflow } from './SharedWorkflow'; -import type { SharedCredentials } from './SharedCredentials'; -import { WithTimestamps } from './AbstractEntity'; -import { idStringifier } from '../utils/transformers'; - -export type RoleNames = 'owner' | 'member' | 'user' | 'editor' | 'admin'; -export type RoleScopes = 'global' | 'workflow' | 'credential'; - -@Entity() -@Unique(['scope', 'name']) -export class Role extends WithTimestamps { - @PrimaryColumn({ transformer: idStringifier }) - id: string; - - @Column({ length: 32 }) - @IsString({ message: 'Role name must be of type string.' }) - @Length(1, 32, { message: 'Role name must be 1 to 32 characters long.' }) - name: RoleNames; - - @Column() - scope: RoleScopes; - - @OneToMany('User', 'globalRole') - globalForUsers: User[]; - - @OneToMany('SharedWorkflow', 'role') - sharedWorkflows: SharedWorkflow[]; - - @OneToMany('SharedCredentials', 'role') - sharedCredentials: SharedCredentials[]; - - get cacheKey() { - return `role:${this.scope}:${this.name}`; - } -} diff --git a/packages/cli/src/databases/entities/SharedCredentials.ts b/packages/cli/src/databases/entities/SharedCredentials.ts index 6686e5a3c4..1685732004 100644 --- a/packages/cli/src/databases/entities/SharedCredentials.ts +++ b/packages/cli/src/databases/entities/SharedCredentials.ts @@ -1,16 +1,14 @@ import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm'; import { CredentialsEntity } from './CredentialsEntity'; import { User } from './User'; -import { Role } from './Role'; import { WithTimestamps } from './AbstractEntity'; +export type CredentialSharingRole = 'credential:owner' | 'credential:user'; + @Entity() export class SharedCredentials extends WithTimestamps { - @ManyToOne('Role', 'sharedCredentials', { nullable: false }) - role: Role; - @Column() - roleId: string; + role: CredentialSharingRole; @ManyToOne('User', 'sharedCredentials') user: User; diff --git a/packages/cli/src/databases/entities/SharedWorkflow.ts b/packages/cli/src/databases/entities/SharedWorkflow.ts index 4b181e5ab1..adb94beb5a 100644 --- a/packages/cli/src/databases/entities/SharedWorkflow.ts +++ b/packages/cli/src/databases/entities/SharedWorkflow.ts @@ -1,16 +1,14 @@ import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm'; import { WorkflowEntity } from './WorkflowEntity'; import { User } from './User'; -import { Role } from './Role'; import { WithTimestamps } from './AbstractEntity'; +export type WorkflowSharingRole = 'workflow:owner' | 'workflow:editor' | 'workflow:user'; + @Entity() export class SharedWorkflow extends WithTimestamps { - @ManyToOne('Role', 'sharedWorkflows', { nullable: false }) - role: Role; - @Column() - roleId: string; + role: WorkflowSharingRole; @ManyToOne('User', 'sharedWorkflows') user: User; diff --git a/packages/cli/src/databases/entities/User.ts b/packages/cli/src/databases/entities/User.ts index 974315cc08..9a1a96d5f7 100644 --- a/packages/cli/src/databases/entities/User.ts +++ b/packages/cli/src/databases/entities/User.ts @@ -6,13 +6,11 @@ import { Entity, Index, OneToMany, - ManyToOne, PrimaryGeneratedColumn, BeforeInsert, } from 'typeorm'; import { IsEmail, IsString, Length } from 'class-validator'; import type { IUser, IUserSettings } from 'n8n-workflow'; -import { Role } from './Role'; import type { SharedWorkflow } from './SharedWorkflow'; import type { SharedCredentials } from './SharedCredentials'; import { NoXss } from '../utils/customValidators'; @@ -23,10 +21,13 @@ import type { AuthIdentity } from './AuthIdentity'; import { ownerPermissions, memberPermissions, adminPermissions } from '@/permissions/roles'; import { hasScope, type ScopeOptions, type Scope } from '@n8n/permissions'; -const STATIC_SCOPE_MAP: Record = { - owner: ownerPermissions, - member: memberPermissions, - admin: adminPermissions, +export type GlobalRole = 'global:owner' | 'global:admin' | 'global:member'; +export type AssignableRole = Exclude; + +const STATIC_SCOPE_MAP: Record = { + 'global:owner': ownerPermissions, + 'global:member': memberPermissions, + 'global:admin': adminPermissions, }; @Entity() @@ -72,11 +73,8 @@ export class User extends WithTimestamps implements IUser { }) settings: IUserSettings | null; - @ManyToOne('Role', 'globalForUsers', { nullable: false }) - globalRole: Role; - @Column() - globalRoleId: string; + role: GlobalRole; @OneToMany('AuthIdentity', 'user') authIdentities: AuthIdentity[]; @@ -127,11 +125,11 @@ export class User extends WithTimestamps implements IUser { @AfterLoad() computeIsOwner(): void { - this.isOwner = this.globalRole?.name === 'owner'; + this.isOwner = this.role === 'global:owner'; } get globalScopes() { - return STATIC_SCOPE_MAP[this.globalRole?.name] ?? []; + return STATIC_SCOPE_MAP[this.role] ?? []; } hasGlobalScope(scope: Scope | Scope[], scopeOptions?: ScopeOptions): boolean { diff --git a/packages/cli/src/databases/entities/index.ts b/packages/cli/src/databases/entities/index.ts index 9fd3c0b72c..db1f5a5ce7 100644 --- a/packages/cli/src/databases/entities/index.ts +++ b/packages/cli/src/databases/entities/index.ts @@ -6,7 +6,6 @@ import { EventDestinations } from './EventDestinations'; import { ExecutionEntity } from './ExecutionEntity'; import { InstalledNodes } from './InstalledNodes'; import { InstalledPackages } from './InstalledPackages'; -import { Role } from './Role'; import { Settings } from './Settings'; import { SharedCredentials } from './SharedCredentials'; import { SharedWorkflow } from './SharedWorkflow'; @@ -29,7 +28,6 @@ export const entities = { ExecutionEntity, InstalledNodes, InstalledPackages, - Role, Settings, SharedCredentials, SharedWorkflow, diff --git a/packages/cli/src/databases/migrations/common/1620821879465-UniqueWorkflowNames.ts b/packages/cli/src/databases/migrations/common/1620821879465-UniqueWorkflowNames.ts index 15a8063ef4..d867dcd51c 100644 --- a/packages/cli/src/databases/migrations/common/1620821879465-UniqueWorkflowNames.ts +++ b/packages/cli/src/databases/migrations/common/1620821879465-UniqueWorkflowNames.ts @@ -20,7 +20,7 @@ export class UniqueWorkflowNames1620821879465 implements ReversibleMigration { await Promise.all( duplicates.map(async (workflow, index) => { if (index === 0) return; - return runQuery(`UPDATE ${tableName} SET name = :name WHERE id = :id`, { + return await runQuery(`UPDATE ${tableName} SET name = :name WHERE id = :id`, { name: `${workflow.name} ${index + 1}`, id: workflow.id, }); diff --git a/packages/cli/src/databases/migrations/common/1659888469333-AddJsonKeyPinData.ts b/packages/cli/src/databases/migrations/common/1659888469333-AddJsonKeyPinData.ts index bb21e758fa..dfdaee213f 100644 --- a/packages/cli/src/databases/migrations/common/1659888469333-AddJsonKeyPinData.ts +++ b/packages/cli/src/databases/migrations/common/1659888469333-AddJsonKeyPinData.ts @@ -26,11 +26,12 @@ export class AddJsonKeyPinData1659888469333 implements IrreversibleMigration { const selectQuery = `SELECT id, ${columnName} FROM ${tableName} WHERE ${columnName} IS NOT NULL`; await runInBatches(selectQuery, async (workflows) => { await Promise.all( - this.makeUpdateParams(workflows).map(async (workflow) => - runQuery(`UPDATE ${tableName} SET ${columnName} = :pinData WHERE id = :id;`, { - pinData: workflow.pinData, - id: workflow.id, - }), + this.makeUpdateParams(workflows).map( + async (workflow) => + await runQuery(`UPDATE ${tableName} SET ${columnName} = :pinData WHERE id = :id;`, { + pinData: workflow.pinData, + id: workflow.id, + }), ), ); }); diff --git a/packages/cli/src/databases/migrations/common/1671726148419-RemoveWorkflowDataLoadedFlag.ts b/packages/cli/src/databases/migrations/common/1671726148419-RemoveWorkflowDataLoadedFlag.ts index d1ba1ea95e..a310f8b30b 100644 --- a/packages/cli/src/databases/migrations/common/1671726148419-RemoveWorkflowDataLoadedFlag.ts +++ b/packages/cli/src/databases/migrations/common/1671726148419-RemoveWorkflowDataLoadedFlag.ts @@ -18,13 +18,13 @@ export class RemoveWorkflowDataLoadedFlag1671726148419 implements ReversibleMigr await Promise.all( workflowIds.map( async ({ id, dataLoaded }) => - dataLoaded && - runQuery( - `INSERT INTO ${statisticsTableName} + await (dataLoaded && + runQuery( + `INSERT INTO ${statisticsTableName} (${escape.columnName('workflowId')}, name, count, ${escape.columnName('latestEvent')}) VALUES (:id, :name, 1, ${now})`, - { id, name: StatisticsNames.dataLoaded }, - ), + { id, name: StatisticsNames.dataLoaded }, + )), ), ); @@ -47,10 +47,11 @@ export class RemoveWorkflowDataLoadedFlag1671726148419 implements ReversibleMigr ); await Promise.all( - workflowsIds.map(async ({ workflowId }) => - runQuery(`UPDATE ${workflowTableName} SET ${columnName} = true WHERE id = :id`, { - id: workflowId, - }), + workflowsIds.map( + async ({ workflowId }) => + await runQuery(`UPDATE ${workflowTableName} SET ${columnName} = true WHERE id = :id`, { + id: workflowId, + }), ), ); diff --git a/packages/cli/src/databases/migrations/common/1675940580449-PurgeInvalidWorkflowConnections.ts b/packages/cli/src/databases/migrations/common/1675940580449-PurgeInvalidWorkflowConnections.ts index a15c873d36..51526aaead 100644 --- a/packages/cli/src/databases/migrations/common/1675940580449-PurgeInvalidWorkflowConnections.ts +++ b/packages/cli/src/databases/migrations/common/1675940580449-PurgeInvalidWorkflowConnections.ts @@ -47,10 +47,13 @@ export class PurgeInvalidWorkflowConnections1675940580449 implements Irreversibl }); // Update database with new connections - return runQuery(`UPDATE ${workflowsTable} SET connections = :connections WHERE id = :id`, { - connections: JSON.stringify(connections), - id: workflow.id, - }); + return await runQuery( + `UPDATE ${workflowsTable} SET connections = :connections WHERE id = :id`, + { + connections: JSON.stringify(connections), + id: workflow.id, + }, + ); }), ); } diff --git a/packages/cli/src/databases/migrations/common/1705429061930-DropRoleMapping.ts b/packages/cli/src/databases/migrations/common/1705429061930-DropRoleMapping.ts new file mode 100644 index 0000000000..dd91e6f541 --- /dev/null +++ b/packages/cli/src/databases/migrations/common/1705429061930-DropRoleMapping.ts @@ -0,0 +1,127 @@ +import type { MigrationContext, ReversibleMigration } from '@db/types'; + +type Table = 'user' | 'shared_workflow' | 'shared_credentials'; + +const idColumns: Record = { + user: 'id', + shared_credentials: 'credentialsId', + shared_workflow: 'workflowId', +}; + +const roleScopes: Record = { + user: 'global', + shared_credentials: 'credential', + shared_workflow: 'workflow', +}; + +const foreignKeySuffixes: Record = { + user: 'f0609be844f9200ff4365b1bb3d', + shared_credentials: 'c68e056637562000b68f480815a', + shared_workflow: '3540da03964527aa24ae014b780', +}; + +export class DropRoleMapping1705429061930 implements ReversibleMigration { + async up(context: MigrationContext) { + await this.migrateUp('user', context); + await this.migrateUp('shared_workflow', context); + await this.migrateUp('shared_credentials', context); + } + + async down(context: MigrationContext) { + await this.migrateDown('shared_workflow', context); + await this.migrateDown('shared_credentials', context); + await this.migrateDown('user', context); + } + + private async migrateUp( + table: Table, + { + dbType, + escape, + runQuery, + schemaBuilder: { addNotNull, addColumns, dropColumns, dropForeignKey, column }, + tablePrefix, + }: MigrationContext, + ) { + await addColumns(table, [column('role').text]); + + const roleTable = escape.tableName('role'); + const tableName = escape.tableName(table); + const idColumn = escape.columnName(idColumns[table]); + const roleColumnName = table === 'user' ? 'globalRoleId' : 'roleId'; + const roleColumn = escape.columnName(roleColumnName); + const scope = roleScopes[table]; + const isMySQL = ['mariadb', 'mysqldb'].includes(dbType); + const roleField = isMySQL ? `CONCAT('${scope}:', R.name)` : `'${scope}:' || R.name`; + const subQuery = ` + SELECT ${roleField} as role, T.${idColumn} as id + FROM ${tableName} T + LEFT JOIN ${roleTable} R + ON T.${roleColumn} = R.id and R.scope = '${scope}'`; + const swQuery = isMySQL + ? `UPDATE ${tableName}, (${subQuery}) as mapping + SET ${tableName}.role = mapping.role + WHERE ${tableName}.${idColumn} = mapping.id` + : `UPDATE ${tableName} + SET role = mapping.role + FROM (${subQuery}) as mapping + WHERE ${tableName}.${idColumn} = mapping.id`; + await runQuery(swQuery); + + await addNotNull(table, 'role'); + + await dropForeignKey( + table, + roleColumnName, + ['role', 'id'], + `FK_${tablePrefix}${foreignKeySuffixes[table]}`, + ); + await dropColumns(table, [roleColumnName]); + } + + private async migrateDown( + table: Table, + { + dbType, + escape, + runQuery, + schemaBuilder: { addNotNull, addColumns, dropColumns, addForeignKey, column }, + tablePrefix, + }: MigrationContext, + ) { + const roleColumnName = table === 'user' ? 'globalRoleId' : 'roleId'; + await addColumns(table, [column(roleColumnName).int]); + + const roleTable = escape.tableName('role'); + const tableName = escape.tableName(table); + const idColumn = escape.columnName(idColumns[table]); + const roleColumn = escape.columnName(roleColumnName); + const scope = roleScopes[table]; + const isMySQL = ['mariadb', 'mysqldb'].includes(dbType); + const roleField = isMySQL ? `CONCAT('${scope}:', R.name)` : `'${scope}:' || R.name`; + const subQuery = ` + SELECT R.id as role_id, T.${idColumn} as id + FROM ${tableName} T + LEFT JOIN ${roleTable} R + ON T.role = ${roleField} and R.scope = '${scope}'`; + const query = isMySQL + ? `UPDATE ${tableName}, (${subQuery}) as mapping + SET ${tableName}.${roleColumn} = mapping.role_id + WHERE ${tableName}.${idColumn} = mapping.id` + : `UPDATE ${tableName} + SET ${roleColumn} = mapping.role_id + FROM (${subQuery}) as mapping + WHERE ${tableName}.${idColumn} = mapping.id`; + await runQuery(query); + + await addNotNull(table, roleColumnName); + await addForeignKey( + table, + roleColumnName, + ['role', 'id'], + `FK_${tablePrefix}${foreignKeySuffixes[table]}`, + ); + + await dropColumns(table, ['role']); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index 66effc584b..8ce92158cf 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -51,6 +51,7 @@ import { ExecutionSoftDelete1693491613982 } from '../common/1693491613982-Execut import { AddWorkflowMetadata1695128658538 } from '../common/1695128658538-AddWorkflowMetadata'; import { ModifyWorkflowHistoryNodesAndConnections1695829275184 } from '../common/1695829275184-ModifyWorkflowHistoryNodesAndConnections'; import { AddGlobalAdminRole1700571993961 } from '../common/1700571993961-AddGlobalAdminRole'; +import { DropRoleMapping1705429061930 } from '../common/1705429061930-DropRoleMapping'; export const mysqlMigrations: Migration[] = [ InitialMigration1588157391238, @@ -105,4 +106,5 @@ export const mysqlMigrations: Migration[] = [ AddWorkflowMetadata1695128658538, ModifyWorkflowHistoryNodesAndConnections1695829275184, AddGlobalAdminRole1700571993961, + DropRoleMapping1705429061930, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/1681134145996-AddUserActivatedProperty.ts b/packages/cli/src/databases/migrations/postgresdb/1681134145996-AddUserActivatedProperty.ts index 50040ea9ca..38c1b22da4 100644 --- a/packages/cli/src/databases/migrations/postgresdb/1681134145996-AddUserActivatedProperty.ts +++ b/packages/cli/src/databases/migrations/postgresdb/1681134145996-AddUserActivatedProperty.ts @@ -18,15 +18,15 @@ export class AddUserActivatedProperty1681134145996 implements ReversibleMigratio AND r.scope = 'workflow'`, )) as UserSettings[]; - const updatedUsers = activatedUsers.map(async (user) => - queryRunner.query( + const updatedUserPromises = activatedUsers.map(async (user) => { + await queryRunner.query( `UPDATE "${tablePrefix}user" SET settings = '${JSON.stringify( user.settings, )}' WHERE id = '${user.id}' `, - ), - ); + ); + }); - await Promise.all(updatedUsers); + await Promise.all(updatedUserPromises); if (!activatedUsers.length) { await queryRunner.query( diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index 6af140bd08..8ae3733976 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -50,6 +50,7 @@ import { AddWorkflowMetadata1695128658538 } from '../common/1695128658538-AddWor import { MigrateToTimestampTz1694091729095 } from './1694091729095-MigrateToTimestampTz'; import { ModifyWorkflowHistoryNodesAndConnections1695829275184 } from '../common/1695829275184-ModifyWorkflowHistoryNodesAndConnections'; import { AddGlobalAdminRole1700571993961 } from '../common/1700571993961-AddGlobalAdminRole'; +import { DropRoleMapping1705429061930 } from '../common/1705429061930-DropRoleMapping'; export const postgresMigrations: Migration[] = [ InitialMigration1587669153312, @@ -103,4 +104,5 @@ export const postgresMigrations: Migration[] = [ MigrateToTimestampTz1694091729095, ModifyWorkflowHistoryNodesAndConnections1695829275184, AddGlobalAdminRole1700571993961, + DropRoleMapping1705429061930, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/1681134145996-AddUserActivatedProperty.ts b/packages/cli/src/databases/migrations/sqlite/1681134145996-AddUserActivatedProperty.ts index 3648a55cd5..d4e73014a7 100644 --- a/packages/cli/src/databases/migrations/sqlite/1681134145996-AddUserActivatedProperty.ts +++ b/packages/cli/src/databases/migrations/sqlite/1681134145996-AddUserActivatedProperty.ts @@ -18,14 +18,14 @@ export class AddUserActivatedProperty1681134145996 implements ReversibleMigratio AND r.scope = "workflow"`, )) as UserSettings[]; - const updatedUsers = activatedUsers.map(async (user) => - queryRunner.query( + const updatedUserPromises = activatedUsers.map(async (user) => { + await queryRunner.query( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `UPDATE ${tablePrefix}user SET settings = '${user.settings}' WHERE id = '${user.id}' `, - ), - ); + ); + }); - await Promise.all(updatedUsers); + await Promise.all(updatedUserPromises); if (!activatedUsers.length) { await queryRunner.query( diff --git a/packages/cli/src/databases/migrations/sqlite/1705429061930-DropRoleMapping.ts b/packages/cli/src/databases/migrations/sqlite/1705429061930-DropRoleMapping.ts new file mode 100644 index 0000000000..73f559b7aa --- /dev/null +++ b/packages/cli/src/databases/migrations/sqlite/1705429061930-DropRoleMapping.ts @@ -0,0 +1,5 @@ +import { DropRoleMapping1705429061930 as BaseMigration } from '../common/1705429061930-DropRoleMapping'; + +export class DropRoleMapping1705429061930 extends BaseMigration { + transaction = false as const; +} diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index d92a567bce..7db45788ac 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -48,6 +48,7 @@ import { ExecutionSoftDelete1693491613982 } from './1693491613982-ExecutionSoftD import { AddWorkflowMetadata1695128658538 } from '../common/1695128658538-AddWorkflowMetadata'; import { ModifyWorkflowHistoryNodesAndConnections1695829275184 } from '../common/1695829275184-ModifyWorkflowHistoryNodesAndConnections'; import { AddGlobalAdminRole1700571993961 } from '../common/1700571993961-AddGlobalAdminRole'; +import { DropRoleMapping1705429061930 } from './1705429061930-DropRoleMapping'; const sqliteMigrations: Migration[] = [ InitialMigration1588102412422, @@ -99,6 +100,7 @@ const sqliteMigrations: Migration[] = [ AddWorkflowMetadata1695128658538, ModifyWorkflowHistoryNodesAndConnections1695829275184, AddGlobalAdminRole1700571993961, + DropRoleMapping1705429061930, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/databases/repositories/credentials.repository.ts b/packages/cli/src/databases/repositories/credentials.repository.ts index f596f79ae5..4c6e75cfcd 100644 --- a/packages/cli/src/databases/repositories/credentials.repository.ts +++ b/packages/cli/src/databases/repositories/credentials.repository.ts @@ -20,11 +20,11 @@ export class CredentialsRepository extends Repository { credentialsId: credentialId, userId: Not(In(userIds)), }; - return transaction.delete(SharedCredentials, conditions); + return await transaction.delete(SharedCredentials, conditions); } async findStartingWith(credentialName: string) { - return this.find({ + return await this.find({ select: ['name'], where: { name: Like(`${credentialName}%`) }, }); @@ -37,7 +37,7 @@ export class CredentialsRepository extends Repository { findManyOptions.where = { ...findManyOptions.where, id: In(credentialIds) }; } - return this.find(findManyOptions); + return await this.find(findManyOptions); } private toFindManyOptions(listQueryOptions?: ListQuery.Options) { @@ -45,7 +45,7 @@ export class CredentialsRepository extends Repository { type Select = Array; - const defaultRelations = ['shared', 'shared.role', 'shared.user']; + const defaultRelations = ['shared', 'shared.user']; const defaultSelect: Select = ['id', 'name', 'type', 'nodesAccess', 'createdAt', 'updatedAt']; if (!listQueryOptions) return { select: defaultSelect, relations: defaultRelations }; @@ -81,9 +81,9 @@ export class CredentialsRepository extends Repository { const findManyOptions: FindManyOptions = { where: { id: In(ids) } }; if (withSharings) { - findManyOptions.relations = ['shared', 'shared.user', 'shared.role']; + findManyOptions.relations = ['shared', 'shared.user']; } - return this.find(findManyOptions); + return await this.find(findManyOptions); } } diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 8021433f3b..3f17767d0f 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -4,15 +4,18 @@ import { DataSource, In, IsNull, + LessThan, LessThanOrEqual, MoreThanOrEqual, Not, + Raw, Repository, } from 'typeorm'; import { DateUtils } from 'typeorm/util/DateUtils'; import type { FindManyOptions, FindOneOptions, + FindOperator, FindOptionsWhere, SelectQueryBuilder, } from 'typeorm'; @@ -20,7 +23,7 @@ import { parse, stringify } from 'flatted'; import { ApplicationError, type ExecutionStatus, - type IExecutionsSummary, + type ExecutionSummary, type IRunExecutionData, } from 'n8n-workflow'; import { BinaryDataService } from 'n8n-core'; @@ -32,13 +35,13 @@ import type { } from '@/Interfaces'; import config from '@/config'; -import type { IGetExecutionsQueryFilter } from '@/executions/executions.service'; import { isAdvancedExecutionFiltersEnabled } from '@/executions/executionHelpers'; import type { ExecutionData } from '../entities/ExecutionData'; import { ExecutionEntity } from '../entities/ExecutionEntity'; import { ExecutionMetadata } from '../entities/ExecutionMetadata'; import { ExecutionDataRepository } from './executionData.repository'; import { Logger } from '@/Logger'; +import type { GetManyActiveFilter } from '@/executions/execution.types'; function parseFiltersToQueryBuilder( qb: SelectQueryBuilder, @@ -249,7 +252,10 @@ export class ExecutionRepository extends Repository { * Permanently remove a single execution and its binary data. */ async hardDelete(ids: { workflowId: string; executionId: string }) { - return Promise.all([this.delete(ids.executionId), this.binaryDataService.deleteMany([ids])]); + return await Promise.all([ + this.delete(ids.executionId), + this.binaryDataService.deleteMany([ids]), + ]); } async updateStatus(executionId: string, status: ExecutionStatus) { @@ -338,7 +344,7 @@ export class ExecutionRepository extends Repository { excludedExecutionIds: string[], accessibleWorkflowIds: string[], additionalFilters?: { lastId?: string; firstId?: string }, - ): Promise { + ): Promise { if (accessibleWorkflowIds.length === 0) { return []; } @@ -436,7 +442,7 @@ export class ExecutionRepository extends Repository { } async getIdsSince(date: Date) { - return this.find({ + return await this.find({ select: ['id'], where: { startedAt: MoreThanOrEqual(DateUtils.mixedDateToUtcDatetimeString(date)), @@ -472,7 +478,7 @@ export class ExecutionRepository extends Repository { const [timeBasedWhere, countBasedWhere] = toPrune; - return this.createQueryBuilder() + return await this.createQueryBuilder() .update(ExecutionEntity) .set({ deletedAt: new Date() }) .where({ @@ -514,7 +520,7 @@ export class ExecutionRepository extends Repository { } async deleteByIds(executionIds: string[]) { - return this.delete({ id: In(executionIds) }); + return await this.delete({ id: In(executionIds) }); } async getWaitingExecutions() { @@ -532,7 +538,7 @@ export class ExecutionRepository extends Repository { where.waitTill = LessThanOrEqual(DateUtils.mixedDateToUtcDatetimeString(waitTill)); } - return this.findMultipleExecutions({ + return await this.findMultipleExecutions({ select: ['id', 'waitTill'], where, order: { @@ -540,4 +546,171 @@ export class ExecutionRepository extends Repository { }, }); } + + async getExecutionsCountForPublicApi(data: { + limit: number; + lastId?: string; + workflowIds?: string[]; + status?: ExecutionStatus; + excludedWorkflowIds?: string[]; + }): Promise { + const executions = await this.count({ + where: { + ...(data.lastId && { id: LessThan(data.lastId) }), + ...(data.status && { ...this.getStatusCondition(data.status) }), + ...(data.workflowIds && { workflowId: In(data.workflowIds) }), + ...(data.excludedWorkflowIds && { workflowId: Not(In(data.excludedWorkflowIds)) }), + }, + take: data.limit, + }); + + return executions; + } + + private getStatusCondition(status: ExecutionStatus) { + const condition: Pick, 'status'> = {}; + + if (status === 'success') { + condition.status = 'success'; + } else if (status === 'waiting') { + condition.status = 'waiting'; + } else if (status === 'error') { + condition.status = In(['error', 'crashed', 'failed']); + } + + return condition; + } + + async getExecutionsForPublicApi(params: { + limit: number; + includeData?: boolean; + lastId?: string; + workflowIds?: string[]; + status?: ExecutionStatus; + excludedExecutionsIds?: string[]; + }): Promise { + let where: FindOptionsWhere = {}; + + if (params.lastId && params.excludedExecutionsIds?.length) { + where.id = Raw((id) => `${id} < :lastId AND ${id} NOT IN (:...excludedExecutionsIds)`, { + lastId: params.lastId, + excludedExecutionsIds: params.excludedExecutionsIds, + }); + } else if (params.lastId) { + where.id = LessThan(params.lastId); + } else if (params.excludedExecutionsIds?.length) { + where.id = Not(In(params.excludedExecutionsIds)); + } + + if (params.status) { + where = { ...where, ...this.getStatusCondition(params.status) }; + } + + if (params.workflowIds) { + where = { ...where, workflowId: In(params.workflowIds) }; + } + + return await this.findMultipleExecutions( + { + select: [ + 'id', + 'mode', + 'retryOf', + 'retrySuccessId', + 'startedAt', + 'stoppedAt', + 'workflowId', + 'waitTill', + 'finished', + ], + where, + order: { id: 'DESC' }, + take: params.limit, + relations: ['executionData'], + }, + { + includeData: params.includeData, + unflattenData: true, + }, + ); + } + + async getExecutionInWorkflowsForPublicApi( + id: string, + workflowIds: string[], + includeData?: boolean, + ): Promise { + return await this.findSingleExecution(id, { + where: { + workflowId: In(workflowIds), + }, + includeData, + unflattenData: true, + }); + } + + async findIfShared(executionId: string, sharedWorkflowIds: string[]) { + return await this.findSingleExecution(executionId, { + where: { + workflowId: In(sharedWorkflowIds), + }, + includeData: true, + unflattenData: false, + }); + } + + async findIfAccessible(executionId: string, accessibleWorkflowIds: string[]) { + return await this.findSingleExecution(executionId, { + where: { workflowId: In(accessibleWorkflowIds) }, + }); + } + + async getManyActive( + activeExecutionIds: string[], + accessibleWorkflowIds: string[], + filter?: GetManyActiveFilter, + ) { + const where: FindOptionsWhere = { + id: In(activeExecutionIds), + status: Not(In(['finished', 'stopped', 'failed', 'crashed'] as ExecutionStatus[])), + }; + + if (filter) { + const { workflowId, status, finished } = filter; + if (workflowId && accessibleWorkflowIds.includes(workflowId)) { + where.workflowId = workflowId; + } else { + where.workflowId = In(accessibleWorkflowIds); + } + if (status) { + // @ts-ignore + where.status = In(status); + } + if (finished !== undefined) { + where.finished = finished; + } + } else { + where.workflowId = In(accessibleWorkflowIds); + } + + return await this.findMultipleExecutions({ + select: ['id', 'workflowId', 'mode', 'retryOf', 'startedAt', 'stoppedAt', 'status'], + order: { id: 'DESC' }, + where, + }); + } +} + +export interface IGetExecutionsQueryFilter { + id?: FindOperator | string; + finished?: boolean; + mode?: string; + retryOf?: string; + retrySuccessId?: string; + status?: ExecutionStatus[]; + workflowId?: string; + waitTill?: FindOperator | boolean; + metadata?: Array<{ key: string; value: string }>; + startedAfter?: string; + startedBefore?: string; } diff --git a/packages/cli/src/databases/repositories/executionData.repository.ts b/packages/cli/src/databases/repositories/executionData.repository.ts index e0b49de7ef..3eebab0122 100644 --- a/packages/cli/src/databases/repositories/executionData.repository.ts +++ b/packages/cli/src/databases/repositories/executionData.repository.ts @@ -9,7 +9,7 @@ export class ExecutionDataRepository extends Repository { } async findByExecutionIds(executionIds: string[]) { - return this.find({ + return await this.find({ select: ['workflowData'], where: { executionId: In(executionIds), diff --git a/packages/cli/src/databases/repositories/installedPackages.repository.ts b/packages/cli/src/databases/repositories/installedPackages.repository.ts index 06ef523c07..743528e35f 100644 --- a/packages/cli/src/databases/repositories/installedPackages.repository.ts +++ b/packages/cli/src/databases/repositories/installedPackages.repository.ts @@ -41,7 +41,7 @@ export class InstalledPackagesRepository extends Repository { installedPackage.installedNodes.push(installedNode); - return manager.save(installedNode); + return await manager.save(installedNode); }); }); diff --git a/packages/cli/src/databases/repositories/role.repository.ts b/packages/cli/src/databases/repositories/role.repository.ts deleted file mode 100644 index 9694ec1033..0000000000 --- a/packages/cli/src/databases/repositories/role.repository.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Service } from 'typedi'; -import { DataSource, In, Repository } from 'typeorm'; -import type { RoleNames, RoleScopes } from '../entities/Role'; -import { Role } from '../entities/Role'; -import { User } from '../entities/User'; - -@Service() -export class RoleRepository extends Repository { - constructor(dataSource: DataSource) { - super(Role, dataSource.manager); - } - - 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 | string }; - - 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] = typeof item.count === 'number' ? item.count : parseInt(item.count, 10); - return acc; - }, {}); - } - - async getIdsInScopeWorkflowByNames(roleNames: RoleNames[]) { - return this.find({ - select: ['id'], - where: { name: In(roleNames), scope: 'workflow' }, - }).then((role) => role.map(({ id }) => id)); - } -} diff --git a/packages/cli/src/databases/repositories/sharedCredentials.repository.ts b/packages/cli/src/databases/repositories/sharedCredentials.repository.ts index 8250a61357..0a52f52153 100644 --- a/packages/cli/src/databases/repositories/sharedCredentials.repository.ts +++ b/packages/cli/src/databases/repositories/sharedCredentials.repository.ts @@ -1,9 +1,8 @@ import { Service } from 'typedi'; -import type { EntityManager, FindOptionsWhere } from 'typeorm'; +import type { EntityManager } from 'typeorm'; import { DataSource, In, Not, Repository } from 'typeorm'; import { SharedCredentials } from '../entities/SharedCredentials'; import type { User } from '../entities/User'; -import type { Role } from '../entities/Role'; @Service() export class SharedCredentialsRepository extends Repository { @@ -25,16 +24,16 @@ export class SharedCredentialsRepository extends Repository { } async findByCredentialIds(credentialIds: string[]) { - return this.find({ - relations: ['credentials', 'role', 'user'], + return await this.find({ + relations: ['credentials', 'user'], where: { credentialsId: In(credentialIds), }, }); } - async makeOwnerOfAllCredentials(user: User, role: Role) { - return this.update({ userId: Not(user.id), roleId: role.id }, { user }); + async makeOwnerOfAllCredentials(user: User) { + return await this.update({ userId: Not(user.id), role: 'credential:owner' }, { user }); } /** @@ -42,27 +41,26 @@ export class SharedCredentialsRepository extends Repository { */ async getAccessibleCredentials(userId: string) { const sharings = await this.find({ - relations: ['role'], where: { userId, - role: { name: In(['owner', 'user']), scope: 'credential' }, + role: In(['credential:owner', 'credential:user']), }, }); return sharings.map((s) => s.credentialsId); } - async findSharings(userIds: string[], roleId?: string) { - const where: FindOptionsWhere = { userId: In(userIds) }; - - // If credential sharing is not enabled, get only credentials owned by this user - if (roleId) where.roleId = roleId; - - return this.find({ where }); + async findOwnedSharings(userIds: string[]) { + return await this.find({ + where: { + userId: In(userIds), + role: 'credential:owner', + }, + }); } async deleteByIds(transaction: EntityManager, sharedCredentialsIds: string[], user?: User) { - return transaction.delete(SharedCredentials, { + return await transaction.delete(SharedCredentials, { user, credentialsId: In(sharedCredentialsIds), }); diff --git a/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts b/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts index 8e026cfa00..e3d321cab4 100644 --- a/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts +++ b/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts @@ -1,10 +1,9 @@ import { Service } from 'typedi'; import { DataSource, Repository, In, Not } from 'typeorm'; -import type { EntityManager, FindOptionsSelect, FindOptionsWhere } from 'typeorm'; -import { SharedWorkflow } from '../entities/SharedWorkflow'; +import type { EntityManager, FindManyOptions, FindOptionsWhere } from 'typeorm'; +import { SharedWorkflow, type WorkflowSharingRole } from '../entities/SharedWorkflow'; import { type User } from '../entities/User'; import type { Scope } from '@n8n/permissions'; -import type { Role } from '../entities/Role'; import type { WorkflowEntity } from '../entities/WorkflowEntity'; @Service() @@ -20,7 +19,7 @@ export class SharedWorkflowRepository extends Repository { if (!user.hasGlobalScope('workflow:read')) { where.userId = user.id; } - return this.exist({ where }); + return await this.exist({ where }); } async getSharedWorkflowIds(workflowIds: string[]) { @@ -34,23 +33,30 @@ export class SharedWorkflowRepository extends Repository { } async findByWorkflowIds(workflowIds: string[]) { - return this.find({ - relations: ['role', 'user'], + return await this.find({ + relations: ['user'], where: { - role: { - name: 'owner', - scope: 'workflow', - }, + role: 'workflow:owner', workflowId: In(workflowIds), }, }); } + async findSharingRole( + userId: string, + workflowId: string, + ): Promise { + return await this.findOne({ + select: ['role'], + where: { workflowId, userId }, + }).then((shared) => shared?.role); + } + async findSharing( workflowId: string, user: User, scope: Scope, - { roles, extraRelations }: { roles?: string[]; extraRelations?: string[] } = {}, + { roles, extraRelations }: { roles?: WorkflowSharingRole[]; extraRelations?: string[] } = {}, ) { const where: FindOptionsWhere = { workflow: { id: workflowId }, @@ -61,18 +67,18 @@ export class SharedWorkflowRepository extends Repository { } if (roles) { - where.role = { name: In(roles) }; + where.role = In(roles); } - const relations = ['workflow', 'role']; + const relations = ['workflow']; if (extraRelations) relations.push(...extraRelations); - return this.findOne({ relations, where }); + return await this.findOne({ relations, where }); } - async makeOwnerOfAllWorkflows(user: User, role: Role) { - return this.update({ userId: Not(user.id), roleId: role.id }, { user }); + async makeOwnerOfAllWorkflows(user: User) { + return await this.update({ userId: Not(user.id), role: 'workflow:owner' }, { user }); } async getSharing( @@ -90,7 +96,7 @@ export class SharedWorkflowRepository extends Repository { where.userId = user.id; } - return this.findOne({ where, relations }); + return await this.findOne({ where, relations }); } async getSharedWorkflows( @@ -100,16 +106,16 @@ export class SharedWorkflowRepository extends Repository { workflowIds?: string[]; }, ): Promise { - return this.find({ + return await this.find({ where: { - ...(!['owner', 'admin'].includes(user.globalRole.name) && { userId: user.id }), + ...(!['global:owner', 'global:admin'].includes(user.role) && { userId: user.id }), ...(options.workflowIds && { workflowId: In(options.workflowIds) }), }, ...(options.relations && { relations: options.relations }), }); } - async share(transaction: EntityManager, workflow: WorkflowEntity, users: User[], roleId: string) { + async share(transaction: EntityManager, workflow: WorkflowEntity, users: User[]) { const newSharedWorkflows = users.reduce((acc, user) => { if (user.isPending) { return acc; @@ -117,26 +123,29 @@ export class SharedWorkflowRepository extends Repository { const entity: Partial = { workflowId: workflow.id, userId: user.id, - roleId, + role: 'workflow:editor', }; acc.push(this.create(entity)); return acc; }, []); - return transaction.save(newSharedWorkflows); + return await transaction.save(newSharedWorkflows); } - async findWithFields(workflowIds: string[], { fields }: { fields: string[] }) { - return this.find({ + async findWithFields( + workflowIds: string[], + { select }: Pick, 'select'>, + ) { + return await this.find({ where: { workflowId: In(workflowIds), }, - select: fields as FindOptionsSelect, + select, }); } async deleteByIds(transaction: EntityManager, sharedWorkflowIds: string[], user?: User) { - return transaction.delete(SharedWorkflow, { + return await transaction.delete(SharedWorkflow, { user, workflowId: In(sharedWorkflowIds), }); diff --git a/packages/cli/src/databases/repositories/tag.repository.ts b/packages/cli/src/databases/repositories/tag.repository.ts index 285a4de096..ff41a28b03 100644 --- a/packages/cli/src/databases/repositories/tag.repository.ts +++ b/packages/cli/src/databases/repositories/tag.repository.ts @@ -12,7 +12,7 @@ export class TagRepository extends Repository { } async findMany(tagIds: string[]) { - return this.find({ + return await this.find({ select: ['id', 'name'], where: { id: In(tagIds) }, }); diff --git a/packages/cli/src/databases/repositories/usageMetrics.repository.ts b/packages/cli/src/databases/repositories/usageMetrics.repository.ts new file mode 100644 index 0000000000..c7cd36ed9b --- /dev/null +++ b/packages/cli/src/databases/repositories/usageMetrics.repository.ts @@ -0,0 +1,75 @@ +import config from '@/config'; +import { Service } from 'typedi'; +import { DataSource, Repository, Entity } from 'typeorm'; + +@Entity() +export class UsageMetrics {} + +@Service() +export class UsageMetricsRepository extends Repository { + constructor(dataSource: DataSource) { + super(UsageMetrics, dataSource.manager); + } + + toTableName(name: string) { + const tablePrefix = config.getEnv('database.tablePrefix'); + + let tableName = + config.getEnv('database.type') === 'mysqldb' + ? `\`${tablePrefix}${name}\`` + : `"${tablePrefix}${name}"`; + + const pgSchema = config.getEnv('database.postgresdb.schema'); + + if (pgSchema !== 'public') tableName = [pgSchema, tablePrefix + name].join('.'); + + return tableName; + } + + async getLicenseRenewalMetrics() { + type Row = { + enabled_user_count: string | number; + active_workflow_count: string | number; + total_workflow_count: string | number; + total_credentials_count: string | number; + production_executions_count: string | number; + manual_executions_count: string | number; + }; + + const userTable = this.toTableName('user'); + const workflowTable = this.toTableName('workflow_entity'); + const credentialTable = this.toTableName('credentials_entity'); + const workflowStatsTable = this.toTableName('workflow_statistics'); + + const [ + { + enabled_user_count: enabledUsers, + active_workflow_count: activeWorkflows, + total_workflow_count: totalWorkflows, + total_credentials_count: totalCredentials, + production_executions_count: productionExecutions, + manual_executions_count: manualExecutions, + }, + ] = (await this.query(` + SELECT + (SELECT COUNT(*) FROM ${userTable} WHERE disabled = false) AS enabled_user_count, + (SELECT COUNT(*) FROM ${workflowTable} WHERE active = true) AS active_workflow_count, + (SELECT COUNT(*) FROM ${workflowTable}) AS total_workflow_count, + (SELECT COUNT(*) FROM ${credentialTable}) AS total_credentials_count, + (SELECT SUM(count) FROM ${workflowStatsTable} WHERE name IN ('production_success', 'production_error')) AS production_executions_count, + (SELECT SUM(count) FROM ${workflowStatsTable} WHERE name IN ('manual_success', 'manual_error')) AS manual_executions_count; + `)) as Row[]; + + const toNumber = (value: string | number) => + typeof value === 'number' ? value : parseInt(value, 10); + + return { + enabledUsers: toNumber(enabledUsers), + activeWorkflows: toNumber(activeWorkflows), + totalWorkflows: toNumber(totalWorkflows), + totalCredentials: toNumber(totalCredentials), + productionExecutions: toNumber(productionExecutions), + manualExecutions: toNumber(manualExecutions), + }; + } +} diff --git a/packages/cli/src/databases/repositories/user.repository.ts b/packages/cli/src/databases/repositories/user.repository.ts index e6be912579..fd0039bb6c 100644 --- a/packages/cli/src/databases/repositories/user.repository.ts +++ b/packages/cli/src/databases/repositories/user.repository.ts @@ -1,19 +1,18 @@ import { Service } from 'typedi'; import type { EntityManager, FindManyOptions } from 'typeorm'; import { DataSource, In, IsNull, Not, Repository } from 'typeorm'; -import { User } from '../entities/User'; import type { ListQuery } from '@/requests'; +import { type GlobalRole, User } from '../entities/User'; @Service() export class UserRepository extends Repository { constructor(dataSource: DataSource) { super(User, dataSource.manager); } - async findManybyIds(userIds: string[]) { - return this.find({ + async findManyByIds(userIds: string[]) { + return await this.find({ where: { id: In(userIds) }, - relations: ['globalRole'], }); } @@ -22,36 +21,50 @@ export class UserRepository extends Repository { } async getByIds(transaction: EntityManager, ids: string[]) { - return transaction.find(User, { where: { id: In(ids) } }); + return await transaction.find(User, { where: { id: In(ids) } }); } async findManyByEmail(emails: string[]) { - return this.find({ + return await this.find({ where: { email: In(emails) }, - relations: ['globalRole'], select: ['email', 'password', 'id'], }); } async deleteMany(userIds: string[]) { - return this.delete({ id: In(userIds) }); + return await this.delete({ id: In(userIds) }); } async findNonShellUser(email: string) { - return this.findOne({ + return await this.findOne({ where: { email, password: Not(IsNull()), }, - relations: ['authIdentities', 'globalRole'], + relations: ['authIdentities'], }); } - async toFindManyOptions(listQueryOptions?: ListQuery.Options, globalOwnerRoleId?: string) { + /** Counts the number of users in each role, e.g. `{ admin: 2, member: 6, owner: 1 }` */ + async countUsersByRole() { + const rows = (await this.createQueryBuilder() + .select(['role', 'COUNT(role) as count']) + .groupBy('role') + .execute()) as Array<{ role: GlobalRole; count: string }>; + return rows.reduce( + (acc, row) => { + acc[row.role] = parseInt(row.count, 10); + return acc; + }, + {} as Record, + ); + } + + async toFindManyOptions(listQueryOptions?: ListQuery.Options) { const findManyOptions: FindManyOptions = {}; if (!listQueryOptions) { - findManyOptions.relations = ['globalRole', 'authIdentities']; + findManyOptions.relations = ['authIdentities']; return findManyOptions; } @@ -62,7 +75,7 @@ export class UserRepository extends Repository { if (skip) findManyOptions.skip = skip; if (take && !select) { - findManyOptions.relations = ['globalRole', 'authIdentities']; + findManyOptions.relations = ['authIdentities']; } if (take && select && !select?.id) { @@ -74,14 +87,21 @@ export class UserRepository extends Repository { findManyOptions.where = otherFilters; - if (isOwner !== undefined && globalOwnerRoleId) { - findManyOptions.relations = ['globalRole']; - findManyOptions.where.globalRole = { - id: isOwner ? globalOwnerRoleId : Not(globalOwnerRoleId), - }; + if (isOwner !== undefined) { + findManyOptions.where.role = isOwner ? 'global:owner' : Not('global:owner'); } } return findManyOptions; } + + /** + * Get emails of users who have completed setup, by user IDs. + */ + async getEmailsByIds(userIds: string[]) { + return await this.find({ + select: ['email'], + where: { id: In(userIds), password: Not(IsNull()) }, + }); + } } diff --git a/packages/cli/src/databases/repositories/workflow.repository.ts b/packages/cli/src/databases/repositories/workflow.repository.ts index 79cd6461c0..addec8802c 100644 --- a/packages/cli/src/databases/repositories/workflow.repository.ts +++ b/packages/cli/src/databases/repositories/workflow.repository.ts @@ -26,16 +26,16 @@ export class WorkflowRepository extends Repository { } async get(where: FindOptionsWhere, options?: { relations: string[] }) { - return this.findOne({ + return await this.findOne({ where, relations: options?.relations, }); } async getAllActive() { - return this.find({ + return await this.find({ where: { active: true }, - relations: ['shared', 'shared.user', 'shared.user.globalRole', 'shared.role'], + relations: ['shared', 'shared.user'], }); } @@ -48,9 +48,9 @@ export class WorkflowRepository extends Repository { } async findById(workflowId: string) { - return this.findOne({ + return await this.findOne({ where: { id: workflowId }, - relations: ['shared', 'shared.user', 'shared.user.globalRole', 'shared.role'], + relations: ['shared', 'shared.user'], }); } @@ -61,7 +61,7 @@ export class WorkflowRepository extends Repository { if (fields?.length) options.select = fields as FindOptionsSelect; - return this.find(options); + return await this.find(options); } async getActiveTriggerCount() { @@ -88,7 +88,7 @@ export class WorkflowRepository extends Repository { workflowId: string, userIds: string[], ): Promise { - return transaction.delete(SharedWorkflow, { + return await transaction.delete(SharedWorkflow, { workflowId, userId: Not(In(userIds)), }); @@ -96,7 +96,7 @@ export class WorkflowRepository extends Repository { async updateWorkflowTriggerCount(id: string, triggerCount: number): Promise { const qb = this.createQueryBuilder('workflow'); - return qb + return await qb .update() .set({ triggerCount, @@ -135,7 +135,7 @@ export class WorkflowRepository extends Repository { createdAt: true, updatedAt: true, versionId: true, - shared: { userId: true, roleId: true }, + shared: { userId: true, role: true }, }; delete select?.ownedBy; // remove non-entity field, handled after query @@ -152,7 +152,7 @@ export class WorkflowRepository extends Repository { select.tags = { id: true, name: true }; } - if (isOwnedByIncluded) relations.push('shared', 'shared.role', 'shared.user'); + if (isOwnedByIncluded) relations.push('shared', 'shared.user'); if (typeof where.name === 'string' && where.name !== '') { where.name = Like(`%${where.name}%`); @@ -185,35 +185,39 @@ export class WorkflowRepository extends Repository { } async findStartingWith(workflowName: string): Promise> { - return this.find({ + return await this.find({ select: ['name'], where: { name: Like(`${workflowName}%`) }, }); } async findIn(workflowIds: string[]) { - return this.find({ + return await this.find({ select: ['id', 'name'], where: { id: In(workflowIds) }, }); } async findWebhookBasedActiveWorkflows() { - return this.createQueryBuilder('workflow') + return await (this.createQueryBuilder('workflow') .select('DISTINCT workflow.id, workflow.name') .innerJoin(WebhookEntity, 'webhook_entity', 'workflow.id = webhook_entity.workflowId') - .execute() as Promise>; + .execute() as Promise>); } async updateActiveState(workflowId: string, newState: boolean) { - return this.update({ id: workflowId }, { active: newState }); + return await this.update({ id: workflowId }, { active: newState }); } async deactivateAll() { - return this.update({ active: true }, { active: false }); + return await this.update({ active: true }, { active: false }); + } + + async activateAll() { + return await this.update({ active: false }, { active: true }); } async findByActiveState(activeState: boolean) { - return this.findBy({ active: activeState }); + return await this.findBy({ active: activeState }); } } diff --git a/packages/cli/src/databases/repositories/workflowHistory.repository.ts b/packages/cli/src/databases/repositories/workflowHistory.repository.ts index eda4a18e31..bc21b3dca7 100644 --- a/packages/cli/src/databases/repositories/workflowHistory.repository.ts +++ b/packages/cli/src/databases/repositories/workflowHistory.repository.ts @@ -9,6 +9,6 @@ export class WorkflowHistoryRepository extends Repository { } async deleteEarlierThan(date: Date) { - return this.delete({ createdAt: LessThan(date) }); + return await this.delete({ createdAt: LessThan(date) }); } } diff --git a/packages/cli/src/databases/repositories/workflowStatistics.repository.ts b/packages/cli/src/databases/repositories/workflowStatistics.repository.ts index 5d6a9261da..67601c00f1 100644 --- a/packages/cli/src/databases/repositories/workflowStatistics.repository.ts +++ b/packages/cli/src/databases/repositories/workflowStatistics.repository.ts @@ -1,8 +1,10 @@ import { Service } from 'typedi'; import { DataSource, QueryFailedError, Repository } from 'typeorm'; import config from '@/config'; -import type { StatisticsNames } from '../entities/WorkflowStatistics'; -import { WorkflowStatistics } from '../entities/WorkflowStatistics'; +import { StatisticsNames, WorkflowStatistics } from '../entities/WorkflowStatistics'; +import type { User } from '@/databases/entities/User'; +import { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; +import { SharedWorkflow } from '@/databases/entities/SharedWorkflow'; type StatisticsInsertResult = 'insert' | 'failed' | 'alreadyExists'; type StatisticsUpsertResult = StatisticsInsertResult | 'update'; @@ -98,4 +100,20 @@ export class WorkflowStatisticsRepository extends Repository throw error; } } + + async queryNumWorkflowsUserHasWithFiveOrMoreProdExecs(userId: User['id']): Promise { + return await this.createQueryBuilder('workflow_statistics') + .innerJoin(WorkflowEntity, 'workflow', 'workflow.id = workflow_statistics.workflowId') + .innerJoin( + SharedWorkflow, + 'shared_workflow', + 'shared_workflow.workflowId = workflow_statistics.workflowId', + ) + .where('shared_workflow.userId = :userId', { userId }) + .andWhere('workflow.active = :isActive', { isActive: true }) + .andWhere('workflow_statistics.name = :name', { name: StatisticsNames.productionSuccess }) + .andWhere('workflow_statistics.count >= 5') + .andWhere('role = :roleName', { roleName: 'workflow:owner' }) + .getCount(); + } } diff --git a/packages/cli/src/databases/types.ts b/packages/cli/src/databases/types.ts index 9574e5f201..25a9363a00 100644 --- a/packages/cli/src/databases/types.ts +++ b/packages/cli/src/databases/types.ts @@ -59,3 +59,5 @@ export interface Migration extends Function { } export type InsertResult = Array<{ insertId: number }>; + +export { QueryFailedError } from 'typeorm/error/QueryFailedError'; diff --git a/packages/cli/src/databases/utils/migrationHelpers.ts b/packages/cli/src/databases/utils/migrationHelpers.ts index c254d8c44d..24fc80d86f 100644 --- a/packages/cli/src/databases/utils/migrationHelpers.ts +++ b/packages/cli/src/databases/utils/migrationHelpers.ts @@ -118,9 +118,9 @@ const createContext = (queryRunner: QueryRunner, migration: Migration): Migratio namedParameters, {}, ); - return queryRunner.query(query, parameters) as Promise; + return await (queryRunner.query(query, parameters) as Promise); } else { - return queryRunner.query(sql) as Promise; + return await (queryRunner.query(sql) as Promise); } }, runInBatches: async ( diff --git a/packages/cli/src/decorators/registerController.ts b/packages/cli/src/decorators/registerController.ts index c3b38c8183..f6abd260fa 100644 --- a/packages/cli/src/decorators/registerController.ts +++ b/packages/cli/src/decorators/registerController.ts @@ -36,9 +36,7 @@ export const createAuthMiddleware = if (!user) return res.status(401).json({ status: 'error', message: 'Unauthorized' }); - const { globalRole } = user; - if (authRole === 'any' || (globalRole.scope === authRole[0] && globalRole.name === authRole[1])) - return next(); + if (authRole === 'any' || authRole === user.role) return next(); res.status(403).json({ status: 'error', message: 'Unauthorized' }); }; @@ -121,7 +119,8 @@ export const registerController = (app: Application, controllerClass: Class controller[handlerName](req, res); + const handler = async (req: Request, res: Response) => + await controller[handlerName](req, res); router[method]( path, ...(authRole ? [createAuthMiddleware(authRole)] : []), diff --git a/packages/cli/src/decorators/types.ts b/packages/cli/src/decorators/types.ts index 919e748686..bbaccf39ab 100644 --- a/packages/cli/src/decorators/types.ts +++ b/packages/cli/src/decorators/types.ts @@ -1,11 +1,11 @@ import type { Request, Response, RequestHandler } from 'express'; -import type { RoleNames, RoleScopes } from '@db/entities/Role'; +import type { GlobalRole } from '@db/entities/User'; import type { BooleanLicenseFeature } from '@/Interfaces'; import type { Scope } from '@n8n/permissions'; export type Method = 'get' | 'post' | 'put' | 'patch' | 'delete'; -export type AuthRole = [RoleScopes, RoleNames] | 'any' | 'none'; +export type AuthRole = GlobalRole | 'any' | 'none'; export type AuthRoleMetadata = Record; export type LicenseMetadata = Record; diff --git a/packages/cli/src/environments/sourceControl/sourceControl.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControl.service.ee.ts index a590050525..189d3b9e77 100644 --- a/packages/cli/src/environments/sourceControl/sourceControl.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControl.service.ee.ts @@ -171,7 +171,7 @@ export class SourceControlService { await this.initGitService(); } await this.gitService.fetch(); - return this.gitService.getBranches(); + return await this.gitService.getBranches(); } async setBranch(branch: string): Promise<{ branches: string[]; currentBranch: string }> { @@ -182,7 +182,7 @@ export class SourceControlService { branchName: branch, connected: branch?.length > 0, }); - return this.gitService.setBranch(branch); + return await this.gitService.setBranch(branch); } // will reset the branch to the remote branch and pull diff --git a/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts index 2c56b84ecd..771d4fdee4 100644 --- a/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts @@ -95,7 +95,7 @@ export class SourceControlExportService { owner: owners[e.id], }; this.logger.debug(`Writing workflow ${e.id} to ${fileName}`); - return fsWriteFile(fileName, JSON.stringify(sanitizedWorkflow, null, 2)); + return await fsWriteFile(fileName, JSON.stringify(sanitizedWorkflow, null, 2)); }), ); } @@ -253,7 +253,7 @@ export class SourceControlExportService { nodesAccess: sharedCredential.credentials.nodesAccess, }; this.logger.debug(`Writing credential ${sharedCredential.credentials.id} to ${fileName}`); - return fsWriteFile(fileName, JSON.stringify(sanitizedCredential, null, 2)); + return await fsWriteFile(fileName, JSON.stringify(sanitizedCredential, null, 2)); }), ); return { diff --git a/packages/cli/src/environments/sourceControl/sourceControlGit.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControlGit.service.ee.ts index 4a440f14e6..64690a632d 100644 --- a/packages/cli/src/environments/sourceControl/sourceControlGit.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControlGit.service.ee.ts @@ -232,7 +232,7 @@ export class SourceControlGitService { } await this.git.checkout(branch); await this.git.branch([`--set-upstream-to=${SOURCE_CONTROL_ORIGIN}/${branch}`, branch]); - return this.getBranches(); + return await this.getBranches(); } async getCurrentBranch(): Promise<{ current: string; remote: string }> { @@ -253,7 +253,7 @@ export class SourceControlGitService { const currentBranch = await this.getCurrentBranch(); if (currentBranch.remote) { const target = currentBranch.remote; - return this.git.diffSummary(['...' + target, '--ignore-all-space']); + return await this.git.diffSummary(['...' + target, '--ignore-all-space']); } return; } @@ -265,7 +265,7 @@ export class SourceControlGitService { const currentBranch = await this.getCurrentBranch(); if (currentBranch.remote) { const target = currentBranch.current; - return this.git.diffSummary([target, '--ignore-all-space']); + return await this.git.diffSummary([target, '--ignore-all-space']); } return; } @@ -274,7 +274,7 @@ export class SourceControlGitService { if (!this.git) { throw new ApplicationError('Git is not initialized (fetch)'); } - return this.git.fetch(); + return await this.git.fetch(); } async pull(options: { ffOnly: boolean } = { ffOnly: true }): Promise { @@ -286,7 +286,7 @@ export class SourceControlGitService { // eslint-disable-next-line @typescript-eslint/naming-convention Object.assign(params, { '--ff-only': true }); } - return this.git.pull(params); + return await this.git.pull(params); } async push( @@ -300,9 +300,9 @@ export class SourceControlGitService { throw new ApplicationError('Git is not initialized ({)'); } if (force) { - return this.git.push(SOURCE_CONTROL_ORIGIN, branch, ['-f']); + return await this.git.push(SOURCE_CONTROL_ORIGIN, branch, ['-f']); } - return this.git.push(SOURCE_CONTROL_ORIGIN, branch); + return await this.git.push(SOURCE_CONTROL_ORIGIN, branch); } async stage(files: Set, deletedFiles?: Set): Promise { @@ -316,7 +316,7 @@ export class SourceControlGitService { this.logger.debug(`Git rm: ${(error as Error).message}`); } } - return this.git.add(Array.from(files)); + return await this.git.add(Array.from(files)); } async resetBranch( @@ -326,9 +326,9 @@ export class SourceControlGitService { throw new ApplicationError('Git is not initialized (Promise)'); } if (options?.hard) { - return this.git.raw(['reset', '--hard', options.target]); + return await this.git.raw(['reset', '--hard', options.target]); } - return this.git.raw(['reset', options.target]); + return await this.git.raw(['reset', options.target]); // built-in reset method does not work // return this.git.reset(); } @@ -337,7 +337,7 @@ export class SourceControlGitService { if (!this.git) { throw new ApplicationError('Git is not initialized (commit)'); } - return this.git.commit(message); + return await this.git.commit(message); } async status(): Promise { diff --git a/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts index f4743a3d67..2fe8525996 100644 --- a/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts @@ -23,12 +23,10 @@ import { isUniqueConstraintError } from '@/ResponseHelper'; import type { SourceControlWorkflowVersionId } from './types/sourceControlWorkflowVersionId'; import { getCredentialExportPath, getWorkflowExportPath } from './sourceControlHelper.ee'; import type { SourceControlledFile } from './types/sourceControlledFile'; -import { RoleService } from '@/services/role.service'; import { VariablesService } from '../variables/variables.service.ee'; import { TagRepository } from '@db/repositories/tag.repository'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { UserRepository } from '@db/repositories/user.repository'; -import { UM_FIX_INSTRUCTION } from '@/constants'; import { Logger } from '@/Logger'; import { CredentialsRepository } from '@db/repositories/credentials.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; @@ -59,36 +57,6 @@ export class SourceControlImportService { ); } - private async getOwnerGlobalRole() { - const globalOwnerRole = await Container.get(RoleService).findGlobalOwnerRole(); - - if (!globalOwnerRole) { - throw new ApplicationError(`Failed to find owner. ${UM_FIX_INSTRUCTION}`); - } - - return globalOwnerRole; - } - - private async getCredentialOwnerRole() { - const credentialOwnerRole = await Container.get(RoleService).findCredentialOwnerRole(); - - if (!credentialOwnerRole) { - throw new ApplicationError(`Failed to find owner. ${UM_FIX_INSTRUCTION}`); - } - - return credentialOwnerRole; - } - - private async getWorkflowOwnerRole() { - const workflowOwnerRole = await Container.get(RoleService).findWorkflowOwnerRole(); - - if (!workflowOwnerRole) { - throw new ApplicationError(`Failed to find owner workflow role. ${UM_FIX_INSTRUCTION}`); - } - - return workflowOwnerRole; - } - public async getRemoteVersionIdsFromFiles(): Promise { const remoteWorkflowFiles = await glob('*.json', { cwd: this.workflowExportFolder, @@ -186,7 +154,7 @@ export class SourceControlImportService { } public async getLocalVariablesFromDb(): Promise { - return this.variablesService.getAllCached(); + return await this.variablesService.getAllCached(); } public async getRemoteTagsAndMappingsFromFile(): Promise<{ @@ -222,7 +190,6 @@ export class SourceControlImportService { } public async importWorkflowFromWorkFolder(candidates: SourceControlledFile[], userId: string) { - const ownerWorkflowRole = await this.getWorkflowOwnerRole(); const workflowRunner = this.activeWorkflowRunner; const candidateIds = candidates.map((c) => c.id); const existingWorkflows = await Container.get(WorkflowRepository).findByIds(candidateIds, { @@ -230,7 +197,7 @@ export class SourceControlImportService { }); const allSharedWorkflows = await Container.get(SharedWorkflowRepository).findWithFields( candidateIds, - { fields: ['workflowId', 'roleId', 'userId'] }, + { select: ['workflowId', 'role', 'userId'] }, ); const cachedOwnerIds = new Map(); const importWorkflowsResult = await Promise.all( @@ -273,35 +240,29 @@ export class SourceControlImportService { } const existingSharedWorkflowOwnerByRoleId = allSharedWorkflows.find( - (e) => - e.workflowId === importedWorkflow.id && - e.roleId.toString() === ownerWorkflowRole.id.toString(), + (e) => e.workflowId === importedWorkflow.id && e.role === 'workflow:owner', ); const existingSharedWorkflowOwnerByUserId = allSharedWorkflows.find( - (e) => - e.workflowId === importedWorkflow.id && - e.roleId.toString() === workflowOwnerId.toString(), + (e) => e.workflowId === importedWorkflow.id && e.role === 'workflow:owner', ); if (!existingSharedWorkflowOwnerByUserId && !existingSharedWorkflowOwnerByRoleId) { // no owner exists yet, so create one await Container.get(SharedWorkflowRepository).insert({ workflowId: importedWorkflow.id, userId: workflowOwnerId, - roleId: ownerWorkflowRole.id, + role: 'workflow:owner', }); } else if (existingSharedWorkflowOwnerByRoleId) { // skip, because the workflow already has a global owner } else if (existingSharedWorkflowOwnerByUserId && !existingSharedWorkflowOwnerByRoleId) { - // if the worklflow has a non-global owner that is referenced by the owner file, + // if the workflow has a non-global owner that is referenced by the owner file, // and no existing global owner, update the owner to the user referenced in the owner file await Container.get(SharedWorkflowRepository).update( { workflowId: importedWorkflow.id, userId: workflowOwnerId, }, - { - roleId: ownerWorkflowRole.id, - }, + { role: 'workflow:owner' }, ); } if (existingWorkflow?.active) { @@ -343,13 +304,11 @@ export class SourceControlImportService { }, select: ['id', 'name', 'type', 'data'], }); - const ownerCredentialRole = await this.getCredentialOwnerRole(); - const ownerGlobalRole = await this.getOwnerGlobalRole(); const existingSharedCredentials = await Container.get(SharedCredentialsRepository).find({ - select: ['userId', 'credentialsId', 'roleId'], + select: ['userId', 'credentialsId', 'role'], where: { credentialsId: In(candidateIds), - roleId: In([ownerCredentialRole.id, ownerGlobalRole.id]), + role: 'credential:owner', }, }); let importCredentialsResult: Array<{ id: string; name: string; type: string }> = []; @@ -382,7 +341,7 @@ export class SourceControlImportService { const newSharedCredential = new SharedCredentials(); newSharedCredential.credentialsId = newCredentialObject.id as string; newSharedCredential.userId = userId; - newSharedCredential.roleId = ownerCredentialRole.id; + newSharedCredential.role = 'credential:owner'; await Container.get(SharedCredentialsRepository).upsert({ ...newSharedCredential }, [ 'credentialsId', diff --git a/packages/cli/src/environments/variables/variables.controller.ee.ts b/packages/cli/src/environments/variables/variables.controller.ee.ts index 69a0b03d2d..5e3eb6452f 100644 --- a/packages/cli/src/environments/variables/variables.controller.ee.ts +++ b/packages/cli/src/environments/variables/variables.controller.ee.ts @@ -23,7 +23,7 @@ export class VariablesController { @Get('/') @RequireGlobalScope('variable:list') async getVariables() { - return this.variablesService.getAllCached(); + return await this.variablesService.getAllCached(); } @Post('/') diff --git a/packages/cli/src/environments/variables/variables.service.ee.ts b/packages/cli/src/environments/variables/variables.service.ee.ts index c249b31f60..0ea2cc6e57 100644 --- a/packages/cli/src/environments/variables/variables.service.ee.ts +++ b/packages/cli/src/environments/variables/variables.service.ee.ts @@ -18,7 +18,7 @@ export class VariablesService { async getAllCached(): Promise { const variables = await this.cacheService.get('variables', { async refreshFn() { - return Container.get(VariablesService).findAll(); + return await Container.get(VariablesService).findAll(); }, }); return (variables as Array>).map((v) => this.variablesRepository.create(v)); @@ -49,7 +49,7 @@ export class VariablesService { } async findAll(): Promise { - return this.variablesRepository.find(); + return await this.variablesRepository.find(); } validateVariable(variable: Omit): void { diff --git a/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts b/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts index e578aed95f..5ceef85f6e 100644 --- a/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts +++ b/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts @@ -32,7 +32,7 @@ import { ExecutionRepository } from '@db/repositories/execution.repository'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; import type { AbstractEventMessageOptions } from '../EventMessageClasses/AbstractEventMessageOptions'; import { getEventMessageObjectByType } from '../EventMessageClasses/Helpers'; -import { SingleMainSetup } from '@/services/orchestration/main/SingleMainSetup'; +import { OrchestrationService } from '@/services/orchestration.service'; import { Logger } from '@/Logger'; import { EventDestinationsRepository } from '@db/repositories/eventDestinations.repository'; @@ -207,7 +207,7 @@ export class MessageEventBus extends EventEmitter { this.destinations[destination.getId()] = destination; this.destinations[destination.getId()].startListening(); if (notifyWorkers) { - await Container.get(SingleMainSetup).broadcastRestartEventbusAfterDestinationUpdate(); + await Container.get(OrchestrationService).publish('restartEventBus'); } return destination; } @@ -233,7 +233,7 @@ export class MessageEventBus extends EventEmitter { delete this.destinations[id]; } if (notifyWorkers) { - await Container.get(SingleMainSetup).broadcastRestartEventbusAfterDestinationUpdate(); + await Container.get(OrchestrationService).publish('restartEventBus'); } return result; } diff --git a/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestination.ee.ts b/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestination.ee.ts index 28736873cc..bf68bb859c 100644 --- a/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestination.ee.ts +++ b/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestination.ee.ts @@ -99,7 +99,7 @@ export abstract class MessageEventBusDestination implements MessageEventBusDesti } async deleteFromDb() { - return MessageEventBusDestination.deleteFromDb(this.getId()); + return await MessageEventBusDestination.deleteFromDb(this.getId()); } static async deleteFromDb(id: string) { diff --git a/packages/cli/src/eventbus/eventBus.controller.ee.ts b/packages/cli/src/eventbus/eventBus.controller.ee.ts index 32346dd27f..f88628bfec 100644 --- a/packages/cli/src/eventbus/eventBus.controller.ee.ts +++ b/packages/cli/src/eventbus/eventBus.controller.ee.ts @@ -61,9 +61,9 @@ export class EventBusControllerEE { @RequireGlobalScope('eventBusDestination:list') async getDestination(req: express.Request): Promise { if (isWithIdString(req.query)) { - return eventBus.findDestination(req.query.id); + return await eventBus.findDestination(req.query.id); } else { - return eventBus.findDestination(); + return await eventBus.findDestination(); } } @@ -115,7 +115,7 @@ export class EventBusControllerEE { @RequireGlobalScope('eventBusDestination:test') async sendTestMessage(req: express.Request): Promise { if (isWithIdString(req.query)) { - return eventBus.testDestination(req.query.id); + return await eventBus.testDestination(req.query.id); } return false; } @@ -124,7 +124,7 @@ export class EventBusControllerEE { @RequireGlobalScope('eventBusDestination:delete') async deleteDestination(req: AuthenticatedRequest) { if (isWithIdString(req.query)) { - return eventBus.removeDestination(req.query.id); + return await eventBus.removeDestination(req.query.id); } else { throw new BadRequestError('Query is missing id'); } diff --git a/packages/cli/src/eventbus/eventBus.controller.ts b/packages/cli/src/eventbus/eventBus.controller.ts index 6e46c75d55..f10b4e5674 100644 --- a/packages/cli/src/eventbus/eventBus.controller.ts +++ b/packages/cli/src/eventbus/eventBus.controller.ts @@ -45,17 +45,17 @@ export class EventBusController { if (isWithQueryString(req.query)) { switch (req.query.query as EventMessageReturnMode) { case 'sent': - return eventBus.getEventsSent(); + return await eventBus.getEventsSent(); case 'unsent': - return eventBus.getEventsUnsent(); + return await eventBus.getEventsUnsent(); case 'unfinished': - return eventBus.getUnfinishedExecutions(); + return await eventBus.getUnfinishedExecutions(); case 'all': default: - return eventBus.getEventsAll(); + return await eventBus.getEventsAll(); } } else { - return eventBus.getEventsAll(); + return await eventBus.getEventsAll(); } } @@ -63,7 +63,7 @@ export class EventBusController { @RequireGlobalScope('eventBusEvent:list') async getFailedEvents(req: express.Request): Promise { const amount = parseInt(req.query?.amount as string) ?? 5; - return eventBus.getEventsFailed(amount); + return await eventBus.getEventsFailed(amount); } @Get('/execution/:id') @@ -74,7 +74,7 @@ export class EventBusController { if (req.query?.logHistory) { logHistory = parseInt(req.query.logHistory as string, 10); } - return eventBus.getEventsByExecutionId(req.params.id, logHistory); + return await eventBus.getEventsByExecutionId(req.params.id, logHistory); } return; } @@ -88,7 +88,7 @@ export class EventBusController { const applyToDb = req.query.applyToDb !== undefined ? !!req.query.applyToDb : true; const messages = await eventBus.getEventsByExecutionId(id, logHistory); if (messages.length > 0) { - return recoverExecutionDataFromEventLogMessages(id, messages, applyToDb); + return await recoverExecutionDataFromEventLogMessages(id, messages, applyToDb); } } return; diff --git a/packages/cli/src/executions/active-execution.service.ts b/packages/cli/src/executions/active-execution.service.ts new file mode 100644 index 0000000000..335c0a7faf --- /dev/null +++ b/packages/cli/src/executions/active-execution.service.ts @@ -0,0 +1,134 @@ +import { Service } from 'typedi'; +import { ActiveExecutions } from '@/ActiveExecutions'; +import { Logger } from '@/Logger'; +import { Queue } from '@/Queue'; +import { WaitTracker } from '@/WaitTracker'; +import { ExecutionRepository } from '@db/repositories/execution.repository'; +import { getStatusUsingPreviousExecutionStatusMethod } from '@/executions/executionHelpers'; +import config from '@/config'; + +import type { ExecutionSummary } from 'n8n-workflow'; +import type { IExecutionBase, IExecutionsCurrentSummary } from '@/Interfaces'; +import type { GetManyActiveFilter } from './execution.types'; + +@Service() +export class ActiveExecutionService { + constructor( + private readonly logger: Logger, + private readonly queue: Queue, + private readonly activeExecutions: ActiveExecutions, + private readonly executionRepository: ExecutionRepository, + private readonly waitTracker: WaitTracker, + ) {} + + private readonly isRegularMode = config.getEnv('executions.mode') === 'regular'; + + async findOne(executionId: string, accessibleWorkflowIds: string[]) { + return await this.executionRepository.findIfAccessible(executionId, accessibleWorkflowIds); + } + + private toSummary(execution: IExecutionsCurrentSummary | IExecutionBase): ExecutionSummary { + return { + id: execution.id, + workflowId: execution.workflowId ?? '', + mode: execution.mode, + retryOf: execution.retryOf !== null ? execution.retryOf : undefined, + startedAt: new Date(execution.startedAt), + status: execution.status, + stoppedAt: 'stoppedAt' in execution ? execution.stoppedAt : undefined, + }; + } + + // ---------------------------------- + // regular mode + // ---------------------------------- + + async findManyInRegularMode( + filter: GetManyActiveFilter, + accessibleWorkflowIds: string[], + ): Promise { + return this.activeExecutions + .getActiveExecutions() + .filter(({ workflowId }) => { + if (filter.workflowId && filter.workflowId !== workflowId) return false; + if (workflowId && !accessibleWorkflowIds.includes(workflowId)) return false; + return true; + }) + .map((execution) => this.toSummary(execution)) + .sort((a, b) => Number(b.id) - Number(a.id)); + } + + // ---------------------------------- + // queue mode + // ---------------------------------- + + async findManyInQueueMode(filter: GetManyActiveFilter, accessibleWorkflowIds: string[]) { + const activeManualExecutionIds = this.activeExecutions + .getActiveExecutions() + .map((execution) => execution.id); + + const activeJobs = await this.queue.getJobs(['active', 'waiting']); + + const activeProductionExecutionIds = activeJobs.map((job) => job.data.executionId); + + const activeExecutionIds = activeProductionExecutionIds.concat(activeManualExecutionIds); + + if (activeExecutionIds.length === 0) return []; + + const activeExecutions = await this.executionRepository.getManyActive( + activeExecutionIds, + accessibleWorkflowIds, + filter, + ); + + return activeExecutions.map((execution) => { + if (!execution.status) { + // @tech-debt Status should never be nullish + execution.status = getStatusUsingPreviousExecutionStatusMethod(execution); + } + + return this.toSummary(execution); + }); + } + + async stop(execution: IExecutionBase) { + const result = await this.activeExecutions.stopExecution(execution.id); + + if (result) { + return { + mode: result.mode, + startedAt: new Date(result.startedAt), + stoppedAt: result.stoppedAt ? new Date(result.stoppedAt) : undefined, + finished: result.finished, + status: result.status, + }; + } + + if (!this.isRegularMode) return await this.waitTracker.stopExecution(execution.id); + + // queue mode + + try { + return await this.waitTracker.stopExecution(execution.id); + } catch {} + + const activeJobs = await this.queue.getJobs(['active', 'waiting']); + const job = activeJobs.find(({ data }) => data.executionId === execution.id); + + if (!job) { + this.logger.debug('Could not stop job because it is no longer in queue', { + jobId: execution.id, + }); + } else { + await this.queue.stopJob(job); + } + + return { + mode: execution.mode, + startedAt: new Date(execution.startedAt), + stoppedAt: execution.stoppedAt ? new Date(execution.stoppedAt) : undefined, + finished: execution.finished, + status: execution.status, + }; + } +} diff --git a/packages/cli/src/executions/execution.service.ee.ts b/packages/cli/src/executions/execution.service.ee.ts new file mode 100644 index 0000000000..29e2c5b8f3 --- /dev/null +++ b/packages/cli/src/executions/execution.service.ee.ts @@ -0,0 +1,46 @@ +import { ExecutionService } from './execution.service'; +import type { ExecutionRequest } from './execution.types'; +import type { IExecutionResponse, IExecutionFlattedResponse } from '@/Interfaces'; +import { EnterpriseWorkflowService } from '../workflows/workflow.service.ee'; +import type { WorkflowWithSharingsAndCredentials } from '@/workflows/workflows.types'; +import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; +import { Service } from 'typedi'; + +@Service() +export class EnterpriseExecutionsService { + constructor( + private readonly executionService: ExecutionService, + private readonly workflowRepository: WorkflowRepository, + private readonly enterpriseWorkflowService: EnterpriseWorkflowService, + ) {} + + async findOne( + req: ExecutionRequest.GetOne, + sharedWorkflowIds: string[], + ): Promise { + const execution = await this.executionService.findOne(req, sharedWorkflowIds); + + if (!execution) return; + + const relations = ['shared', 'shared.user']; + + const workflow = (await this.workflowRepository.get( + { id: execution.workflowId }, + { relations }, + )) as WorkflowWithSharingsAndCredentials; + + if (!workflow) return; + + this.enterpriseWorkflowService.addOwnerAndSharings(workflow); + await this.enterpriseWorkflowService.addCredentialsToWorkflow(workflow, req.user); + + execution.workflowData = { + ...execution.workflowData, + ownedBy: workflow.ownedBy, + sharedWith: workflow.sharedWith, + usedCredentials: workflow.usedCredentials, + } as WorkflowWithSharingsAndCredentials; + + return execution; + } +} diff --git a/packages/cli/src/executions/executions.service.ts b/packages/cli/src/executions/execution.service.ts similarity index 70% rename from packages/cli/src/executions/executions.service.ts rename to packages/cli/src/executions/execution.service.ts index 64fb3ff6c9..8f92538fa4 100644 --- a/packages/cli/src/executions/executions.service.ts +++ b/packages/cli/src/executions/execution.service.ts @@ -1,55 +1,37 @@ +import { Service } from 'typedi'; import { validate as jsonSchemaValidate } from 'jsonschema'; import type { IWorkflowBase, JsonObject, - ExecutionStatus, ExecutionError, INode, IRunExecutionData, WorkflowExecuteMode, } from 'n8n-workflow'; import { ApplicationError, jsonParse, Workflow, WorkflowOperationError } from 'n8n-workflow'; -import type { FindOperator } from 'typeorm'; -import { In } from 'typeorm'; + import { ActiveExecutions } from '@/ActiveExecutions'; import config from '@/config'; -import type { User } from '@db/entities/User'; import type { ExecutionPayload, IExecutionFlattedResponse, IExecutionResponse, - IExecutionsListResponse, IWorkflowDb, IWorkflowExecutionDataProcess, } from '@/Interfaces'; import { NodeTypes } from '@/NodeTypes'; import { Queue } from '@/Queue'; -import type { ExecutionRequest } from '@/requests'; -import { getSharedWorkflowIds } from '@/WorkflowHelpers'; +import type { ExecutionRequest } from './execution.types'; import { WorkflowRunner } from '@/WorkflowRunner'; import * as GenericHelpers from '@/GenericHelpers'; -import { Container, Service } from 'typedi'; import { getStatusUsingPreviousExecutionStatusMethod } from './executionHelpers'; +import type { IGetExecutionsQueryFilter } from '@db/repositories/execution.repository'; import { ExecutionRepository } from '@db/repositories/execution.repository'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { Logger } from '@/Logger'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; -export interface IGetExecutionsQueryFilter { - id?: FindOperator | string; - finished?: boolean; - mode?: string; - retryOf?: string; - retrySuccessId?: string; - status?: ExecutionStatus[]; - workflowId?: string; - waitTill?: FindOperator | boolean; - metadata?: Array<{ key: string; value: string }>; - startedAfter?: string; - startedBefore?: string; -} - const schemaGetExecutionsQueryFilter = { $id: '/IGetExecutionsQueryFilter', type: 'object', @@ -86,28 +68,17 @@ const schemaGetExecutionsQueryFilter = { const allowedExecutionsQueryFilterFields = Object.keys(schemaGetExecutionsQueryFilter.properties); @Service() -export class ExecutionsService { - /** - * Function to get the workflow Ids for a User - * Overridden in EE version to ignore roles - */ - static async getWorkflowIdsForUser(user: User): Promise { - // Get all workflows using owner role - return getSharedWorkflowIds(user, ['owner']); - } - - static async getExecutionsList(req: ExecutionRequest.GetAll): Promise { - const sharedWorkflowIds = await this.getWorkflowIdsForUser(req.user); - if (sharedWorkflowIds.length === 0) { - // return early since without shared workflows there can be no hits - // (note: getSharedWorkflowIds() returns _all_ workflow ids for global owners) - return { - count: 0, - estimated: false, - results: [], - }; - } +export class ExecutionService { + constructor( + private readonly logger: Logger, + private readonly queue: Queue, + private readonly activeExecutions: ActiveExecutions, + private readonly executionRepository: ExecutionRepository, + private readonly workflowRepository: WorkflowRepository, + private readonly nodeTypes: NodeTypes, + ) {} + async findMany(req: ExecutionRequest.GetMany, sharedWorkflowIds: string[]) { // parse incoming filter object and remove non-valid fields let filter: IGetExecutionsQueryFilter | undefined = undefined; if (req.query.filter) { @@ -122,7 +93,7 @@ export class ExecutionsService { } } } catch (error) { - Container.get(Logger).error('Failed to parse filter', { + this.logger.error('Failed to parse filter', { userId: req.user.id, filter: req.query.filter, }); @@ -133,7 +104,7 @@ export class ExecutionsService { // safeguard against querying workflowIds not shared with the user const workflowId = filter?.workflowId?.toString(); if (workflowId !== undefined && !sharedWorkflowIds.includes(workflowId)) { - Container.get(Logger).verbose( + this.logger.verbose( `User ${req.user.id} attempted to query non-shared workflow ${workflowId}`, ); return { @@ -150,26 +121,21 @@ export class ExecutionsService { const executingWorkflowIds: string[] = []; if (config.getEnv('executions.mode') === 'queue') { - const queue = Container.get(Queue); - const currentJobs = await queue.getJobs(['active', 'waiting']); + const currentJobs = await this.queue.getJobs(['active', 'waiting']); executingWorkflowIds.push(...currentJobs.map(({ data }) => data.executionId)); } // We may have manual executions even with queue so we must account for these. - executingWorkflowIds.push( - ...Container.get(ActiveExecutions) - .getActiveExecutions() - .map(({ id }) => id), - ); + executingWorkflowIds.push(...this.activeExecutions.getActiveExecutions().map(({ id }) => id)); - const { count, estimated } = await Container.get(ExecutionRepository).countExecutions( + const { count, estimated } = await this.executionRepository.countExecutions( filter, sharedWorkflowIds, executingWorkflowIds, req.user.hasGlobalScope('workflow:list'), ); - const formattedExecutions = await Container.get(ExecutionRepository).searchExecutions( + const formattedExecutions = await this.executionRepository.searchExecutions( filter, limit, executingWorkflowIds, @@ -186,30 +152,20 @@ export class ExecutionsService { }; } - static async getExecution( - req: ExecutionRequest.Get, + async findOne( + req: ExecutionRequest.GetOne, + sharedWorkflowIds: string[], ): Promise { - const sharedWorkflowIds = await this.getWorkflowIdsForUser(req.user); if (!sharedWorkflowIds.length) return undefined; const { id: executionId } = req.params; - const execution = await Container.get(ExecutionRepository).findSingleExecution(executionId, { - where: { - id: executionId, - workflowId: In(sharedWorkflowIds), - }, - includeData: true, - unflattenData: false, - }); + const execution = await this.executionRepository.findIfShared(executionId, sharedWorkflowIds); if (!execution) { - Container.get(Logger).info( - 'Attempt to read execution was blocked due to insufficient permissions', - { - userId: req.user.id, - executionId, - }, - ); + this.logger.info('Attempt to read execution was blocked due to insufficient permissions', { + userId: req.user.id, + executionId, + }); return undefined; } @@ -220,21 +176,15 @@ export class ExecutionsService { return execution; } - static async retryExecution(req: ExecutionRequest.Retry): Promise { - const sharedWorkflowIds = await this.getWorkflowIdsForUser(req.user); - if (!sharedWorkflowIds.length) return false; - + async retry(req: ExecutionRequest.Retry, sharedWorkflowIds: string[]) { const { id: executionId } = req.params; - const execution = await Container.get(ExecutionRepository).findSingleExecution(executionId, { - where: { - workflowId: In(sharedWorkflowIds), - }, - includeData: true, - unflattenData: true, - }); + const execution = (await this.executionRepository.findIfShared( + executionId, + sharedWorkflowIds, + )) as unknown as IExecutionResponse; if (!execution) { - Container.get(Logger).info( + this.logger.info( 'Attempt to retry an execution was blocked due to insufficient permissions', { userId: req.user.id, @@ -282,7 +232,7 @@ export class ExecutionsService { // Loads the currently saved workflow to execute instead of the // one saved at the time of the execution. const workflowId = execution.workflowData.id; - const workflowData = (await Container.get(WorkflowRepository).findOneBy({ + const workflowData = (await this.workflowRepository.findOneBy({ id: workflowId, })) as IWorkflowBase; @@ -294,14 +244,14 @@ export class ExecutionsService { } data.workflowData = workflowData; - const nodeTypes = Container.get(NodeTypes); + const workflowInstance = new Workflow({ id: workflowData.id, name: workflowData.name, nodes: workflowData.nodes, connections: workflowData.connections, active: false, - nodeTypes, + nodeTypes: this.nodeTypes, staticData: undefined, settings: workflowData.settings, }); @@ -311,14 +261,11 @@ export class ExecutionsService { // Find the data of the last executed node in the new workflow const node = workflowInstance.getNode(stack.node.name); if (node === null) { - Container.get(Logger).error( - 'Failed to retry an execution because a node could not be found', - { - userId: req.user.id, - executionId, - nodeName: stack.node.name, - }, - ); + this.logger.error('Failed to retry an execution because a node could not be found', { + userId: req.user.id, + executionId, + nodeName: stack.node.name, + }); throw new WorkflowOperationError( `Could not find the node "${stack.node.name}" in workflow. It probably got deleted or renamed. Without it the workflow can sadly not be retried.`, ); @@ -332,8 +279,7 @@ export class ExecutionsService { const workflowRunner = new WorkflowRunner(); const retriedExecutionId = await workflowRunner.run(data); - const executionData = - await Container.get(ActiveExecutions).getPostExecutePromise(retriedExecutionId); + const executionData = await this.activeExecutions.getPostExecutePromise(retriedExecutionId); if (!executionData) { throw new ApplicationError('The retry did not start for an unknown reason.'); @@ -342,13 +288,7 @@ export class ExecutionsService { return !!executionData.finished; } - static async deleteExecutions(req: ExecutionRequest.Delete): Promise { - const sharedWorkflowIds = await this.getWorkflowIdsForUser(req.user); - if (sharedWorkflowIds.length === 0) { - // return early since without shared workflows there can be no hits - // (note: getSharedWorkflowIds() returns _all_ workflow ids for global owners) - return; - } + async delete(req: ExecutionRequest.Delete, sharedWorkflowIds: string[]) { const { deleteBefore, ids, filters: requestFiltersRaw } = req.body; let requestFilters; if (requestFiltersRaw) { @@ -364,7 +304,7 @@ export class ExecutionsService { } } - return Container.get(ExecutionRepository).deleteExecutionsByFilter( + return await this.executionRepository.deleteExecutionsByFilter( requestFilters, sharedWorkflowIds, { @@ -380,7 +320,7 @@ export class ExecutionsService { workflowData: IWorkflowDb, workflow: Workflow, mode: WorkflowExecuteMode, - ): Promise { + ) { const saveDataErrorExecutionDisabled = workflowData?.settings?.saveDataErrorExecution === 'none'; @@ -442,6 +382,6 @@ export class ExecutionsService { status: 'error', }; - await Container.get(ExecutionRepository).createNewExecution(fullExecutionData); + await this.executionRepository.createNewExecution(fullExecutionData); } } diff --git a/packages/cli/src/executions/execution.types.ts b/packages/cli/src/executions/execution.types.ts new file mode 100644 index 0000000000..3ad21ae357 --- /dev/null +++ b/packages/cli/src/executions/execution.types.ts @@ -0,0 +1,48 @@ +import type { ExecutionEntity } from '@/databases/entities/ExecutionEntity'; +import type { AuthenticatedRequest } from '@/requests'; +import type { ExecutionStatus, IDataObject } from 'n8n-workflow'; + +export declare namespace ExecutionRequest { + namespace QueryParams { + type GetMany = { + filter: string; // '{ waitTill: string; finished: boolean, [other: string]: string }' + limit: string; + lastId: string; + firstId: string; + }; + + type GetOne = { unflattedResponse: 'true' | 'false' }; + } + + namespace BodyParams { + type DeleteFilter = { + deleteBefore?: Date; + filters?: IDataObject; + ids?: string[]; + }; + } + + namespace RouteParams { + type ExecutionId = { + id: ExecutionEntity['id']; + }; + } + + type GetMany = AuthenticatedRequest<{}, {}, {}, QueryParams.GetMany>; + + type GetOne = AuthenticatedRequest; + + type Delete = AuthenticatedRequest<{}, {}, BodyParams.DeleteFilter>; + + type Retry = AuthenticatedRequest; + + type Stop = AuthenticatedRequest; + + type GetManyActive = AuthenticatedRequest<{}, {}, {}, { filter?: string }>; +} + +export type GetManyActiveFilter = { + workflowId?: string; + status?: ExecutionStatus; + finished?: boolean; +}; diff --git a/packages/cli/src/executions/executions.controller.ee.ts b/packages/cli/src/executions/executions.controller.ee.ts deleted file mode 100644 index 673d74d15c..0000000000 --- a/packages/cli/src/executions/executions.controller.ee.ts +++ /dev/null @@ -1,68 +0,0 @@ -import express from 'express'; -import type { - IExecutionFlattedResponse, - IExecutionResponse, - IExecutionsListResponse, -} from '@/Interfaces'; -import type { ExecutionRequest } from '@/requests'; -import * as ResponseHelper from '@/ResponseHelper'; -import { isSharingEnabled } from '@/UserManagement/UserManagementHelper'; -import { EEExecutionsService } from './executions.service.ee'; - -export const EEExecutionsController = express.Router(); - -EEExecutionsController.use((req, res, next) => { - if (!isSharingEnabled()) { - // skip ee router and use free one - next('router'); - return; - } - // use ee router - next(); -}); - -/** - * GET /executions - */ -EEExecutionsController.get( - '/', - ResponseHelper.send(async (req: ExecutionRequest.GetAll): Promise => { - return EEExecutionsService.getExecutionsList(req); - }), -); - -/** - * GET /executions/:id - */ -EEExecutionsController.get( - '/:id(\\d+)', - ResponseHelper.send( - async ( - req: ExecutionRequest.Get, - ): Promise => { - return EEExecutionsService.getExecution(req); - }, - ), -); - -/** - * POST /executions/:id/retry - */ -EEExecutionsController.post( - '/:id/retry', - ResponseHelper.send(async (req: ExecutionRequest.Retry): Promise => { - return EEExecutionsService.retryExecution(req); - }), -); - -/** - * POST /executions/delete - * INFORMATION: We use POST instead of DELETE to not run into any issues with the query data - * getting too long - */ -EEExecutionsController.post( - '/delete', - ResponseHelper.send(async (req: ExecutionRequest.Delete): Promise => { - await EEExecutionsService.deleteExecutions(req); - }), -); diff --git a/packages/cli/src/executions/executions.controller.ts b/packages/cli/src/executions/executions.controller.ts index fffd493fd1..aa9c214800 100644 --- a/packages/cli/src/executions/executions.controller.ts +++ b/packages/cli/src/executions/executions.controller.ts @@ -1,59 +1,94 @@ -import express from 'express'; -import type { - IExecutionFlattedResponse, - IExecutionResponse, - IExecutionsListResponse, -} from '@/Interfaces'; -import * as ResponseHelper from '@/ResponseHelper'; -import type { ExecutionRequest } from '@/requests'; -import { EEExecutionsController } from './executions.controller.ee'; -import { ExecutionsService } from './executions.service'; +import type { GetManyActiveFilter } from './execution.types'; +import { ExecutionRequest } from './execution.types'; +import { ExecutionService } from './execution.service'; +import { Authorized, Get, Post, RestController } from '@/decorators'; +import { EnterpriseExecutionsService } from './execution.service.ee'; +import { License } from '@/License'; +import { WorkflowSharingService } from '@/workflows/workflowSharing.service'; +import type { User } from '@/databases/entities/User'; +import config from '@/config'; +import { jsonParse } from 'n8n-workflow'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ActiveExecutionService } from './active-execution.service'; -export const executionsController = express.Router(); -executionsController.use('/', EEExecutionsController); +@Authorized() +@RestController('/executions') +export class ExecutionsController { + private readonly isQueueMode = config.getEnv('executions.mode') === 'queue'; -/** - * GET /executions - */ -executionsController.get( - '/', - ResponseHelper.send(async (req: ExecutionRequest.GetAll): Promise => { - return ExecutionsService.getExecutionsList(req); - }), -); + constructor( + private readonly executionService: ExecutionService, + private readonly enterpriseExecutionService: EnterpriseExecutionsService, + private readonly workflowSharingService: WorkflowSharingService, + private readonly activeExecutionService: ActiveExecutionService, + private readonly license: License, + ) {} -/** - * GET /executions/:id - */ -executionsController.get( - '/:id(\\d+)', - ResponseHelper.send( - async ( - req: ExecutionRequest.Get, - ): Promise => { - return ExecutionsService.getExecution(req); - }, - ), -); + private async getAccessibleWorkflowIds(user: User) { + return this.license.isSharingEnabled() + ? await this.workflowSharingService.getSharedWorkflowIds(user) + : await this.workflowSharingService.getSharedWorkflowIds(user, ['workflow:owner']); + } -/** - * POST /executions/:id/retry - */ -executionsController.post( - '/:id/retry', - ResponseHelper.send(async (req: ExecutionRequest.Retry): Promise => { - return ExecutionsService.retryExecution(req); - }), -); + @Get('/') + async getMany(req: ExecutionRequest.GetMany) { + const workflowIds = await this.getAccessibleWorkflowIds(req.user); -/** - * POST /executions/delete - * INFORMATION: We use POST instead of DELETE to not run into any issues with the query data - * getting too long - */ -executionsController.post( - '/delete', - ResponseHelper.send(async (req: ExecutionRequest.Delete): Promise => { - await ExecutionsService.deleteExecutions(req); - }), -); + if (workflowIds.length === 0) return { count: 0, estimated: false, results: [] }; + + return await this.executionService.findMany(req, workflowIds); + } + + @Get('/active') + async getActive(req: ExecutionRequest.GetManyActive) { + const filter = req.query.filter?.length ? jsonParse(req.query.filter) : {}; + + const workflowIds = await this.getAccessibleWorkflowIds(req.user); + + return this.isQueueMode + ? await this.activeExecutionService.findManyInQueueMode(filter, workflowIds) + : await this.activeExecutionService.findManyInRegularMode(filter, workflowIds); + } + + @Post('/active/:id/stop') + async stop(req: ExecutionRequest.Stop) { + const workflowIds = await this.getAccessibleWorkflowIds(req.user); + + if (workflowIds.length === 0) throw new NotFoundError('Execution not found'); + + const execution = await this.activeExecutionService.findOne(req.params.id, workflowIds); + + if (!execution) throw new NotFoundError('Execution not found'); + + return await this.activeExecutionService.stop(execution); + } + + @Get('/:id') + async getOne(req: ExecutionRequest.GetOne) { + const workflowIds = await this.getAccessibleWorkflowIds(req.user); + + if (workflowIds.length === 0) throw new NotFoundError('Execution not found'); + + return this.license.isSharingEnabled() + ? await this.enterpriseExecutionService.findOne(req, workflowIds) + : await this.executionService.findOne(req, workflowIds); + } + + @Post('/:id/retry') + async retry(req: ExecutionRequest.Retry) { + const workflowIds = await this.getAccessibleWorkflowIds(req.user); + + if (workflowIds.length === 0) throw new NotFoundError('Execution not found'); + + return await this.executionService.retry(req, workflowIds); + } + + @Post('/delete') + async delete(req: ExecutionRequest.Delete) { + const workflowIds = await this.getAccessibleWorkflowIds(req.user); + + if (workflowIds.length === 0) throw new NotFoundError('Execution not found'); + + return await this.executionService.delete(req, workflowIds); + } +} diff --git a/packages/cli/src/executions/executions.service.ee.ts b/packages/cli/src/executions/executions.service.ee.ts deleted file mode 100644 index 662ce38759..0000000000 --- a/packages/cli/src/executions/executions.service.ee.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { User } from '@db/entities/User'; -import { getSharedWorkflowIds } from '@/WorkflowHelpers'; -import { ExecutionsService } from './executions.service'; -import type { ExecutionRequest } from '@/requests'; -import type { IExecutionResponse, IExecutionFlattedResponse } from '@/Interfaces'; -import { EnterpriseWorkflowService } from '../workflows/workflow.service.ee'; -import type { WorkflowWithSharingsAndCredentials } from '@/workflows/workflows.types'; -import Container from 'typedi'; -import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; - -export class EEExecutionsService extends ExecutionsService { - /** - * Function to get the workflow Ids for a User regardless of role - */ - static async getWorkflowIdsForUser(user: User): Promise { - // Get all workflows - return getSharedWorkflowIds(user); - } - - static async getExecution( - req: ExecutionRequest.Get, - ): Promise { - const execution = await super.getExecution(req); - - if (!execution) return; - - const relations = ['shared', 'shared.user', 'shared.role']; - - const workflow = (await Container.get(WorkflowRepository).get( - { id: execution.workflowId }, - { relations }, - )) as WorkflowWithSharingsAndCredentials; - if (!workflow) return; - - const enterpriseWorkflowService = Container.get(EnterpriseWorkflowService); - - enterpriseWorkflowService.addOwnerAndSharings(workflow); - await enterpriseWorkflowService.addCredentialsToWorkflow(workflow, req.user); - - execution.workflowData = { - ...execution.workflowData, - ownedBy: workflow.ownedBy, - sharedWith: workflow.sharedWith, - usedCredentials: workflow.usedCredentials, - } as WorkflowWithSharingsAndCredentials; - - return execution; - } -} diff --git a/packages/cli/src/license/license.controller.ts b/packages/cli/src/license/license.controller.ts index ea0df696c9..9690036628 100644 --- a/packages/cli/src/license/license.controller.ts +++ b/packages/cli/src/license/license.controller.ts @@ -9,7 +9,7 @@ export class LicenseController { @Get('/') async getLicenseData() { - return this.licenseService.getLicenseData(); + return await this.licenseService.getLicenseData(); } @Post('/activate') @@ -17,14 +17,14 @@ export class LicenseController { async activateLicense(req: LicenseRequest.Activate) { const { activationKey } = req.body; await this.licenseService.activateLicense(activationKey); - return this.getTokenAndData(); + return await this.getTokenAndData(); } @Post('/renew') @RequireGlobalScope('license:manage') async renewLicense() { await this.licenseService.renewLicense(); - return this.getTokenAndData(); + return await this.getTokenAndData(); } private async getTokenAndData() { diff --git a/packages/cli/src/middlewares/auth.ts b/packages/cli/src/middlewares/auth.ts index 195b174b74..12c1d78d65 100644 --- a/packages/cli/src/middlewares/auth.ts +++ b/packages/cli/src/middlewares/auth.ts @@ -11,6 +11,7 @@ import { issueCookie, resolveJwtContent } from '@/auth/jwt'; import { canSkipAuth } from '@/decorators/registerController'; import { Logger } from '@/Logger'; import { JwtService } from '@/services/jwt.service'; +import config from '@/config'; const jwtFromRequest = (req: Request) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access @@ -41,17 +42,29 @@ const userManagementJwtAuth = (): RequestHandler => { /** * middleware to refresh cookie before it expires */ -const refreshExpiringCookie: RequestHandler = async (req: AuthenticatedRequest, res, next) => { +export const refreshExpiringCookie = (async (req: AuthenticatedRequest, res, next) => { + const jwtRefreshTimeoutHours = config.get('userManagement.jwtRefreshTimeoutHours'); + + let jwtRefreshTimeoutMilliSeconds: number; + + if (jwtRefreshTimeoutHours === 0) { + const jwtSessionDurationHours = config.get('userManagement.jwtSessionDurationHours'); + + jwtRefreshTimeoutMilliSeconds = Math.floor(jwtSessionDurationHours * 0.25 * 60 * 60 * 1000); + } else { + jwtRefreshTimeoutMilliSeconds = Math.floor(jwtRefreshTimeoutHours * 60 * 60 * 1000); + } + const cookieAuth = jwtFromRequest(req); - if (cookieAuth && req.user) { + + if (cookieAuth && req.user && jwtRefreshTimeoutHours !== -1) { const cookieContents = jwt.decode(cookieAuth) as JwtPayload & { exp: number }; - if (cookieContents.exp * 1000 - Date.now() < 259200000) { - // if cookie expires in < 3 days, renew it. + if (cookieContents.exp * 1000 - Date.now() < jwtRefreshTimeoutMilliSeconds) { await issueCookie(res, req.user); } } next(); -}; +}) satisfies RequestHandler; const passportMiddleware = passport.authenticate('jwt', { session: false }) as RequestHandler; diff --git a/packages/cli/src/middlewares/listQuery/dtos/credentials.filter.dto.ts b/packages/cli/src/middlewares/listQuery/dtos/credentials.filter.dto.ts index de7d5cf230..5b5cdb1a63 100644 --- a/packages/cli/src/middlewares/listQuery/dtos/credentials.filter.dto.ts +++ b/packages/cli/src/middlewares/listQuery/dtos/credentials.filter.dto.ts @@ -14,6 +14,6 @@ export class CredentialsFilter extends BaseFilter { type?: string; static async fromString(rawFilter: string) { - return this.toFilter(rawFilter, CredentialsFilter); + return await this.toFilter(rawFilter, CredentialsFilter); } } diff --git a/packages/cli/src/middlewares/listQuery/dtos/user.filter.dto.ts b/packages/cli/src/middlewares/listQuery/dtos/user.filter.dto.ts index 6c7e8a0131..82f124c8e9 100644 --- a/packages/cli/src/middlewares/listQuery/dtos/user.filter.dto.ts +++ b/packages/cli/src/middlewares/listQuery/dtos/user.filter.dto.ts @@ -24,6 +24,6 @@ export class UserFilter extends BaseFilter { isOwner?: boolean; static async fromString(rawFilter: string) { - return this.toFilter(rawFilter, UserFilter); + return await this.toFilter(rawFilter, UserFilter); } } diff --git a/packages/cli/src/middlewares/listQuery/dtos/workflow.filter.dto.ts b/packages/cli/src/middlewares/listQuery/dtos/workflow.filter.dto.ts index 389246cf6c..cadb945a60 100644 --- a/packages/cli/src/middlewares/listQuery/dtos/workflow.filter.dto.ts +++ b/packages/cli/src/middlewares/listQuery/dtos/workflow.filter.dto.ts @@ -21,6 +21,6 @@ export class WorkflowFilter extends BaseFilter { tags?: string[]; static async fromString(rawFilter: string) { - return this.toFilter(rawFilter, WorkflowFilter); + return await this.toFilter(rawFilter, WorkflowFilter); } } diff --git a/packages/cli/src/posthog/index.ts b/packages/cli/src/posthog/index.ts index b62a6eba8d..1fb688c6d7 100644 --- a/packages/cli/src/posthog/index.ts +++ b/packages/cli/src/posthog/index.ts @@ -49,7 +49,7 @@ export class PostHogClient { // cannot use local evaluation because that requires PostHog personal api key with org-wide // https://github.com/PostHog/posthog/issues/4849 - return this.postHog.getAllFlags(fullId, { + return await this.postHog.getAllFlags(fullId, { personProperties: { created_at_timestamp: user.createdAt.getTime().toString(), }, diff --git a/packages/cli/src/push/abstract.push.ts b/packages/cli/src/push/abstract.push.ts index 655e5486b5..c74554f160 100644 --- a/packages/cli/src/push/abstract.push.ts +++ b/packages/cli/src/push/abstract.push.ts @@ -3,7 +3,7 @@ import { assert, jsonStringify } from 'n8n-workflow'; import type { IPushDataType } from '@/Interfaces'; import type { Logger } from '@/Logger'; import type { User } from '@db/entities/User'; -import type { MultiMainSetup } from '@/services/orchestration/main/MultiMainSetup.ee'; +import type { OrchestrationService } from '@/services/orchestration.service'; /** * Abstract class for two-way push communication. @@ -21,7 +21,7 @@ export abstract class AbstractPush extends EventEmitter { constructor( protected readonly logger: Logger, - private readonly multiMainSetup: MultiMainSetup, + private readonly orchestrationService: OrchestrationService, ) { super(); } @@ -84,10 +84,10 @@ export abstract class AbstractPush extends EventEmitter { * the webhook. If so, the handler process commands the creator process to * relay the former's execution lifecyle events to the creator's frontend. */ - if (this.multiMainSetup.isEnabled && !this.hasSessionId(sessionId)) { + if (this.orchestrationService.isMultiMainSetupEnabled && !this.hasSessionId(sessionId)) { const payload = { type, args: data, sessionId }; - void this.multiMainSetup.publish('relay-execution-lifecycle-event', payload); + void this.orchestrationService.publish('relay-execution-lifecycle-event', payload); return; } diff --git a/packages/cli/src/push/sse.push.ts b/packages/cli/src/push/sse.push.ts index 17f4c7ad9e..6c2432917c 100644 --- a/packages/cli/src/push/sse.push.ts +++ b/packages/cli/src/push/sse.push.ts @@ -4,7 +4,7 @@ import { Logger } from '@/Logger'; import { AbstractPush } from './abstract.push'; import type { PushRequest, PushResponse } from './types'; import type { User } from '@db/entities/User'; -import { MultiMainSetup } from '@/services/orchestration/main/MultiMainSetup.ee'; +import { OrchestrationService } from '@/services/orchestration.service'; type Connection = { req: PushRequest; res: PushResponse }; @@ -14,8 +14,8 @@ export class SSEPush extends AbstractPush { readonly connections: Record = {}; - constructor(logger: Logger, multiMainSetup: MultiMainSetup) { - super(logger, multiMainSetup); + constructor(logger: Logger, orchestrationService: OrchestrationService) { + super(logger, orchestrationService); this.channel.on('disconnect', (channel, { req }) => { this.remove(req?.query?.sessionId); diff --git a/packages/cli/src/push/websocket.push.ts b/packages/cli/src/push/websocket.push.ts index 6f47b1fb62..cda286274e 100644 --- a/packages/cli/src/push/websocket.push.ts +++ b/packages/cli/src/push/websocket.push.ts @@ -3,7 +3,7 @@ import { Service } from 'typedi'; import { Logger } from '@/Logger'; import { AbstractPush } from './abstract.push'; import type { User } from '@db/entities/User'; -import { MultiMainSetup } from '@/services/orchestration/main/MultiMainSetup.ee'; +import { OrchestrationService } from '@/services/orchestration.service'; function heartbeat(this: WebSocket) { this.isAlive = true; @@ -11,8 +11,8 @@ function heartbeat(this: WebSocket) { @Service() export class WebSocketPush extends AbstractPush { - constructor(logger: Logger, multiMainSetup: MultiMainSetup) { - super(logger, multiMainSetup); + constructor(logger: Logger, orchestrationService: OrchestrationService) { + super(logger, orchestrationService); // Ping all connected clients every 60 seconds setInterval(() => this.pingAll(), 60 * 1000); diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 641d1c72fd..27c2ebb110 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -13,14 +13,8 @@ import type { import { IsBoolean, IsEmail, IsIn, IsOptional, IsString, Length } from 'class-validator'; import { NoXss } from '@db/utils/customValidators'; -import type { - PublicUser, - IExecutionDeleteFilter, - SecretsProvider, - SecretsProviderState, -} from '@/Interfaces'; -import type { Role, RoleNames } from '@db/entities/Role'; -import type { User } from '@db/entities/User'; +import type { PublicUser, SecretsProvider, SecretsProviderState } from '@/Interfaces'; +import { AssignableRole, type User } from '@db/entities/User'; import type { UserManagementMailer } from '@/UserManagement/email'; import type { Variables } from '@db/entities/Variables'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; @@ -53,8 +47,8 @@ export class UserSettingsUpdatePayload { } export class UserRoleChangePayload { - @IsIn(['member', 'admin']) - newRoleName: Exclude; + @IsIn(['global:admin', 'global:member']) + newRoleName: AssignableRole; } export type AuthlessRequest< @@ -72,7 +66,6 @@ export type AuthenticatedRequest< > = Omit, 'user'> & { user: User; mailer?: UserManagementMailer; - globalMemberRole?: Role; }; // ---------------------------------- @@ -171,32 +164,6 @@ export declare namespace CredentialRequest { type Share = AuthenticatedRequest<{ credentialId: string }, {}, { shareWithIds: string[] }>; } -// ---------------------------------- -// /executions -// ---------------------------------- - -export declare namespace ExecutionRequest { - namespace QueryParam { - type GetAll = { - filter: string; // '{ waitTill: string; finished: boolean, [other: string]: string }' - limit: string; - lastId: string; - firstId: string; - }; - - type GetAllCurrent = { - filter: string; // '{ workflowId: string }' - }; - } - - type GetAll = AuthenticatedRequest<{}, {}, {}, QueryParam.GetAll>; - type Get = AuthenticatedRequest<{ id: string }, {}, {}, { unflattedResponse: 'true' | 'false' }>; - type Delete = AuthenticatedRequest<{}, {}, IExecutionDeleteFilter>; - type Retry = AuthenticatedRequest<{ id: string }, {}, { loadWorkflow: boolean }, {}>; - type Stop = AuthenticatedRequest<{ id: string }>; - type GetAllCurrent = AuthenticatedRequest<{}, {}, {}, QueryParam.GetAllCurrent>; -} - // ---------------------------------- // /me // ---------------------------------- @@ -256,7 +223,7 @@ export declare namespace UserRequest { export type Invite = AuthenticatedRequest< {}, {}, - Array<{ email: string; role?: 'member' | 'admin' }> + Array<{ email: string; role?: AssignableRole }> >; export type InviteResponse = { diff --git a/packages/cli/src/security-audit/SecurityAudit.service.ts b/packages/cli/src/security-audit/SecurityAudit.service.ts index 3c41df5e39..afec146065 100644 --- a/packages/cli/src/security-audit/SecurityAudit.service.ts +++ b/packages/cli/src/security-audit/SecurityAudit.service.ts @@ -30,7 +30,7 @@ export class SecurityAuditService { select: ['id', 'name', 'active', 'nodes', 'connections'], }); - const promises = categories.map(async (c) => this.reporters[c].report(workflows)); + const promises = categories.map(async (c) => await this.reporters[c].report(workflows)); const reports = (await Promise.all(promises)).filter((r): r is Risk.Report => r !== null); diff --git a/packages/cli/src/security-audit/risk-reporters/CredentialsRiskReporter.ts b/packages/cli/src/security-audit/risk-reporters/CredentialsRiskReporter.ts index 869aa7c7e3..04d20199b6 100644 --- a/packages/cli/src/security-audit/risk-reporters/CredentialsRiskReporter.ts +++ b/packages/cli/src/security-audit/risk-reporters/CredentialsRiskReporter.ts @@ -119,7 +119,7 @@ export class CredentialsRiskReporter implements RiskReporter { const executionIds = await this.executionRepository.getIdsSince(date); - return this.executionDataRepository.findByExecutionIds(executionIds); + return await this.executionDataRepository.findByExecutionIds(executionIds); } /** diff --git a/packages/cli/src/services/activeWorkflows.service.ts b/packages/cli/src/services/activeWorkflows.service.ts index 7684cb3275..25de43fd1c 100644 --- a/packages/cli/src/services/activeWorkflows.service.ts +++ b/packages/cli/src/services/activeWorkflows.service.ts @@ -47,6 +47,6 @@ export class ActiveWorkflowsService { throw new BadRequestError(`Workflow with ID "${workflowId}" could not be found.`); } - return this.activationErrorsService.get(workflowId); + return await this.activationErrorsService.get(workflowId); } } diff --git a/packages/cli/src/services/cache/cache.service.ts b/packages/cli/src/services/cache/cache.service.ts index 421597a172..09499a4da4 100644 --- a/packages/cli/src/services/cache/cache.service.ts +++ b/packages/cli/src/services/cache/cache.service.ts @@ -332,7 +332,7 @@ export class CacheService extends EventEmitter { if (keys.length === 0) return; - return this.cache.store.mdel(...keys); + return await this.cache.store.mdel(...keys); } /** diff --git a/packages/cli/src/services/cache/redis.cache-manager.ts b/packages/cli/src/services/cache/redis.cache-manager.ts index d556dacdc7..429e0d93c0 100644 --- a/packages/cli/src/services/cache/redis.cache-manager.ts +++ b/packages/cli/src/services/cache/redis.cache-manager.ts @@ -88,7 +88,7 @@ function builder( ); }, mget: async (...args) => - redisCache + await redisCache .mget(args) .then((results) => results.map((result) => @@ -101,8 +101,8 @@ function builder( async del(key) { await redisCache.del(key); }, - ttl: async (key) => redisCache.pttl(key), - keys: async (pattern = '*') => keys(pattern), + ttl: async (key) => await redisCache.pttl(key), + keys: async (pattern = '*') => await keys(pattern), reset, isCacheable, get client() { @@ -137,7 +137,7 @@ function builder( await redisCache.hset(key, fieldValueRecord); }, async hkeys(key: string) { - return redisCache.hkeys(key); + return await redisCache.hkeys(key); }, async hvals(key: string): Promise { const values = await redisCache.hvals(key); @@ -147,7 +147,7 @@ function builder( return (await redisCache.hexists(key, field)) === 1; }, async hdel(key: string, field: string) { - return redisCache.hdel(key, field); + return await redisCache.hdel(key, field); }, } as RedisStore; } @@ -156,7 +156,7 @@ export function redisStoreUsingClient(redisCache: Redis | Cluster, options?: Con const reset = async () => { await redisCache.flushdb(); }; - const keys = async (pattern: string) => redisCache.keys(pattern); + const keys = async (pattern: string) => await redisCache.keys(pattern); return builder(redisCache, reset, keys, options); } diff --git a/packages/cli/src/services/communityPackages.service.ts b/packages/cli/src/services/communityPackages.service.ts index 53b37b99ea..9ee89f61a0 100644 --- a/packages/cli/src/services/communityPackages.service.ts +++ b/packages/cli/src/services/communityPackages.service.ts @@ -59,22 +59,22 @@ export class CommunityPackagesService { } async findInstalledPackage(packageName: string) { - return this.installedPackageRepository.findOne({ + return await this.installedPackageRepository.findOne({ where: { packageName }, relations: ['installedNodes'], }); } async isPackageInstalled(packageName: string) { - return this.installedPackageRepository.exist({ where: { packageName } }); + return await this.installedPackageRepository.exist({ where: { packageName } }); } async getAllInstalledPackages() { - return this.installedPackageRepository.find({ relations: ['installedNodes'] }); + return await this.installedPackageRepository.find({ relations: ['installedNodes'] }); } async removePackageFromDatabase(packageName: InstalledPackages) { - return this.installedPackageRepository.remove(packageName); + return await this.installedPackageRepository.remove(packageName); } async persistInstalledPackage(packageLoader: PackageDirectoryLoader) { @@ -297,14 +297,14 @@ export class CommunityPackagesService { } async installNpmModule(packageName: string, version?: string): Promise { - return this.installOrUpdateNpmModule(packageName, { version }); + return await this.installOrUpdateNpmModule(packageName, { version }); } async updateNpmModule( packageName: string, installedPackage: InstalledPackages, ): Promise { - return this.installOrUpdateNpmModule(packageName, { installedPackage }); + return await this.installOrUpdateNpmModule(packageName, { installedPackage }); } async removeNpmModule(packageName: string, installedPackage: InstalledPackages): Promise { diff --git a/packages/cli/src/services/cta.service.ts b/packages/cli/src/services/cta.service.ts new file mode 100644 index 0000000000..e5c08a437b --- /dev/null +++ b/packages/cli/src/services/cta.service.ts @@ -0,0 +1,18 @@ +import { Service } from 'typedi'; +import { WorkflowStatisticsRepository } from '@/databases/repositories/workflowStatistics.repository'; +import type { User } from '@/databases/entities/User'; + +@Service() +export class CtaService { + constructor(private readonly workflowStatisticsRepository: WorkflowStatisticsRepository) {} + + async getBecomeCreatorCta(userId: User['id']) { + // There need to be at least 3 workflows with at least 5 executions + const numWfsWithOver5ProdExecutions = + await this.workflowStatisticsRepository.queryNumWorkflowsUserHasWithFiveOrMoreProdExecs( + userId, + ); + + return numWfsWithOver5ProdExecutions >= 3; + } +} diff --git a/packages/cli/src/services/events.service.ts b/packages/cli/src/services/events.service.ts index 64c8a0e499..10a0e7dc6c 100644 --- a/packages/cli/src/services/events.service.ts +++ b/packages/cli/src/services/events.service.ts @@ -17,9 +17,13 @@ export class EventsService extends EventEmitter { super({ captureRejections: true }); if ('SKIP_STATISTICS_EVENTS' in process.env) return; - this.on('nodeFetchedData', async (workflowId, node) => this.nodeFetchedData(workflowId, node)); - this.on('workflowExecutionCompleted', async (workflowData, runData) => - this.workflowExecutionCompleted(workflowData, runData), + this.on( + 'nodeFetchedData', + async (workflowId, node) => await this.nodeFetchedData(workflowId, node), + ); + this.on( + 'workflowExecutionCompleted', + async (workflowData, runData) => await this.workflowExecutionCompleted(workflowData, runData), ); } diff --git a/packages/cli/src/services/executionMetadata.service.ts b/packages/cli/src/services/executionMetadata.service.ts index 6b62ee2365..26db3dcb4c 100644 --- a/packages/cli/src/services/executionMetadata.service.ts +++ b/packages/cli/src/services/executionMetadata.service.ts @@ -19,6 +19,6 @@ export class ExecutionMetadataService { }); } - return this.executionMetadataRepository.save(metadataRows); + return await this.executionMetadataRepository.save(metadataRows); } } diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 9c0828c912..8e815c9d2a 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -49,7 +49,7 @@ export class FrontendService { private readonly urlService: UrlService, private readonly internalHooks: InternalHooks, ) { - loadNodesAndCredentials.addPostProcessor(async () => this.generateTypes()); + loadNodesAndCredentials.addPostProcessor(async () => await this.generateTypes()); void this.generateTypes(); this.initSettings(); @@ -115,9 +115,7 @@ export class FrontendService { apiHost: config.getEnv('diagnostics.config.posthog.apiHost'), apiKey: config.getEnv('diagnostics.config.posthog.apiKey'), autocapture: false, - disableSessionRecording: config.getEnv( - 'diagnostics.config.posthog.disableSessionRecording', - ), + disableSessionRecording: config.getEnv('deployment.type') !== 'cloud', debug: config.getEnv('logs.level') === 'debug', }, personalizationSurveyEnabled: diff --git a/packages/cli/src/services/import.service.ts b/packages/cli/src/services/import.service.ts index df9050e82a..32f6894f9b 100644 --- a/packages/cli/src/services/import.service.ts +++ b/packages/cli/src/services/import.service.ts @@ -4,16 +4,13 @@ import { type INode, type INodeCredentialsDetails } from 'n8n-workflow'; import { Logger } from '@/Logger'; import * as Db from '@/Db'; -import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; -import { TagRepository } from '@/databases/repositories/tag.repository'; -import { SharedWorkflow } from '@/databases/entities/SharedWorkflow'; -import { RoleService } from '@/services/role.service'; +import { CredentialsRepository } from '@db/repositories/credentials.repository'; +import { TagRepository } from '@db/repositories/tag.repository'; +import { SharedWorkflow } from '@db/entities/SharedWorkflow'; import { replaceInvalidCredentials } from '@/WorkflowHelpers'; -import { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; -import { WorkflowTagMapping } from '@/databases/entities/WorkflowTagMapping'; - -import type { TagEntity } from '@/databases/entities/TagEntity'; -import type { Role } from '@/databases/entities/Role'; +import { WorkflowEntity } from '@db/entities/WorkflowEntity'; +import { WorkflowTagMapping } from '@db/entities/WorkflowTagMapping'; +import type { TagEntity } from '@db/entities/TagEntity'; import type { ICredentialsDb } from '@/Interfaces'; @Service() @@ -22,19 +19,15 @@ export class ImportService { private dbTags: TagEntity[] = []; - private workflowOwnerRole: Role; - constructor( private readonly logger: Logger, private readonly credentialsRepository: CredentialsRepository, private readonly tagRepository: TagRepository, - private readonly roleService: RoleService, ) {} async initRecords() { this.dbCredentials = await this.credentialsRepository.find(); this.dbTags = await this.tagRepository.find(); - this.workflowOwnerRole = await this.roleService.findWorkflowOwnerRole(); } async importWorkflows(workflows: WorkflowEntity[], userId: string) { @@ -64,7 +57,7 @@ export class ImportService { const workflowId = upsertResult.identifiers.at(0)?.id as string; - await tx.upsert(SharedWorkflow, { workflowId, userId, roleId: this.workflowOwnerRole.id }, [ + await tx.upsert(SharedWorkflow, { workflowId, userId, role: 'workflow:owner' }, [ 'workflowId', 'userId', ]); diff --git a/packages/cli/src/services/naming.service.ts b/packages/cli/src/services/naming.service.ts index 3b49e48803..4244f114de 100644 --- a/packages/cli/src/services/naming.service.ts +++ b/packages/cli/src/services/naming.service.ts @@ -10,11 +10,11 @@ export class NamingService { ) {} async getUniqueWorkflowName(requestedName: string) { - return this.getUniqueName(requestedName, 'workflow'); + return await this.getUniqueName(requestedName, 'workflow'); } async getUniqueCredentialName(requestedName: string) { - return this.getUniqueName(requestedName, 'credential'); + return await this.getUniqueName(requestedName, 'credential'); } private async getUniqueName(requestedName: string, entity: 'workflow' | 'credential') { diff --git a/packages/cli/src/services/orchestration.base.service.ts b/packages/cli/src/services/orchestration.base.service.ts deleted file mode 100644 index a2540a4a5a..0000000000 --- a/packages/cli/src/services/orchestration.base.service.ts +++ /dev/null @@ -1,55 +0,0 @@ -import Container from 'typedi'; -import { RedisService } from './redis.service'; -import type { RedisServicePubSubPublisher } from './redis/RedisServicePubSubPublisher'; -import config from '@/config'; -import { EventEmitter } from 'node:events'; - -export abstract class OrchestrationService extends EventEmitter { - protected isInitialized = false; - - protected queueModeId: string; - - redisPublisher: RedisServicePubSubPublisher; - - readonly redisService: RedisService; - - get isQueueMode(): boolean { - return config.get('executions.mode') === 'queue'; - } - - get isMainInstance(): boolean { - return config.get('generic.instanceType') === 'main'; - } - - get isWebhookInstance(): boolean { - return config.get('generic.instanceType') === 'webhook'; - } - - get isWorkerInstance(): boolean { - return config.get('generic.instanceType') === 'worker'; - } - - constructor() { - super(); - this.redisService = Container.get(RedisService); - this.queueModeId = config.getEnv('redis.queueModeId'); - } - - sanityCheck(): boolean { - return this.isInitialized && this.isQueueMode; - } - - async init() { - await this.initPublisher(); - this.isInitialized = true; - } - - async shutdown() { - await this.redisPublisher?.destroy(); - this.isInitialized = false; - } - - protected async initPublisher() { - this.redisPublisher = await this.redisService.getPubSubPublisher(); - } -} diff --git a/packages/cli/src/services/orchestration.service.ts b/packages/cli/src/services/orchestration.service.ts new file mode 100644 index 0000000000..af56955805 --- /dev/null +++ b/packages/cli/src/services/orchestration.service.ts @@ -0,0 +1,121 @@ +import { Service } from 'typedi'; +import { Logger } from '@/Logger'; +import config from '@/config'; +import type { RedisServicePubSubPublisher } from './redis/RedisServicePubSubPublisher'; +import type { RedisServiceBaseCommand, RedisServiceCommand } from './redis/RedisServiceCommands'; + +import { RedisService } from './redis.service'; +import { MultiMainSetup } from './orchestration/main/MultiMainSetup.ee'; + +@Service() +export class OrchestrationService { + constructor( + private readonly logger: Logger, + private readonly redisService: RedisService, + readonly multiMainSetup: MultiMainSetup, + ) {} + + protected isInitialized = false; + + private isMultiMainSetupLicensed = false; + + setMultiMainSetupLicensed(newState: boolean) { + this.isMultiMainSetupLicensed = newState; + } + + get isMultiMainSetupEnabled() { + return ( + config.getEnv('executions.mode') === 'queue' && + config.getEnv('multiMainSetup.enabled') && + config.getEnv('generic.instanceType') === 'main' && + this.isMultiMainSetupLicensed + ); + } + + redisPublisher: RedisServicePubSubPublisher; + + get instanceId() { + return config.getEnv('redis.queueModeId'); + } + + get isLeader() { + return config.getEnv('multiMainSetup.instanceType') === 'leader'; + } + + get isFollower() { + return config.getEnv('multiMainSetup.instanceType') !== 'leader'; + } + + sanityCheck() { + return this.isInitialized && config.get('executions.mode') === 'queue'; + } + + async init() { + if (this.isInitialized) return; + + if (config.get('executions.mode') === 'queue') await this.initPublisher(); + + if (this.isMultiMainSetupEnabled) { + await this.multiMainSetup.init(); + } else { + config.set('multiMainSetup.instanceType', 'leader'); + } + + this.isInitialized = true; + } + + async shutdown() { + if (!this.isInitialized) return; + + if (this.isMultiMainSetupEnabled) await this.multiMainSetup.shutdown(); + + await this.redisPublisher.destroy(); + + this.isInitialized = false; + } + + // ---------------------------------- + // pubsub + // ---------------------------------- + + protected async initPublisher() { + this.redisPublisher = await this.redisService.getPubSubPublisher(); + } + + async publish(command: RedisServiceCommand, data?: unknown) { + if (!this.sanityCheck()) return; + + const payload = data as RedisServiceBaseCommand['payload']; + + this.logger.debug(`[Instance ID ${this.instanceId}] Publishing command "${command}"`, payload); + + await this.redisPublisher.publishToCommandChannel({ command, payload }); + } + + // ---------------------------------- + // workers status + // ---------------------------------- + + async getWorkerStatus(id?: string) { + if (!this.sanityCheck()) return; + + const command = 'getStatus'; + + this.logger.debug(`Sending "${command}" to command channel`); + + await this.redisPublisher.publishToCommandChannel({ + command, + targets: id ? [id] : undefined, + }); + } + + async getWorkerIds() { + if (!this.sanityCheck()) return; + + const command = 'getId'; + + this.logger.debug(`Sending "${command}" to command channel`); + + await this.redisPublisher.publishToCommandChannel({ command }); + } +} diff --git a/packages/cli/src/services/orchestration/main/MultiMainSetup.ee.ts b/packages/cli/src/services/orchestration/main/MultiMainSetup.ee.ts index 55822c9ac8..070834ac7b 100644 --- a/packages/cli/src/services/orchestration/main/MultiMainSetup.ee.ts +++ b/packages/cli/src/services/orchestration/main/MultiMainSetup.ee.ts @@ -1,43 +1,23 @@ +import { EventEmitter } from 'node:events'; import config from '@/config'; import { Service } from 'typedi'; import { TIME } from '@/constants'; -import { SingleMainSetup } from '@/services/orchestration/main/SingleMainSetup'; import { getRedisPrefix } from '@/services/redis/RedisServiceHelper'; import { ErrorReporterProxy as EventReporter } from 'n8n-workflow'; -import type { - RedisServiceBaseCommand, - RedisServiceCommand, -} from '@/services/redis/RedisServiceCommands'; +import { Logger } from '@/Logger'; +import { RedisServicePubSubPublisher } from '@/services/redis/RedisServicePubSubPublisher'; @Service() -export class MultiMainSetup extends SingleMainSetup { - private id = this.queueModeId; - - private isLicensed = false; - - get isEnabled() { - return ( - config.getEnv('executions.mode') === 'queue' && - config.getEnv('multiMainSetup.enabled') && - config.getEnv('generic.instanceType') === 'main' && - this.isLicensed - ); - } - - get isLeader() { - return config.getEnv('multiMainSetup.instanceType') === 'leader'; - } - - get isFollower() { - return !this.isLeader; +export class MultiMainSetup extends EventEmitter { + constructor( + private readonly logger: Logger, + private readonly redisPublisher: RedisServicePubSubPublisher, + ) { + super(); } get instanceId() { - return this.id; - } - - setLicensed(newState: boolean) { - this.isLicensed = newState; + return config.getEnv('redis.queueModeId'); } private readonly leaderKey = getRedisPrefix() + ':main_instance_leader'; @@ -47,12 +27,6 @@ export class MultiMainSetup extends SingleMainSetup { private leaderCheckInterval: NodeJS.Timer | undefined; async init() { - if (!this.isEnabled || this.isInitialized) return; - - await this.initPublisher(); - - this.isInitialized = true; - await this.tryBecomeLeader(); // prevent initial wait this.leaderCheckInterval = setInterval( @@ -64,35 +38,35 @@ export class MultiMainSetup extends SingleMainSetup { } async shutdown() { - if (!this.isInitialized) return; - clearInterval(this.leaderCheckInterval); - if (this.isLeader) await this.redisPublisher.clear(this.leaderKey); + const isLeader = config.getEnv('multiMainSetup.instanceType') === 'leader'; + + if (isLeader) await this.redisPublisher.clear(this.leaderKey); } private async checkLeader() { const leaderId = await this.redisPublisher.get(this.leaderKey); - if (leaderId === this.id) { - this.logger.debug(`[Instance ID ${this.id}] Leader is this instance`); + if (leaderId === this.instanceId) { + this.logger.debug(`[Instance ID ${this.instanceId}] Leader is this instance`); await this.redisPublisher.setExpiration(this.leaderKey, this.leaderKeyTtl); return; } - if (leaderId && leaderId !== this.id) { - this.logger.debug(`[Instance ID ${this.id}] Leader is other instance "${leaderId}"`); + if (leaderId && leaderId !== this.instanceId) { + this.logger.debug(`[Instance ID ${this.instanceId}] Leader is other instance "${leaderId}"`); if (config.getEnv('multiMainSetup.instanceType') === 'leader') { - this.emit('leadershipChange', leaderId); // stop triggers, pruning, etc. + config.set('multiMainSetup.instanceType', 'follower'); + + this.emit('leadershipChange'); // stop triggers, pollers, pruning EventReporter.report('[Multi-main setup] Leader failed to renew leader key', { level: 'info', }); - - config.set('multiMainSetup.instanceType', 'follower'); } return; @@ -100,43 +74,38 @@ export class MultiMainSetup extends SingleMainSetup { if (!leaderId) { this.logger.debug( - `[Instance ID ${this.id}] Leadership vacant, attempting to become leader...`, + `[Instance ID ${this.instanceId}] Leadership vacant, attempting to become leader...`, ); config.set('multiMainSetup.instanceType', 'follower'); + this.emit('leadershipVacant'); // stop triggers, pollers, pruning + await this.tryBecomeLeader(); } } private async tryBecomeLeader() { // this can only succeed if leadership is currently vacant - const keySetSuccessfully = await this.redisPublisher.setIfNotExists(this.leaderKey, this.id); + const keySetSuccessfully = await this.redisPublisher.setIfNotExists( + this.leaderKey, + this.instanceId, + ); if (keySetSuccessfully) { - this.logger.debug(`[Instance ID ${this.id}] Leader is now this instance`); + this.logger.debug(`[Instance ID ${this.instanceId}] Leader is now this instance`); config.set('multiMainSetup.instanceType', 'leader'); await this.redisPublisher.setExpiration(this.leaderKey, this.leaderKeyTtl); - this.emit('leadershipChange', this.id); + this.emit('leadershipChange'); // start triggers, pollers, pruning } else { config.set('multiMainSetup.instanceType', 'follower'); } } - async publish(command: RedisServiceCommand, data: unknown) { - if (!this.sanityCheck()) return; - - const payload = data as RedisServiceBaseCommand['payload']; - - this.logger.debug(`[Instance ID ${this.id}] Publishing command "${command}"`, payload); - - await this.redisPublisher.publishToCommandChannel({ command, payload }); - } - async fetchLeaderKey() { - return this.redisPublisher.get(this.leaderKey); + return await this.redisPublisher.get(this.leaderKey); } } diff --git a/packages/cli/src/services/orchestration/main/SingleMainSetup.ts b/packages/cli/src/services/orchestration/main/SingleMainSetup.ts deleted file mode 100644 index 10b020b7d8..0000000000 --- a/packages/cli/src/services/orchestration/main/SingleMainSetup.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Logger } from '@/Logger'; -import { Service } from 'typedi'; -import { OrchestrationService } from '@/services/orchestration.base.service'; - -/** - * For use in main instance, in single main instance scenario. - */ -@Service() -export class SingleMainSetup extends OrchestrationService { - constructor(protected readonly logger: Logger) { - super(); - } - - sanityCheck() { - return this.isInitialized && this.isQueueMode && this.isMainInstance; - } - - async getWorkerStatus(id?: string) { - if (!this.sanityCheck()) return; - - const command = 'getStatus'; - - this.logger.debug(`Sending "${command}" to command channel`); - - await this.redisPublisher.publishToCommandChannel({ - command, - targets: id ? [id] : undefined, - }); - } - - async getWorkerIds() { - if (!this.sanityCheck()) return; - - const command = 'getId'; - - this.logger.debug(`Sending "${command}" to command channel`); - - await this.redisPublisher.publishToCommandChannel({ command }); - } - - async broadcastRestartEventbusAfterDestinationUpdate() { - if (!this.sanityCheck()) return; - - const command = 'restartEventBus'; - - this.logger.debug(`Sending "${command}" to command channel`); - - await this.redisPublisher.publishToCommandChannel({ command }); - } - - async broadcastReloadExternalSecretsProviders() { - if (!this.sanityCheck()) return; - - const command = 'reloadExternalSecretsProviders'; - - this.logger.debug(`Sending "${command}" to command channel`); - - await this.redisPublisher.publishToCommandChannel({ command }); - } -} diff --git a/packages/cli/src/services/orchestration/main/handleCommandMessageMain.ts b/packages/cli/src/services/orchestration/main/handleCommandMessageMain.ts index f41106ca2d..ddf2c0e7fe 100644 --- a/packages/cli/src/services/orchestration/main/handleCommandMessageMain.ts +++ b/packages/cli/src/services/orchestration/main/handleCommandMessageMain.ts @@ -7,7 +7,7 @@ import { License } from '@/License'; import { Logger } from '@/Logger'; import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; import { Push } from '@/push'; -import { MultiMainSetup } from './MultiMainSetup.ee'; +import { OrchestrationService } from '@/services/orchestration.service'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { TestWebhooks } from '@/TestWebhooks'; @@ -100,7 +100,7 @@ export async function handleCommandMessageMain(messageString: string) { versionId, }); - await Container.get(MultiMainSetup).publish('workflowFailedToActivate', { + await Container.get(OrchestrationService).publish('workflowFailedToActivate', { workflowId, errorMessage: error.message, }); diff --git a/packages/cli/src/services/orchestration/webhook/handleCommandMessageWebhook.ts b/packages/cli/src/services/orchestration/webhook/handleCommandMessageWebhook.ts index f93474e043..3be43ae835 100644 --- a/packages/cli/src/services/orchestration/webhook/handleCommandMessageWebhook.ts +++ b/packages/cli/src/services/orchestration/webhook/handleCommandMessageWebhook.ts @@ -2,5 +2,5 @@ import { handleCommandMessageMain } from '../main/handleCommandMessageMain'; export async function handleCommandMessageWebhook(messageString: string) { // currently webhooks handle commands the same way as the main instance - return handleCommandMessageMain(messageString); + return await handleCommandMessageMain(messageString); } diff --git a/packages/cli/src/services/orchestration/webhook/orchestration.webhook.service.ts b/packages/cli/src/services/orchestration/webhook/orchestration.webhook.service.ts index c186a9c1d8..9e97078348 100644 --- a/packages/cli/src/services/orchestration/webhook/orchestration.webhook.service.ts +++ b/packages/cli/src/services/orchestration/webhook/orchestration.webhook.service.ts @@ -1,9 +1,14 @@ import { Service } from 'typedi'; -import { OrchestrationService } from '../../orchestration.base.service'; +import { OrchestrationService } from '../../orchestration.service'; +import config from '@/config'; @Service() export class OrchestrationWebhookService extends OrchestrationService { sanityCheck(): boolean { - return this.isInitialized && this.isQueueMode && this.isWebhookInstance; + return ( + this.isInitialized && + config.get('executions.mode') === 'queue' && + config.get('generic.instanceType') === 'webhook' + ); } } diff --git a/packages/cli/src/services/orchestration/worker/orchestration.worker.service.ts b/packages/cli/src/services/orchestration/worker/orchestration.worker.service.ts index f044e5403b..9a00d312b2 100644 --- a/packages/cli/src/services/orchestration/worker/orchestration.worker.service.ts +++ b/packages/cli/src/services/orchestration/worker/orchestration.worker.service.ts @@ -1,11 +1,16 @@ import { Service } from 'typedi'; import type { AbstractEventMessage } from '@/eventbus/EventMessageClasses/AbstractEventMessage'; -import { OrchestrationService } from '../../orchestration.base.service'; +import { OrchestrationService } from '../../orchestration.service'; +import config from '@/config'; @Service() export class OrchestrationWorkerService extends OrchestrationService { sanityCheck(): boolean { - return this.isInitialized && this.isQueueMode && this.isWorkerInstance; + return ( + this.isInitialized && + config.get('executions.mode') === 'queue' && + config.get('generic.instanceType') === 'worker' + ); } async publishToEventLog(message: AbstractEventMessage) { diff --git a/packages/cli/src/services/ownership.service.ts b/packages/cli/src/services/ownership.service.ts index 669356250f..10c8da6334 100644 --- a/packages/cli/src/services/ownership.service.ts +++ b/packages/cli/src/services/ownership.service.ts @@ -2,17 +2,14 @@ import { Service } from 'typedi'; import { CacheService } from '@/services/cache/cache.service'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import type { User } from '@db/entities/User'; -import { RoleService } from './role.service'; -import { UserRepository } from '@/databases/repositories/user.repository'; +import { UserRepository } from '@db/repositories/user.repository'; import type { ListQuery } from '@/requests'; -import { ApplicationError } from 'n8n-workflow'; @Service() export class OwnershipService { constructor( private cacheService: CacheService, private userRepository: UserRepository, - private roleService: RoleService, private sharedWorkflowRepository: SharedWorkflowRepository, ) {} @@ -27,13 +24,9 @@ export class OwnershipService { if (cachedValue) return this.userRepository.create(cachedValue); - const workflowOwnerRole = await this.roleService.findWorkflowOwnerRole(); - - if (!workflowOwnerRole) throw new ApplicationError('Failed to find workflow owner role'); - const sharedWorkflow = await this.sharedWorkflowRepository.findOneOrFail({ - where: { workflowId, roleId: workflowOwnerRole.id }, - relations: ['user', 'user.globalRole'], + where: { workflowId, role: 'workflow:owner' }, + relations: ['user'], }); void this.cacheService.setHash('workflow-ownership', { [workflowId]: sharedWorkflow.user }); @@ -61,7 +54,7 @@ export class OwnershipService { shared?.forEach(({ user, role }) => { const { id, email, firstName, lastName } = user; - if (role.name === 'owner') { + if (role === 'credential:owner' || role === 'workflow:owner') { entity.ownedBy = { id, email, firstName, lastName }; } else { entity.sharedWith.push({ id, email, firstName, lastName }); @@ -72,11 +65,8 @@ export class OwnershipService { } async getInstanceOwner() { - const globalOwnerRole = await this.roleService.findGlobalOwnerRole(); - - return this.userRepository.findOneOrFail({ - where: { globalRoleId: globalOwnerRole.id }, - relations: ['globalRole'], + return await this.userRepository.findOneOrFail({ + where: { role: 'global:owner' }, }); } } diff --git a/packages/cli/src/services/password.utility.ts b/packages/cli/src/services/password.utility.ts index d600371e55..314a3fef71 100644 --- a/packages/cli/src/services/password.utility.ts +++ b/packages/cli/src/services/password.utility.ts @@ -12,11 +12,11 @@ export class PasswordUtility { const SALT_ROUNDS = 10; const salt = genSaltSync(SALT_ROUNDS); - return hash(plaintext, salt); + return await hash(plaintext, salt); } async compare(plaintext: string, hashed: string) { - return compare(plaintext, hashed); + return await compare(plaintext, hashed); } validate(plaintext?: string) { diff --git a/packages/cli/src/services/pruning.service.ts b/packages/cli/src/services/pruning.service.ts index f2b142bf1f..3f78c220cb 100644 --- a/packages/cli/src/services/pruning.service.ts +++ b/packages/cli/src/services/pruning.service.ts @@ -28,7 +28,7 @@ export class PruningService { private readonly binaryDataService: BinaryDataService, ) {} - isPruningEnabled() { + private isPruningEnabled() { if ( !config.getEnv('executions.pruneData') || inTest || @@ -52,6 +52,8 @@ export class PruningService { * @important Call this method only after DB migrations have completed. */ startPruning() { + if (!this.isPruningEnabled()) return; + if (this.isShuttingDown) { this.logger.warn('[Pruning] Cannot start pruning while shutting down'); return; @@ -64,6 +66,8 @@ export class PruningService { } stopPruning() { + if (!this.isPruningEnabled()) return; + this.logger.debug('[Pruning] Removing soft-deletion and hard-deletion timers'); clearInterval(this.softDeletionInterval); @@ -74,7 +78,7 @@ export class PruningService { const when = [rateMs / TIME.MINUTE, 'min'].join(' '); this.softDeletionInterval = setInterval( - async () => this.softDeleteOnPruningCycle(), + async () => await this.softDeleteOnPruningCycle(), this.rates.softDeletion, ); diff --git a/packages/cli/src/services/redis/RedisServicePubSubPublisher.ts b/packages/cli/src/services/redis/RedisServicePubSubPublisher.ts index 3e23208138..fe080d8e0f 100644 --- a/packages/cli/src/services/redis/RedisServicePubSubPublisher.ts +++ b/packages/cli/src/services/redis/RedisServicePubSubPublisher.ts @@ -57,7 +57,7 @@ export class RedisServicePubSubPublisher extends RedisServiceBaseSender { async get(key: string) { if (!this.redisClient) await this.init(); - return this.redisClient?.get(key); + return await this.redisClient?.get(key); } async clear(key: string) { diff --git a/packages/cli/src/services/role.service.ts b/packages/cli/src/services/role.service.ts deleted file mode 100644 index ea880bb9eb..0000000000 --- a/packages/cli/src/services/role.service.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { Service } from 'typedi'; -import { RoleRepository } from '@db/repositories/role.repository'; -import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; -import { CacheService } from '@/services/cache/cache.service'; -import type { RoleNames, RoleScopes } from '@db/entities/Role'; -import { InvalidRoleError } from '@/errors/invalid-role.error'; -import { isSharingEnabled } from '@/UserManagement/UserManagementHelper'; - -@Service() -export class RoleService { - constructor( - private roleRepository: RoleRepository, - private sharedWorkflowRepository: SharedWorkflowRepository, - private cacheService: CacheService, - ) { - void this.populateCache(); - } - - async populateCache() { - const allRoles = await this.roleRepository.find({}); - - if (!allRoles) return; - - void this.cacheService.setMany(allRoles.map((r) => [r.cacheKey, r])); - } - - async findCached(scope: RoleScopes, name: RoleNames) { - const cacheKey = `role:${scope}:${name}`; - - const cachedRole = await this.cacheService.get(cacheKey); - - if (cachedRole) return this.roleRepository.create(cachedRole); - - let dbRole = await this.roleRepository.findRole(scope, name); - - if (dbRole === null) { - if (!this.isValid(scope, name)) { - throw new InvalidRoleError(`${scope}:${name} is not a valid role`); - } - - const toSave = this.roleRepository.create({ scope, name }); - dbRole = await this.roleRepository.save(toSave); - } - - void this.cacheService.set(cacheKey, dbRole); - - return dbRole; - } - - private roles: Array<{ name: RoleNames; scope: RoleScopes }> = [ - { scope: 'global', name: 'owner' }, - { scope: 'global', name: 'member' }, - { scope: 'global', name: 'admin' }, - { scope: 'workflow', name: 'owner' }, - { scope: 'credential', name: 'owner' }, - { scope: 'credential', name: 'user' }, - { scope: 'workflow', name: 'editor' }, - ]; - - listRoles() { - return this.roles; - } - - private isValid(scope: RoleScopes, name: RoleNames) { - return this.roles.some((r) => r.scope === scope && r.name === name); - } - - async findGlobalOwnerRole() { - return this.findCached('global', 'owner'); - } - - async findGlobalMemberRole() { - return this.findCached('global', 'member'); - } - - async findGlobalAdminRole() { - return this.findCached('global', 'admin'); - } - - async findWorkflowOwnerRole() { - return this.findCached('workflow', 'owner'); - } - - async findWorkflowEditorRole() { - return this.findCached('workflow', 'editor'); - } - - async findCredentialOwnerRole() { - return this.findCached('credential', 'owner'); - } - - async findCredentialUserRole() { - return this.findCached('credential', 'user'); - } - - async findRoleByUserAndWorkflow(userId: string, workflowId: string) { - return this.sharedWorkflowRepository - .findOne({ - where: { workflowId, userId }, - relations: ['role'], - }) - .then((shared) => shared?.role); - } - - async findCredentialOwnerRoleId() { - return isSharingEnabled() ? undefined : (await this.findCredentialOwnerRole()).id; - } -} diff --git a/packages/cli/src/services/tag.service.ts b/packages/cli/src/services/tag.service.ts index 0de35883af..f4f888efb4 100644 --- a/packages/cli/src/services/tag.service.ts +++ b/packages/cli/src/services/tag.service.ts @@ -31,7 +31,7 @@ export class TagService { await this.externalHooks.run(`tag.after${action}`, [tag]); - return savedTag; + return await savedTag; } async delete(id: string) { @@ -41,7 +41,7 @@ export class TagService { await this.externalHooks.run('tag.afterDelete', [id]); - return deleteResult; + return await deleteResult; } async getAll(options?: T): Promise> { @@ -59,9 +59,9 @@ export class TagService { }) as GetAllResult; } - return this.tagRepository.find({ + return await (this.tagRepository.find({ select: ['id', 'name', 'createdAt', 'updatedAt'], - }) as Promise>; + }) as Promise>); } /** diff --git a/packages/cli/src/services/test-webhook-registrations.service.ts b/packages/cli/src/services/test-webhook-registrations.service.ts index 58a80dd758..098562e54a 100644 --- a/packages/cli/src/services/test-webhook-registrations.service.ts +++ b/packages/cli/src/services/test-webhook-registrations.service.ts @@ -46,7 +46,7 @@ export class TestWebhookRegistrationsService { } async get(key: string) { - return this.cacheService.getHashValue(this.cacheKey, key); + return await this.cacheService.getHashValue(this.cacheKey, key); } async getAllKeys() { diff --git a/packages/cli/src/services/usageMetrics.service.ts b/packages/cli/src/services/usageMetrics.service.ts new file mode 100644 index 0000000000..4f8b83923b --- /dev/null +++ b/packages/cli/src/services/usageMetrics.service.ts @@ -0,0 +1,27 @@ +import { UsageMetricsRepository } from '@/databases/repositories/usageMetrics.repository'; +import { Service } from 'typedi'; + +@Service() +export class UsageMetricsService { + constructor(private readonly usageMetricsRepository: UsageMetricsRepository) {} + + async collectUsageMetrics() { + const { + activeWorkflows, + totalWorkflows, + enabledUsers, + totalCredentials, + productionExecutions, + manualExecutions, + } = await this.usageMetricsRepository.getLicenseRenewalMetrics(); + + return [ + { name: 'activeWorkflows', value: activeWorkflows }, + { name: 'totalWorkflows', value: totalWorkflows }, + { name: 'enabledUsers', value: enabledUsers }, + { name: 'totalCredentials', value: totalCredentials }, + { name: 'productionExecutions', value: productionExecutions }, + { name: 'manualExecutions', value: manualExecutions }, + ]; + } +} diff --git a/packages/cli/src/services/user.service.ts b/packages/cli/src/services/user.service.ts index 1d3306d4be..6c753788fa 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -1,5 +1,5 @@ import { Container, Service } from 'typedi'; -import { User } from '@db/entities/User'; +import { type AssignableRole, User } from '@db/entities/User'; import type { IUserSettings } from 'n8n-workflow'; import { UserRepository } from '@db/repositories/user.repository'; import type { PublicUser } from '@/Interfaces'; @@ -10,7 +10,6 @@ import { Logger } from '@/Logger'; import { createPasswordSha } from '@/auth/jwt'; import { UserManagementMailer } from '@/UserManagement/email'; import { InternalHooks } from '@/InternalHooks'; -import { RoleService } from '@/services/role.service'; import { UrlService } from '@/services/url.service'; import { ApplicationError, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; import type { UserRequest } from '@/requests'; @@ -23,12 +22,11 @@ export class UserService { private readonly userRepository: UserRepository, private readonly jwtService: JwtService, private readonly mailer: UserManagementMailer, - private readonly roleService: RoleService, private readonly urlService: UrlService, ) {} async update(userId: string, data: Partial) { - return this.userRepository.update(userId, data); + return await this.userRepository.update(userId, data); } getManager() { @@ -38,7 +36,7 @@ export class UserService { async updateSettings(userId: string, newSettings: Partial) { const { settings } = await this.userRepository.findOneOrFail({ where: { id: userId } }); - return this.userRepository.update(userId, { settings: { ...settings, ...newSettings } }); + return await this.userRepository.update(userId, { settings: { ...settings, ...newSettings } }); } generatePasswordResetToken(user: User, expiresIn = '20m') { @@ -73,7 +71,7 @@ export class UserService { const user = await this.userRepository.findOne({ where: { id: decodedToken.sub }, - relations: ['authIdentities', 'globalRole'], + relations: ['authIdentities'], }); if (!user) { @@ -156,17 +154,17 @@ export class UserService { resolve(publicUser); }); - return Promise.race([fetchPromise, timeoutPromise]); + return await Promise.race([fetchPromise, timeoutPromise]); } private async sendEmails( owner: User, toInviteUsers: { [key: string]: string }, - role: 'member' | 'admin', + role: AssignableRole, ) { const domain = this.urlService.getInstanceBaseUrl(); - return Promise.all( + return await Promise.all( Object.entries(toInviteUsers).map(async ([email, id]) => { const inviteAcceptUrl = `${domain}/signup?inviterId=${owner.id}&inviteeId=${id}`; const invitedUser: UserRequest.InviteResponse = { @@ -224,9 +222,7 @@ export class UserService { ); } - async inviteUsers(owner: User, attributes: Array<{ email: string; role: 'member' | 'admin' }>) { - const memberRole = await this.roleService.findGlobalMemberRole(); - const adminRole = await this.roleService.findGlobalAdminRole(); + async inviteUsers(owner: User, attributes: Array<{ email: string; role: AssignableRole }>) { const emails = attributes.map(({ email }) => email); const existingUsers = await this.userRepository.findManyByEmail(emails); @@ -246,18 +242,16 @@ export class UserService { ); try { - await this.getManager().transaction(async (transactionManager) => - Promise.all( - toCreateUsers.map(async ({ email, role }) => { - const newUser = Object.assign(new User(), { - email, - globalRole: role === 'member' ? memberRole : adminRole, - }); - const savedUser = await transactionManager.save(newUser); - createdUsers.set(email, savedUser.id); - return savedUser; - }), - ), + await this.getManager().transaction( + async (transactionManager) => + await Promise.all( + toCreateUsers.map(async ({ email, role }) => { + const newUser = transactionManager.create(User, { email, role }); + const savedUser = await transactionManager.save(newUser); + createdUsers.set(email, savedUser.id); + return savedUser; + }), + ), ); } catch (error) { ErrorReporter.error(error); diff --git a/packages/cli/src/services/userOnboarding.service.ts b/packages/cli/src/services/userOnboarding.service.ts new file mode 100644 index 0000000000..ab8dbb98c1 --- /dev/null +++ b/packages/cli/src/services/userOnboarding.service.ts @@ -0,0 +1,66 @@ +import { Service } from 'typedi'; +import { In } from 'typeorm'; + +import type { User } from '@db/entities/User'; +import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; +import { WorkflowRepository } from '@db/repositories/workflow.repository'; +import { UserService } from '@/services/user.service'; + +@Service() +export class UserOnboardingService { + constructor( + private readonly sharedWorkflowRepository: SharedWorkflowRepository, + private readonly workflowRepository: WorkflowRepository, + private readonly userService: UserService, + ) {} + + /** + * Check if user owns more than 15 workflows or more than 2 workflows with at least 2 nodes. + * If user does, set flag in its settings. + */ + async isBelowThreshold(user: User): Promise { + let belowThreshold = true; + const skippedTypes = ['n8n-nodes-base.start', 'n8n-nodes-base.stickyNote']; + + const ownedWorkflowsIds = await this.sharedWorkflowRepository + .find({ + where: { + userId: user.id, + role: 'workflow:owner', + }, + select: ['workflowId'], + }) + .then((ownedWorkflows) => ownedWorkflows.map(({ workflowId }) => workflowId)); + + if (ownedWorkflowsIds.length > 15) { + belowThreshold = false; + } else { + // just fetch workflows' nodes to keep memory footprint low + const workflows = await this.workflowRepository.find({ + where: { id: In(ownedWorkflowsIds) }, + select: ['nodes'], + }); + + // valid workflow: 2+ nodes without start node + const validWorkflowCount = workflows.reduce((counter, workflow) => { + if (counter <= 2 && workflow.nodes.length > 2) { + const nodes = workflow.nodes.filter((node) => !skippedTypes.includes(node.type)); + if (nodes.length >= 2) { + return counter + 1; + } + } + return counter; + }, 0); + + // more than 2 valid workflows required + belowThreshold = validWorkflowCount <= 2; + } + + // user is above threshold --> set flag in settings + if (!belowThreshold) { + void this.userService.updateSettings(user.id, { isOnboarded: true }); + } + + return belowThreshold; + } +} diff --git a/packages/cli/src/services/webhook.service.ts b/packages/cli/src/services/webhook.service.ts index d8bf6fe579..9840d90dec 100644 --- a/packages/cli/src/services/webhook.service.ts +++ b/packages/cli/src/services/webhook.service.ts @@ -43,7 +43,7 @@ export class WebhookService { * Find a matching webhook with zero dynamic path segments, e.g. `` or `user/profile`. */ private async findStaticWebhook(method: Method, path: string) { - return this.webhookRepository.findOneBy({ webhookPath: path, method }); + return await this.webhookRepository.findOneBy({ webhookPath: path, method }); } /** @@ -87,13 +87,13 @@ export class WebhookService { } async findWebhook(method: Method, path: string) { - return this.findCached(method, path); + return await this.findCached(method, path); } async storeWebhook(webhook: WebhookEntity) { void this.cacheService.set(webhook.cacheKey, webhook); - return this.webhookRepository.insert(webhook); + return await this.webhookRepository.insert(webhook); } createWebhook(data: Partial) { @@ -103,17 +103,17 @@ export class WebhookService { async deleteWorkflowWebhooks(workflowId: string) { const webhooks = await this.webhookRepository.findBy({ workflowId }); - return this.deleteWebhooks(webhooks); + return await this.deleteWebhooks(webhooks); } private async deleteWebhooks(webhooks: WebhookEntity[]) { void this.cacheService.deleteMany(webhooks.map((w) => w.cacheKey)); - return this.webhookRepository.remove(webhooks); + return await this.webhookRepository.remove(webhooks); } async getWebhookMethods(path: string) { - return this.webhookRepository + return await this.webhookRepository .find({ select: ['method'], where: { webhookPath: path } }) .then((rows) => rows.map((r) => r.method)); } diff --git a/packages/cli/src/shutdown/Shutdown.service.ts b/packages/cli/src/shutdown/Shutdown.service.ts index 14b3367c4b..1dd858f271 100644 --- a/packages/cli/src/shutdown/Shutdown.service.ts +++ b/packages/cli/src/shutdown/Shutdown.service.ts @@ -85,7 +85,7 @@ export class ShutdownService { const handlers = Object.values(this.handlersByPriority).reverse(); for (const handlerGroup of handlers) { await Promise.allSettled( - handlerGroup.map(async (handler) => this.shutdownComponent(handler)), + handlerGroup.map(async (handler) => await this.shutdownComponent(handler)), ); } } diff --git a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts index 0dd3bb8ba2..cfa68bfdd6 100644 --- a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts +++ b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts @@ -107,7 +107,7 @@ export class SamlController { @NoAuthRequired() @Get(SamlUrls.acs, { middlewares: [samlLicensedMiddleware] }) async acsGet(req: SamlConfiguration.AcsRequest, res: express.Response) { - return this.acsHandler(req, res, 'redirect'); + return await this.acsHandler(req, res, 'redirect'); } /** @@ -117,7 +117,7 @@ export class SamlController { @NoAuthRequired() @Post(SamlUrls.acs, { middlewares: [samlLicensedMiddleware] }) async acsPost(req: SamlConfiguration.AcsRequest, res: express.Response) { - return this.acsHandler(req, res, 'post'); + return await this.acsHandler(req, res, 'post'); } /** @@ -198,7 +198,7 @@ export class SamlController { } catch { // ignore } - return this.handleInitSSO(res, redirectUrl); + return await this.handleInitSSO(res, redirectUrl); } /** @@ -209,7 +209,7 @@ export class SamlController { @Get(SamlUrls.configTest, { middlewares: [samlLicensedMiddleware] }) @RequireGlobalScope('saml:manage') async configTestGet(req: AuthenticatedRequest, res: express.Response) { - return this.handleInitSSO(res, getServiceProviderConfigTestReturnUrl()); + return await this.handleInitSSO(res, getServiceProviderConfigTestReturnUrl()); } private async handleInitSSO(res: express.Response, relayState?: string) { diff --git a/packages/cli/src/sso/saml/saml.service.ee.ts b/packages/cli/src/sso/saml/saml.service.ee.ts index 2a20c25c7d..b3e16861f1 100644 --- a/packages/cli/src/sso/saml/saml.service.ee.ts +++ b/packages/cli/src/sso/saml/saml.service.ee.ts @@ -174,7 +174,7 @@ export class SamlService { const lowerCasedEmail = attributes.email.toLowerCase(); const user = await Container.get(UserRepository).findOne({ where: { email: lowerCasedEmail }, - relations: ['globalRole', 'authIdentities'], + relations: ['authIdentities'], }); if (user) { // Login path for existing users that are fully set up and that have a SAML authIdentity set up diff --git a/packages/cli/src/sso/saml/samlHelpers.ts b/packages/cli/src/sso/saml/samlHelpers.ts index 87603231a1..799277eba8 100644 --- a/packages/cli/src/sso/saml/samlHelpers.ts +++ b/packages/cli/src/sso/saml/samlHelpers.ts @@ -17,7 +17,6 @@ import { } from '../ssoHelpers'; import { getServiceProviderConfigTestReturnUrl } from './serviceProvider.ee'; import type { SamlConfiguration } from './types/requests'; -import { RoleService } from '@/services/role.service'; import { UserRepository } from '@db/repositories/user.repository'; import { AuthIdentityRepository } from '@db/repositories/authIdentity.repository'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; @@ -104,7 +103,7 @@ export async function createUserFromSamlAttributes(attributes: SamlUserAttribute user.email = lowerCasedEmail; user.firstName = attributes.firstName; user.lastName = attributes.lastName; - user.globalRole = await Container.get(RoleService).findGlobalMemberRole(); + user.role = 'global:member'; // generates a password that is not used or known to the user user.password = await Container.get(PasswordUtility).hash(generatePassword()); authIdentity.providerId = attributes.userPrincipalName; diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index 6fa2fdac18..373fc20150 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -11,7 +11,7 @@ import { License } from '@/License'; import { N8N_VERSION } from '@/constants'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { SourceControlPreferencesService } from '../environments/sourceControl/sourceControlPreferences.service.ee'; -import { RoleRepository } from '@/databases/repositories/role.repository'; +import { UserRepository } from '@db/repositories/user.repository'; type ExecutionTrackDataKey = 'manual_error' | 'manual_success' | 'prod_error' | 'prod_success'; @@ -97,7 +97,7 @@ export class Telemetry { ...this.executionCountsBuffer[workflowId], }); - return promise; + return await promise; }); this.executionCountsBuffer = {}; @@ -111,13 +111,13 @@ export class Telemetry { plan_name_current: this.license.getPlanName(), quota: this.license.getTriggerLimit(), usage: await this.workflowRepository.getActiveTriggerCount(), - role_count: await Container.get(RoleRepository).countUsersByRole(), + role_count: await Container.get(UserRepository).countUsersByRole(), source_control_set_up: Container.get(SourceControlPreferencesService).isSourceControlSetup(), branchName: sourceControlPreferences.branchName, read_only_instance: sourceControlPreferences.branchReadOnly, }; allPromises.push(this.track('pulse', pulsePacket)); - return Promise.all(allPromises); + return await Promise.all(allPromises); } async trackWorkflowExecution(properties: IExecutionTrackProperties): Promise { @@ -155,7 +155,7 @@ export class Telemetry { async trackN8nStop(): Promise { clearInterval(this.pulseIntervalReference); void this.track('User instance stopped'); - return new Promise(async (resolve) => { + return await new Promise(async (resolve) => { await this.postHog.stop(); if (this.rudderStack) { @@ -170,7 +170,7 @@ export class Telemetry { [key: string]: string | number | boolean | object | undefined | null; }): Promise { const { instanceId } = this.instanceSettings; - return new Promise((resolve) => { + return await new Promise((resolve) => { if (this.rudderStack) { this.rudderStack.identify( { @@ -191,7 +191,7 @@ export class Telemetry { { withPostHog } = { withPostHog: false }, // whether to additionally track with PostHog ): Promise { const { instanceId } = this.instanceSettings; - return new Promise((resolve) => { + return await new Promise((resolve) => { if (this.rudderStack) { const { user_id } = properties; const updatedProperties: ITelemetryTrackProperties = { diff --git a/packages/cli/src/workflows/workflow.service.ee.ts b/packages/cli/src/workflows/workflow.service.ee.ts index aba0422491..5c96808451 100644 --- a/packages/cli/src/workflows/workflow.service.ee.ts +++ b/packages/cli/src/workflows/workflow.service.ee.ts @@ -1,30 +1,29 @@ -import * as WorkflowHelpers from '@/WorkflowHelpers'; +import { Service } from 'typedi'; +import omit from 'lodash/omit'; +import { ApplicationError, NodeOperationError } from 'n8n-workflow'; + +import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; import type { User } from '@db/entities/User'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; +import { CredentialsRepository } from '@db/repositories/credentials.repository'; +import { WorkflowRepository } from '@db/repositories/workflow.repository'; +import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; +import { CredentialsService } from '@/credentials/credentials.service'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { Logger } from '@/Logger'; import type { CredentialUsedByWorkflow, WorkflowWithSharingsAndCredentials, } from './workflows.types'; -import { CredentialsService } from '@/credentials/credentials.service'; -import { ApplicationError, NodeOperationError } from 'n8n-workflow'; -import { Service } from 'typedi'; -import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; -import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; -import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; -import { RoleService } from '@/services/role.service'; -import { UserRepository } from '@/databases/repositories/user.repository'; @Service() export class EnterpriseWorkflowService { constructor( + private readonly logger: Logger, private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly workflowRepository: WorkflowRepository, private readonly credentialsRepository: CredentialsRepository, - private readonly userRepository: UserRepository, - private readonly roleService: RoleService, ) {} async isOwned( @@ -35,10 +34,10 @@ export class EnterpriseWorkflowService { user, workflowId, { allowGlobalScope: false }, - ['workflow', 'role'], + ['workflow'], ); - if (!sharing || sharing.role.name !== 'owner') return { ownsWorkflow: false }; + if (!sharing || sharing.role !== 'workflow:owner') return { ownsWorkflow: false }; const { workflow } = sharing; @@ -55,7 +54,7 @@ export class EnterpriseWorkflowService { workflow.shared?.forEach(({ user, role }) => { const { id, email, firstName, lastName } = user; - if (role.name === 'owner') { + if (role === 'workflow:owner') { workflow.ownedBy = { id, email, firstName, lastName }; return; } @@ -102,7 +101,7 @@ export class EnterpriseWorkflowService { }; credential.shared?.forEach(({ user, role }) => { const { id, email, firstName, lastName } = user; - if (role.name === 'owner') { + if (role === 'credential:owner') { workflowCredential.ownedBy = { id, email, firstName, lastName }; } else { workflowCredential.sharedWith?.push({ id, email, firstName, lastName }); @@ -143,11 +142,7 @@ export class EnterpriseWorkflowService { const allCredentials = await CredentialsService.getMany(user); try { - return WorkflowHelpers.validateWorkflowCredentialUsage( - workflow, - previousVersion, - allCredentials, - ); + return this.validateWorkflowCredentialUsage(workflow, previousVersion, allCredentials); } catch (error) { if (error instanceof NodeOperationError) { throw new BadRequestError(error.message); @@ -157,4 +152,90 @@ export class EnterpriseWorkflowService { ); } } + + validateWorkflowCredentialUsage( + newWorkflowVersion: WorkflowEntity, + previousWorkflowVersion: WorkflowEntity, + credentialsUserHasAccessTo: CredentialsEntity[], + ) { + /** + * We only need to check nodes that use credentials the current user cannot access, + * since these can be 2 possibilities: + * - Same ID already exist: it's a read only node and therefore cannot be changed + * - It's a new node which indicates tampering and therefore must fail saving + */ + + const allowedCredentialIds = credentialsUserHasAccessTo.map((cred) => cred.id); + + const nodesWithCredentialsUserDoesNotHaveAccessTo = this.getNodesWithInaccessibleCreds( + newWorkflowVersion, + allowedCredentialIds, + ); + + // If there are no nodes with credentials the user does not have access to we can skip the rest + if (nodesWithCredentialsUserDoesNotHaveAccessTo.length === 0) { + return newWorkflowVersion; + } + + const previouslyExistingNodeIds = previousWorkflowVersion.nodes.map((node) => node.id); + + // If it's a new node we can't allow it to be saved + // since it uses creds the node doesn't have access + const isTamperingAttempt = (inaccessibleCredNodeId: string) => + !previouslyExistingNodeIds.includes(inaccessibleCredNodeId); + + nodesWithCredentialsUserDoesNotHaveAccessTo.forEach((node) => { + if (isTamperingAttempt(node.id)) { + this.logger.verbose('Blocked workflow update due to tampering attempt', { + nodeType: node.type, + nodeName: node.name, + nodeId: node.id, + nodeCredentials: node.credentials, + }); + // Node is new, so this is probably a tampering attempt. Throw an error + throw new NodeOperationError( + node, + `You don't have access to the credentials in the '${node.name}' node. Ask the owner to share them with you.`, + ); + } + // Replace the node with the previous version of the node + // Since it cannot be modified (read only node) + const nodeIdx = newWorkflowVersion.nodes.findIndex( + (newWorkflowNode) => newWorkflowNode.id === node.id, + ); + + this.logger.debug('Replacing node with previous version when saving updated workflow', { + nodeType: node.type, + nodeName: node.name, + nodeId: node.id, + }); + const previousNodeVersion = previousWorkflowVersion.nodes.find( + (previousNode) => previousNode.id === node.id, + ); + // Allow changing only name, position and disabled status for read-only nodes + Object.assign( + newWorkflowVersion.nodes[nodeIdx], + omit(previousNodeVersion, ['name', 'position', 'disabled']), + ); + }); + + return newWorkflowVersion; + } + + /** Get all nodes in a workflow where the node credential is not accessible to the user. */ + getNodesWithInaccessibleCreds(workflow: WorkflowEntity, userCredIds: string[]) { + if (!workflow.nodes) { + return []; + } + return workflow.nodes.filter((node) => { + if (!node.credentials) return false; + + const allUsedCredentials = Object.values(node.credentials); + + const allUsedCredentialIds = allUsedCredentials.map((nodeCred) => nodeCred.id?.toString()); + return allUsedCredentialIds.some( + (nodeCredId) => nodeCredId && !userCredIds.includes(nodeCredId), + ); + }); + } } diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index c6546155b0..3833510b75 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -1,34 +1,29 @@ import Container, { Service } from 'typedi'; -import type { INode, IPinData } from 'n8n-workflow'; -import { NodeApiError, Workflow } from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; import pick from 'lodash/pick'; import omit from 'lodash/omit'; import { v4 as uuid } from 'uuid'; -import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; -import * as WorkflowHelpers from '@/WorkflowHelpers'; +import { BinaryDataService } from 'n8n-core'; + import config from '@/config'; import type { User } from '@db/entities/User'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; +import type { WorkflowSharingRole } from '@db/entities/SharedWorkflow'; +import { ExecutionRepository } from '@db/repositories/execution.repository'; +import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; +import { WorkflowTagMappingRepository } from '@db/repositories/workflowTagMapping.repository'; +import { WorkflowRepository } from '@db/repositories/workflow.repository'; +import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; +import * as WorkflowHelpers from '@/WorkflowHelpers'; import { validateEntity } from '@/GenericHelpers'; import { ExternalHooks } from '@/ExternalHooks'; import { hasSharing, type ListQuery } from '@/requests'; -import type { WorkflowRequest } from '@/workflows/workflow.request'; import { TagService } from '@/services/tag.service'; -import type { IWorkflowDb, IWorkflowExecutionDataProcess } from '@/Interfaces'; -import { NodeTypes } from '@/NodeTypes'; -import { WorkflowRunner } from '@/WorkflowRunner'; -import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; -import { TestWebhooks } from '@/TestWebhooks'; import { InternalHooks } from '@/InternalHooks'; -import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { OwnershipService } from '@/services/ownership.service'; import { WorkflowHistoryService } from './workflowHistory/workflowHistory.service.ee'; -import { BinaryDataService } from 'n8n-core'; import { Logger } from '@/Logger'; -import { MultiMainSetup } from '@/services/orchestration/main/MultiMainSetup.ee'; -import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; -import { WorkflowTagMappingRepository } from '@db/repositories/workflowTagMapping.repository'; -import { ExecutionRepository } from '@db/repositories/execution.repository'; +import { OrchestrationService } from '@/services/orchestration.service'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; @@ -44,55 +39,11 @@ export class WorkflowService { private readonly ownershipService: OwnershipService, private readonly tagService: TagService, private readonly workflowHistoryService: WorkflowHistoryService, - private readonly multiMainSetup: MultiMainSetup, - private readonly nodeTypes: NodeTypes, - private readonly testWebhooks: TestWebhooks, + private readonly orchestrationService: OrchestrationService, private readonly externalHooks: ExternalHooks, private readonly activeWorkflowRunner: ActiveWorkflowRunner, ) {} - /** - * Find the pinned trigger to execute the workflow from, if any. - * - * - In a full execution, select the _first_ pinned trigger. - * - In a partial execution, - * - select the _first_ pinned trigger that leads to the executed node, - * - else select the executed pinned trigger. - */ - findPinnedTrigger(workflow: IWorkflowDb, startNodes?: string[], pinData?: IPinData) { - if (!pinData || !startNodes) return null; - - const isTrigger = (nodeTypeName: string) => - ['trigger', 'webhook'].some((suffix) => nodeTypeName.toLowerCase().includes(suffix)); - - const pinnedTriggers = workflow.nodes.filter( - (node) => !node.disabled && pinData[node.name] && isTrigger(node.type), - ); - - if (pinnedTriggers.length === 0) return null; - - if (startNodes?.length === 0) return pinnedTriggers[0]; // full execution - - const [startNodeName] = startNodes; - - const parentNames = new Workflow({ - nodes: workflow.nodes, - connections: workflow.connections, - active: workflow.active, - nodeTypes: this.nodeTypes, - }).getParentNodes(startNodeName); - - let checkNodeName = ''; - - if (parentNames.length === 0) { - checkNodeName = startNodeName; - } else { - checkNodeName = parentNames.find((pn) => pn === pinnedTriggers[0].name) as string; - } - - return pinnedTriggers.find((pt) => pt.name === checkNodeName) ?? null; // partial execution - } - async getMany(sharedWorkflowIds: string[], options?: ListQuery.Options) { const { workflows, count } = await this.workflowRepository.getMany(sharedWorkflowIds, options); @@ -110,7 +61,7 @@ export class WorkflowService { workflowId: string, tagIds?: string[], forceSave?: boolean, - roles?: string[], + roles?: WorkflowSharingRole[], ): Promise { const shared = await this.sharedWorkflowRepository.findSharing( workflowId, @@ -277,12 +228,12 @@ export class WorkflowService { } } - await this.multiMainSetup.init(); + await this.orchestrationService.init(); const newState = updatedWorkflow.active; - if (this.multiMainSetup.isEnabled && oldState !== newState) { - await this.multiMainSetup.publish('workflowActiveStateChanged', { + if (this.orchestrationService.isMultiMainSetupEnabled && oldState !== newState) { + await this.orchestrationService.publish('workflowActiveStateChanged', { workflowId, oldState, newState, @@ -293,70 +244,6 @@ export class WorkflowService { return updatedWorkflow; } - async runManually( - { - workflowData, - runData, - pinData, - startNodes, - destinationNode, - }: WorkflowRequest.ManualRunPayload, - user: User, - sessionId?: string, - ) { - const pinnedTrigger = this.findPinnedTrigger(workflowData, startNodes, pinData); - - // If webhooks nodes exist and are active we have to wait for till we receive a call - if ( - pinnedTrigger === null && - (runData === undefined || - startNodes === undefined || - startNodes.length === 0 || - destinationNode === undefined) - ) { - const additionalData = await WorkflowExecuteAdditionalData.getBase(user.id); - - const needsWebhook = await this.testWebhooks.needsWebhook( - user.id, - workflowData, - additionalData, - runData, - sessionId, - destinationNode, - ); - - if (needsWebhook) return { waitingForWebhook: true }; - } - - // For manual testing always set to not active - workflowData.active = false; - - // Start the workflow - const data: IWorkflowExecutionDataProcess = { - destinationNode, - executionMode: 'manual', - runData, - pinData, - sessionId, - startNodes, - workflowData, - userId: user.id, - }; - - const hasRunData = (node: INode) => runData !== undefined && !!runData[node.name]; - - if (pinnedTrigger && !hasRunData(pinnedTrigger)) { - data.startNodes = [pinnedTrigger.name]; - } - - const workflowRunner = new WorkflowRunner(); - const executionId = await workflowRunner.run(data); - - return { - executionId, - }; - } - async delete(user: User, workflowId: string): Promise { await this.externalHooks.run('workflow.delete', [workflowId]); @@ -364,7 +251,7 @@ export class WorkflowService { workflowId, user, 'workflow:delete', - { roles: ['owner'] }, + { roles: ['workflow:owner'] }, ); if (!sharedWorkflow) { diff --git a/packages/cli/src/workflows/workflowExecution.service.ts b/packages/cli/src/workflows/workflowExecution.service.ts new file mode 100644 index 0000000000..8ee41955a6 --- /dev/null +++ b/packages/cli/src/workflows/workflowExecution.service.ts @@ -0,0 +1,306 @@ +import { Service } from 'typedi'; +import type { IExecuteData, INode, IPinData, IRunExecutionData } from 'n8n-workflow'; +import { + SubworkflowOperationError, + Workflow, + ErrorReporterProxy as ErrorReporter, +} from 'n8n-workflow'; + +import config from '@/config'; +import type { User } from '@db/entities/User'; +import { ExecutionRepository } from '@db/repositories/execution.repository'; +import { WorkflowRepository } from '@db/repositories/workflow.repository'; +import * as WorkflowHelpers from '@/WorkflowHelpers'; +import type { WorkflowRequest } from '@/workflows/workflow.request'; +import type { + ExecutionPayload, + IWorkflowDb, + IWorkflowErrorData, + IWorkflowExecutionDataProcess, +} from '@/Interfaces'; +import { NodeTypes } from '@/NodeTypes'; +import { WorkflowRunner } from '@/WorkflowRunner'; +import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; +import { TestWebhooks } from '@/TestWebhooks'; +import { Logger } from '@/Logger'; +import { PermissionChecker } from '@/UserManagement/PermissionChecker'; + +@Service() +export class WorkflowExecutionService { + constructor( + private readonly logger: Logger, + private readonly executionRepository: ExecutionRepository, + private readonly workflowRepository: WorkflowRepository, + private readonly nodeTypes: NodeTypes, + private readonly testWebhooks: TestWebhooks, + private readonly permissionChecker: PermissionChecker, + ) {} + + async executeManually( + { + workflowData, + runData, + pinData, + startNodes, + destinationNode, + }: WorkflowRequest.ManualRunPayload, + user: User, + sessionId?: string, + ) { + const pinnedTrigger = this.selectPinnedActivatorStarter(workflowData, startNodes, pinData); + + // If webhooks nodes exist and are active we have to wait for till we receive a call + if ( + pinnedTrigger === null && + (runData === undefined || + startNodes === undefined || + startNodes.length === 0 || + destinationNode === undefined) + ) { + const additionalData = await WorkflowExecuteAdditionalData.getBase(user.id); + + const needsWebhook = await this.testWebhooks.needsWebhook( + user.id, + workflowData, + additionalData, + runData, + sessionId, + destinationNode, + ); + + if (needsWebhook) return { waitingForWebhook: true }; + } + + // For manual testing always set to not active + workflowData.active = false; + + // Start the workflow + const data: IWorkflowExecutionDataProcess = { + destinationNode, + executionMode: 'manual', + runData, + pinData, + sessionId, + startNodes, + workflowData, + userId: user.id, + }; + + const hasRunData = (node: INode) => runData !== undefined && !!runData[node.name]; + + if (pinnedTrigger && !hasRunData(pinnedTrigger)) { + data.startNodes = [pinnedTrigger.name]; + } + + const workflowRunner = new WorkflowRunner(); + const executionId = await workflowRunner.run(data); + + return { + executionId, + }; + } + + /** Executes an error workflow */ + async executeErrorWorkflow( + workflowId: string, + workflowErrorData: IWorkflowErrorData, + runningUser: User, + ): Promise { + // Wrap everything in try/catch to make sure that no errors bubble up and all get caught here + try { + const workflowData = await this.workflowRepository.findOneBy({ id: workflowId }); + if (workflowData === null) { + // The error workflow could not be found + this.logger.error( + `Calling Error Workflow for "${workflowErrorData.workflow.id}". Could not find error workflow "${workflowId}"`, + { workflowId }, + ); + return; + } + + const executionMode = 'error'; + const workflowInstance = new Workflow({ + id: workflowId, + name: workflowData.name, + nodeTypes: this.nodeTypes, + nodes: workflowData.nodes, + connections: workflowData.connections, + active: workflowData.active, + staticData: workflowData.staticData, + settings: workflowData.settings, + }); + + try { + const failedNode = workflowErrorData.execution?.lastNodeExecuted + ? workflowInstance.getNode(workflowErrorData.execution?.lastNodeExecuted) + : undefined; + await this.permissionChecker.checkSubworkflowExecutePolicy( + workflowInstance, + workflowErrorData.workflow.id!, + failedNode ?? undefined, + ); + } catch (error) { + const initialNode = workflowInstance.getStartNode(); + if (initialNode) { + const errorWorkflowPermissionError = new SubworkflowOperationError( + `Another workflow: (ID ${workflowErrorData.workflow.id}) tried to invoke this workflow to handle errors.`, + "Unfortunately current permissions do not allow this. Please check that this workflow's settings allow it to be called by others", + ); + + // Create a fake execution and save it to DB. + const fakeExecution = WorkflowHelpers.generateFailedExecutionFromError( + 'error', + errorWorkflowPermissionError, + initialNode, + ); + + const fullExecutionData: ExecutionPayload = { + data: fakeExecution.data, + mode: fakeExecution.mode, + finished: false, + startedAt: new Date(), + stoppedAt: new Date(), + workflowData, + waitTill: null, + status: fakeExecution.status, + workflowId: workflowData.id, + }; + + await this.executionRepository.createNewExecution(fullExecutionData); + } + this.logger.info('Error workflow execution blocked due to subworkflow settings', { + erroredWorkflowId: workflowErrorData.workflow.id, + errorWorkflowId: workflowId, + }); + return; + } + + let node: INode; + let workflowStartNode: INode | undefined; + const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType'); + for (const nodeName of Object.keys(workflowInstance.nodes)) { + node = workflowInstance.nodes[nodeName]; + if (node.type === ERROR_TRIGGER_TYPE) { + workflowStartNode = node; + } + } + + if (workflowStartNode === undefined) { + this.logger.error( + `Calling Error Workflow for "${workflowErrorData.workflow.id}". Could not find "${ERROR_TRIGGER_TYPE}" in workflow "${workflowId}"`, + ); + return; + } + + // Can execute without webhook so go on + // Initialize the data of the webhook node + const nodeExecutionStack: IExecuteData[] = []; + nodeExecutionStack.push({ + node: workflowStartNode, + data: { + main: [ + [ + { + json: workflowErrorData, + }, + ], + ], + }, + source: null, + }); + + const runExecutionData: IRunExecutionData = { + startData: {}, + resultData: { + runData: {}, + }, + executionData: { + contextData: {}, + metadata: {}, + nodeExecutionStack, + waitingExecution: {}, + waitingExecutionSource: {}, + }, + }; + + const runData: IWorkflowExecutionDataProcess = { + executionMode, + executionData: runExecutionData, + workflowData, + userId: runningUser.id, + }; + + const workflowRunner = new WorkflowRunner(); + await workflowRunner.run(runData); + } catch (error) { + ErrorReporter.error(error); + this.logger.error( + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + `Calling Error Workflow for "${workflowErrorData.workflow.id}": "${error.message}"`, + { workflowId: workflowErrorData.workflow.id }, + ); + } + } + + /** + * Select the pinned activator node to use as starter for a manual execution. + * + * In a full manual execution, select the pinned activator that was first added + * to the workflow, prioritizing `n8n-nodes-base.webhook` over other activators. + * + * In a partial manual execution, if the executed node has parent nodes among the + * pinned activators, select the pinned activator that was first added to the workflow, + * prioritizing `n8n-nodes-base.webhook` over other activators. If the executed node + * has no upstream nodes and is itself is a pinned activator, select it. + */ + selectPinnedActivatorStarter(workflow: IWorkflowDb, startNodes?: string[], pinData?: IPinData) { + if (!pinData || !startNodes) return null; + + const allPinnedActivators = this.findAllPinnedActivators(workflow, pinData); + + if (allPinnedActivators.length === 0) return null; + + const [firstPinnedActivator] = allPinnedActivators; + + // full manual execution + + if (startNodes?.length === 0) return firstPinnedActivator ?? null; + + // partial manual execution + + /** + * If the partial manual execution has 2+ start nodes, we search only the zeroth + * start node's parents for a pinned activator. If we had 2+ start nodes without + * a common ancestor and so if we end up finding multiple pinned activators, we + * would still need to return one to comply with existing usage. + */ + const [firstStartNodeName] = startNodes; + + const parentNodeNames = new Workflow({ + nodes: workflow.nodes, + connections: workflow.connections, + active: workflow.active, + nodeTypes: this.nodeTypes, + }).getParentNodes(firstStartNodeName); + + if (parentNodeNames.length > 0) { + const parentNodeName = parentNodeNames.find((p) => p === firstPinnedActivator.name); + + return allPinnedActivators.find((pa) => pa.name === parentNodeName) ?? null; + } + + return allPinnedActivators.find((pa) => pa.name === firstStartNodeName) ?? null; + } + + private findAllPinnedActivators(workflow: IWorkflowDb, pinData?: IPinData) { + return workflow.nodes + .filter( + (node) => + !node.disabled && + pinData?.[node.name] && + ['trigger', 'webhook'].some((suffix) => node.type.toLowerCase().endsWith(suffix)) && + node.type !== 'n8n-nodes-base.respondToWebhook', + ) + .sort((a) => (a.type.endsWith('webhook') ? -1 : 1)); + } +} diff --git a/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts b/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts index 853c7260f5..4bdc337bc3 100644 --- a/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts +++ b/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts @@ -19,7 +19,7 @@ export class WorkflowHistoryService { ) {} private async getSharedWorkflow(user: User, workflowId: string): Promise { - return this.sharedWorkflowRepository.findOne({ + return await this.sharedWorkflowRepository.findOne({ where: { ...(!user.hasGlobalScope('workflow:read') && { userId: user.id }), workflowId, @@ -37,7 +37,7 @@ export class WorkflowHistoryService { if (!sharedWorkflow) { throw new SharedWorkflowNotFoundError(''); } - return this.workflowHistoryRepository.find({ + return await this.workflowHistoryRepository.find({ where: { workflowId: sharedWorkflow.workflowId, }, diff --git a/packages/cli/src/workflows/workflowHistory/workflowHistoryManager.ee.ts b/packages/cli/src/workflows/workflowHistory/workflowHistoryManager.ee.ts index dac682c234..4898666c88 100644 --- a/packages/cli/src/workflows/workflowHistory/workflowHistoryManager.ee.ts +++ b/packages/cli/src/workflows/workflowHistory/workflowHistoryManager.ee.ts @@ -15,7 +15,7 @@ export class WorkflowHistoryManager { clearInterval(this.pruneTimer); } - this.pruneTimer = setInterval(async () => this.prune(), WORKFLOW_HISTORY_PRUNE_INTERVAL); + this.pruneTimer = setInterval(async () => await this.prune(), WORKFLOW_HISTORY_PRUNE_INTERVAL); } shutdown() { diff --git a/packages/cli/src/workflows/workflowSharing.service.ts b/packages/cli/src/workflows/workflowSharing.service.ts new file mode 100644 index 0000000000..78f325c719 --- /dev/null +++ b/packages/cli/src/workflows/workflowSharing.service.ts @@ -0,0 +1,30 @@ +import { Service } from 'typedi'; +import { In, type FindOptionsWhere } from 'typeorm'; + +import type { SharedWorkflow, WorkflowSharingRole } from '@db/entities/SharedWorkflow'; +import type { User } from '@db/entities/User'; +import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; + +@Service() +export class WorkflowSharingService { + constructor(private readonly sharedWorkflowRepository: SharedWorkflowRepository) {} + + /** + * Get the IDs of the workflows that have been shared with the user. + * Returns all IDs if user has the 'workflow:read' scope. + */ + async getSharedWorkflowIds(user: User, roles?: WorkflowSharingRole[]): Promise { + const where: FindOptionsWhere = {}; + if (!user.hasGlobalScope('workflow:read')) { + where.userId = user.id; + } + if (roles?.length) { + where.role = In(roles); + } + const sharedWorkflows = await this.sharedWorkflowRepository.find({ + where, + select: ['workflowId'], + }); + return sharedWorkflows.map(({ workflowId }) => workflowId); + } +} diff --git a/packages/cli/src/workflows/workflowStaticData.service.ts b/packages/cli/src/workflows/workflowStaticData.service.ts index b569c69a30..b77d1b716c 100644 --- a/packages/cli/src/workflows/workflowStaticData.service.ts +++ b/packages/cli/src/workflows/workflowStaticData.service.ts @@ -11,6 +11,15 @@ export class WorkflowStaticDataService { private readonly workflowRepository: WorkflowRepository, ) {} + /** Returns the static data of workflow */ + async getStaticDataById(workflowId: string) { + const workflowData = await this.workflowRepository.findOne({ + select: ['staticData'], + where: { id: workflowId }, + }); + return workflowData?.staticData ?? {}; + } + /** Saves the static data if it changed */ async saveStaticData(workflow: Workflow): Promise { if (workflow.staticData.__dataChanged === true) { diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index c38fa32fb6..912457760a 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -1,43 +1,45 @@ +import { Service } from 'typedi'; import express from 'express'; import { v4 as uuid } from 'uuid'; - import axios from 'axios'; + import * as Db from '@/Db'; import * as GenericHelpers from '@/GenericHelpers'; import * as ResponseHelper from '@/ResponseHelper'; import * as WorkflowHelpers from '@/WorkflowHelpers'; import type { IWorkflowResponse } from '@/Interfaces'; import config from '@/config'; -import { SharedWorkflow } from '@db/entities/SharedWorkflow'; +import { Authorized, Delete, Get, Patch, Post, Put, RestController } from '@/decorators'; +import { SharedWorkflow, type WorkflowSharingRole } from '@db/entities/SharedWorkflow'; import { WorkflowEntity } from '@db/entities/WorkflowEntity'; +import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; +import { TagRepository } from '@db/repositories/tag.repository'; +import { WorkflowRepository } from '@db/repositories/workflow.repository'; +import { UserRepository } from '@db/repositories/user.repository'; import { validateEntity } from '@/GenericHelpers'; import { ExternalHooks } from '@/ExternalHooks'; import { ListQuery } from '@/requests'; -import { isBelowOnboardingThreshold } from '@/WorkflowHelpers'; import { WorkflowService } from './workflow.service'; -import { isSharingEnabled } from '@/UserManagement/UserManagementHelper'; -import Container, { Service } from 'typedi'; +import { License } from '@/License'; import { InternalHooks } from '@/InternalHooks'; -import { RoleService } from '@/services/role.service'; import * as utils from '@/utils'; import { listQueryMiddleware } from '@/middlewares'; import { TagService } from '@/services/tag.service'; import { WorkflowHistoryService } from './workflowHistory/workflowHistory.service.ee'; import { Logger } from '@/Logger'; -import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; -import { NamingService } from '@/services/naming.service'; -import { TagRepository } from '@/databases/repositories/tag.repository'; -import { EnterpriseWorkflowService } from './workflow.service.ee'; -import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; -import type { RoleNames } from '@/databases/entities/Role'; import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; +import { NamingService } from '@/services/naming.service'; +import { UserOnboardingService } from '@/services/userOnboarding.service'; import { CredentialsService } from '../credentials/credentials.service'; -import { UserRepository } from '@/databases/repositories/user.repository'; -import { Authorized, Delete, Get, Patch, Post, Put, RestController } from '@/decorators'; import { WorkflowRequest } from './workflow.request'; +import { EnterpriseWorkflowService } from './workflow.service.ee'; +import { WorkflowExecutionService } from './workflowExecution.service'; +import { WorkflowSharingService } from './workflowSharing.service'; +import { UserManagementMailer } from '@/UserManagement/email'; +import { UrlService } from '@/services/url.service'; @Service() @Authorized() @@ -49,14 +51,19 @@ export class WorkflowsController { private readonly externalHooks: ExternalHooks, private readonly tagRepository: TagRepository, private readonly enterpriseWorkflowService: EnterpriseWorkflowService, - private readonly roleService: RoleService, private readonly workflowHistoryService: WorkflowHistoryService, private readonly tagService: TagService, private readonly namingService: NamingService, + private readonly userOnboardingService: UserOnboardingService, private readonly workflowRepository: WorkflowRepository, private readonly workflowService: WorkflowService, + private readonly workflowExecutionService: WorkflowExecutionService, + private readonly workflowSharingService: WorkflowSharingService, private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly userRepository: UserRepository, + private readonly license: License, + private readonly mailer: UserManagementMailer, + private readonly urlService: UrlService, ) {} @Post('/') @@ -83,7 +90,7 @@ export class WorkflowsController { WorkflowHelpers.addNodeIds(newWorkflow); - if (isSharingEnabled()) { + if (this.license.isSharingEnabled()) { // This is a new workflow, so we simply check if the user has access to // all used workflows @@ -106,12 +113,10 @@ export class WorkflowsController { await Db.transaction(async (transactionManager) => { savedWorkflow = await transactionManager.save(newWorkflow); - const role = await this.roleService.findWorkflowOwnerRole(); - const newSharedWorkflow = new SharedWorkflow(); Object.assign(newSharedWorkflow, { - role, + role: 'workflow:owner', user: req.user, workflow: savedWorkflow, }); @@ -141,10 +146,15 @@ export class WorkflowsController { @Get('/', { middlewares: listQueryMiddleware }) async getAll(req: ListQuery.Request, res: express.Response) { try { - const roles: RoleNames[] = isSharingEnabled() ? [] : ['owner']; - const sharedWorkflowIds = await WorkflowHelpers.getSharedWorkflowIds(req.user, roles); + const roles: WorkflowSharingRole[] = this.license.isSharingEnabled() + ? [] + : ['workflow:owner']; + const sharedWorkflowIds = await this.workflowSharingService.getSharedWorkflowIds( + req.user, + roles, + ); - const { workflows: data, count } = await Container.get(WorkflowService).getMany( + const { workflows: data, count } = await this.workflowService.getMany( sharedWorkflowIds, req.listQueryOptions, ); @@ -166,7 +176,7 @@ export class WorkflowsController { const onboardingFlowEnabled = !config.getEnv('workflows.onboardingFlowDisabled') && !req.user.settings?.isOnboarded && - (await isBelowOnboardingThreshold(req.user)); + (await this.userOnboardingService.isBelowThreshold(req.user)); return { name, onboardingFlowEnabled }; } @@ -209,8 +219,8 @@ export class WorkflowsController { async getWorkflow(req: WorkflowRequest.Get) { const { id: workflowId } = req.params; - if (isSharingEnabled()) { - const relations = ['shared', 'shared.user', 'shared.role']; + if (this.license.isSharingEnabled()) { + const relations = ['shared', 'shared.user']; if (!config.getEnv('workflowTagsDisabled')) { relations.push('tags'); } @@ -268,7 +278,8 @@ export class WorkflowsController { const { tags, ...rest } = req.body; Object.assign(updateData, rest); - if (isSharingEnabled()) { + const isSharingEnabled = this.license.isSharingEnabled(); + if (isSharingEnabled) { updateData = await this.enterpriseWorkflowService.preventTampering( updateData, workflowId, @@ -281,8 +292,8 @@ export class WorkflowsController { updateData, workflowId, tags, - isSharingEnabled() ? forceSave : true, - isSharingEnabled() ? undefined : ['owner'], + isSharingEnabled ? forceSave : true, + isSharingEnabled ? undefined : ['workflow:owner'], ); return updatedWorkflow; @@ -308,7 +319,7 @@ export class WorkflowsController { @Post('/run') async runManually(req: WorkflowRequest.ManualRun) { - if (isSharingEnabled()) { + if (this.license.isSharingEnabled()) { const workflow = this.workflowRepository.create(req.body.workflowData); if (req.body.workflowData.id !== undefined) { @@ -321,12 +332,16 @@ export class WorkflowsController { } } - return this.workflowService.runManually(req.body, req.user, GenericHelpers.getSessionId(req)); + return await this.workflowExecutionService.executeManually( + req.body, + req.user, + GenericHelpers.getSessionId(req), + ); } @Put('/:workflowId/share') async share(req: WorkflowRequest.Share) { - if (!isSharingEnabled()) throw new NotFoundError('Route not found'); + if (!this.license.isSharingEnabled()) throw new NotFoundError('Route not found'); const { workflowId } = req.params; const { shareWithIds } = req.body; @@ -361,10 +376,10 @@ export class WorkflowsController { await this.workflowRepository.getSharings( Db.getConnection().createEntityManager(), workflowId, - ['shared', 'shared.role'], + ['shared'], ) ) - .filter((e) => e.role.name === 'owner') + .filter((e) => e.role === 'workflow:owner') .map((e) => e.userId); let newShareeIds: string[] = []; @@ -382,12 +397,41 @@ export class WorkflowsController { if (newShareeIds.length) { const users = await this.userRepository.getByIds(trx, newShareeIds); - const role = await this.roleService.findWorkflowEditorRole(); - - await this.sharedWorkflowRepository.share(trx, workflow!, users, role.id); + await this.sharedWorkflowRepository.share(trx, workflow!, users); } }); void this.internalHooks.onWorkflowSharingUpdate(workflowId, req.user.id, shareWithIds); + + const recipients = await this.userRepository.getEmailsByIds(newShareeIds); + + if (recipients.length === 0) return; + + try { + await this.mailer.notifyWorkflowShared({ + recipientEmails: recipients.map(({ email }) => email), + workflowName: workflow.name, + workflowId, + sharerFirstName: req.user.firstName, + baseUrl: this.urlService.getInstanceBaseUrl(), + }); + } catch (error) { + void this.internalHooks.onEmailFailed({ + user: req.user, + message_type: 'Workflow shared', + public_api: false, + }); + if (error instanceof Error) { + throw new InternalServerError(`Please contact your administrator: ${error.message}`); + } + } + + this.logger.info('Sent workflow shared email successfully', { sharerId: req.user.id }); + + void this.internalHooks.onUserTransactionalEmail({ + user_id: req.user.id, + message_type: 'Workflow shared', + public_api: false, + }); } } diff --git a/packages/cli/templates/form-trigger-404.handlebars b/packages/cli/templates/form-trigger-404.handlebars index e4118fd810..234cb0e4dc 100644 --- a/packages/cli/templates/form-trigger-404.handlebars +++ b/packages/cli/templates/form-trigger-404.handlebars @@ -34,7 +34,7 @@ {{#if isTestWebhook}}

Form Trigger isn't listening yet

-

Click the "Test Step" button in your form trigger

+

Click the "Test step" button in your form trigger

{{else}}
diff --git a/packages/cli/test/integration/ActiveWorkflowRunner.test.ts b/packages/cli/test/integration/ActiveWorkflowRunner.test.ts index d4f2baa5e2..4e6138fd96 100644 --- a/packages/cli/test/integration/ActiveWorkflowRunner.test.ts +++ b/packages/cli/test/integration/ActiveWorkflowRunner.test.ts @@ -17,25 +17,25 @@ import type { User } from '@db/entities/User'; import type { WebhookEntity } from '@db/entities/WebhookEntity'; import { NodeTypes } from '@/NodeTypes'; import { chooseRandomly } from './shared/random'; -import { MultiMainSetup } from '@/services/orchestration/main/MultiMainSetup.ee'; +import { OrchestrationService } from '@/services/orchestration.service'; import { mockInstance } from '../shared/mocking'; import { setSchedulerAsLoadedNode } from './shared/utils'; import * as testDb from './shared/testDb'; import { createOwner } from './shared/db/users'; import { createWorkflow } from './shared/db/workflows'; -import { ExecutionsService } from '@/executions/executions.service'; +import { ExecutionService } from '@/executions/execution.service'; import { WorkflowService } from '@/workflows/workflow.service'; import { ActiveWorkflowsService } from '@/services/activeWorkflows.service'; mockInstance(ActiveExecutions); mockInstance(Push); mockInstance(SecretsHelper); -mockInstance(ExecutionsService); +mockInstance(ExecutionService); mockInstance(WorkflowService); const webhookService = mockInstance(WebhookService); -const multiMainSetup = mockInstance(MultiMainSetup, { - isEnabled: false, +const orchestrationService = mockInstance(OrchestrationService, { + isMultiMainSetupEnabled: false, isLeader: false, isFollower: false, }); @@ -266,8 +266,8 @@ describe('add()', () => { const workflow = await createWorkflow({ active: true }, owner); - jest.replaceProperty(multiMainSetup, 'isEnabled', true); - jest.replaceProperty(multiMainSetup, 'isLeader', true); + jest.replaceProperty(orchestrationService, 'isMultiMainSetupEnabled', true); + jest.replaceProperty(orchestrationService, 'isLeader', true); const addWebhooksSpy = jest.spyOn(activeWorkflowRunner, 'addWebhooks'); const addTriggersAndPollersSpy = jest.spyOn( @@ -290,8 +290,8 @@ describe('add()', () => { test('should add triggers and pollers only', async () => { const mode = 'leadershipChange'; - jest.replaceProperty(multiMainSetup, 'isEnabled', true); - jest.replaceProperty(multiMainSetup, 'isLeader', true); + jest.replaceProperty(orchestrationService, 'isMultiMainSetupEnabled', true); + jest.replaceProperty(orchestrationService, 'isLeader', true); const workflow = await createWorkflow({ active: true }, owner); @@ -318,8 +318,8 @@ describe('add()', () => { test('should not add webhooks, triggers or pollers', async () => { const mode = chooseRandomly(NON_LEADERSHIP_CHANGE_MODES); - jest.replaceProperty(multiMainSetup, 'isEnabled', true); - jest.replaceProperty(multiMainSetup, 'isLeader', false); + jest.replaceProperty(orchestrationService, 'isMultiMainSetupEnabled', true); + jest.replaceProperty(orchestrationService, 'isLeader', false); const workflow = await createWorkflow({ active: true }, owner); diff --git a/packages/cli/test/integration/ExternalSecrets/externalSecrets.api.test.ts b/packages/cli/test/integration/ExternalSecrets/externalSecrets.api.test.ts index 6eeb7e5b3d..868c695f4a 100644 --- a/packages/cli/test/integration/ExternalSecrets/externalSecrets.api.test.ts +++ b/packages/cli/test/integration/ExternalSecrets/externalSecrets.api.test.ts @@ -35,7 +35,7 @@ const testServer = setupTestServer({ const connectedDate = '2023-08-01T12:32:29.000Z'; async function setExternalSecretsSettings(settings: ExternalSecretsSettings) { - return Container.get(SettingsRepository).saveEncryptedSecretsProviderSettings( + return await Container.get(SettingsRepository).saveEncryptedSecretsProviderSettings( Container.get(Cipher).encrypt(settings), ); } @@ -45,7 +45,7 @@ async function getExternalSecretsSettings(): Promise { diff --git a/packages/cli/test/integration/auth.api.test.ts b/packages/cli/test/integration/auth.api.test.ts index ab3f3a8dd3..768acc467d 100644 --- a/packages/cli/test/integration/auth.api.test.ts +++ b/packages/cli/test/integration/auth.api.test.ts @@ -3,19 +3,15 @@ import { Container } from 'typedi'; import validator from 'validator'; import config from '@/config'; import { AUTH_COOKIE_NAME } from '@/constants'; -import type { Role } from '@db/entities/Role'; import type { User } from '@db/entities/User'; import { LOGGED_OUT_RESPONSE_BODY } from './shared/constants'; import { randomValidPassword } from './shared/random'; import * as testDb from './shared/testDb'; import * as utils from './shared/utils/'; -import { getGlobalMemberRole, getGlobalOwnerRole } from './shared/db/roles'; import { createUser, createUserShell } from './shared/db/users'; import { UserRepository } from '@db/repositories/user.repository'; import { MfaService } from '@/Mfa/mfa.service'; -let globalOwnerRole: Role; -let globalMemberRole: Role; let owner: User; let authOwnerAgent: SuperAgentTest; const ownerPassword = randomValidPassword(); @@ -26,8 +22,6 @@ const license = testServer.license; let mfaService: MfaService; beforeAll(async () => { - globalOwnerRole = await getGlobalOwnerRole(); - globalMemberRole = await getGlobalMemberRole(); mfaService = Container.get(MfaService); }); @@ -41,7 +35,7 @@ describe('POST /login', () => { beforeEach(async () => { owner = await createUser({ password: ownerPassword, - globalRole: globalOwnerRole, + role: 'global:owner', }); }); @@ -60,7 +54,7 @@ describe('POST /login', () => { lastName, password, personalizationAnswers, - globalRole, + role, apiKey, globalScopes, mfaSecret, @@ -74,9 +68,7 @@ describe('POST /login', () => { expect(password).toBeUndefined(); expect(personalizationAnswers).toBeNull(); expect(password).toBeUndefined(); - expect(globalRole).toBeDefined(); - expect(globalRole.name).toBe('owner'); - expect(globalRole.scope).toBe('global'); + expect(role).toBe('global:owner'); expect(apiKey).toBeUndefined(); expect(globalScopes).toBeDefined(); expect(mfaRecoveryCodes).toBeUndefined(); @@ -107,7 +99,7 @@ describe('POST /login', () => { lastName, password, personalizationAnswers, - globalRole, + role, apiKey, mfaRecoveryCodes, mfaSecret, @@ -120,9 +112,7 @@ describe('POST /login', () => { expect(password).toBeUndefined(); expect(personalizationAnswers).toBeNull(); expect(password).toBeUndefined(); - expect(globalRole).toBeDefined(); - expect(globalRole.name).toBe('owner'); - expect(globalRole.scope).toBe('global'); + expect(role).toBe('global:owner'); expect(apiKey).toBeUndefined(); expect(mfaRecoveryCodes).toBeUndefined(); expect(mfaSecret).toBeUndefined(); @@ -149,7 +139,7 @@ describe('POST /login', () => { license.setQuota('quota:users', 0); const ownerUser = await createUser({ password: randomValidPassword(), - globalRole: globalOwnerRole, + role: 'global:owner', }); const response = await testServer.authAgentFor(ownerUser).get('/login'); @@ -168,7 +158,7 @@ describe('GET /login', () => { }); test('should return cookie if UM is disabled and no cookie is already set', async () => { - await createUserShell(globalOwnerRole); + await createUserShell('global:owner'); await utils.setInstanceOwnerSetUp(false); const response = await testServer.authlessAgent.get('/login'); @@ -191,7 +181,7 @@ describe('GET /login', () => { }); test('should return logged-in owner shell', async () => { - const ownerShell = await createUserShell(globalOwnerRole); + const ownerShell = await createUserShell('global:owner'); const response = await testServer.authAgentFor(ownerShell).get('/login'); @@ -204,7 +194,7 @@ describe('GET /login', () => { lastName, password, personalizationAnswers, - globalRole, + role, apiKey, globalScopes, } = response.body.data; @@ -216,9 +206,7 @@ describe('GET /login', () => { expect(password).toBeUndefined(); expect(personalizationAnswers).toBeNull(); expect(password).toBeUndefined(); - expect(globalRole).toBeDefined(); - expect(globalRole.name).toBe('owner'); - expect(globalRole.scope).toBe('global'); + expect(role).toBe('global:owner'); expect(apiKey).toBeUndefined(); expect(globalScopes).toBeDefined(); expect(globalScopes).toContain('workflow:read'); @@ -228,7 +216,7 @@ describe('GET /login', () => { }); test('should return logged-in member shell', async () => { - const memberShell = await createUserShell(globalMemberRole); + const memberShell = await createUserShell('global:member'); const response = await testServer.authAgentFor(memberShell).get('/login'); @@ -241,7 +229,7 @@ describe('GET /login', () => { lastName, password, personalizationAnswers, - globalRole, + role, apiKey, globalScopes, } = response.body.data; @@ -253,9 +241,7 @@ describe('GET /login', () => { expect(password).toBeUndefined(); expect(personalizationAnswers).toBeNull(); expect(password).toBeUndefined(); - expect(globalRole).toBeDefined(); - expect(globalRole.name).toBe('member'); - expect(globalRole.scope).toBe('global'); + expect(role).toBe('global:member'); expect(apiKey).toBeUndefined(); expect(globalScopes).toBeDefined(); expect(globalScopes).not.toContain('workflow:read'); @@ -265,7 +251,7 @@ describe('GET /login', () => { }); test('should return logged-in owner', async () => { - const owner = await createUser({ globalRole: globalOwnerRole }); + const owner = await createUser({ role: 'global:owner' }); const response = await testServer.authAgentFor(owner).get('/login'); @@ -278,7 +264,7 @@ describe('GET /login', () => { lastName, password, personalizationAnswers, - globalRole, + role, apiKey, globalScopes, } = response.body.data; @@ -290,9 +276,7 @@ describe('GET /login', () => { expect(password).toBeUndefined(); expect(personalizationAnswers).toBeNull(); expect(password).toBeUndefined(); - expect(globalRole).toBeDefined(); - expect(globalRole.name).toBe('owner'); - expect(globalRole.scope).toBe('global'); + expect(role).toBe('global:owner'); expect(apiKey).toBeUndefined(); expect(globalScopes).toBeDefined(); expect(globalScopes).toContain('workflow:read'); @@ -302,7 +286,7 @@ describe('GET /login', () => { }); test('should return logged-in member', async () => { - const member = await createUser({ globalRole: globalMemberRole }); + const member = await createUser({ role: 'global:member' }); const response = await testServer.authAgentFor(member).get('/login'); @@ -315,7 +299,7 @@ describe('GET /login', () => { lastName, password, personalizationAnswers, - globalRole, + role, apiKey, globalScopes, } = response.body.data; @@ -327,9 +311,7 @@ describe('GET /login', () => { expect(password).toBeUndefined(); expect(personalizationAnswers).toBeNull(); expect(password).toBeUndefined(); - expect(globalRole).toBeDefined(); - expect(globalRole.name).toBe('member'); - expect(globalRole.scope).toBe('global'); + expect(role).toBe('global:member'); expect(apiKey).toBeUndefined(); expect(globalScopes).toBeDefined(); expect(globalScopes).not.toContain('workflow:read'); @@ -343,13 +325,13 @@ describe('GET /resolve-signup-token', () => { beforeEach(async () => { owner = await createUser({ password: ownerPassword, - globalRole: globalOwnerRole, + role: 'global:owner', }); authOwnerAgent = testServer.authAgentFor(owner); }); test('should validate invite token', async () => { - const memberShell = await createUserShell(globalMemberRole); + const memberShell = await createUserShell('global:member'); const response = await authOwnerAgent .get('/resolve-signup-token') @@ -369,7 +351,7 @@ describe('GET /resolve-signup-token', () => { test('should return 403 if user quota reached', async () => { license.setQuota('quota:users', 0); - const memberShell = await createUserShell(globalMemberRole); + const memberShell = await createUserShell('global:member'); const response = await authOwnerAgent .get('/resolve-signup-token') @@ -380,7 +362,7 @@ describe('GET /resolve-signup-token', () => { }); test('should fail with invalid inputs', async () => { - const { id: inviteeId } = await createUser({ globalRole: globalMemberRole }); + const { id: inviteeId } = await createUser({ role: 'global:member' }); const first = await authOwnerAgent.get('/resolve-signup-token').query({ inviterId: owner.id }); @@ -412,7 +394,7 @@ describe('GET /resolve-signup-token', () => { describe('POST /logout', () => { test('should log user out', async () => { - const owner = await createUser({ globalRole: globalOwnerRole }); + const owner = await createUser({ role: 'global:owner' }); const response = await testServer.authAgentFor(owner).post('/logout'); diff --git a/packages/cli/test/integration/auth.mw.test.ts b/packages/cli/test/integration/auth.mw.test.ts index f958a630d8..7a117e95be 100644 --- a/packages/cli/test/integration/auth.mw.test.ts +++ b/packages/cli/test/integration/auth.mw.test.ts @@ -1,8 +1,8 @@ +import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; + import type { SuperAgentTest } from 'supertest'; import * as utils from './shared/utils/'; -import { getGlobalMemberRole } from './shared/db/roles'; import { createUser } from './shared/db/users'; -import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; import { mockInstance } from '../shared/mocking'; describe('Auth Middleware', () => { @@ -42,8 +42,7 @@ describe('Auth Middleware', () => { describe('Routes requiring Authorization', () => { let authMemberAgent: SuperAgentTest; beforeAll(async () => { - const globalMemberRole = await getGlobalMemberRole(); - const member = await createUser({ globalRole: globalMemberRole }); + const member = await createUser({ role: 'global:member' }); authMemberAgent = testServer.authAgentFor(member); }); diff --git a/packages/cli/test/integration/commands/credentials.cmd.test.ts b/packages/cli/test/integration/commands/credentials.cmd.test.ts index 1104e3c6b9..d5beb03552 100644 --- a/packages/cli/test/integration/commands/credentials.cmd.test.ts +++ b/packages/cli/test/integration/commands/credentials.cmd.test.ts @@ -1,4 +1,4 @@ -import * as Config from '@oclif/config'; +import { Config } from '@oclif/core'; import { InternalHooks } from '@/InternalHooks'; import { ImportCredentialsCommand } from '@/commands/import/credentials'; @@ -8,6 +8,8 @@ import { mockInstance } from '../../shared/mocking'; import * as testDb from '../shared/testDb'; import { getAllCredentials } from '../shared/db/credentials'; +const oclifConfig = new Config({ root: __dirname }); + beforeAll(async () => { mockInstance(InternalHooks); mockInstance(LoadNodesAndCredentials); @@ -23,12 +25,11 @@ afterAll(async () => { }); test('import:credentials should import a credential', async () => { - const config: Config.IConfig = new Config.Config({ root: __dirname }); const before = await getAllCredentials(); expect(before.length).toBe(0); const importer = new ImportCredentialsCommand( ['--input=./test/integration/commands/importCredentials/credentials.json'], - config, + oclifConfig, ); const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); diff --git a/packages/cli/test/integration/commands/import.cmd.test.ts b/packages/cli/test/integration/commands/import.cmd.test.ts index 44e92261f6..211fde5641 100644 --- a/packages/cli/test/integration/commands/import.cmd.test.ts +++ b/packages/cli/test/integration/commands/import.cmd.test.ts @@ -1,4 +1,4 @@ -import * as Config from '@oclif/config'; +import { Config } from '@oclif/core'; import { InternalHooks } from '@/InternalHooks'; import { ImportWorkflowsCommand } from '@/commands/import/workflow'; @@ -8,6 +8,8 @@ import { mockInstance } from '../../shared/mocking'; import * as testDb from '../shared/testDb'; import { getAllWorkflows } from '../shared/db/workflows'; +const oclifConfig = new Config({ root: __dirname }); + beforeAll(async () => { mockInstance(InternalHooks); mockInstance(LoadNodesAndCredentials); @@ -23,12 +25,11 @@ afterAll(async () => { }); test('import:workflow should import active workflow and deactivate it', async () => { - const config: Config.IConfig = new Config.Config({ root: __dirname }); const before = await getAllWorkflows(); expect(before.length).toBe(0); const importer = new ImportWorkflowsCommand( ['--separate', '--input=./test/integration/commands/importWorkflows/separate'], - config, + oclifConfig, ); const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); @@ -50,12 +51,11 @@ test('import:workflow should import active workflow and deactivate it', async () }); test('import:workflow should import active workflow from combined file and deactivate it', async () => { - const config: Config.IConfig = new Config.Config({ root: __dirname }); const before = await getAllWorkflows(); expect(before.length).toBe(0); const importer = new ImportWorkflowsCommand( ['--input=./test/integration/commands/importWorkflows/combined/combined.json'], - config, + oclifConfig, ); const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); diff --git a/packages/cli/test/integration/commands/reset.cmd.test.ts b/packages/cli/test/integration/commands/reset.cmd.test.ts index 6ffc229bb7..fd32fee1fc 100644 --- a/packages/cli/test/integration/commands/reset.cmd.test.ts +++ b/packages/cli/test/integration/commands/reset.cmd.test.ts @@ -1,5 +1,4 @@ import { Reset } from '@/commands/user-management/reset'; -import type { Role } from '@db/entities/Role'; import { InternalHooks } from '@/InternalHooks'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { NodeTypes } from '@/NodeTypes'; @@ -8,18 +7,13 @@ import { UserRepository } from '@db/repositories/user.repository'; import { mockInstance } from '../../shared/mocking'; import * as testDb from '../shared/testDb'; -import { getGlobalOwnerRole } from '../shared/db/roles'; import { createUser } from '../shared/db/users'; -let globalOwnerRole: Role; - beforeAll(async () => { mockInstance(InternalHooks); mockInstance(LoadNodesAndCredentials); mockInstance(NodeTypes); await testDb.init(); - - globalOwnerRole = await getGlobalOwnerRole(); }); beforeEach(async () => { @@ -32,11 +26,11 @@ afterAll(async () => { // eslint-disable-next-line n8n-local-rules/no-skipped-tests test.skip('user-management:reset should reset DB to default user state', async () => { - await createUser({ globalRole: globalOwnerRole }); + await createUser({ role: 'global:owner' }); await Reset.run(); - const user = await Container.get(UserRepository).findOneBy({ globalRoleId: globalOwnerRole.id }); + const user = await Container.get(UserRepository).findOneBy({ role: 'global:owner' }); if (!user) { fail('No owner found after DB reset to default user state'); diff --git a/packages/cli/test/integration/commands/update/workflow.test.ts b/packages/cli/test/integration/commands/update/workflow.test.ts new file mode 100644 index 0000000000..922eaccb38 --- /dev/null +++ b/packages/cli/test/integration/commands/update/workflow.test.ts @@ -0,0 +1,126 @@ +import { Config } from '@oclif/core'; +import { InternalHooks } from '@/InternalHooks'; +import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; +import { UpdateWorkflowCommand } from '@/commands/update/workflow'; + +import * as testDb from '../../shared/testDb'; +import { createWorkflowWithTrigger, getAllWorkflows } from '../../shared/db/workflows'; +import { mockInstance } from '../../../shared/mocking'; + +beforeAll(async () => { + mockInstance(InternalHooks); + mockInstance(LoadNodesAndCredentials); + await testDb.init(); +}); + +beforeEach(async () => { + await testDb.truncate(['Workflow']); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +test('update:workflow can activate all workflows', async () => { + // + // ARRANGE + // + const workflows = await Promise.all([ + createWorkflowWithTrigger({}), + createWorkflowWithTrigger({}), + ]); + expect(workflows).toMatchObject([{ active: false }, { active: false }]); + + // + // ACT + // + const config = new Config({ root: __dirname }); + const updater = new UpdateWorkflowCommand(['--all', '--active=true'], config); + await updater.init(); + await updater.run(); + + // + // ASSERT + // + const after = await getAllWorkflows(); + expect(after).toMatchObject([{ active: true }, { active: true }]); +}); + +test('update:workflow can deactivate all workflows', async () => { + // + // ARRANGE + // + const workflows = await Promise.all([ + createWorkflowWithTrigger({ active: true }), + createWorkflowWithTrigger({ active: true }), + ]); + expect(workflows).toMatchObject([{ active: true }, { active: true }]); + + // + // ACT + // + const config = new Config({ root: __dirname }); + const updater = new UpdateWorkflowCommand(['--all', '--active=false'], config); + await updater.init(); + await updater.run(); + + // + // ASSERT + // + const after = await getAllWorkflows(); + expect(after).toMatchObject([{ active: false }, { active: false }]); +}); + +test('update:workflow can activate a specific workflow', async () => { + // + // ARRANGE + // + const workflows = ( + await Promise.all([ + createWorkflowWithTrigger({ active: false }), + createWorkflowWithTrigger({ active: false }), + ]) + ).sort((wf1, wf2) => wf1.id.localeCompare(wf2.id)); + expect(workflows).toMatchObject([{ active: false }, { active: false }]); + + // + // ACT + // + const config = new Config({ root: __dirname }); + const updater = new UpdateWorkflowCommand([`--id=${workflows[0].id}`, '--active=true'], config); + await updater.init(); + await updater.run(); + + // + // ASSERT + // + const after = (await getAllWorkflows()).sort((wf1, wf2) => wf1.id.localeCompare(wf2.id)); + expect(after).toMatchObject([{ active: true }, { active: false }]); +}); + +test('update:workflow can deactivate a specific workflow', async () => { + // + // ARRANGE + // + const workflows = ( + await Promise.all([ + createWorkflowWithTrigger({ active: true }), + createWorkflowWithTrigger({ active: true }), + ]) + ).sort((wf1, wf2) => wf1.id.localeCompare(wf2.id)); + expect(workflows).toMatchObject([{ active: true }, { active: true }]); + + // + // ACT + // + const config = new Config({ root: __dirname }); + const updater = new UpdateWorkflowCommand([`--id=${workflows[0].id}`, '--active=false'], config); + await updater.init(); + await updater.run(); + + // + // ASSERT + // + const after = (await getAllWorkflows()).sort((wf1, wf2) => wf1.id.localeCompare(wf2.id)); + expect(after).toMatchObject([{ active: false }, { active: true }]); +}); diff --git a/packages/cli/test/integration/commands/worker.cmd.test.ts b/packages/cli/test/integration/commands/worker.cmd.test.ts index a7d5c97a71..4bbb5c5ecb 100644 --- a/packages/cli/test/integration/commands/worker.cmd.test.ts +++ b/packages/cli/test/integration/commands/worker.cmd.test.ts @@ -1,5 +1,5 @@ +import { Config } from '@oclif/core'; import { Worker } from '@/commands/worker'; -import * as Config from '@oclif/config'; import config from '@/config'; import { Telemetry } from '@/telemetry'; import { ExternalSecretsManager } from '@/ExternalSecrets/ExternalSecretsManager.ee'; @@ -16,11 +16,12 @@ import { PostHogClient } from '@/posthog'; import { RedisService } from '@/services/redis.service'; import { OrchestrationHandlerWorkerService } from '@/services/orchestration/worker/orchestration.handler.worker.service'; import { OrchestrationWorkerService } from '@/services/orchestration/worker/orchestration.worker.service'; -import { MultiMainSetup } from '@/services/orchestration/main/MultiMainSetup.ee'; +import { OrchestrationService } from '@/services/orchestration.service'; +import * as testDb from '../shared/testDb'; import { mockInstance } from '../../shared/mocking'; -const oclifConfig: Config.IConfig = new Config.Config({ root: __dirname }); +const oclifConfig = new Config({ root: __dirname }); beforeAll(async () => { config.set('executions.mode', 'queue'); @@ -38,7 +39,12 @@ beforeAll(async () => { mockInstance(RedisService); mockInstance(RedisServicePubSubPublisher); mockInstance(RedisServicePubSubSubscriber); - mockInstance(MultiMainSetup); + mockInstance(OrchestrationService); + await testDb.init(); +}); + +afterAll(async () => { + await testDb.terminate(); }); test('worker initializes all its components', async () => { diff --git a/packages/cli/test/integration/credentials.controller.test.ts b/packages/cli/test/integration/credentials.controller.test.ts index 3c122f2b4e..806d30eb95 100644 --- a/packages/cli/test/integration/credentials.controller.test.ts +++ b/packages/cli/test/integration/credentials.controller.test.ts @@ -5,7 +5,6 @@ import { setupTestServer } from './shared/utils/'; import { randomCredentialPayload as payload } from './shared/random'; import { saveCredential } from './shared/db/credentials'; import { createMember, createOwner } from './shared/db/users'; -import { getCredentialOwnerRole } from './shared/db/roles'; const { any } = expect; @@ -26,10 +25,14 @@ type GetAllResponse = { body: { data: ListQuery.Credentials.WithOwnedByAndShared describe('GET /credentials', () => { describe('should return', () => { test('all credentials for owner', async () => { - const role = await getCredentialOwnerRole(); - - const { id: id1 } = await saveCredential(payload(), { user: owner, role }); - const { id: id2 } = await saveCredential(payload(), { user: member, role }); + const { id: id1 } = await saveCredential(payload(), { + user: owner, + role: 'credential:owner', + }); + const { id: id2 } = await saveCredential(payload(), { + user: member, + role: 'credential:owner', + }); const response: GetAllResponse = await testServer .authAgentFor(owner) @@ -47,13 +50,11 @@ describe('GET /credentials', () => { }); test('only own credentials for member', async () => { - const role = await getCredentialOwnerRole(); - const firstMember = member; const secondMember = await createMember(); - const c1 = await saveCredential(payload(), { user: firstMember, role }); - const c2 = await saveCredential(payload(), { user: secondMember, role }); + const c1 = await saveCredential(payload(), { user: firstMember, role: 'credential:owner' }); + const c2 = await saveCredential(payload(), { user: secondMember, role: 'credential:owner' }); const response: GetAllResponse = await testServer .authAgentFor(firstMember) @@ -72,8 +73,7 @@ describe('GET /credentials', () => { describe('filter', () => { test('should filter credentials by field: name - full match', async () => { - const role = await getCredentialOwnerRole(); - const savedCred = await saveCredential(payload(), { user: owner, role }); + const savedCred = await saveCredential(payload(), { user: owner, role: 'credential:owner' }); const response: GetAllResponse = await testServer .authAgentFor(owner) @@ -97,8 +97,7 @@ describe('GET /credentials', () => { }); test('should filter credentials by field: name - partial match', async () => { - const role = await getCredentialOwnerRole(); - const savedCred = await saveCredential(payload(), { user: owner, role }); + const savedCred = await saveCredential(payload(), { user: owner, role: 'credential:owner' }); const partialName = savedCred.name.slice(3); @@ -124,9 +123,7 @@ describe('GET /credentials', () => { }); test('should filter credentials by field: type - full match', async () => { - const role = await getCredentialOwnerRole(); - - const savedCred = await saveCredential(payload(), { user: owner, role }); + const savedCred = await saveCredential(payload(), { user: owner, role: 'credential:owner' }); const response: GetAllResponse = await testServer .authAgentFor(owner) @@ -150,9 +147,7 @@ describe('GET /credentials', () => { }); test('should filter credentials by field: type - partial match', async () => { - const role = await getCredentialOwnerRole(); - - const savedCred = await saveCredential(payload(), { user: owner, role }); + const savedCred = await saveCredential(payload(), { user: owner, role: 'credential:owner' }); const partialType = savedCred.type.slice(3); @@ -180,10 +175,8 @@ describe('GET /credentials', () => { describe('select', () => { test('should select credential field: id', async () => { - const role = await getCredentialOwnerRole(); - - await saveCredential(payload(), { user: owner, role }); - await saveCredential(payload(), { user: owner, role }); + await saveCredential(payload(), { user: owner, role: 'credential:owner' }); + await saveCredential(payload(), { user: owner, role: 'credential:owner' }); const response: GetAllResponse = await testServer .authAgentFor(owner) @@ -197,10 +190,8 @@ describe('GET /credentials', () => { }); test('should select credential field: name', async () => { - const role = await getCredentialOwnerRole(); - - await saveCredential(payload(), { user: owner, role }); - await saveCredential(payload(), { user: owner, role }); + await saveCredential(payload(), { user: owner, role: 'credential:owner' }); + await saveCredential(payload(), { user: owner, role: 'credential:owner' }); const response: GetAllResponse = await testServer .authAgentFor(owner) @@ -214,10 +205,8 @@ describe('GET /credentials', () => { }); test('should select credential field: type', async () => { - const role = await getCredentialOwnerRole(); - - await saveCredential(payload(), { user: owner, role }); - await saveCredential(payload(), { user: owner, role }); + await saveCredential(payload(), { user: owner, role: 'credential:owner' }); + await saveCredential(payload(), { user: owner, role: 'credential:owner' }); const response: GetAllResponse = await testServer .authAgentFor(owner) @@ -233,10 +222,8 @@ describe('GET /credentials', () => { describe('take', () => { test('should return n credentials or less, without skip', async () => { - const role = await getCredentialOwnerRole(); - - await saveCredential(payload(), { user: owner, role }); - await saveCredential(payload(), { user: owner, role }); + await saveCredential(payload(), { user: owner, role: 'credential:owner' }); + await saveCredential(payload(), { user: owner, role: 'credential:owner' }); const response = await testServer .authAgentFor(owner) @@ -260,10 +247,8 @@ describe('GET /credentials', () => { }); test('should return n credentials or less, with skip', async () => { - const role = await getCredentialOwnerRole(); - - await saveCredential(payload(), { user: owner, role }); - await saveCredential(payload(), { user: owner, role }); + await saveCredential(payload(), { user: owner, role: 'credential:owner' }); + await saveCredential(payload(), { user: owner, role: 'credential:owner' }); const response = await testServer .authAgentFor(owner) diff --git a/packages/cli/test/integration/credentials.ee.test.ts b/packages/cli/test/integration/credentials.ee.test.ts index f0792244aa..056d0f5329 100644 --- a/packages/cli/test/integration/credentials.ee.test.ts +++ b/packages/cli/test/integration/credentials.ee.test.ts @@ -1,52 +1,53 @@ +import { Container } from 'typedi'; import type { SuperAgentTest } from 'supertest'; import { In } from 'typeorm'; import type { IUser } from 'n8n-workflow'; import type { ListQuery } from '@/requests'; -import * as UserManagementHelpers from '@/UserManagement/UserManagementHelper'; -import type { Role } from '@db/entities/Role'; import type { User } from '@db/entities/User'; +import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; +import { License } from '@/License'; import { randomCredentialPayload } from './shared/random'; import * as testDb from './shared/testDb'; import type { SaveCredentialFunction } from './shared/types'; import * as utils from './shared/utils/'; import { affixRoleToSaveCredential, shareCredentialWithUsers } from './shared/db/credentials'; -import { getCredentialOwnerRole, getGlobalMemberRole, getGlobalOwnerRole } from './shared/db/roles'; import { createManyUsers, createUser, createUserShell } from './shared/db/users'; -import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; -import Container from 'typedi'; +import { UserManagementMailer } from '@/UserManagement/email'; -const sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true); +import { mockInstance } from '../shared/mocking'; + +const sharingSpy = jest.spyOn(License.prototype, 'isSharingEnabled').mockReturnValue(true); const testServer = utils.setupTestServer({ endpointGroups: ['credentials'] }); -let globalMemberRole: Role; let owner: User; let member: User; let anotherMember: User; let authOwnerAgent: SuperAgentTest; let authAnotherMemberAgent: SuperAgentTest; let saveCredential: SaveCredentialFunction; +const mailer = mockInstance(UserManagementMailer); beforeAll(async () => { - const globalOwnerRole = await getGlobalOwnerRole(); - globalMemberRole = await getGlobalMemberRole(); - const credentialOwnerRole = await getCredentialOwnerRole(); - - owner = await createUser({ globalRole: globalOwnerRole }); - member = await createUser({ globalRole: globalMemberRole }); - anotherMember = await createUser({ globalRole: globalMemberRole }); + owner = await createUser({ role: 'global:owner' }); + member = await createUser({ role: 'global:member' }); + anotherMember = await createUser({ role: 'global:member' }); authOwnerAgent = testServer.authAgentFor(owner); authAnotherMemberAgent = testServer.authAgentFor(anotherMember); - saveCredential = affixRoleToSaveCredential(credentialOwnerRole); + saveCredential = affixRoleToSaveCredential('credential:owner'); }); beforeEach(async () => { await testDb.truncate(['SharedCredentials', 'Credentials']); }); +afterEach(() => { + jest.clearAllMocks(); +}); + // ---------------------------------------- // dynamic router switching // ---------------------------------------- @@ -85,7 +86,7 @@ describe('router should switch based on flag', () => { describe('GET /credentials', () => { test('should return all creds for owner', async () => { const [member1, member2, member3] = await createManyUsers(3, { - globalRole: globalMemberRole, + role: 'global:member', }); const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); @@ -149,7 +150,7 @@ describe('GET /credentials', () => { test('should return only relevant creds for member', async () => { const [member1, member2] = await createManyUsers(2, { - globalRole: globalMemberRole, + role: 'global:member', }); await saveCredential(randomCredentialPayload(), { user: member2 }); @@ -225,7 +226,7 @@ describe('GET /credentials/:id', () => { test('should retrieve non-owned cred for owner', async () => { const [member1, member2] = await createManyUsers(2, { - globalRole: globalMemberRole, + role: 'global:member', }); const savedCredential = await saveCredential(randomCredentialPayload(), { user: member1 }); @@ -264,7 +265,7 @@ describe('GET /credentials/:id', () => { test('should retrieve owned cred for member', async () => { const [member1, member2, member3] = await createManyUsers(3, { - globalRole: globalMemberRole, + role: 'global:member', }); const authMemberAgent = testServer.authAgentFor(member1); const savedCredential = await saveCredential(randomCredentialPayload(), { user: member1 }); @@ -332,7 +333,7 @@ describe('PUT /credentials/:id/share', () => { const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); const [member1, member2, member3, member4, member5] = await createManyUsers(5, { - globalRole: globalMemberRole, + role: 'global:member', }); const shareWithIds = [member1.id, member2.id, member3.id]; @@ -346,7 +347,6 @@ describe('PUT /credentials/:id/share', () => { expect(response.body.data).toBeUndefined(); const sharedCredentials = await Container.get(SharedCredentialsRepository).find({ - relations: ['role'], where: { credentialsId: savedCredential.id }, }); @@ -355,19 +355,19 @@ describe('PUT /credentials/:id/share', () => { sharedCredentials.forEach((sharedCredential) => { if (sharedCredential.userId === owner.id) { - expect(sharedCredential.role.name).toBe('owner'); - expect(sharedCredential.role.scope).toBe('credential'); + expect(sharedCredential.role).toBe('credential:owner'); return; } expect(shareWithIds).toContain(sharedCredential.userId); - expect(sharedCredential.role.name).toBe('user'); - expect(sharedCredential.role.scope).toBe('credential'); + expect(sharedCredential.role).toBe('credential:user'); }); + + expect(mailer.notifyCredentialsShared).toHaveBeenCalledTimes(1); }); test('should share the credential with the provided userIds', async () => { const [member1, member2, member3] = await createManyUsers(3, { - globalRole: globalMemberRole, + role: 'global:member', }); const memberIds = [member1.id, member2.id, member3.id]; const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); @@ -381,25 +381,22 @@ describe('PUT /credentials/:id/share', () => { // check that sharings got correctly set in DB const sharedCredentials = await Container.get(SharedCredentialsRepository).find({ - relations: ['role'], where: { credentialsId: savedCredential.id, userId: In([...memberIds]) }, }); expect(sharedCredentials.length).toBe(memberIds.length); sharedCredentials.forEach((sharedCredential) => { - expect(sharedCredential.role.name).toBe('user'); - expect(sharedCredential.role.scope).toBe('credential'); + expect(sharedCredential.role).toBe('credential:user'); }); // check that owner still exists const ownerSharedCredential = await Container.get(SharedCredentialsRepository).findOneOrFail({ - relations: ['role'], where: { credentialsId: savedCredential.id, userId: owner.id }, }); - expect(ownerSharedCredential.role.name).toBe('owner'); - expect(ownerSharedCredential.role.scope).toBe('credential'); + expect(ownerSharedCredential.role).toBe('credential:owner'); + expect(mailer.notifyCredentialsShared).toHaveBeenCalledTimes(1); }); test('should respond 403 for non-existing credentials', async () => { @@ -408,6 +405,7 @@ describe('PUT /credentials/:id/share', () => { .send({ shareWithIds: [member.id] }); expect(response.statusCode).toBe(403); + expect(mailer.notifyCredentialsShared).toHaveBeenCalledTimes(0); }); test('should respond 403 for non-owned credentials for shared members', async () => { @@ -424,6 +422,7 @@ describe('PUT /credentials/:id/share', () => { where: { credentialsId: savedCredential.id }, }); expect(sharedCredentials).toHaveLength(2); + expect(mailer.notifyCredentialsShared).toHaveBeenCalledTimes(0); }); test('should respond 403 for non-owned credentials for non-shared members sharing with self', async () => { @@ -439,11 +438,12 @@ describe('PUT /credentials/:id/share', () => { where: { credentialsId: savedCredential.id }, }); expect(sharedCredentials).toHaveLength(1); + expect(mailer.notifyCredentialsShared).toHaveBeenCalledTimes(0); }); test('should respond 403 for non-owned credentials for non-shared members sharing', async () => { const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); - const tempUser = await createUser({ globalRole: globalMemberRole }); + const tempUser = await createUser({ role: 'global:member' }); const response = await authAnotherMemberAgent .put(`/credentials/${savedCredential.id}/share`) @@ -455,6 +455,7 @@ describe('PUT /credentials/:id/share', () => { where: { credentialsId: savedCredential.id }, }); expect(sharedCredentials).toHaveLength(1); + expect(mailer.notifyCredentialsShared).toHaveBeenCalledTimes(0); }); test('should respond 200 for non-owned credentials for owners', async () => { @@ -469,10 +470,11 @@ describe('PUT /credentials/:id/share', () => { where: { credentialsId: savedCredential.id }, }); expect(sharedCredentials).toHaveLength(2); + expect(mailer.notifyCredentialsShared).toHaveBeenCalledTimes(1); }); test('should ignore pending sharee', async () => { - const memberShell = await createUserShell(globalMemberRole); + const memberShell = await createUserShell('global:member'); const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); const response = await authOwnerAgent @@ -487,6 +489,7 @@ describe('PUT /credentials/:id/share', () => { expect(sharedCredentials).toHaveLength(1); expect(sharedCredentials[0].userId).toBe(owner.id); + expect(mailer.notifyCredentialsShared).toHaveBeenCalledTimes(0); }); test('should ignore non-existing sharee', async () => { @@ -504,6 +507,7 @@ describe('PUT /credentials/:id/share', () => { expect(sharedCredentials).toHaveLength(1); expect(sharedCredentials[0].userId).toBe(owner.id); + expect(mailer.notifyCredentialsShared).toHaveBeenCalledTimes(0); }); test('should respond 400 if invalid payload is provided', async () => { @@ -515,12 +519,13 @@ describe('PUT /credentials/:id/share', () => { ]); responses.forEach((response) => expect(response.statusCode).toBe(400)); + expect(mailer.notifyCredentialsShared).toHaveBeenCalledTimes(0); }); test('should unshare the credential', async () => { const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); const [member1, member2] = await createManyUsers(2, { - globalRole: globalMemberRole, + role: 'global:member', }); await shareCredentialWithUsers(savedCredential, [member1, member2]); @@ -537,6 +542,7 @@ describe('PUT /credentials/:id/share', () => { expect(sharedCredentials).toHaveLength(1); expect(sharedCredentials[0].userId).toBe(owner.id); + expect(mailer.notifyCredentialsShared).toHaveBeenCalledTimes(0); }); }); diff --git a/packages/cli/test/integration/credentials.test.ts b/packages/cli/test/integration/credentials.test.ts index 09a8c2973f..f665d3f7b5 100644 --- a/packages/cli/test/integration/credentials.test.ts +++ b/packages/cli/test/integration/credentials.test.ts @@ -1,28 +1,24 @@ +import { Container } from 'typedi'; import type { SuperAgentTest } from 'supertest'; import config from '@/config'; -import * as UserManagementHelpers from '@/UserManagement/UserManagementHelper'; import type { ListQuery } from '@/requests'; -import type { Role } from '@db/entities/Role'; import type { User } from '@db/entities/User'; +import { CredentialsRepository } from '@db/repositories/credentials.repository'; +import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; +import { License } from '@/License'; import { randomCredentialPayload, randomName, randomString } from './shared/random'; import * as testDb from './shared/testDb'; import type { SaveCredentialFunction } from './shared/types'; import * as utils from './shared/utils/'; import { affixRoleToSaveCredential, shareCredentialWithUsers } from './shared/db/credentials'; -import { getCredentialOwnerRole, getGlobalMemberRole, getGlobalOwnerRole } from './shared/db/roles'; import { createManyUsers, createUser } from './shared/db/users'; -import { CredentialsRepository } from '@db/repositories/credentials.repository'; -import Container from 'typedi'; -import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; // mock that credentialsSharing is not enabled -jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(false); +jest.spyOn(License.prototype, 'isSharingEnabled').mockReturnValue(false); const testServer = utils.setupTestServer({ endpointGroups: ['credentials'] }); -let globalOwnerRole: Role; -let globalMemberRole: Role; let owner: User; let member: User; let secondMember: User; @@ -31,15 +27,11 @@ let authMemberAgent: SuperAgentTest; let saveCredential: SaveCredentialFunction; beforeAll(async () => { - globalOwnerRole = await getGlobalOwnerRole(); - globalMemberRole = await getGlobalMemberRole(); - const credentialOwnerRole = await getCredentialOwnerRole(); + owner = await createUser({ role: 'global:owner' }); + member = await createUser({ role: 'global:member' }); + secondMember = await createUser({ role: 'global:member' }); - owner = await createUser({ globalRole: globalOwnerRole }); - member = await createUser({ globalRole: globalMemberRole }); - secondMember = await createUser({ globalRole: globalMemberRole }); - - saveCredential = affixRoleToSaveCredential(credentialOwnerRole); + saveCredential = affixRoleToSaveCredential('credential:owner'); authOwnerAgent = testServer.authAgentFor(owner); authMemberAgent = testServer.authAgentFor(member); @@ -74,7 +66,7 @@ describe('GET /credentials', () => { test('should return only own creds for member', async () => { const [member1, member2] = await createManyUsers(2, { - globalRole: globalMemberRole, + role: 'global:member', }); const [savedCredential1] = await Promise.all([ diff --git a/packages/cli/test/integration/cta.service.test.ts b/packages/cli/test/integration/cta.service.test.ts new file mode 100644 index 0000000000..27cdc7b604 --- /dev/null +++ b/packages/cli/test/integration/cta.service.test.ts @@ -0,0 +1,54 @@ +import Container from 'typedi'; +import * as testDb from './shared/testDb'; +import { CtaService } from '@/services/cta.service'; +import { createUser } from './shared/db/users'; +import { createManyWorkflows } from './shared/db/workflows'; +import type { User } from '@/databases/entities/User'; +import { createWorkflowStatisticsItem } from './shared/db/workflowStatistics'; +import { StatisticsNames } from '@/databases/entities/WorkflowStatistics'; + +describe('CtaService', () => { + let ctaService: CtaService; + let user: User; + + beforeAll(async () => { + await testDb.init(); + + ctaService = Container.get(CtaService); + user = await createUser(); + }); + + afterAll(async () => { + await testDb.terminate(); + }); + + describe('getBecomeCreatorCta()', () => { + afterEach(async () => { + await testDb.truncate(['Workflow', 'SharedWorkflow']); + }); + + test.each([ + [false, 0, 0], + [false, 2, 5], + [false, 3, 4], + [true, 3, 5], + ])( + 'should return %p if user has %d active workflows with %d successful production executions', + async (expected, numWorkflows, numExecutions) => { + const workflows = await createManyWorkflows(numWorkflows, { active: true }, user); + + await Promise.all( + workflows.map( + async (workflow) => + await createWorkflowStatisticsItem(workflow.id, { + count: numExecutions, + name: StatisticsNames.productionSuccess, + }), + ), + ); + + expect(await ctaService.getBecomeCreatorCta(user.id)).toBe(expected); + }, + ); + }); +}); diff --git a/packages/cli/test/integration/database/repositories/workflow.repository.test.ts b/packages/cli/test/integration/database/repositories/workflow.repository.test.ts new file mode 100644 index 0000000000..d9e1ea22fd --- /dev/null +++ b/packages/cli/test/integration/database/repositories/workflow.repository.test.ts @@ -0,0 +1,70 @@ +import Container from 'typedi'; + +import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; + +import * as testDb from '../../shared/testDb'; +import { createWorkflowWithTrigger, getAllWorkflows } from '../../shared/db/workflows'; + +describe('WorkflowRepository', () => { + beforeAll(async () => { + await testDb.init(); + }); + + beforeEach(async () => { + await testDb.truncate(['Workflow']); + }); + + afterAll(async () => { + await testDb.terminate(); + }); + + describe('activateAll', () => { + it('should activate all workflows', async () => { + // + // ARRANGE + // + const workflowRepository = Container.get(WorkflowRepository); + const workflows = await Promise.all([ + createWorkflowWithTrigger(), + createWorkflowWithTrigger(), + ]); + expect(workflows).toMatchObject([{ active: false }, { active: false }]); + + // + // ACT + // + await workflowRepository.activateAll(); + + // + // ASSERT + // + const after = await getAllWorkflows(); + expect(after).toMatchObject([{ active: true }, { active: true }]); + }); + }); + + describe('deactivateAll', () => { + it('should deactivate all workflows', async () => { + // + // ARRANGE + // + const workflowRepository = Container.get(WorkflowRepository); + const workflows = await Promise.all([ + createWorkflowWithTrigger({ active: true }), + createWorkflowWithTrigger({ active: true }), + ]); + expect(workflows).toMatchObject([{ active: true }, { active: true }]); + + // + // ACT + // + await workflowRepository.deactivateAll(); + + // + // ASSERT + // + const after = await getAllWorkflows(); + expect(after).toMatchObject([{ active: false }, { active: false }]); + }); + }); +}); diff --git a/packages/cli/test/integration/debug.controller.test.ts b/packages/cli/test/integration/debug.controller.test.ts index 7b5fb31413..9acd6a3993 100644 --- a/packages/cli/test/integration/debug.controller.test.ts +++ b/packages/cli/test/integration/debug.controller.test.ts @@ -7,6 +7,7 @@ import type { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; import { setupTestServer } from './shared/utils'; import type { SuperAgentTest } from 'supertest'; import { createOwner } from './shared/db/users'; +import { OrchestrationService } from '@/services/orchestration.service'; import { MultiMainSetup } from '@/services/orchestration/main/MultiMainSetup.ee'; describe('DebugController', () => { @@ -36,9 +37,9 @@ describe('DebugController', () => { activeWorkflowRunner.allActiveInMemory.mockReturnValue([workflowId]); activeWorkflowRunner.getAllWorkflowActivationErrors.mockResolvedValue(activationErrors); - jest.spyOn(MultiMainSetup.prototype, 'instanceId', 'get').mockReturnValue(instanceId); + jest.spyOn(OrchestrationService.prototype, 'instanceId', 'get').mockReturnValue(instanceId); jest.spyOn(MultiMainSetup.prototype, 'fetchLeaderKey').mockResolvedValue(leaderKey); - jest.spyOn(MultiMainSetup.prototype, 'isLeader', 'get').mockReturnValue(true); + jest.spyOn(OrchestrationService.prototype, 'isLeader', 'get').mockReturnValue(true); const response = await ownerAgent.get('/debug/multi-main-setup').expect(200); diff --git a/packages/cli/test/integration/environments/SourceControl.test.ts b/packages/cli/test/integration/environments/SourceControl.test.ts index 384c02d446..f1da8d0672 100644 --- a/packages/cli/test/integration/environments/SourceControl.test.ts +++ b/packages/cli/test/integration/environments/SourceControl.test.ts @@ -8,7 +8,6 @@ import { SourceControlService } from '@/environments/sourceControl/sourceControl import type { SourceControlledFile } from '@/environments/sourceControl/types/sourceControlledFile'; import * as utils from '../shared/utils/'; -import { getGlobalOwnerRole } from '../shared/db/roles'; import { createUser } from '../shared/db/users'; let authOwnerAgent: SuperAgentTest; @@ -20,8 +19,7 @@ const testServer = utils.setupTestServer({ }); beforeAll(async () => { - const globalOwnerRole = await getGlobalOwnerRole(); - owner = await createUser({ globalRole: globalOwnerRole }); + owner = await createUser({ role: 'global:owner' }); authOwnerAgent = testServer.authAgentFor(owner); Container.get(SourceControlPreferencesService).isSourceControlConnected = () => true; diff --git a/packages/cli/test/integration/eventbus.ee.test.ts b/packages/cli/test/integration/eventbus.ee.test.ts index 0e37936ccb..25e761914f 100644 --- a/packages/cli/test/integration/eventbus.ee.test.ts +++ b/packages/cli/test/integration/eventbus.ee.test.ts @@ -3,7 +3,6 @@ import axios from 'axios'; import syslog from 'syslog-client'; import { v4 as uuid } from 'uuid'; import type { SuperAgentTest } from 'supertest'; -import type { Role } from '@db/entities/Role'; import type { User } from '@db/entities/User'; import type { MessageEventBusDestinationSentryOptions, @@ -26,7 +25,6 @@ import { EventMessageWorkflow } from '@/eventbus/EventMessageClasses/EventMessag import { EventMessageNode } from '@/eventbus/EventMessageClasses/EventMessageNode'; import * as utils from './shared/utils'; -import { getGlobalOwnerRole } from './shared/db/roles'; import { createUser } from './shared/db/users'; jest.unmock('@/eventbus/MessageEventBus/MessageEventBus'); @@ -35,7 +33,6 @@ const mockedAxios = axios as jest.Mocked; jest.mock('syslog-client'); const mockedSyslog = syslog as jest.Mocked; -let globalOwnerRole: Role; let owner: User; let authOwnerAgent: SuperAgentTest; @@ -85,8 +82,7 @@ const testServer = utils.setupTestServer({ }); beforeAll(async () => { - globalOwnerRole = await getGlobalOwnerRole(); - owner = await createUser({ globalRole: globalOwnerRole }); + owner = await createUser({ role: 'global:owner' }); authOwnerAgent = testServer.authAgentFor(owner); mockedSyslog.createClient.mockImplementation(() => new syslog.Client()); diff --git a/packages/cli/test/integration/eventbus.test.ts b/packages/cli/test/integration/eventbus.test.ts index b2c62ba11c..9f6581d049 100644 --- a/packages/cli/test/integration/eventbus.test.ts +++ b/packages/cli/test/integration/eventbus.test.ts @@ -1,8 +1,6 @@ import type { SuperAgentTest } from 'supertest'; import * as utils from './shared/utils/'; -import type { Role } from '@db/entities/Role'; import type { User } from '@db/entities/User'; -import { getGlobalOwnerRole } from './shared/db/roles'; import { createUser } from './shared/db/users'; /** @@ -11,7 +9,6 @@ import { createUser } from './shared/db/users'; * The tests in this file are only checking endpoint permissions. */ -let globalOwnerRole: Role; let owner: User; let authOwnerAgent: SuperAgentTest; @@ -21,8 +18,7 @@ const testServer = utils.setupTestServer({ }); beforeAll(async () => { - globalOwnerRole = await getGlobalOwnerRole(); - owner = await createUser({ globalRole: globalOwnerRole }); + owner = await createUser({ role: 'global:owner' }); authOwnerAgent = testServer.authAgentFor(owner); }); diff --git a/packages/cli/test/integration/executions.controller.test.ts b/packages/cli/test/integration/executions.controller.test.ts index 16f7e8d780..711b0716b5 100644 --- a/packages/cli/test/integration/executions.controller.test.ts +++ b/packages/cli/test/integration/executions.controller.test.ts @@ -1,17 +1,23 @@ import type { User } from '@db/entities/User'; +import { Push } from '@/push'; import { createSuccessfulExecution, getAllExecutions } from './shared/db/executions'; import { createOwner } from './shared/db/users'; import { createWorkflow } from './shared/db/workflows'; import * as testDb from './shared/testDb'; import { setupTestServer } from './shared/utils'; +import { mockInstance } from '../shared/mocking'; +import { EnterpriseExecutionsService } from '@/executions/execution.service.ee'; +mockInstance(EnterpriseExecutionsService); + +mockInstance(Push); let testServer = setupTestServer({ endpointGroups: ['executions'] }); let owner: User; const saveExecution = async ({ belongingTo }: { belongingTo: User }) => { const workflow = await createWorkflow({}, belongingTo); - return createSuccessfulExecution(workflow); + return await createSuccessfulExecution(workflow); }; beforeEach(async () => { diff --git a/packages/cli/test/integration/import.service.test.ts b/packages/cli/test/integration/import.service.test.ts index 07b7f1a600..4809e58138 100644 --- a/packages/cli/test/integration/import.service.test.ts +++ b/packages/cli/test/integration/import.service.test.ts @@ -6,7 +6,6 @@ import type { INode } from 'n8n-workflow'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; import { TagRepository } from '@/databases/repositories/tag.repository'; import { ImportService } from '@/services/import.service'; -import { RoleService } from '@/services/role.service'; import { TagEntity } from '@/databases/entities/TagEntity'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; @@ -34,12 +33,7 @@ describe('ImportService', () => { credentialsRepository.find.mockResolvedValue([]); - importService = new ImportService( - mock(), - credentialsRepository, - tagRepository, - Container.get(RoleService), - ); + importService = new ImportService(mock(), credentialsRepository, tagRepository); }); afterEach(async () => { @@ -67,10 +61,8 @@ describe('ImportService', () => { await importService.importWorkflows([workflowToImport], owner.id); - const workflowOwnerRole = await Container.get(RoleService).findWorkflowOwnerRole(); - const dbSharing = await Container.get(SharedWorkflowRepository).findOneOrFail({ - where: { workflowId: workflowToImport.id, userId: owner.id, roleId: workflowOwnerRole.id }, + where: { workflowId: workflowToImport.id, userId: owner.id, role: 'workflow:owner' }, }); expect(dbSharing.userId).toBe(owner.id); diff --git a/packages/cli/test/integration/invitations.api.test.ts b/packages/cli/test/integration/invitations.api.test.ts index 2ac4d4c1d6..4ca48712ea 100644 --- a/packages/cli/test/integration/invitations.api.test.ts +++ b/packages/cli/test/integration/invitations.api.test.ts @@ -17,7 +17,6 @@ import { } from './shared/random'; import * as testDb from './shared/testDb'; import * as utils from './shared/utils/'; -import { getGlobalAdminRole, getGlobalMemberRole } from './shared/db/roles'; import { createMember, createOwner, createUser, createUserShell } from './shared/db/users'; import { ExternalHooks } from '@/ExternalHooks'; import { InternalHooks } from '@/InternalHooks'; @@ -56,8 +55,7 @@ describe('POST /invitations/:id/accept', () => { }); test('should fill out a member shell', async () => { - const globalMemberRole = await getGlobalMemberRole(); - const memberShell = await createUserShell(globalMemberRole); + const memberShell = await createUserShell('global:member'); const memberData = { inviterId: owner.id, @@ -78,7 +76,7 @@ describe('POST /invitations/:id/accept', () => { lastName, personalizationAnswers, password, - globalRole, + role, isPending, apiKey, globalScopes, @@ -91,8 +89,7 @@ describe('POST /invitations/:id/accept', () => { expect(personalizationAnswers).toBeNull(); expect(password).toBeUndefined(); expect(isPending).toBe(false); - expect(globalRole.scope).toBe('global'); - expect(globalRole.name).toBe('member'); + expect(role).toBe('global:member'); expect(apiKey).not.toBeDefined(); expect(globalScopes).toBeDefined(); expect(globalScopes).not.toHaveLength(0); @@ -110,8 +107,7 @@ describe('POST /invitations/:id/accept', () => { }); test('should fill out an admin shell', async () => { - const globalAdminRole = await getGlobalAdminRole(); - const adminShell = await createUserShell(globalAdminRole); + const adminShell = await createUserShell('global:admin'); const memberData = { inviterId: owner.id, @@ -132,7 +128,7 @@ describe('POST /invitations/:id/accept', () => { lastName, personalizationAnswers, password, - globalRole, + role, isPending, apiKey, globalScopes, @@ -145,8 +141,7 @@ describe('POST /invitations/:id/accept', () => { expect(personalizationAnswers).toBeNull(); expect(password).toBeUndefined(); expect(isPending).toBe(false); - expect(globalRole.scope).toBe('global'); - expect(globalRole.name).toBe('admin'); + expect(role).toBe('global:admin'); expect(apiKey).not.toBeDefined(); expect(globalScopes).toBeDefined(); expect(globalScopes).not.toHaveLength(0); @@ -166,11 +161,9 @@ describe('POST /invitations/:id/accept', () => { test('should fail with invalid payloads', async () => { const memberShellEmail = randomEmail(); - const globalMemberRole = await getGlobalMemberRole(); - const memberShell = await Container.get(UserRepository).save({ email: memberShellEmail, - globalRole: globalMemberRole, + role: 'global:member', }); const invalidPayloads = [ @@ -219,8 +212,7 @@ describe('POST /invitations/:id/accept', () => { }); test('should fail with already accepted invite', async () => { - const globalMemberRole = await getGlobalMemberRole(); - const member = await createUser({ globalRole: globalMemberRole }); + const member = await createUser({ role: 'global:member' }); const memberData = { inviterId: owner.id, @@ -334,7 +326,7 @@ describe('POST /invitations', () => { const response = await ownerAgent .post('/invitations') - .send([{ email: randomEmail(), role: 'admin' }]) + .send([{ email: randomEmail(), role: 'global:admin' }]) .expect(200); const [result] = response.body.data as UserInvitationResponse[]; @@ -349,11 +341,11 @@ describe('POST /invitations', () => { test('should reinvite member', async () => { mailer.invite.mockResolvedValue({ emailSent: false }); - await ownerAgent.post('/invitations').send([{ email: randomEmail(), role: 'member' }]); + await ownerAgent.post('/invitations').send([{ email: randomEmail(), role: 'global:member' }]); await ownerAgent .post('/invitations') - .send([{ email: randomEmail(), role: 'member' }]) + .send([{ email: randomEmail(), role: 'global:member' }]) .expect(200); }); @@ -361,11 +353,11 @@ describe('POST /invitations', () => { license.isAdvancedPermissionsLicensed.mockReturnValue(true); mailer.invite.mockResolvedValue({ emailSent: false }); - await ownerAgent.post('/invitations').send([{ email: randomEmail(), role: 'admin' }]); + await ownerAgent.post('/invitations').send([{ email: randomEmail(), role: 'global:admin' }]); await ownerAgent .post('/invitations') - .send([{ email: randomEmail(), role: 'admin' }]) + .send([{ email: randomEmail(), role: 'global:admin' }]) .expect(200); }); @@ -375,7 +367,7 @@ describe('POST /invitations', () => { await ownerAgent .post('/invitations') - .send([{ email: randomEmail(), role: 'admin' }]) + .send([{ email: randomEmail(), role: 'global:admin' }]) .expect(403); }); @@ -384,8 +376,7 @@ describe('POST /invitations', () => { mailer.invite.mockResolvedValue({ emailSent: true }); - const globalMemberRole = await getGlobalMemberRole(); - const memberShell = await createUserShell(globalMemberRole); + const memberShell = await createUserShell('global:member'); const newUser = randomEmail(); diff --git a/packages/cli/test/integration/ldap/ldap.api.test.ts b/packages/cli/test/integration/ldap/ldap.api.test.ts index 0a1ce8f437..5969fa4b7f 100644 --- a/packages/cli/test/integration/ldap/ldap.api.test.ts +++ b/packages/cli/test/integration/ldap/ldap.api.test.ts @@ -6,7 +6,6 @@ import { jsonParse } from 'n8n-workflow'; import { Cipher } from 'n8n-core'; import config from '@/config'; -import type { Role } from '@db/entities/Role'; import type { User } from '@db/entities/User'; import { LDAP_DEFAULT_CONFIGURATION, LDAP_FEATURE_NAME } from '@/Ldap/constants'; import { LdapService } from '@/Ldap/ldap.service'; @@ -18,7 +17,6 @@ import { randomEmail, randomName, uniqueId } from './../shared/random'; import * as testDb from './../shared/testDb'; import * as utils from '../shared/utils/'; -import { getGlobalMemberRole, getGlobalOwnerRole } from '../shared/db/roles'; import { createLdapUser, createUser, getAllUsers, getLdapIdentities } from '../shared/db/users'; import { UserRepository } from '@db/repositories/user.repository'; import { SettingsRepository } from '@db/repositories/settings.repository'; @@ -26,7 +24,6 @@ import { AuthProviderSyncHistoryRepository } from '@db/repositories/authProvider jest.mock('@/telemetry'); -let globalMemberRole: Role; let owner: User; let authOwnerAgent: SuperAgentTest; @@ -50,14 +47,7 @@ const testServer = utils.setupTestServer({ }); beforeAll(async () => { - const [globalOwnerRole, fetchedGlobalMemberRole] = await Promise.all([ - getGlobalOwnerRole(), - getGlobalMemberRole(), - ]); - - globalMemberRole = fetchedGlobalMemberRole; - - owner = await createUser({ globalRole: globalOwnerRole, password: 'password' }); + owner = await createUser({ role: 'global:owner', password: 'password' }); authOwnerAgent = testServer.authAgentFor(owner); defaultLdapConfig.bindingAdminPassword = Container.get(Cipher).encrypt( @@ -93,11 +83,11 @@ const createLdapConfig = async (attributes: Partial = {}): Promise { - const member = await createUser({ globalRole: globalMemberRole }); + const member = await createUser({ role: 'global:member' }); const authAgent = testServer.authAgentFor(member); await authAgent.get('/ldap/config').expect(403); await authAgent.put('/ldap/config').expect(403); @@ -169,7 +159,7 @@ describe('PUT /ldap/config', () => { const ldapConfig = await createLdapConfig(); Container.get(LdapService).setConfig(ldapConfig); - const member = await createLdapUser({ globalRole: globalMemberRole }, uniqueId()); + const member = await createLdapUser({ role: 'global:member' }, uniqueId()); const configuration = ldapConfig; @@ -282,7 +272,7 @@ describe('POST /ldap/sync', () => { const ldapUserId = uniqueId(); const member = await createLdapUser( - { globalRole: globalMemberRole, email: ldapUserEmail }, + { role: 'global:member', email: ldapUserEmail }, ldapUserId, ); @@ -311,7 +301,7 @@ describe('POST /ldap/sync', () => { const ldapUserId = uniqueId(); const member = await createLdapUser( - { globalRole: globalMemberRole, email: ldapUserEmail }, + { role: 'global:member', email: ldapUserEmail }, ldapUserId, ); @@ -394,7 +384,7 @@ describe('POST /ldap/sync', () => { await createLdapUser( { - globalRole: globalMemberRole, + role: 'global:member', email: ldapUser.mail, firstName: ldapUser.givenName, lastName: randomName(), @@ -427,7 +417,7 @@ describe('POST /ldap/sync', () => { await createLdapUser( { - globalRole: globalMemberRole, + role: 'global:member', email: ldapUser.mail, firstName: ldapUser.givenName, lastName: ldapUser.sn, @@ -456,7 +446,7 @@ describe('POST /ldap/sync', () => { }); test('should remove user instance access once the user is disabled during synchronization', async () => { - const member = await createLdapUser({ globalRole: globalMemberRole }, uniqueId()); + const member = await createLdapUser({ role: 'global:member' }, uniqueId()); jest.spyOn(LdapService.prototype, 'searchWithAdminBinding').mockResolvedValue([]); @@ -543,7 +533,7 @@ describe('POST /login', () => { await createLdapUser( { - globalRole: globalMemberRole, + role: 'global:member', email: ldapUser.mail, firstName: 'firstname', lastName: 'lastname', @@ -577,7 +567,7 @@ describe('POST /login', () => { }; await createUser({ - globalRole: globalMemberRole, + role: 'global:member', email: ldapUser.mail, firstName: ldapUser.givenName, lastName: 'lastname', @@ -592,7 +582,7 @@ describe('Instance owner should able to delete LDAP users', () => { const ldapConfig = await createLdapConfig(); Container.get(LdapService).setConfig(ldapConfig); - const member = await createLdapUser({ globalRole: globalMemberRole }, uniqueId()); + const member = await createLdapUser({ role: 'global:member' }, uniqueId()); await authOwnerAgent.post(`/users/${member.id}`); }); @@ -601,7 +591,7 @@ describe('Instance owner should able to delete LDAP users', () => { const ldapConfig = await createLdapConfig(); Container.get(LdapService).setConfig(ldapConfig); - const member = await createLdapUser({ globalRole: globalMemberRole }, uniqueId()); + const member = await createLdapUser({ role: 'global:member' }, uniqueId()); // delete the LDAP member and transfer its workflows/credentials to instance owner await authOwnerAgent.post(`/users/${member.id}?transferId=${owner.id}`); diff --git a/packages/cli/test/integration/license.api.test.ts b/packages/cli/test/integration/license.api.test.ts index 1430beb064..3d1fc4cda8 100644 --- a/packages/cli/test/integration/license.api.test.ts +++ b/packages/cli/test/integration/license.api.test.ts @@ -5,7 +5,6 @@ import type { ILicensePostResponse, ILicenseReadResponse } from '@/Interfaces'; import { License } from '@/License'; import * as testDb from './shared/testDb'; import * as utils from './shared/utils/'; -import { getGlobalMemberRole, getGlobalOwnerRole } from './shared/db/roles'; import { createUserShell } from './shared/db/users'; const MOCK_SERVER_URL = 'https://server.com/v1'; @@ -19,10 +18,8 @@ let authMemberAgent: SuperAgentTest; const testServer = utils.setupTestServer({ endpointGroups: ['license'] }); beforeAll(async () => { - const globalOwnerRole = await getGlobalOwnerRole(); - const globalMemberRole = await getGlobalMemberRole(); - owner = await createUserShell(globalOwnerRole); - member = await createUserShell(globalMemberRole); + owner = await createUserShell('global:owner'); + member = await createUserShell('global:member'); authOwnerAgent = testServer.authAgentFor(owner); authMemberAgent = testServer.authAgentFor(member); diff --git a/packages/cli/test/integration/me.api.test.ts b/packages/cli/test/integration/me.api.test.ts index 552e0d649c..61dde8c92f 100644 --- a/packages/cli/test/integration/me.api.test.ts +++ b/packages/cli/test/integration/me.api.test.ts @@ -1,7 +1,6 @@ import type { SuperAgentTest } from 'supertest'; import { IsNull } from 'typeorm'; import validator from 'validator'; -import type { Role } from '@db/entities/Role'; import type { User } from '@db/entities/User'; import { SUCCESS_RESPONSE_BODY } from './shared/constants'; import { @@ -13,21 +12,12 @@ import { } from './shared/random'; import * as testDb from './shared/testDb'; import * as utils from './shared/utils/'; -import { getGlobalMemberRole, getGlobalOwnerRole } from './shared/db/roles'; import { addApiKey, createUser, createUserShell } from './shared/db/users'; import Container from 'typedi'; import { UserRepository } from '@db/repositories/user.repository'; const testServer = utils.setupTestServer({ endpointGroups: ['me'] }); -let globalOwnerRole: Role; -let globalMemberRole: Role; - -beforeAll(async () => { - globalOwnerRole = await getGlobalOwnerRole(); - globalMemberRole = await getGlobalMemberRole(); -}); - beforeEach(async () => { await testDb.truncate(['User']); }); @@ -37,7 +27,7 @@ describe('Owner shell', () => { let authOwnerShellAgent: SuperAgentTest; beforeEach(async () => { - ownerShell = await createUserShell(globalOwnerRole); + ownerShell = await createUserShell('global:owner'); await addApiKey(ownerShell); authOwnerShellAgent = testServer.authAgentFor(ownerShell); }); @@ -54,7 +44,7 @@ describe('Owner shell', () => { firstName, lastName, personalizationAnswers, - globalRole, + role, password, isPending, apiKey, @@ -67,8 +57,7 @@ describe('Owner shell', () => { expect(personalizationAnswers).toBeNull(); expect(password).toBeUndefined(); expect(isPending).toBe(false); - expect(globalRole.name).toBe('owner'); - expect(globalRole.scope).toBe('global'); + expect(role).toBe('global:owner'); expect(apiKey).toBeUndefined(); const storedOwnerShell = await Container.get(UserRepository).findOneByOrFail({ id }); @@ -177,7 +166,7 @@ describe('Member', () => { beforeEach(async () => { member = await createUser({ password: memberPassword, - globalRole: globalMemberRole, + role: 'global:member', apiKey: randomApiKey(), }); authMemberAgent = testServer.authAgentFor(member); @@ -197,7 +186,7 @@ describe('Member', () => { firstName, lastName, personalizationAnswers, - globalRole, + role, password, isPending, apiKey, @@ -210,8 +199,7 @@ describe('Member', () => { expect(personalizationAnswers).toBeNull(); expect(password).toBeUndefined(); expect(isPending).toBe(false); - expect(globalRole.name).toBe('member'); - expect(globalRole.scope).toBe('global'); + expect(role).toBe('global:member'); expect(apiKey).toBeUndefined(); const storedMember = await Container.get(UserRepository).findOneByOrFail({ id }); @@ -317,7 +305,7 @@ describe('Owner', () => { }); test('PATCH /me should succeed with valid inputs', async () => { - const owner = await createUser({ globalRole: globalOwnerRole }); + const owner = await createUser({ role: 'global:owner' }); const authOwnerAgent = testServer.authAgentFor(owner); for (const validPayload of VALID_PATCH_ME_PAYLOADS) { @@ -331,7 +319,7 @@ describe('Owner', () => { firstName, lastName, personalizationAnswers, - globalRole, + role, password, isPending, apiKey, @@ -344,8 +332,7 @@ describe('Owner', () => { expect(personalizationAnswers).toBeNull(); expect(password).toBeUndefined(); expect(isPending).toBe(false); - expect(globalRole.name).toBe('owner'); - expect(globalRole.scope).toBe('global'); + expect(role).toBe('global:owner'); expect(apiKey).toBeUndefined(); const storedOwner = await Container.get(UserRepository).findOneByOrFail({ id }); diff --git a/packages/cli/test/integration/mfa/mfa.api.test.ts b/packages/cli/test/integration/mfa/mfa.api.test.ts index 1f9a5318ea..2e7df144ca 100644 --- a/packages/cli/test/integration/mfa/mfa.api.test.ts +++ b/packages/cli/test/integration/mfa/mfa.api.test.ts @@ -1,6 +1,5 @@ import Container from 'typedi'; import config from '@/config'; -import type { Role } from '@db/entities/Role'; import type { User } from '@db/entities/User'; import { randomPassword } from '@/Ldap/helpers'; import { TOTPService } from '@/Mfa/totp.service'; @@ -13,7 +12,6 @@ import { UserRepository } from '@db/repositories/user.repository'; jest.mock('@/telemetry'); -let globalOwnerRole: Role; let owner: User; const testServer = utils.setupTestServer({ @@ -23,7 +21,7 @@ const testServer = utils.setupTestServer({ beforeEach(async () => { await testDb.truncate(['User']); - owner = await createUser({ globalRole: globalOwnerRole }); + owner = await createUser({ role: 'global:owner' }); config.set('userManagement.disabled', false); }); diff --git a/packages/cli/test/integration/owner.api.test.ts b/packages/cli/test/integration/owner.api.test.ts index 2d044ee43a..cbe8a84c91 100644 --- a/packages/cli/test/integration/owner.api.test.ts +++ b/packages/cli/test/integration/owner.api.test.ts @@ -2,7 +2,6 @@ import validator from 'validator'; import type { SuperAgentTest } from 'supertest'; import config from '@/config'; -import type { Role } from '@db/entities/Role'; import type { User } from '@db/entities/User'; import { randomEmail, @@ -12,23 +11,17 @@ import { } from './shared/random'; import * as testDb from './shared/testDb'; import * as utils from './shared/utils/'; -import { getGlobalOwnerRole } from './shared/db/roles'; import { createUserShell } from './shared/db/users'; import { UserRepository } from '@db/repositories/user.repository'; import Container from 'typedi'; const testServer = utils.setupTestServer({ endpointGroups: ['owner'] }); -let globalOwnerRole: Role; let ownerShell: User; let authOwnerShellAgent: SuperAgentTest; -beforeAll(async () => { - globalOwnerRole = await getGlobalOwnerRole(); -}); - beforeEach(async () => { - ownerShell = await createUserShell(globalOwnerRole); + ownerShell = await createUserShell('global:owner'); authOwnerShellAgent = testServer.authAgentFor(ownerShell); config.set('userManagement.isInstanceOwnerSetUp', false); }); @@ -56,7 +49,7 @@ describe('POST /owner/setup', () => { firstName, lastName, personalizationAnswers, - globalRole, + role, password, isPending, apiKey, @@ -70,8 +63,7 @@ describe('POST /owner/setup', () => { expect(personalizationAnswers).toBeNull(); expect(password).toBeUndefined(); expect(isPending).toBe(false); - expect(globalRole.name).toBe('owner'); - expect(globalRole.scope).toBe('global'); + expect(role).toBe('global:owner'); expect(apiKey).toBeUndefined(); expect(globalScopes).not.toHaveLength(0); diff --git a/packages/cli/test/integration/passwordReset.api.test.ts b/packages/cli/test/integration/passwordReset.api.test.ts index cf263a890d..996fc0ab77 100644 --- a/packages/cli/test/integration/passwordReset.api.test.ts +++ b/packages/cli/test/integration/passwordReset.api.test.ts @@ -5,7 +5,6 @@ import { mock } from 'jest-mock-extended'; import { License } from '@/License'; import config from '@/config'; -import type { Role } from '@db/entities/Role'; import type { User } from '@db/entities/User'; import { setCurrentAuthenticationMethod } from '@/sso/ssoHelpers'; import { ExternalHooks } from '@/ExternalHooks'; @@ -24,14 +23,11 @@ import { randomValidPassword, } from './shared/random'; import * as testDb from './shared/testDb'; -import { getGlobalMemberRole, getGlobalOwnerRole } from './shared/db/roles'; import { createUser } from './shared/db/users'; import { PasswordUtility } from '@/services/password.utility'; config.set('userManagement.jwtSecret', randomString(5, 10)); -let globalOwnerRole: Role; -let globalMemberRole: Role; let owner: User; let member: User; @@ -41,15 +37,10 @@ const testServer = setupTestServer({ endpointGroups: ['passwordReset'] }); const jwtService = Container.get(JwtService); let userService: UserService; -beforeAll(async () => { - globalOwnerRole = await getGlobalOwnerRole(); - globalMemberRole = await getGlobalMemberRole(); -}); - beforeEach(async () => { await testDb.truncate(['User']); - owner = await createUser({ globalRole: globalOwnerRole }); - member = await createUser({ globalRole: globalMemberRole }); + owner = await createUser({ role: 'global:owner' }); + member = await createUser({ role: 'global:member' }); externalHooks.run.mockReset(); jest.replaceProperty(mailer, 'isEmailSetUp', true); userService = Container.get(UserService); @@ -59,7 +50,7 @@ describe('POST /forgot-password', () => { test('should send password reset email', async () => { const member = await createUser({ email: 'test@test.com', - globalRole: globalMemberRole, + role: 'global:member', }); await Promise.all( @@ -85,7 +76,7 @@ describe('POST /forgot-password', () => { await setCurrentAuthenticationMethod('saml'); const member = await createUser({ email: 'test@test.com', - globalRole: globalMemberRole, + role: 'global:member', }); await testServer.authlessAgent diff --git a/packages/cli/test/integration/pruning.service.test.ts b/packages/cli/test/integration/pruning.service.test.ts index 14ed6b4874..59b601eb3a 100644 --- a/packages/cli/test/integration/pruning.service.test.ts +++ b/packages/cli/test/integration/pruning.service.test.ts @@ -47,7 +47,7 @@ describe('softDeleteOnPruningCycle()', () => { }); async function findAllExecutions() { - return Container.get(ExecutionRepository).find({ + return await Container.get(ExecutionRepository).find({ order: { id: 'asc' }, withDeleted: true, }); diff --git a/packages/cli/test/integration/publicApi/credentials.test.ts b/packages/cli/test/integration/publicApi/credentials.test.ts index b754208dec..6ce0723874 100644 --- a/packages/cli/test/integration/publicApi/credentials.test.ts +++ b/packages/cli/test/integration/publicApi/credentials.test.ts @@ -1,5 +1,4 @@ import type { SuperAgentTest } from 'supertest'; -import type { Role } from '@db/entities/Role'; import type { User } from '@db/entities/User'; import { randomApiKey, randomName, randomString } from '../shared/random'; @@ -7,14 +6,11 @@ import * as utils from '../shared/utils/'; import type { CredentialPayload, SaveCredentialFunction } from '../shared/types'; import * as testDb from '../shared/testDb'; import { affixRoleToSaveCredential } from '../shared/db/credentials'; -import { getAllRoles } from '../shared/db/roles'; import { addApiKey, createUser, createUserShell } from '../shared/db/users'; import { CredentialsRepository } from '@db/repositories/credentials.repository'; import Container from 'typedi'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; -let globalMemberRole: Role; -let credentialOwnerRole: Role; let owner: User; let member: User; let authOwnerAgent: SuperAgentTest; @@ -25,19 +21,13 @@ let saveCredential: SaveCredentialFunction; const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); beforeAll(async () => { - const [globalOwnerRole, fetchedGlobalMemberRole, _, fetchedCredentialOwnerRole] = - await getAllRoles(); - - globalMemberRole = fetchedGlobalMemberRole; - credentialOwnerRole = fetchedCredentialOwnerRole; - - owner = await addApiKey(await createUserShell(globalOwnerRole)); - member = await createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() }); + owner = await addApiKey(await createUserShell('global:owner')); + member = await createUser({ role: 'global:member', apiKey: randomApiKey() }); authOwnerAgent = testServer.publicApiAgentFor(owner); authMemberAgent = testServer.publicApiAgentFor(member); - saveCredential = affixRoleToSaveCredential(credentialOwnerRole); + saveCredential = affixRoleToSaveCredential('credential:owner'); await utils.initCredentialsTypes(); }); @@ -73,11 +63,11 @@ describe('POST /credentials', () => { expect(credential.data).not.toBe(payload.data); const sharedCredential = await Container.get(SharedCredentialsRepository).findOneOrFail({ - relations: ['user', 'credentials', 'role'], + relations: ['user', 'credentials'], where: { credentialsId: credential.id, userId: owner.id }, }); - expect(sharedCredential.role).toEqual(credentialOwnerRole); + expect(sharedCredential.role).toEqual('credential:owner'); expect(sharedCredential.credentials.name).toBe(payload.name); }); @@ -156,7 +146,7 @@ describe('DELETE /credentials/:id', () => { test('should delete owned cred for member but leave others untouched', async () => { const anotherMember = await createUser({ - globalRole: globalMemberRole, + role: 'global:member', apiKey: randomApiKey(), }); diff --git a/packages/cli/test/integration/publicApi/executions.test.ts b/packages/cli/test/integration/publicApi/executions.test.ts index 32c069bd0f..012519df66 100644 --- a/packages/cli/test/integration/publicApi/executions.test.ts +++ b/packages/cli/test/integration/publicApi/executions.test.ts @@ -5,7 +5,6 @@ import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; import { randomApiKey } from '../shared/random'; import * as utils from '../shared/utils/'; import * as testDb from '../shared/testDb'; -import { getGlobalMemberRole, getGlobalOwnerRole } from '../shared/db/roles'; import { createUser } from '../shared/db/users'; import { createManyWorkflows, @@ -30,11 +29,9 @@ let workflowRunner: ActiveWorkflowRunner; const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); beforeAll(async () => { - const globalOwnerRole = await getGlobalOwnerRole(); - const globalUserRole = await getGlobalMemberRole(); - owner = await createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); - user1 = await createUser({ globalRole: globalUserRole, apiKey: randomApiKey() }); - user2 = await createUser({ globalRole: globalUserRole, apiKey: randomApiKey() }); + owner = await createUser({ role: 'global:owner', apiKey: randomApiKey() }); + user1 = await createUser({ role: 'global:member', apiKey: randomApiKey() }); + user2 = await createUser({ role: 'global:member', apiKey: randomApiKey() }); // TODO: mock BinaryDataService instead await utils.initBinaryDataService(); diff --git a/packages/cli/test/integration/publicApi/users.ee.test.ts b/packages/cli/test/integration/publicApi/users.ee.test.ts index 40709e8926..8dfae84625 100644 --- a/packages/cli/test/integration/publicApi/users.ee.test.ts +++ b/packages/cli/test/integration/publicApi/users.ee.test.ts @@ -2,14 +2,12 @@ import type { SuperAgentTest } from 'supertest'; import validator from 'validator'; import { v4 as uuid } from 'uuid'; -import type { Role } from '@db/entities/Role'; import { License } from '@/License'; import { mockInstance } from '../../shared/mocking'; import { randomApiKey } from '../shared/random'; import * as utils from '../shared/utils/'; import * as testDb from '../shared/testDb'; -import { getGlobalMemberRole, getGlobalOwnerRole } from '../shared/db/roles'; import { createUser, createUserShell } from '../shared/db/users'; mockInstance(License, { @@ -18,16 +16,6 @@ mockInstance(License, { const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); -let globalOwnerRole: Role; -let globalMemberRole: Role; - -beforeAll(async () => { - [globalOwnerRole, globalMemberRole] = await Promise.all([ - getGlobalOwnerRole(), - getGlobalMemberRole(), - ]); -}); - beforeEach(async () => { await testDb.truncate(['SharedCredentials', 'SharedWorkflow', 'Workflow', 'Credentials', 'User']); }); @@ -35,14 +23,14 @@ beforeEach(async () => { describe('With license unlimited quota:users', () => { describe('GET /users', () => { test('should fail due to missing API Key', async () => { - const owner = await createUser({ globalRole: globalOwnerRole }); + const owner = await createUser({ role: 'global:owner' }); const authOwnerAgent = testServer.publicApiAgentFor(owner); await authOwnerAgent.get('/users').expect(401); }); test('should fail due to invalid API Key', async () => { const owner = await createUser({ - globalRole: globalOwnerRole, + role: 'global:owner', apiKey: randomApiKey(), }); owner.apiKey = 'invalid-key'; @@ -58,7 +46,7 @@ describe('With license unlimited quota:users', () => { test('should return all users', async () => { const owner = await createUser({ - globalRole: globalOwnerRole, + role: 'global:owner', apiKey: randomApiKey(), }); @@ -77,7 +65,7 @@ describe('With license unlimited quota:users', () => { firstName, lastName, personalizationAnswers, - globalRole, + role, password, isPending, createdAt, @@ -91,7 +79,7 @@ describe('With license unlimited quota:users', () => { expect(personalizationAnswers).toBeUndefined(); expect(password).toBeUndefined(); expect(isPending).toBe(false); - expect(globalRole).toBeUndefined(); + expect(role).toBeUndefined(); expect(createdAt).toBeDefined(); expect(updatedAt).toBeDefined(); } @@ -100,14 +88,14 @@ describe('With license unlimited quota:users', () => { describe('GET /users/:id', () => { test('should fail due to missing API Key', async () => { - const owner = await createUser({ globalRole: globalOwnerRole }); + const owner = await createUser({ role: 'global:owner' }); const authOwnerAgent = testServer.publicApiAgentFor(owner); await authOwnerAgent.get(`/users/${owner.id}`).expect(401); }); test('should fail due to invalid API Key', async () => { const owner = await createUser({ - globalRole: globalOwnerRole, + role: 'global:owner', apiKey: randomApiKey(), }); owner.apiKey = 'invalid-key'; @@ -122,7 +110,7 @@ describe('With license unlimited quota:users', () => { }); test('should return 404 for non-existing id ', async () => { const owner = await createUser({ - globalRole: globalOwnerRole, + role: 'global:owner', apiKey: randomApiKey(), }); const authOwnerAgent = testServer.publicApiAgentFor(owner); @@ -131,11 +119,11 @@ describe('With license unlimited quota:users', () => { test('should return a pending user', async () => { const owner = await createUser({ - globalRole: globalOwnerRole, + role: 'global:owner', apiKey: randomApiKey(), }); - const { id: memberId } = await createUserShell(globalMemberRole); + const { id: memberId } = await createUserShell('global:member'); const authOwnerAgent = testServer.publicApiAgentFor(owner); const response = await authOwnerAgent.get(`/users/${memberId}`).expect(200); @@ -146,7 +134,7 @@ describe('With license unlimited quota:users', () => { firstName, lastName, personalizationAnswers, - globalRole, + role, password, isPending, createdAt, @@ -159,7 +147,7 @@ describe('With license unlimited quota:users', () => { expect(lastName).toBeDefined(); expect(personalizationAnswers).toBeUndefined(); expect(password).toBeUndefined(); - expect(globalRole).toBeUndefined(); + expect(role).toBeUndefined(); expect(createdAt).toBeDefined(); expect(isPending).toBeDefined(); expect(isPending).toBeTruthy(); @@ -170,7 +158,7 @@ describe('With license unlimited quota:users', () => { describe('GET /users/:email', () => { test('with non-existing email should return 404', async () => { const owner = await createUser({ - globalRole: globalOwnerRole, + role: 'global:owner', apiKey: randomApiKey(), }); const authOwnerAgent = testServer.publicApiAgentFor(owner); @@ -179,7 +167,7 @@ describe('With license unlimited quota:users', () => { test('should return a user', async () => { const owner = await createUser({ - globalRole: globalOwnerRole, + role: 'global:owner', apiKey: randomApiKey(), }); @@ -192,7 +180,7 @@ describe('With license unlimited quota:users', () => { firstName, lastName, personalizationAnswers, - globalRole, + role, password, isPending, createdAt, @@ -206,7 +194,7 @@ describe('With license unlimited quota:users', () => { expect(personalizationAnswers).toBeUndefined(); expect(password).toBeUndefined(); expect(isPending).toBe(false); - expect(globalRole).toBeUndefined(); + expect(role).toBeUndefined(); expect(createdAt).toBeDefined(); expect(updatedAt).toBeDefined(); }); @@ -220,7 +208,7 @@ describe('With license without quota:users', () => { mockInstance(License, { getUsersLimit: jest.fn().mockReturnValue(null) }); const owner = await createUser({ - globalRole: globalOwnerRole, + role: 'global:owner', apiKey: randomApiKey(), }); authOwnerAgent = testServer.publicApiAgentFor(owner); diff --git a/packages/cli/test/integration/publicApi/workflows.test.ts b/packages/cli/test/integration/publicApi/workflows.test.ts index 47b0c2c15f..53aa198f09 100644 --- a/packages/cli/test/integration/publicApi/workflows.test.ts +++ b/packages/cli/test/integration/publicApi/workflows.test.ts @@ -2,25 +2,22 @@ import type { SuperAgentTest } from 'supertest'; import Container from 'typedi'; import type { INode } from 'n8n-workflow'; import { STARTING_NODES } from '@/constants'; -import type { Role } from '@db/entities/Role'; import type { TagEntity } from '@db/entities/TagEntity'; import type { User } from '@db/entities/User'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { WorkflowHistoryRepository } from '@db/repositories/workflowHistory.repository'; import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; +import { Push } from '@/push'; +import { ExecutionService } from '@/executions/execution.service'; import { randomApiKey } from '../shared/random'; import * as utils from '../shared/utils/'; import * as testDb from '../shared/testDb'; -import { getAllRoles } from '../shared/db/roles'; import { createUser } from '../shared/db/users'; import { createWorkflow, createWorkflowWithTrigger } from '../shared/db/workflows'; import { createTag } from '../shared/db/tags'; import { mockInstance } from '../../shared/mocking'; -import { Push } from '@/push'; -import { ExecutionsService } from '@/executions/executions.service'; -let workflowOwnerRole: Role; let owner: User; let member: User; let authOwnerAgent: SuperAgentTest; @@ -31,20 +28,16 @@ const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); const license = testServer.license; mockInstance(Push); -mockInstance(ExecutionsService); +mockInstance(ExecutionService); beforeAll(async () => { - const [globalOwnerRole, globalMemberRole, fetchedWorkflowOwnerRole] = await getAllRoles(); - - workflowOwnerRole = fetchedWorkflowOwnerRole; - owner = await createUser({ - globalRole: globalOwnerRole, + role: 'global:owner', apiKey: randomApiKey(), }); member = await createUser({ - globalRole: globalMemberRole, + role: 'global:member', apiKey: randomApiKey(), }); @@ -693,12 +686,12 @@ describe('POST /workflows', () => { userId: member.id, workflowId: response.body.id, }, - relations: ['workflow', 'role'], + relations: ['workflow'], }); expect(sharedWorkflow?.workflow.name).toBe(name); expect(sharedWorkflow?.workflow.createdAt.toISOString()).toBe(createdAt); - expect(sharedWorkflow?.role).toEqual(workflowOwnerRole); + expect(sharedWorkflow?.role).toEqual('workflow:owner'); }); test('should create workflow history version when licensed', async () => { @@ -1110,13 +1103,13 @@ describe('PUT /workflows/:id', () => { userId: member.id, workflowId: response.body.id, }, - relations: ['workflow', 'role'], + relations: ['workflow'], }); expect(sharedWorkflow?.workflow.name).toBe(payload.name); expect(sharedWorkflow?.workflow.updatedAt.getTime()).toBeGreaterThan( workflow.updatedAt.getTime(), ); - expect(sharedWorkflow?.role).toEqual(workflowOwnerRole); + expect(sharedWorkflow?.role).toEqual('workflow:owner'); }); }); diff --git a/packages/cli/test/integration/role.api.test.ts b/packages/cli/test/integration/role.api.test.ts deleted file mode 100644 index 024f0d6950..0000000000 --- a/packages/cli/test/integration/role.api.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import * as utils from './shared/utils/'; -import * as testDb from './shared/testDb'; -import { createAdmin, createMember, createOwner } from './shared/db/users'; - -import type { SuperAgentTest } from 'supertest'; -import type { User } from '@db/entities/User'; - -const testServer = utils.setupTestServer({ - endpointGroups: ['role'], - enabledFeatures: ['feat:advancedPermissions'], -}); - -const license = testServer.license; - -describe('GET /roles', () => { - let owner: User; - let admin: User; - let member: User; - - let ownerAgent: SuperAgentTest; - let adminAgent: SuperAgentTest; - let memberAgent: SuperAgentTest; - - let toAgent: Record = {}; - - beforeAll(async () => { - await testDb.truncate(['User']); - - owner = await createOwner(); - admin = await createAdmin(); - member = await createMember(); - - ownerAgent = testServer.authAgentFor(owner); - adminAgent = testServer.authAgentFor(admin); - memberAgent = testServer.authAgentFor(member); - - toAgent = { - owner: ownerAgent, - admin: adminAgent, - member: memberAgent, - }; - }); - - describe('with advanced permissions licensed', () => { - test.each(['owner', 'admin', 'member'])('should return all roles to %s', async (user) => { - license.enable('feat:advancedPermissions'); - - const response = await toAgent[user].get('/roles').expect(200); - - expect(response.body.data).toEqual([ - { scope: 'global', name: 'owner', isAvailable: true }, - { scope: 'global', name: 'member', isAvailable: true }, - { scope: 'global', name: 'admin', isAvailable: true }, - { scope: 'workflow', name: 'owner', isAvailable: true }, - { scope: 'credential', name: 'owner', isAvailable: true }, - { scope: 'credential', name: 'user', isAvailable: true }, - { scope: 'workflow', name: 'editor', isAvailable: true }, - ]); - }); - }); - - describe('with advanced permissions not licensed', () => { - test.each(['owner', 'admin', 'member'])('should return all roles to %s', async (user) => { - license.disable('feat:advancedPermissions'); - - const response = await toAgent[user].get('/roles').expect(200); - - expect(response.body.data).toEqual([ - { scope: 'global', name: 'owner', isAvailable: true }, - { scope: 'global', name: 'member', isAvailable: true }, - { scope: 'global', name: 'admin', isAvailable: false }, - { scope: 'workflow', name: 'owner', isAvailable: true }, - { scope: 'credential', name: 'owner', isAvailable: true }, - { scope: 'credential', name: 'user', isAvailable: true }, - { scope: 'workflow', name: 'editor', isAvailable: true }, - ]); - }); - }); -}); diff --git a/packages/cli/test/integration/security-audit/DatabaseRiskReporter.test.ts b/packages/cli/test/integration/security-audit/DatabaseRiskReporter.test.ts index 63c50caad9..e7ae638d97 100644 --- a/packages/cli/test/integration/security-audit/DatabaseRiskReporter.test.ts +++ b/packages/cli/test/integration/security-audit/DatabaseRiskReporter.test.ts @@ -55,7 +55,7 @@ test('should report expressions in queries', async () => { ], }; - return Container.get(WorkflowRepository).save(details); + return await Container.get(WorkflowRepository).save(details); }); await Promise.all(promises); @@ -110,7 +110,7 @@ test('should report expressions in query params', async () => { ], }; - return Container.get(WorkflowRepository).save(details); + return await Container.get(WorkflowRepository).save(details); }); await Promise.all(promises); @@ -162,7 +162,7 @@ test('should report unused query params', async () => { ], }; - return Container.get(WorkflowRepository).save(details); + return await Container.get(WorkflowRepository).save(details); }); await Promise.all(promises); diff --git a/packages/cli/test/integration/security-audit/FilesystemRiskReporter.test.ts b/packages/cli/test/integration/security-audit/FilesystemRiskReporter.test.ts index a3e3f69cc9..f6d8537c0d 100644 --- a/packages/cli/test/integration/security-audit/FilesystemRiskReporter.test.ts +++ b/packages/cli/test/integration/security-audit/FilesystemRiskReporter.test.ts @@ -47,7 +47,7 @@ test('should report filesystem interaction nodes', async () => { ], }); - return Container.get(WorkflowRepository).save(details); + return await Container.get(WorkflowRepository).save(details); }); await Promise.all(promises); diff --git a/packages/cli/test/integration/security-audit/InstanceRiskReporter.test.ts b/packages/cli/test/integration/security-audit/InstanceRiskReporter.test.ts index 102ff1c6d6..26e8560345 100644 --- a/packages/cli/test/integration/security-audit/InstanceRiskReporter.test.ts +++ b/packages/cli/test/integration/security-audit/InstanceRiskReporter.test.ts @@ -102,7 +102,7 @@ test('should not report webhooks having basic or header auth', async () => { ], }; - return Container.get(WorkflowRepository).save(details); + return await Container.get(WorkflowRepository).save(details); }); await Promise.all(promises); @@ -165,7 +165,7 @@ test('should not report webhooks validated by direct children', async () => { }, }; - return Container.get(WorkflowRepository).save(details); + return await Container.get(WorkflowRepository).save(details); }); await Promise.all(promises); diff --git a/packages/cli/test/integration/security-audit/NodesRiskReporter.test.ts b/packages/cli/test/integration/security-audit/NodesRiskReporter.test.ts index f10dbee30e..03386bef00 100644 --- a/packages/cli/test/integration/security-audit/NodesRiskReporter.test.ts +++ b/packages/cli/test/integration/security-audit/NodesRiskReporter.test.ts @@ -58,7 +58,7 @@ test('should report risky official nodes', async () => { ], }); - return Container.get(WorkflowRepository).save(details); + return await Container.get(WorkflowRepository).save(details); }); await Promise.all(promises); diff --git a/packages/cli/test/integration/security-audit/utils.ts b/packages/cli/test/integration/security-audit/utils.ts index 1ad445e950..bd0d5a6ead 100644 --- a/packages/cli/test/integration/security-audit/utils.ts +++ b/packages/cli/test/integration/security-audit/utils.ts @@ -53,7 +53,7 @@ export async function saveManualTriggerWorkflow() { ], }; - return Container.get(WorkflowRepository).save(details); + return await Container.get(WorkflowRepository).save(details); } export const MOCK_09990_N8N_VERSION = { diff --git a/packages/cli/test/integration/shared/db/credentials.ts b/packages/cli/test/integration/shared/db/credentials.ts index 67704e21ca..2208e08936 100644 --- a/packages/cli/test/integration/shared/db/credentials.ts +++ b/packages/cli/test/integration/shared/db/credentials.ts @@ -1,12 +1,11 @@ +import { Container } from 'typedi'; import { CredentialsEntity } from '@db/entities/CredentialsEntity'; import type { User } from '@db/entities/User'; -import type { Role } from '@db/entities/Role'; -import type { ICredentialsDb } from '@/Interfaces'; -import { RoleService } from '@/services/role.service'; -import type { CredentialPayload } from '../types'; -import Container from 'typedi'; import { CredentialsRepository } from '@db/repositories/credentials.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; +import type { CredentialSharingRole } from '@db/entities/SharedCredentials'; +import type { ICredentialsDb } from '@/Interfaces'; +import type { CredentialPayload } from '../types'; async function encryptCredentialData(credential: CredentialsEntity) { const { createCredentialsFromCredentialsEntity } = await import('@/CredentialsHelper'); @@ -18,12 +17,37 @@ async function encryptCredentialData(credential: CredentialsEntity) { return coreCredential.getDataToSave() as ICredentialsDb; } +const emptyAttributes = { + name: 'test', + type: 'test', + data: '', + nodesAccess: [], +}; + +export async function createManyCredentials( + amount: number, + attributes: Partial = emptyAttributes, +) { + return await Promise.all( + Array(amount) + .fill(0) + .map(async () => await createCredentials(attributes)), + ); +} + +export async function createCredentials(attributes: Partial = emptyAttributes) { + const credentialsRepository = Container.get(CredentialsRepository); + const entity = credentialsRepository.create(attributes); + + return await credentialsRepository.save(entity); +} + /** * Save a credential to the test DB, sharing it with a user. */ export async function saveCredential( credentialPayload: CredentialPayload, - { user, role }: { user: User; role: Role }, + { user, role }: { user: User; role: CredentialSharingRole }, ) { const newCredential = new CredentialsEntity(); @@ -47,25 +71,24 @@ export async function saveCredential( } export async function shareCredentialWithUsers(credential: CredentialsEntity, users: User[]) { - const role = await Container.get(RoleService).findCredentialUserRole(); const newSharedCredentials = users.map((user) => Container.get(SharedCredentialsRepository).create({ userId: user.id, credentialsId: credential.id, - roleId: role?.id, + role: 'credential:user', }), ); - return Container.get(SharedCredentialsRepository).save(newSharedCredentials); + return await Container.get(SharedCredentialsRepository).save(newSharedCredentials); } -export function affixRoleToSaveCredential(role: Role) { +export function affixRoleToSaveCredential(role: CredentialSharingRole) { return async (credentialPayload: CredentialPayload, { user }: { user: User }) => - saveCredential(credentialPayload, { user, role }); + await saveCredential(credentialPayload, { user, role }); } export async function getAllCredentials() { - return Container.get(CredentialsRepository).find(); + return await Container.get(CredentialsRepository).find(); } export const getCredentialById = async (id: string) => - Container.get(CredentialsRepository).findOneBy({ id }); + await Container.get(CredentialsRepository).findOneBy({ id }); diff --git a/packages/cli/test/integration/shared/db/executions.ts b/packages/cli/test/integration/shared/db/executions.ts index f68e5edf36..f5c6e5ddae 100644 --- a/packages/cli/test/integration/shared/db/executions.ts +++ b/packages/cli/test/integration/shared/db/executions.ts @@ -10,8 +10,8 @@ export async function createManyExecutions( workflow: WorkflowEntity, callback: (workflow: WorkflowEntity) => Promise, ) { - const executionsRequests = [...Array(amount)].map(async (_) => callback(workflow)); - return Promise.all(executionsRequests); + const executionsRequests = [...Array(amount)].map(async (_) => await callback(workflow)); + return await Promise.all(executionsRequests); } /** @@ -47,23 +47,29 @@ export async function createExecution( * Store a successful execution in the DB and assign it to a workflow. */ export async function createSuccessfulExecution(workflow: WorkflowEntity) { - return createExecution({ finished: true, status: 'success' }, workflow); + return await createExecution({ finished: true, status: 'success' }, workflow); } /** * Store an error execution in the DB and assign it to a workflow. */ export async function createErrorExecution(workflow: WorkflowEntity) { - return createExecution({ finished: false, stoppedAt: new Date(), status: 'failed' }, workflow); + return await createExecution( + { finished: false, stoppedAt: new Date(), status: 'failed' }, + workflow, + ); } /** * Store a waiting execution in the DB and assign it to a workflow. */ export async function createWaitingExecution(workflow: WorkflowEntity) { - return createExecution({ finished: false, waitTill: new Date(), status: 'waiting' }, workflow); + return await createExecution( + { finished: false, waitTill: new Date(), status: 'waiting' }, + workflow, + ); } export async function getAllExecutions() { - return Container.get(ExecutionRepository).find(); + return await Container.get(ExecutionRepository).find(); } diff --git a/packages/cli/test/integration/shared/db/roles.ts b/packages/cli/test/integration/shared/db/roles.ts deleted file mode 100644 index 9be0c43193..0000000000 --- a/packages/cli/test/integration/shared/db/roles.ts +++ /dev/null @@ -1,35 +0,0 @@ -import Container from 'typedi'; -import { RoleService } from '@/services/role.service'; - -export async function getGlobalOwnerRole() { - return Container.get(RoleService).findGlobalOwnerRole(); -} - -export async function getGlobalMemberRole() { - return Container.get(RoleService).findGlobalMemberRole(); -} - -export async function getGlobalAdminRole() { - return Container.get(RoleService).findGlobalAdminRole(); -} - -export async function getWorkflowOwnerRole() { - return Container.get(RoleService).findWorkflowOwnerRole(); -} - -export async function getWorkflowEditorRole() { - return Container.get(RoleService).findWorkflowEditorRole(); -} - -export async function getCredentialOwnerRole() { - return Container.get(RoleService).findCredentialOwnerRole(); -} - -export async function getAllRoles() { - return Promise.all([ - getGlobalOwnerRole(), - getGlobalMemberRole(), - getWorkflowOwnerRole(), - getCredentialOwnerRole(), - ]); -} diff --git a/packages/cli/test/integration/shared/db/users.ts b/packages/cli/test/integration/shared/db/users.ts index ce7515b4b7..27defb2184 100644 --- a/packages/cli/test/integration/shared/db/users.ts +++ b/packages/cli/test/integration/shared/db/users.ts @@ -1,33 +1,30 @@ import Container from 'typedi'; import { hash } from 'bcryptjs'; import { AuthIdentity } from '@db/entities/AuthIdentity'; -import type { Role } from '@db/entities/Role'; -import type { User } from '@db/entities/User'; +import type { GlobalRole, User } from '@db/entities/User'; import { AuthIdentityRepository } from '@db/repositories/authIdentity.repository'; import { UserRepository } from '@db/repositories/user.repository'; import { TOTPService } from '@/Mfa/totp.service'; import { MfaService } from '@/Mfa/mfa.service'; import { randomApiKey, randomEmail, randomName, randomValidPassword } from '../random'; -import { getGlobalAdminRole, getGlobalMemberRole, getGlobalOwnerRole } from './roles'; /** * Store a user in the DB, defaulting to a `member`. */ export async function createUser(attributes: Partial = {}): Promise { - const { email, password, firstName, lastName, globalRole, ...rest } = attributes; + const { email, password, firstName, lastName, role, ...rest } = attributes; const user = Container.get(UserRepository).create({ email: email ?? randomEmail(), password: await hash(password ?? randomValidPassword(), 10), firstName: firstName ?? randomName(), lastName: lastName ?? randomName(), - globalRoleId: (globalRole ?? (await getGlobalMemberRole())).id, - globalRole, + role: role ?? 'global:member', ...rest, }); user.computeIsOwner(); - return Container.get(UserRepository).save(user); + return await Container.get(UserRepository).save(user); } export async function createLdapUser(attributes: Partial, ldapId: string): Promise { @@ -70,29 +67,25 @@ export async function createUserWithMfaEnabled( } export async function createOwner() { - return createUser({ globalRole: await getGlobalOwnerRole() }); + return await createUser({ role: 'global:owner' }); } export async function createMember() { - return createUser({ globalRole: await getGlobalMemberRole() }); + return await createUser({ role: 'global:member' }); } export async function createAdmin() { - return createUser({ globalRole: await getGlobalAdminRole() }); + return await createUser({ role: 'global:admin' }); } -export async function createUserShell(globalRole: Role): Promise { - if (globalRole.scope !== 'global') { - throw new Error(`Invalid role received: ${JSON.stringify(globalRole)}`); - } +export async function createUserShell(role: GlobalRole): Promise { + const shell: Partial = { role }; - const shell: Partial = { globalRoleId: globalRole.id }; - - if (globalRole.name !== 'owner') { + if (role !== 'global:owner') { shell.email = randomEmail(); } - return Container.get(UserRepository).save(shell); + return await Container.get(UserRepository).save(shell); } /** @@ -102,10 +95,7 @@ export async function createManyUsers( amount: number, attributes: Partial = {}, ): Promise { - let { email, password, firstName, lastName, globalRole, ...rest } = attributes; - if (!globalRole) { - globalRole = await getGlobalMemberRole(); - } + let { email, password, firstName, lastName, role, ...rest } = attributes; const users = await Promise.all( [...Array(amount)].map(async () => @@ -114,33 +104,33 @@ export async function createManyUsers( password: await hash(password ?? randomValidPassword(), 10), firstName: firstName ?? randomName(), lastName: lastName ?? randomName(), - globalRole, + role: role ?? 'global:member', ...rest, }), ), ); - return Container.get(UserRepository).save(users); + return await Container.get(UserRepository).save(users); } export async function addApiKey(user: User): Promise { user.apiKey = randomApiKey(); - return Container.get(UserRepository).save(user); + return await Container.get(UserRepository).save(user); } export const getAllUsers = async () => - Container.get(UserRepository).find({ - relations: ['globalRole', 'authIdentities'], + await Container.get(UserRepository).find({ + relations: ['authIdentities'], }); export const getUserById = async (id: string) => - Container.get(UserRepository).findOneOrFail({ + await Container.get(UserRepository).findOneOrFail({ where: { id }, - relations: ['globalRole', 'authIdentities'], + relations: ['authIdentities'], }); export const getLdapIdentities = async () => - Container.get(AuthIdentityRepository).find({ + await Container.get(AuthIdentityRepository).find({ where: { providerType: 'ldap' }, relations: ['user'], }); diff --git a/packages/cli/test/integration/shared/db/workflowHistory.ts b/packages/cli/test/integration/shared/db/workflowHistory.ts index 8233d80077..ab4109cef7 100644 --- a/packages/cli/test/integration/shared/db/workflowHistory.ts +++ b/packages/cli/test/integration/shared/db/workflowHistory.ts @@ -7,7 +7,7 @@ export async function createWorkflowHistoryItem( workflowId: string, data?: Partial, ) { - return Container.get(WorkflowHistoryRepository).save({ + return await Container.get(WorkflowHistoryRepository).save({ authors: 'John Smith', connections: {}, nodes: [ @@ -32,12 +32,13 @@ export async function createManyWorkflowHistoryItems( time?: Date, ) { const baseTime = (time ?? new Date()).valueOf(); - return Promise.all( - [...Array(count)].map(async (_, i) => - createWorkflowHistoryItem(workflowId, { - createdAt: new Date(baseTime + i), - updatedAt: new Date(baseTime + i), - }), + return await Promise.all( + [...Array(count)].map( + async (_, i) => + await createWorkflowHistoryItem(workflowId, { + createdAt: new Date(baseTime + i), + updatedAt: new Date(baseTime + i), + }), ), ); } diff --git a/packages/cli/test/integration/shared/db/workflowStatistics.ts b/packages/cli/test/integration/shared/db/workflowStatistics.ts new file mode 100644 index 0000000000..e690cb726a --- /dev/null +++ b/packages/cli/test/integration/shared/db/workflowStatistics.ts @@ -0,0 +1,21 @@ +import Container from 'typedi'; +import { StatisticsNames, type WorkflowStatistics } from '@/databases/entities/WorkflowStatistics'; +import type { Workflow } from 'n8n-workflow'; +import { WorkflowStatisticsRepository } from '@/databases/repositories/workflowStatistics.repository'; + +export async function createWorkflowStatisticsItem( + workflowId: Workflow['id'], + data?: Partial, +) { + const entity = Container.get(WorkflowStatisticsRepository).create({ + count: 0, + latestEvent: new Date().toISOString(), + name: StatisticsNames.manualSuccess, + ...(data ?? {}), + workflowId, + }); + + await Container.get(WorkflowStatisticsRepository).insert(entity); + + return entity; +} diff --git a/packages/cli/test/integration/shared/db/workflows.ts b/packages/cli/test/integration/shared/db/workflows.ts index 981b126cbd..5603db7ab9 100644 --- a/packages/cli/test/integration/shared/db/workflows.ts +++ b/packages/cli/test/integration/shared/db/workflows.ts @@ -1,18 +1,22 @@ import Container from 'typedi'; +import type { DeepPartial } from 'typeorm'; import { v4 as uuid } from 'uuid'; + import type { User } from '@db/entities/User'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; -import { getWorkflowEditorRole, getWorkflowOwnerRole } from './roles'; +import type { SharedWorkflow } from '@db/entities/SharedWorkflow'; export async function createManyWorkflows( amount: number, attributes: Partial = {}, user?: User, ) { - const workflowRequests = [...Array(amount)].map(async (_) => createWorkflow(attributes, user)); - return Promise.all(workflowRequests); + const workflowRequests = [...Array(amount)].map( + async (_) => await createWorkflow(attributes, user), + ); + return await Promise.all(workflowRequests); } /** @@ -47,24 +51,23 @@ export async function createWorkflow(attributes: Partial = {}, u await Container.get(SharedWorkflowRepository).save({ user, workflow, - role: await getWorkflowOwnerRole(), + role: 'workflow:owner', }); } return workflow; } export async function shareWorkflowWithUsers(workflow: WorkflowEntity, users: User[]) { - const role = await getWorkflowEditorRole(); - const sharedWorkflows = users.map((user) => ({ - user, - workflow, - role, + const sharedWorkflows: Array> = users.map((user) => ({ + userId: user.id, + workflowId: workflow.id, + role: 'workflow:editor', })); - return Container.get(SharedWorkflowRepository).save(sharedWorkflows); + return await Container.get(SharedWorkflowRepository).save(sharedWorkflows); } export async function getWorkflowSharing(workflow: WorkflowEntity) { - return Container.get(SharedWorkflowRepository).findBy({ + return await Container.get(SharedWorkflowRepository).findBy({ workflowId: workflow.id, }); } @@ -115,8 +118,8 @@ export async function createWorkflowWithTrigger( } export async function getAllWorkflows() { - return Container.get(WorkflowRepository).find(); + return await Container.get(WorkflowRepository).find(); } export const getWorkflowById = async (id: string) => - Container.get(WorkflowRepository).findOneBy({ id }); + await Container.get(WorkflowRepository).findOneBy({ id }); diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index 6670fc1ff9..8f4b764eeb 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -31,7 +31,6 @@ type EndpointGroup = | 'executions' | 'workflowHistory' | 'binaryData' - | 'role' | 'invitations' | 'debug'; diff --git a/packages/cli/test/integration/shared/utils/index.ts b/packages/cli/test/integration/shared/utils/index.ts index 63fcd48621..711f98a296 100644 --- a/packages/cli/test/integration/shared/utils/index.ts +++ b/packages/cli/test/integration/shared/utils/index.ts @@ -16,9 +16,9 @@ import { AUTH_COOKIE_NAME } from '@/constants'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { SettingsRepository } from '@db/repositories/settings.repository'; import { mockNodeTypesData } from '../../../unit/Helpers'; -import { MultiMainSetup } from '@/services/orchestration/main/MultiMainSetup.ee'; +import { OrchestrationService } from '@/services/orchestration.service'; import { mockInstance } from '../../../shared/mocking'; -import { ExecutionsService } from '@/executions/executions.service'; +import { ExecutionService } from '@/executions/execution.service'; export { setupTestServer } from './testServer'; @@ -30,9 +30,9 @@ export { setupTestServer } from './testServer'; * Initialize node types. */ export async function initActiveWorkflowRunner() { - mockInstance(MultiMainSetup); + mockInstance(OrchestrationService); - mockInstance(ExecutionsService); + mockInstance(ExecutionService); const { ActiveWorkflowRunner } = await import('@/ActiveWorkflowRunner'); const workflowRunner = Container.get(ActiveWorkflowRunner); await workflowRunner.init(); diff --git a/packages/cli/test/integration/shared/utils/testServer.ts b/packages/cli/test/integration/shared/utils/testServer.ts index cb6dc1355f..aea2602bdd 100644 --- a/packages/cli/test/integration/shared/utils/testServer.ts +++ b/packages/cli/test/integration/shared/utils/testServer.ts @@ -38,7 +38,7 @@ function prefix(pathSegment: string) { url.pathname = pathSegment + url.pathname; request.url = url.toString(); - return request; + return await request; }; } @@ -131,8 +131,8 @@ export const setupTestServer = ({ break; case 'executions': - const { executionsController } = await import('@/executions/executions.controller'); - app.use(`/${REST_PATH_SEGMENT}/executions`, executionsController); + const { ExecutionsController } = await import('@/executions/executions.controller'); + registerController(app, ExecutionsController); break; case 'variables': @@ -249,11 +249,6 @@ export const setupTestServer = ({ registerController(app, BinaryDataController); break; - case 'role': - const { RoleController } = await import('@/controllers/role.controller'); - registerController(app, RoleController); - break; - case 'debug': const { DebugController } = await import('@/controllers/debug.controller'); registerController(app, DebugController); diff --git a/packages/cli/test/integration/shared/utils/users.ts b/packages/cli/test/integration/shared/utils/users.ts index c2b4d6be9b..c655f754b2 100644 --- a/packages/cli/test/integration/shared/utils/users.ts +++ b/packages/cli/test/integration/shared/utils/users.ts @@ -13,7 +13,7 @@ export const validateUser = (user: PublicUser) => { expect(user.settings).toBe(null); expect(user.personalizationAnswers).toBeNull(); expect(user.password).toBeUndefined(); - expect(user.globalRole).toBeDefined(); + expect(user.role).toBeDefined(); }; export const assertInviteUserSuccessResponse = (data: UserInvitationResponse) => { diff --git a/packages/cli/test/integration/shared/workflow.ts b/packages/cli/test/integration/shared/workflow.ts new file mode 100644 index 0000000000..0b2b12f53a --- /dev/null +++ b/packages/cli/test/integration/shared/workflow.ts @@ -0,0 +1,78 @@ +import type { INode } from 'n8n-workflow'; +import { WorkflowEntity } from '@db/entities/WorkflowEntity'; + +export const FIRST_CREDENTIAL_ID = '1'; +export const SECOND_CREDENTIAL_ID = '2'; +export const THIRD_CREDENTIAL_ID = '3'; + +const NODE_WITH_NO_CRED = '0133467b-df4a-473d-9295-fdd9d01fa45a'; +const NODE_WITH_ONE_CRED = '4673f869-f2dc-4a33-b053-ca3193bc5226'; +const NODE_WITH_TWO_CRED = '9b4208bd-8f10-4a6a-ad3b-da47a326f7da'; + +const nodeWithNoCredentials: INode = { + id: NODE_WITH_NO_CRED, + name: 'Node with no Credential', + typeVersion: 1, + type: 'n8n-nodes-base.fakeNode', + position: [0, 0], + credentials: {}, + parameters: {}, +}; + +const nodeWithOneCredential: INode = { + id: NODE_WITH_ONE_CRED, + name: 'Node with a single credential', + typeVersion: 1, + type: '', + position: [0, 0], + credentials: { + test: { + id: FIRST_CREDENTIAL_ID, + name: 'First fake credential', + }, + }, + parameters: {}, +}; + +const nodeWithTwoCredentials: INode = { + id: NODE_WITH_TWO_CRED, + name: 'Node with two credentials', + typeVersion: 1, + type: '', + position: [0, 0], + credentials: { + mcTest: { + id: SECOND_CREDENTIAL_ID, + name: 'Second fake credential', + }, + mcTest2: { + id: THIRD_CREDENTIAL_ID, + name: 'Third fake credential', + }, + }, + parameters: {}, +}; + +export function getWorkflow(options?: { + addNodeWithoutCreds?: boolean; + addNodeWithOneCred?: boolean; + addNodeWithTwoCreds?: boolean; +}) { + const workflow = new WorkflowEntity(); + + workflow.nodes = []; + + if (options?.addNodeWithoutCreds) { + workflow.nodes.push(nodeWithNoCredentials); + } + + if (options?.addNodeWithOneCred) { + workflow.nodes.push(nodeWithOneCredential); + } + + if (options?.addNodeWithTwoCreds) { + workflow.nodes.push(nodeWithTwoCredentials); + } + + return workflow; +} diff --git a/packages/cli/test/integration/tags.api.test.ts b/packages/cli/test/integration/tags.api.test.ts index bc44fdf00d..73be97a1c2 100644 --- a/packages/cli/test/integration/tags.api.test.ts +++ b/packages/cli/test/integration/tags.api.test.ts @@ -3,15 +3,13 @@ import * as testDb from './shared/testDb'; import type { SuperAgentTest } from 'supertest'; import { TagRepository } from '@db/repositories/tag.repository'; import Container from 'typedi'; -import { getGlobalOwnerRole } from './shared/db/roles'; import { createUserShell } from './shared/db/users'; let authOwnerAgent: SuperAgentTest; const testServer = utils.setupTestServer({ endpointGroups: ['tags'] }); beforeAll(async () => { - const globalOwnerRole = await getGlobalOwnerRole(); - const ownerShell = await createUserShell(globalOwnerRole); + const ownerShell = await createUserShell('global:owner'); authOwnerAgent = testServer.authAgentFor(ownerShell); }); diff --git a/packages/cli/test/integration/usageMetrics.repository.test.ts b/packages/cli/test/integration/usageMetrics.repository.test.ts new file mode 100644 index 0000000000..602536f12a --- /dev/null +++ b/packages/cli/test/integration/usageMetrics.repository.test.ts @@ -0,0 +1,78 @@ +import { UsageMetricsRepository } from '@/databases/repositories/usageMetrics.repository'; +import { createAdmin, createMember, createOwner, createUser } from './shared/db/users'; +import * as testDb from './shared/testDb'; +import Container from 'typedi'; +import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; +import { createManyWorkflows } from './shared/db/workflows'; +import { createManyCredentials } from './shared/db/credentials'; +import { WorkflowStatisticsRepository } from '@/databases/repositories/workflowStatistics.repository'; +import { StatisticsNames } from '@/databases/entities/WorkflowStatistics'; +import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; + +describe('UsageMetricsRepository', () => { + let usageMetricsRepository: UsageMetricsRepository; + let credentialsRepository: CredentialsRepository; + let workflowStatisticsRepository: WorkflowStatisticsRepository; + let workflowRepository: WorkflowRepository; + + beforeAll(async () => { + await testDb.init(); + + usageMetricsRepository = Container.get(UsageMetricsRepository); + credentialsRepository = Container.get(CredentialsRepository); + workflowStatisticsRepository = Container.get(WorkflowStatisticsRepository); + workflowRepository = Container.get(WorkflowRepository); + + await testDb.truncate(['User', 'Credentials', 'Workflow', 'Execution', 'WorkflowStatistics']); + }); + + afterAll(async () => { + await testDb.terminate(); + }); + + describe('getLicenseRenewalMetrics()', () => { + test('should return license renewal metrics', async () => { + const [firstWorkflow, secondWorkflow] = await createManyWorkflows(2, { active: false }); + + await Promise.all([ + createOwner(), + createAdmin(), + createMember(), + createMember(), + createUser({ disabled: true }), + createManyCredentials(2), + createManyWorkflows(3, { active: true }), + ]); + + await Promise.all([ + workflowStatisticsRepository.insertWorkflowStatistics( + StatisticsNames.productionSuccess, + firstWorkflow.id, + ), + workflowStatisticsRepository.insertWorkflowStatistics( + StatisticsNames.productionError, + firstWorkflow.id, + ), + workflowStatisticsRepository.insertWorkflowStatistics( + StatisticsNames.manualSuccess, + secondWorkflow.id, + ), + workflowStatisticsRepository.insertWorkflowStatistics( + StatisticsNames.manualError, + secondWorkflow.id, + ), + ]); + + const metrics = await usageMetricsRepository.getLicenseRenewalMetrics(); + + expect(metrics).toStrictEqual({ + enabledUsers: 4, + totalCredentials: 2, + totalWorkflows: 5, + activeWorkflows: 3, + productionExecutions: 2, + manualExecutions: 2, + }); + }); + }); +}); diff --git a/packages/cli/test/integration/role.repository.test.ts b/packages/cli/test/integration/user.repository.test.ts similarity index 60% rename from packages/cli/test/integration/role.repository.test.ts rename to packages/cli/test/integration/user.repository.test.ts index 04e645928d..6929326b95 100644 --- a/packages/cli/test/integration/role.repository.test.ts +++ b/packages/cli/test/integration/user.repository.test.ts @@ -1,15 +1,15 @@ +import Container from 'typedi'; +import { UserRepository } from '@db/repositories/user.repository'; 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; +describe('UserRepository', () => { + let userRepository: UserRepository; beforeAll(async () => { await testDb.init(); - roleRepository = Container.get(RoleRepository); + userRepository = Container.get(UserRepository); await testDb.truncate(['User']); }); @@ -29,9 +29,13 @@ describe('RoleRepository', () => { createMember(), ]); - const usersByRole = await roleRepository.countUsersByRole(); + const usersByRole = await userRepository.countUsersByRole(); - expect(usersByRole).toStrictEqual({ admin: 2, member: 3, owner: 1 }); + expect(usersByRole).toStrictEqual({ + 'global:admin': 2, + 'global:member': 3, + 'global:owner': 1, + }); }); }); }); diff --git a/packages/cli/test/integration/users.api.test.ts b/packages/cli/test/integration/users.api.test.ts index 94818e034e..fefb0161b1 100644 --- a/packages/cli/test/integration/users.api.test.ts +++ b/packages/cli/test/integration/users.api.test.ts @@ -1,12 +1,14 @@ import Container from 'typedi'; -import { UserRepository } from '@db/repositories/user.repository'; +import type { SuperAgentTest } from 'supertest'; import { UsersController } from '@/controllers/users.controller'; +import type { User } from '@db/entities/User'; +import { UserRepository } from '@db/repositories/user.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; +import { ExecutionService } from '@/executions/execution.service'; import { getCredentialById, saveCredential } from './shared/db/credentials'; -import { getCredentialOwnerRole, getWorkflowOwnerRole } from './shared/db/roles'; import { createAdmin, createMember, createOwner, getUserById } from './shared/db/users'; import { createWorkflow, getWorkflowById } from './shared/db/workflows'; import { SUCCESS_RESPONSE_BODY } from './shared/constants'; @@ -14,14 +16,9 @@ import { validateUser } from './shared/utils/users'; import { randomName } from './shared/random'; import * as utils from './shared/utils/'; import * as testDb from './shared/testDb'; - -import type { SuperAgentTest } from 'supertest'; -import type { Role } from '@db/entities/Role'; -import type { User } from '@db/entities/User'; -import { ExecutionsService } from '@/executions/executions.service'; import { mockInstance } from '../shared/mocking'; -mockInstance(ExecutionsService); +mockInstance(ExecutionService); const testServer = utils.setupTestServer({ endpointGroups: ['users'], @@ -216,7 +213,6 @@ describe('GET /users', () => { /** * Some list query options require auxiliary fields: * - * - `isOwner` requires `globalRole` * - `select` with `take` requires `id` (for pagination) */ test('should support options that require auxiliary fields', async () => { @@ -235,8 +231,6 @@ describe('DELETE /users/:id', () => { let owner: User; let member: User; let ownerAgent: SuperAgentTest; - let workflowOwnerRole: Role; - let credentialOwnerRole: Role; beforeAll(async () => { await testDb.truncate(['User']); @@ -244,9 +238,6 @@ describe('DELETE /users/:id', () => { owner = await createOwner(); member = await createMember(); ownerAgent = testServer.authAgentFor(owner); - - workflowOwnerRole = await getWorkflowOwnerRole(); - credentialOwnerRole = await getCredentialOwnerRole(); }); test('should delete user and their resources', async () => { @@ -254,7 +245,7 @@ describe('DELETE /users/:id', () => { const savedCredential = await saveCredential( { name: randomName(), type: '', data: {}, nodesAccess: [] }, - { user: member, role: credentialOwnerRole }, + { user: member, role: 'credential:owner' }, ); const response = await ownerAgent.delete(`/users/${member.id}`); @@ -266,12 +257,12 @@ describe('DELETE /users/:id', () => { const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ relations: ['user'], - where: { userId: member.id, roleId: workflowOwnerRole.id }, + where: { userId: member.id, role: 'workflow:owner' }, }); const sharedCredential = await Container.get(SharedCredentialsRepository).findOne({ relations: ['user'], - where: { userId: member.id, roleId: credentialOwnerRole.id }, + where: { userId: member.id, role: 'credential:owner' }, }); const workflow = await getWorkflowById(savedWorkflow.id); @@ -298,7 +289,7 @@ describe('DELETE /users/:id', () => { { name: randomName(), type: '', data: {}, nodesAccess: [] }, { user: member, - role: credentialOwnerRole, + role: 'credential:owner', }, ), ]); @@ -386,7 +377,7 @@ describe('PATCH /users/:id/role', () => { describe('unauthenticated user', () => { test('should receive 401', async () => { const response = await authlessAgent.patch(`/users/${member.id}/role`).send({ - newRoleName: 'admin', + newRoleName: 'global:admin', }); expect(response.statusCode).toBe(401); @@ -396,13 +387,13 @@ describe('PATCH /users/:id/role', () => { describe('Invalid payload should return 400 when newRoleName', () => { test.each([ ['is missing', {}], - ['is `owner`', { newRoleName: 'owner' }], - ['is an array', { newRoleName: ['owner'] }], + ['is `owner`', { newRoleName: 'global:owner' }], + ['is an array', { newRoleName: ['global:owner'] }], ])('%s', async (_, payload) => { const response = await adminAgent.patch(`/users/${member.id}/role`).send(payload); expect(response.statusCode).toBe(400); expect(response.body.message).toBe( - 'newRoleName must be one of the following values: member, admin', + 'newRoleName must be one of the following values: global:admin, global:member', ); }); }); @@ -410,7 +401,7 @@ describe('PATCH /users/:id/role', () => { describe('member', () => { test('should fail to demote owner to member', async () => { const response = await memberAgent.patch(`/users/${owner.id}/role`).send({ - newRoleName: 'member', + newRoleName: 'global:member', }); expect(response.statusCode).toBe(403); @@ -419,7 +410,7 @@ describe('PATCH /users/:id/role', () => { test('should fail to demote owner to admin', async () => { const response = await memberAgent.patch(`/users/${owner.id}/role`).send({ - newRoleName: 'admin', + newRoleName: 'global:admin', }); expect(response.statusCode).toBe(403); @@ -428,7 +419,7 @@ describe('PATCH /users/:id/role', () => { test('should fail to demote admin to member', async () => { const response = await memberAgent.patch(`/users/${admin.id}/role`).send({ - newRoleName: 'member', + newRoleName: 'global:member', }); expect(response.statusCode).toBe(403); @@ -437,7 +428,7 @@ describe('PATCH /users/:id/role', () => { test('should fail to promote other member to owner', async () => { const response = await memberAgent.patch(`/users/${otherMember.id}/role`).send({ - newRoleName: 'owner', + newRoleName: 'global:owner', }); expect(response.statusCode).toBe(403); @@ -446,7 +437,7 @@ describe('PATCH /users/:id/role', () => { test('should fail to promote other member to admin', async () => { const response = await memberAgent.patch(`/users/${otherMember.id}/role`).send({ - newRoleName: 'admin', + newRoleName: 'global:admin', }); expect(response.statusCode).toBe(403); @@ -455,7 +446,7 @@ describe('PATCH /users/:id/role', () => { test('should fail to promote self to admin', async () => { const response = await memberAgent.patch(`/users/${member.id}/role`).send({ - newRoleName: 'admin', + newRoleName: 'global:admin', }); expect(response.statusCode).toBe(403); @@ -464,7 +455,7 @@ describe('PATCH /users/:id/role', () => { test('should fail to promote self to owner', async () => { const response = await memberAgent.patch(`/users/${member.id}/role`).send({ - newRoleName: 'owner', + newRoleName: 'global:owner', }); expect(response.statusCode).toBe(403); @@ -477,7 +468,7 @@ describe('PATCH /users/:id/role', () => { const response = await adminAgent .patch('/users/c2317ff3-7a9f-4fd4-ad2b-7331f6359260/role') .send({ - newRoleName: 'member', + newRoleName: 'global:member', }); expect(response.statusCode).toBe(404); @@ -486,7 +477,7 @@ describe('PATCH /users/:id/role', () => { test('should fail to demote owner to admin', async () => { const response = await adminAgent.patch(`/users/${owner.id}/role`).send({ - newRoleName: 'admin', + newRoleName: 'global:admin', }); expect(response.statusCode).toBe(403); @@ -495,7 +486,7 @@ describe('PATCH /users/:id/role', () => { test('should fail to demote owner to member', async () => { const response = await adminAgent.patch(`/users/${owner.id}/role`).send({ - newRoleName: 'member', + newRoleName: 'global:member', }); expect(response.statusCode).toBe(403); @@ -506,7 +497,7 @@ describe('PATCH /users/:id/role', () => { testServer.license.disable('feat:advancedPermissions'); const response = await adminAgent.patch(`/users/${member.id}/role`).send({ - newRoleName: 'admin', + newRoleName: 'global:admin', }); expect(response.statusCode).toBe(403); @@ -515,7 +506,7 @@ describe('PATCH /users/:id/role', () => { test('should be able to demote admin to member', async () => { const response = await adminAgent.patch(`/users/${otherAdmin.id}/role`).send({ - newRoleName: 'member', + newRoleName: 'global:member', }); expect(response.statusCode).toBe(200); @@ -523,8 +514,7 @@ describe('PATCH /users/:id/role', () => { const user = await getUserById(otherAdmin.id); - expect(user.globalRole.scope).toBe('global'); - expect(user.globalRole.name).toBe('member'); + expect(user.role).toBe('global:member'); // restore other admin @@ -534,7 +524,7 @@ describe('PATCH /users/:id/role', () => { test('should be able to demote self to member', async () => { const response = await adminAgent.patch(`/users/${admin.id}/role`).send({ - newRoleName: 'member', + newRoleName: 'global:member', }); expect(response.statusCode).toBe(200); @@ -542,8 +532,7 @@ describe('PATCH /users/:id/role', () => { const user = await getUserById(admin.id); - expect(user.globalRole.scope).toBe('global'); - expect(user.globalRole.name).toBe('member'); + expect(user.role).toBe('global:member'); // restore admin @@ -553,7 +542,7 @@ describe('PATCH /users/:id/role', () => { test('should be able to promote member to admin if licensed', async () => { const response = await adminAgent.patch(`/users/${member.id}/role`).send({ - newRoleName: 'admin', + newRoleName: 'global:admin', }); expect(response.statusCode).toBe(200); @@ -561,8 +550,7 @@ describe('PATCH /users/:id/role', () => { const user = await getUserById(admin.id); - expect(user.globalRole.scope).toBe('global'); - expect(user.globalRole.name).toBe('admin'); + expect(user.role).toBe('global:admin'); // restore member @@ -574,7 +562,7 @@ describe('PATCH /users/:id/role', () => { describe('owner', () => { test('should fail to demote self to admin', async () => { const response = await ownerAgent.patch(`/users/${owner.id}/role`).send({ - newRoleName: 'admin', + newRoleName: 'global:admin', }); expect(response.statusCode).toBe(403); @@ -583,7 +571,7 @@ describe('PATCH /users/:id/role', () => { test('should fail to demote self to member', async () => { const response = await ownerAgent.patch(`/users/${owner.id}/role`).send({ - newRoleName: 'member', + newRoleName: 'global:member', }); expect(response.statusCode).toBe(403); @@ -594,7 +582,7 @@ describe('PATCH /users/:id/role', () => { testServer.license.disable('feat:advancedPermissions'); const response = await ownerAgent.patch(`/users/${member.id}/role`).send({ - newRoleName: 'admin', + newRoleName: 'global:admin', }); expect(response.statusCode).toBe(403); @@ -603,7 +591,7 @@ describe('PATCH /users/:id/role', () => { test('should be able to promote member to admin if licensed', async () => { const response = await ownerAgent.patch(`/users/${member.id}/role`).send({ - newRoleName: 'admin', + newRoleName: 'global:admin', }); expect(response.statusCode).toBe(200); @@ -611,8 +599,7 @@ describe('PATCH /users/:id/role', () => { const user = await getUserById(admin.id); - expect(user.globalRole.scope).toBe('global'); - expect(user.globalRole.name).toBe('admin'); + expect(user.role).toBe('global:admin'); // restore member @@ -622,7 +609,7 @@ describe('PATCH /users/:id/role', () => { test('should be able to demote admin to member', async () => { const response = await ownerAgent.patch(`/users/${admin.id}/role`).send({ - newRoleName: 'member', + newRoleName: 'global:member', }); expect(response.statusCode).toBe(200); @@ -630,8 +617,7 @@ describe('PATCH /users/:id/role', () => { const user = await getUserById(admin.id); - expect(user.globalRole.scope).toBe('global'); - expect(user.globalRole.name).toBe('member'); + expect(user.role).toBe('global:member'); // restore admin diff --git a/packages/cli/test/integration/variables.test.ts b/packages/cli/test/integration/variables.test.ts index ae8f03fe91..8b84f67dcf 100644 --- a/packages/cli/test/integration/variables.test.ts +++ b/packages/cli/test/integration/variables.test.ts @@ -26,7 +26,7 @@ async function createVariable(key: string, value: string) { } async function getVariableByKey(key: string) { - return Container.get(VariablesRepository).findOne({ + return await Container.get(VariablesRepository).findOne({ where: { key, }, @@ -34,7 +34,7 @@ async function getVariableByKey(key: string) { } async function getVariableById(id: string) { - return Container.get(VariablesRepository).findOne({ + return await Container.get(VariablesRepository).findOne({ where: { id, }, diff --git a/packages/cli/test/integration/workflowHistory.api.test.ts b/packages/cli/test/integration/workflowHistory.api.test.ts index b3beb574a3..1c4d1a9064 100644 --- a/packages/cli/test/integration/workflowHistory.api.test.ts +++ b/packages/cli/test/integration/workflowHistory.api.test.ts @@ -60,8 +60,9 @@ describe('GET /workflow-history/:workflowId', () => { const versions = await Promise.all( new Array(10) .fill(undefined) - .map(async (_, i) => - createWorkflowHistoryItem(workflow.id, { createdAt: new Date(Date.now() + i) }), + .map( + async (_, i) => + await createWorkflowHistoryItem(workflow.id, { createdAt: new Date(Date.now() + i) }), ), ); @@ -84,13 +85,14 @@ describe('GET /workflow-history/:workflowId', () => { const versions = await Promise.all( new Array(10) .fill(undefined) - .map(async (_, i) => - createWorkflowHistoryItem(workflow.id, { createdAt: new Date(Date.now() + i) }), + .map( + async (_, i) => + await createWorkflowHistoryItem(workflow.id, { createdAt: new Date(Date.now() + i) }), ), ); const versions2 = await Promise.all( - new Array(10).fill(undefined).map(async (_) => createWorkflowHistoryItem(workflow2.id)), + new Array(10).fill(undefined).map(async (_) => await createWorkflowHistoryItem(workflow2.id)), ); const last = versions.sort((a, b) => b.createdAt.valueOf() - a.createdAt.valueOf())[0]! as any; @@ -111,8 +113,9 @@ describe('GET /workflow-history/:workflowId', () => { const versions = await Promise.all( new Array(10) .fill(undefined) - .map(async (_, i) => - createWorkflowHistoryItem(workflow.id, { createdAt: new Date(Date.now() + i) }), + .map( + async (_, i) => + await createWorkflowHistoryItem(workflow.id, { createdAt: new Date(Date.now() + i) }), ), ); @@ -134,8 +137,9 @@ describe('GET /workflow-history/:workflowId', () => { const versions = await Promise.all( new Array(10) .fill(undefined) - .map(async (_, i) => - createWorkflowHistoryItem(workflow.id, { createdAt: new Date(Date.now() + i) }), + .map( + async (_, i) => + await createWorkflowHistoryItem(workflow.id, { createdAt: new Date(Date.now() + i) }), ), ); diff --git a/packages/cli/test/integration/workflowHistoryManager.test.ts b/packages/cli/test/integration/workflowHistoryManager.test.ts index fe8690efce..181c7c9266 100644 --- a/packages/cli/test/integration/workflowHistoryManager.test.ts +++ b/packages/cli/test/integration/workflowHistoryManager.test.ts @@ -102,7 +102,7 @@ describe('Workflow History Manager', () => { const createWorkflowHistory = async (ageInDays = 2) => { const workflow = await createWorkflow(); const time = DateTime.now().minus({ days: ageInDays }).toJSDate(); - return createManyWorkflowHistoryItems(workflow.id, 10, time); + return await createManyWorkflowHistoryItems(workflow.id, 10, time); }; const pruneAndAssertCount = async (finalCount = 10, initialCount = 10) => { diff --git a/packages/cli/test/integration/workflows/workflow.service.ee.test.ts b/packages/cli/test/integration/workflows/workflow.service.ee.test.ts new file mode 100644 index 0000000000..4cb823b7c0 --- /dev/null +++ b/packages/cli/test/integration/workflows/workflow.service.ee.test.ts @@ -0,0 +1,180 @@ +import Container from 'typedi'; +import { mock } from 'jest-mock-extended'; +import { CredentialsEntity } from '@db/entities/CredentialsEntity'; +import { CredentialsRepository } from '@db/repositories/credentials.repository'; +import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; +import { WorkflowRepository } from '@db/repositories/workflow.repository'; +import { Telemetry } from '@/telemetry'; +import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee'; + +import * as testDb from '../shared/testDb'; +import { mockInstance } from '../../shared/mocking'; +import { + FIRST_CREDENTIAL_ID, + SECOND_CREDENTIAL_ID, + THIRD_CREDENTIAL_ID, + getWorkflow, +} from '../shared/workflow'; + +describe('EnterpriseWorkflowService', () => { + let service: EnterpriseWorkflowService; + + beforeAll(async () => { + await testDb.init(); + mockInstance(Telemetry); + + service = new EnterpriseWorkflowService( + mock(), + Container.get(SharedWorkflowRepository), + Container.get(WorkflowRepository), + Container.get(CredentialsRepository), + ); + }); + + afterEach(async () => { + await testDb.truncate(['Workflow']); + jest.restoreAllMocks(); + }); + + afterAll(async () => { + await testDb.terminate(); + }); + + describe('validateWorkflowCredentialUsage', () => { + function generateCredentialEntity(credentialId: string) { + const credentialEntity = new CredentialsEntity(); + credentialEntity.id = credentialId; + return credentialEntity; + } + + it('Should throw error saving a workflow using credential without access', () => { + const newWorkflowVersion = getWorkflow({ addNodeWithOneCred: true }); + const previousWorkflowVersion = getWorkflow(); + expect(() => { + service.validateWorkflowCredentialUsage(newWorkflowVersion, previousWorkflowVersion, []); + }).toThrow(); + }); + + it('Should not throw error when saving a workflow using credential with access', () => { + const newWorkflowVersion = getWorkflow({ addNodeWithOneCred: true }); + const previousWorkflowVersion = getWorkflow(); + expect(() => { + service.validateWorkflowCredentialUsage(newWorkflowVersion, previousWorkflowVersion, [ + generateCredentialEntity('1'), + ]); + }).not.toThrow(); + }); + + it('Should not throw error when saving a workflow removing node without credential access', () => { + const newWorkflowVersion = getWorkflow(); + const previousWorkflowVersion = getWorkflow({ addNodeWithOneCred: true }); + expect(() => { + service.validateWorkflowCredentialUsage(newWorkflowVersion, previousWorkflowVersion, [ + generateCredentialEntity('1'), + ]); + }).not.toThrow(); + }); + + it('Should save fine when not making changes to workflow without access', () => { + const workflowWithOneCredential = getWorkflow({ addNodeWithOneCred: true }); + expect(() => { + service.validateWorkflowCredentialUsage( + workflowWithOneCredential, + workflowWithOneCredential, + [], + ); + }).not.toThrow(); + }); + + it('Should throw error saving a workflow adding node without credential access', () => { + const newWorkflowVersion = getWorkflow({ + addNodeWithOneCred: true, + addNodeWithTwoCreds: true, + }); + const previousWorkflowVersion = getWorkflow({ addNodeWithOneCred: true }); + expect(() => { + service.validateWorkflowCredentialUsage(newWorkflowVersion, previousWorkflowVersion, []); + }).toThrow(); + }); + }); + + describe('getNodesWithInaccessibleCreds', () => { + test('Should return an empty list for a workflow without nodes', () => { + const workflow = getWorkflow(); + const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, []); + expect(nodesWithInaccessibleCreds).toHaveLength(0); + }); + + test('Should return an empty list for a workflow with nodes without credentials', () => { + const workflow = getWorkflow({ addNodeWithoutCreds: true }); + const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, []); + expect(nodesWithInaccessibleCreds).toHaveLength(0); + }); + + test('Should return an element for a node with a credential without access', () => { + const workflow = getWorkflow({ addNodeWithOneCred: true }); + const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, []); + expect(nodesWithInaccessibleCreds).toHaveLength(1); + }); + + test('Should return an empty list for a node with a credential with access', () => { + const workflow = getWorkflow({ addNodeWithOneCred: true }); + const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, [ + FIRST_CREDENTIAL_ID, + ]); + expect(nodesWithInaccessibleCreds).toHaveLength(0); + }); + + test('Should return an element for a node with two credentials and mixed access', () => { + const workflow = getWorkflow({ addNodeWithTwoCreds: true }); + const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, [ + SECOND_CREDENTIAL_ID, + ]); + expect(nodesWithInaccessibleCreds).toHaveLength(1); + }); + + test('Should return one node for a workflow with two nodes and two credentials', () => { + const workflow = getWorkflow({ addNodeWithOneCred: true, addNodeWithTwoCreds: true }); + const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, [ + SECOND_CREDENTIAL_ID, + THIRD_CREDENTIAL_ID, + ]); + expect(nodesWithInaccessibleCreds).toHaveLength(1); + }); + + test('Should return one element for a workflows with two nodes and one credential', () => { + const workflow = getWorkflow({ + addNodeWithoutCreds: true, + addNodeWithOneCred: true, + addNodeWithTwoCreds: true, + }); + const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, [ + FIRST_CREDENTIAL_ID, + ]); + expect(nodesWithInaccessibleCreds).toHaveLength(1); + }); + + test('Should return one element for a workflows with two nodes and partial credential access', () => { + const workflow = getWorkflow({ addNodeWithOneCred: true, addNodeWithTwoCreds: true }); + const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, [ + FIRST_CREDENTIAL_ID, + SECOND_CREDENTIAL_ID, + ]); + expect(nodesWithInaccessibleCreds).toHaveLength(1); + }); + + test('Should return two elements for a workflows with two nodes and partial credential access', () => { + const workflow = getWorkflow({ addNodeWithOneCred: true, addNodeWithTwoCreds: true }); + const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, [ + SECOND_CREDENTIAL_ID, + ]); + expect(nodesWithInaccessibleCreds).toHaveLength(2); + }); + + test('Should return two elements for a workflows with two nodes and no credential access', () => { + const workflow = getWorkflow({ addNodeWithOneCred: true, addNodeWithTwoCreds: true }); + const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, []); + expect(nodesWithInaccessibleCreds).toHaveLength(2); + }); + }); +}); diff --git a/packages/cli/test/integration/workflow.service.test.ts b/packages/cli/test/integration/workflows/workflow.service.test.ts similarity index 81% rename from packages/cli/test/integration/workflow.service.test.ts rename to packages/cli/test/integration/workflows/workflow.service.test.ts index b98fb8ff74..056c11ca29 100644 --- a/packages/cli/test/integration/workflow.service.test.ts +++ b/packages/cli/test/integration/workflows/workflow.service.test.ts @@ -1,25 +1,26 @@ import Container from 'typedi'; -import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; -import * as testDb from './shared/testDb'; -import { WorkflowService } from '@/workflows/workflow.service'; -import { mockInstance } from '../shared/mocking'; -import { createOwner } from './shared/db/users'; -import { createWorkflow } from './shared/db/workflows'; -import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; import { mock } from 'jest-mock-extended'; -import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; +import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; +import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; +import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { Telemetry } from '@/telemetry'; -import { MultiMainSetup } from '@/services/orchestration/main/MultiMainSetup.ee'; +import { OrchestrationService } from '@/services/orchestration.service'; +import { WorkflowService } from '@/workflows/workflow.service'; + +import * as testDb from '../shared/testDb'; +import { mockInstance } from '../../shared/mocking'; +import { createOwner } from '../shared/db/users'; +import { createWorkflow } from '../shared/db/workflows'; let workflowService: WorkflowService; let activeWorkflowRunner: ActiveWorkflowRunner; -let multiMainSetup: MultiMainSetup; +let orchestrationService: OrchestrationService; beforeAll(async () => { await testDb.init(); activeWorkflowRunner = mockInstance(ActiveWorkflowRunner); - multiMainSetup = mockInstance(MultiMainSetup); + orchestrationService = mockInstance(OrchestrationService); mockInstance(Telemetry); workflowService = new WorkflowService( @@ -32,9 +33,7 @@ beforeAll(async () => { mock(), mock(), mock(), - multiMainSetup, - mock(), - mock(), + orchestrationService, mock(), activeWorkflowRunner, ); @@ -90,7 +89,7 @@ describe('update()', () => { const owner = await createOwner(); const workflow = await createWorkflow({ active: true }, owner); - const publishSpy = jest.spyOn(multiMainSetup, 'publish'); + const publishSpy = jest.spyOn(orchestrationService, 'publish'); workflow.active = false; await workflowService.update(owner, workflow, workflow.id); @@ -110,7 +109,7 @@ describe('update()', () => { const owner = await createOwner(); const workflow = await createWorkflow({ active: true }, owner); - const publishSpy = jest.spyOn(multiMainSetup, 'publish'); + const publishSpy = jest.spyOn(orchestrationService, 'publish'); await workflowService.update(owner, workflow, workflow.id); diff --git a/packages/cli/test/integration/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts similarity index 94% rename from packages/cli/test/integration/workflows.controller.ee.test.ts rename to packages/cli/test/integration/workflows/workflows.controller.ee.test.ts index 8ca0955442..222ef6ebf7 100644 --- a/packages/cli/test/integration/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts @@ -3,26 +3,24 @@ import type { SuperAgentTest } from 'supertest'; import { v4 as uuid } from 'uuid'; import type { INode } from 'n8n-workflow'; -import * as UserManagementHelpers from '@/UserManagement/UserManagementHelper'; import type { User } from '@db/entities/User'; -import { getSharedWorkflowIds } from '@/WorkflowHelpers'; import { WorkflowHistoryRepository } from '@db/repositories/workflowHistory.repository'; import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; - -import { mockInstance } from '../shared/mocking'; -import * as utils from './shared/utils/'; -import * as testDb from './shared/testDb'; -import type { SaveCredentialFunction } from './shared/types'; -import { makeWorkflow } from './shared/utils/'; -import { randomCredentialPayload } from './shared/random'; -import { affixRoleToSaveCredential, shareCredentialWithUsers } from './shared/db/credentials'; -import { getCredentialOwnerRole, getGlobalMemberRole, getGlobalOwnerRole } from './shared/db/roles'; -import { createUser } from './shared/db/users'; -import { createWorkflow, getWorkflowSharing, shareWorkflowWithUsers } from './shared/db/workflows'; -import type { Role } from '@/databases/entities/Role'; import { Push } from '@/push'; +import { WorkflowSharingService } from '@/workflows/workflowSharing.service'; + +import { mockInstance } from '../../shared/mocking'; +import * as utils from '../shared/utils/'; +import * as testDb from '../shared/testDb'; +import type { SaveCredentialFunction } from '../shared/types'; +import { makeWorkflow } from '../shared/utils/'; +import { randomCredentialPayload } from '../shared/random'; +import { affixRoleToSaveCredential, shareCredentialWithUsers } from '../shared/db/credentials'; +import { createUser } from '../shared/db/users'; +import { createWorkflow, getWorkflowSharing, shareWorkflowWithUsers } from '../shared/db/workflows'; +import { License } from '@/License'; +import { UserManagementMailer } from '@/UserManagement/email'; -let globalMemberRole: Role; let owner: User; let member: User; let anotherMember: User; @@ -31,41 +29,42 @@ let authMemberAgent: SuperAgentTest; let authAnotherMemberAgent: SuperAgentTest; let saveCredential: SaveCredentialFunction; -const activeWorkflowRunnerLike = mockInstance(ActiveWorkflowRunner); +const activeWorkflowRunner = mockInstance(ActiveWorkflowRunner); mockInstance(Push); -const sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true); +const sharingSpy = jest.spyOn(License.prototype, 'isSharingEnabled').mockReturnValue(true); const testServer = utils.setupTestServer({ endpointGroups: ['workflows'], enabledFeatures: ['feat:sharing'], }); const license = testServer.license; +const mailer = mockInstance(UserManagementMailer); beforeAll(async () => { - const globalOwnerRole = await getGlobalOwnerRole(); - globalMemberRole = await getGlobalMemberRole(); - const credentialOwnerRole = await getCredentialOwnerRole(); - - owner = await createUser({ globalRole: globalOwnerRole }); - member = await createUser({ globalRole: globalMemberRole }); - anotherMember = await createUser({ globalRole: globalMemberRole }); + owner = await createUser({ role: 'global:owner' }); + member = await createUser({ role: 'global:member' }); + anotherMember = await createUser({ role: 'global:member' }); authOwnerAgent = testServer.authAgentFor(owner); authMemberAgent = testServer.authAgentFor(member); authAnotherMemberAgent = testServer.authAgentFor(anotherMember); - saveCredential = affixRoleToSaveCredential(credentialOwnerRole); + saveCredential = affixRoleToSaveCredential('credential:owner'); await utils.initNodeTypes(); }); beforeEach(async () => { - activeWorkflowRunnerLike.add.mockReset(); - activeWorkflowRunnerLike.remove.mockReset(); + activeWorkflowRunner.add.mockReset(); + activeWorkflowRunner.remove.mockReset(); await testDb.truncate(['Workflow', 'SharedWorkflow', 'WorkflowHistory']); }); +afterEach(() => { + jest.clearAllMocks(); +}); + describe('router should switch based on flag', () => { let savedWorkflowId: string; @@ -103,6 +102,7 @@ describe('PUT /workflows/:id', () => { const sharedWorkflows = await getWorkflowSharing(workflow); expect(sharedWorkflows).toHaveLength(2); + expect(mailer.notifyWorkflowShared).toHaveBeenCalledTimes(1); }); test('PUT /workflows/:id/share should succeed when sharing with invalid user-id', async () => { @@ -129,6 +129,7 @@ describe('PUT /workflows/:id', () => { const sharedWorkflows = await getWorkflowSharing(workflow); expect(sharedWorkflows).toHaveLength(3); + expect(mailer.notifyWorkflowShared).toHaveBeenCalledTimes(1); }); test('PUT /workflows/:id/share should override sharing', async () => { @@ -150,6 +151,7 @@ describe('PUT /workflows/:id', () => { const secondSharedWorkflows = await getWorkflowSharing(workflow); expect(secondSharedWorkflows).toHaveLength(2); + expect(mailer.notifyWorkflowShared).toHaveBeenCalledTimes(1); }); test('PUT /workflows/:id/share should allow sharing by the owner of the workflow', async () => { @@ -163,6 +165,7 @@ describe('PUT /workflows/:id', () => { const sharedWorkflows = await getWorkflowSharing(workflow); expect(sharedWorkflows).toHaveLength(2); + expect(mailer.notifyWorkflowShared).toHaveBeenCalledTimes(1); }); test('PUT /workflows/:id/share should allow sharing by the instance owner', async () => { @@ -176,6 +179,7 @@ describe('PUT /workflows/:id', () => { const sharedWorkflows = await getWorkflowSharing(workflow); expect(sharedWorkflows).toHaveLength(2); + expect(mailer.notifyWorkflowShared).toHaveBeenCalledTimes(1); }); test('PUT /workflows/:id/share should not allow sharing by another shared member', async () => { @@ -191,6 +195,7 @@ describe('PUT /workflows/:id', () => { const sharedWorkflows = await getWorkflowSharing(workflow); expect(sharedWorkflows).toHaveLength(2); + expect(mailer.notifyWorkflowShared).toHaveBeenCalledTimes(0); }); test('PUT /workflows/:id/share should not allow sharing with self by another non-shared member', async () => { @@ -204,12 +209,13 @@ describe('PUT /workflows/:id', () => { const sharedWorkflows = await getWorkflowSharing(workflow); expect(sharedWorkflows).toHaveLength(1); + expect(mailer.notifyWorkflowShared).toHaveBeenCalledTimes(0); }); test('PUT /workflows/:id/share should not allow sharing by another non-shared member', async () => { const workflow = await createWorkflow({}, member); - const tempUser = await createUser({ globalRole: globalMemberRole }); + const tempUser = await createUser({ role: 'global:member' }); const response = await authAnotherMemberAgent .put(`/workflows/${workflow.id}/share`) @@ -219,6 +225,7 @@ describe('PUT /workflows/:id', () => { const sharedWorkflows = await getWorkflowSharing(workflow); expect(sharedWorkflows).toHaveLength(1); + expect(mailer.notifyWorkflowShared).toHaveBeenCalledTimes(0); }); }); @@ -985,23 +992,25 @@ describe('PATCH /workflows/:id - validate interim updates', () => { describe('getSharedWorkflowIds', () => { it('should show all workflows to owners', async () => { - owner.globalRole = await getGlobalOwnerRole(); + owner.role = 'global:owner'; const workflow1 = await createWorkflow({}, member); const workflow2 = await createWorkflow({}, anotherMember); - const sharedWorkflowIds = await getSharedWorkflowIds(owner); + const sharedWorkflowIds = + await Container.get(WorkflowSharingService).getSharedWorkflowIds(owner); expect(sharedWorkflowIds).toHaveLength(2); expect(sharedWorkflowIds).toContain(workflow1.id); expect(sharedWorkflowIds).toContain(workflow2.id); }); it('should show shared workflows to users', async () => { - member.globalRole = await getGlobalMemberRole(); + member.role = 'global:member'; const workflow1 = await createWorkflow({}, anotherMember); const workflow2 = await createWorkflow({}, anotherMember); const workflow3 = await createWorkflow({}, anotherMember); await shareWorkflowWithUsers(workflow1, [member]); await shareWorkflowWithUsers(workflow3, [member]); - const sharedWorkflowIds = await getSharedWorkflowIds(member); + const sharedWorkflowIds = + await Container.get(WorkflowSharingService).getSharedWorkflowIds(member); expect(sharedWorkflowIds).toHaveLength(2); expect(sharedWorkflowIds).toContain(workflow1.id); expect(sharedWorkflowIds).toContain(workflow3.id); @@ -1130,7 +1139,7 @@ describe('PATCH /workflows/:id - activate workflow', () => { const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); expect(response.statusCode).toBe(200); - expect(activeWorkflowRunnerLike.add).toBeCalled(); + expect(activeWorkflowRunner.add).toBeCalled(); const { data: { id, versionId, active }, @@ -1152,8 +1161,8 @@ describe('PATCH /workflows/:id - activate workflow', () => { const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); expect(response.statusCode).toBe(200); - expect(activeWorkflowRunnerLike.add).not.toBeCalled(); - expect(activeWorkflowRunnerLike.remove).toBeCalled(); + expect(activeWorkflowRunner.add).not.toBeCalled(); + expect(activeWorkflowRunner.remove).toBeCalled(); const { data: { id, versionId, active }, diff --git a/packages/cli/test/integration/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts similarity index 94% rename from packages/cli/test/integration/workflows.controller.test.ts rename to packages/cli/test/integration/workflows/workflows.controller.test.ts index a85dfee776..b02c351b4d 100644 --- a/packages/cli/test/integration/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -1,32 +1,32 @@ -import type { SuperAgentTest } from 'supertest'; -import type { INode, IPinData } from 'n8n-workflow'; -import * as UserManagementHelpers from '@/UserManagement/UserManagementHelper'; -import type { User } from '@db/entities/User'; -import { v4 as uuid } from 'uuid'; -import { RoleService } from '@/services/role.service'; import Container from 'typedi'; +import type { SuperAgentTest } from 'supertest'; +import { v4 as uuid } from 'uuid'; +import type { INode, IPinData } from 'n8n-workflow'; + +import type { User } from '@db/entities/User'; +import { WorkflowRepository } from '@db/repositories/workflow.repository'; +import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import type { ListQuery } from '@/requests'; import { WorkflowHistoryRepository } from '@db/repositories/workflowHistory.repository'; import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; - -import { mockInstance } from '../shared/mocking'; -import * as utils from './shared/utils/'; -import * as testDb from './shared/testDb'; -import { makeWorkflow, MOCK_PINDATA } from './shared/utils/'; -import { randomCredentialPayload } from './shared/random'; -import { saveCredential } from './shared/db/credentials'; -import { createOwner } from './shared/db/users'; -import { createWorkflow } from './shared/db/workflows'; -import { createTag } from './shared/db/tags'; import { Push } from '@/push'; import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee'; -import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; -import type { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; + +import { mockInstance } from '../../shared/mocking'; +import * as utils from '../shared/utils/'; +import * as testDb from '../shared/testDb'; +import { makeWorkflow, MOCK_PINDATA } from '../shared/utils/'; +import { randomCredentialPayload } from '../shared/random'; +import { saveCredential } from '../shared/db/credentials'; +import { createOwner } from '../shared/db/users'; +import { createWorkflow } from '../shared/db/workflows'; +import { createTag } from '../shared/db/tags'; +import { License } from '@/License'; let owner: User; let authOwnerAgent: SuperAgentTest; -jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(false); +jest.spyOn(License.prototype, 'isSharingEnabled').mockReturnValue(false); const testServer = utils.setupTestServer({ endpointGroups: ['workflows'] }); const license = testServer.license; @@ -179,7 +179,7 @@ describe('GET /workflows', () => { test('should return workflows', async () => { const credential = await saveCredential(randomCredentialPayload(), { user: owner, - role: await Container.get(RoleService).findCredentialOwnerRole(), + role: 'credential:owner', }); const nodes: INode[] = [ @@ -639,7 +639,7 @@ describe('POST /workflows/run', () => { const enterpriseWorkflowService = Container.get(EnterpriseWorkflowService); const workflowRepository = Container.get(WorkflowRepository); - sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled'); + sharingSpy = jest.spyOn(License.prototype, 'isSharingEnabled'); tamperingSpy = jest.spyOn(enterpriseWorkflowService, 'preventTampering'); workflow = workflowRepository.create({ id: uuid() }); }); diff --git a/packages/cli/test/setup-test-folder.ts b/packages/cli/test/setup-test-folder.ts index 07a8095373..4435df5ece 100644 --- a/packages/cli/test/setup-test-folder.ts +++ b/packages/cli/test/setup-test-folder.ts @@ -2,6 +2,8 @@ import { tmpdir } from 'os'; import { join } from 'path'; import { mkdirSync, mkdtempSync, writeFileSync } from 'fs'; +process.env.N8N_ENCRYPTION_KEY = 'test_key'; + const baseDir = join(tmpdir(), 'n8n-tests/'); mkdirSync(baseDir, { recursive: true }); @@ -11,6 +13,6 @@ process.env.N8N_USER_FOLDER = testDir; writeFileSync( join(testDir, '.n8n/config'), - JSON.stringify({ encryptionKey: 'testkey', instanceId: '123' }), + JSON.stringify({ encryptionKey: 'test_key', instanceId: '123' }), 'utf-8', ); diff --git a/packages/cli/test/teardown.ts b/packages/cli/test/teardown.ts index 339708d412..57ab933419 100644 --- a/packages/cli/test/teardown.ts +++ b/packages/cli/test/teardown.ts @@ -17,7 +17,9 @@ export default async () => { .filter(({ Database: dbName }) => dbName.startsWith(testDbPrefix)) .map(({ Database: dbName }) => dbName); - const promises = databases.map(async (dbName) => connection.query(`DROP DATABASE ${dbName};`)); + const promises = databases.map( + async (dbName) => await connection.query(`DROP DATABASE ${dbName};`), + ); await Promise.all(promises); await connection.destroy(); }; diff --git a/packages/cli/test/unit/ExternalSecrets/ExternalSecretsManager.test.ts b/packages/cli/test/unit/ExternalSecrets/ExternalSecretsManager.test.ts index b8a728d5f5..b77b33bd02 100644 --- a/packages/cli/test/unit/ExternalSecrets/ExternalSecretsManager.test.ts +++ b/packages/cli/test/unit/ExternalSecrets/ExternalSecretsManager.test.ts @@ -67,7 +67,7 @@ describe('External Secrets Manager', () => { mockProvidersInstance.setProviders({ dummy: ErrorProvider, }); - expect(async () => manager!.init()).not.toThrow(); + expect(async () => await manager!.init()).not.toThrow(); }); test('should not throw errors during shutdown', async () => { diff --git a/packages/cli/test/unit/Helpers.ts b/packages/cli/test/unit/Helpers.ts index 2504f1dfcf..50b9f43489 100644 --- a/packages/cli/test/unit/Helpers.ts +++ b/packages/cli/test/unit/Helpers.ts @@ -5,7 +5,7 @@ import type { INodeTypeData } from 'n8n-workflow'; * the macrotask queue and so called at the next iteration of the event loop * after all promises in the microtask queue have settled first. */ -export const flushPromises = async () => new Promise(setImmediate); +export const flushPromises = async () => await new Promise(setImmediate); export function mockNodeTypesData( nodeNames: string[], diff --git a/packages/cli/test/unit/License.test.ts b/packages/cli/test/unit/License.test.ts index 3079695d46..354e672b0b 100644 --- a/packages/cli/test/unit/License.test.ts +++ b/packages/cli/test/unit/License.test.ts @@ -6,7 +6,7 @@ import { License } from '@/License'; import { Logger } from '@/Logger'; import { N8N_VERSION } from '@/constants'; import { mockInstance } from '../shared/mocking'; -import { MultiMainSetup } from '@/services/orchestration/main/MultiMainSetup.ee'; +import { OrchestrationService } from '@/services/orchestration.service'; jest.mock('@n8n_io/license-sdk'); @@ -28,7 +28,7 @@ describe('License', () => { let license: License; const logger = mockInstance(Logger); const instanceSettings = mockInstance(InstanceSettings, { instanceId: MOCK_INSTANCE_ID }); - mockInstance(MultiMainSetup); + mockInstance(OrchestrationService); beforeEach(async () => { license = new License(logger, instanceSettings, mock(), mock(), mock()); diff --git a/packages/cli/test/unit/PermissionChecker.test.ts b/packages/cli/test/unit/PermissionChecker.test.ts index d411db0ea3..5eb4b6e0ea 100644 --- a/packages/cli/test/unit/PermissionChecker.test.ts +++ b/packages/cli/test/unit/PermissionChecker.test.ts @@ -1,15 +1,19 @@ import { v4 as uuid } from 'uuid'; import { Container } from 'typedi'; -import type { INodeTypes, WorkflowSettings } from 'n8n-workflow'; +import type { WorkflowSettings } from 'n8n-workflow'; import { SubworkflowOperationError, Workflow } from 'n8n-workflow'; import config from '@/config'; -import type { Role } from '@db/entities/Role'; import { User } from '@db/entities/User'; +import { WorkflowRepository } from '@db/repositories/workflow.repository'; +import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; +import { UserRepository } from '@/databases/repositories/user.repository'; +import { generateNanoId } from '@/databases/utils/generators'; +import { License } from '@/License'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { NodeTypes } from '@/NodeTypes'; -import { PermissionChecker } from '@/UserManagement/PermissionChecker'; import { OwnershipService } from '@/services/ownership.service'; +import { PermissionChecker } from '@/UserManagement/PermissionChecker'; import { mockInstance } from '../shared/mocking'; import { @@ -17,18 +21,12 @@ import { randomName, randomPositiveDigit, } from '../integration/shared/random'; +import { LicenseMocker } from '../integration/shared/license'; import * as testDb from '../integration/shared/testDb'; import type { SaveCredentialFunction } from '../integration/shared/types'; import { mockNodeTypesData } from './Helpers'; import { affixRoleToSaveCredential } from '../integration/shared/db/credentials'; -import { getCredentialOwnerRole, getWorkflowOwnerRole } from '../integration/shared/db/roles'; import { createOwner, createUser } from '../integration/shared/db/users'; -import { WorkflowRepository } from '@db/repositories/workflow.repository'; -import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; -import { UserRepository } from '@/databases/repositories/user.repository'; -import { LicenseMocker } from '../integration/shared/license'; -import { License } from '@/License'; -import { generateNanoId } from '@/databases/utils/generators'; export const toTargetCallErrorMsg = (subworkflowId: string) => `Target workflow ID ${subworkflowId} may not be called`; @@ -71,24 +69,21 @@ export function createSubworkflow({ }); } -let mockNodeTypes: INodeTypes; -let credentialOwnerRole: Role; -let workflowOwnerRole: Role; let saveCredential: SaveCredentialFunction; +const mockNodeTypes = mockInstance(NodeTypes); mockInstance(LoadNodesAndCredentials, { loadedNodes: mockNodeTypesData(['start', 'actionNetwork']), }); +let permissionChecker: PermissionChecker; + beforeAll(async () => { await testDb.init(); - mockNodeTypes = Container.get(NodeTypes); + saveCredential = affixRoleToSaveCredential('credential:owner'); - credentialOwnerRole = await getCredentialOwnerRole(); - workflowOwnerRole = await getWorkflowOwnerRole(); - - saveCredential = affixRoleToSaveCredential(credentialOwnerRole); + permissionChecker = Container.get(PermissionChecker); }); describe('check()', () => { @@ -121,7 +116,7 @@ describe('check()', () => { ], }); - expect(async () => PermissionChecker.check(workflow, userId)).not.toThrow(); + expect(async () => await permissionChecker.check(workflow, userId)).not.toThrow(); }); test('should allow if requesting user is instance owner', async () => { @@ -151,7 +146,7 @@ describe('check()', () => { ], }); - expect(async () => PermissionChecker.check(workflow, owner.id)).not.toThrow(); + expect(async () => await permissionChecker.check(workflow, owner.id)).not.toThrow(); }); test('should allow if workflow creds are valid subset', async () => { @@ -198,7 +193,7 @@ describe('check()', () => { ], }); - expect(async () => PermissionChecker.check(workflow, owner.id)).not.toThrow(); + expect(async () => await permissionChecker.check(workflow, owner.id)).not.toThrow(); }); test('should deny if workflow creds are not valid subset', async () => { @@ -249,12 +244,12 @@ describe('check()', () => { await Container.get(SharedWorkflowRepository).save({ workflow: workflowEntity, user: member, - role: workflowOwnerRole, + role: 'workflow:owner', }); const workflow = new Workflow(workflowDetails); - await expect(PermissionChecker.check(workflow, member.id)).rejects.toThrow(); + await expect(permissionChecker.check(workflow, member.id)).rejects.toThrow(); }); }); @@ -278,7 +273,7 @@ describe('checkSubworkflowExecutePolicy()', () => { ownershipService.getWorkflowOwnerCached.mockResolvedValue(new User()); - const check = PermissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id); + const check = permissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id); await expect(check).rejects.toThrow(toTargetCallErrorMsg(subworkflow.id)); @@ -299,12 +294,12 @@ describe('checkSubworkflowExecutePolicy()', () => { ownershipService.getWorkflowOwnerCached.mockResolvedValueOnce(firstUser); // parent workflow ownershipService.getWorkflowOwnerCached.mockResolvedValueOnce(secondUser); // subworkflow - const check = PermissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id); + const check = permissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id); await expect(check).rejects.toThrow(toTargetCallErrorMsg(subworkflow.id)); try { - await PermissionChecker.checkSubworkflowExecutePolicy(subworkflow, uuid()); + await permissionChecker.checkSubworkflowExecutePolicy(subworkflow, uuid()); } catch (error) { if (error instanceof SubworkflowOperationError) { expect(error.description).toBe( @@ -326,7 +321,7 @@ describe('checkSubworkflowExecutePolicy()', () => { callerIds: `123,456,bcdef, ${parentWorkflow.id}`, }); - const check = PermissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id); + const check = permissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id); await expect(check).resolves.not.toThrow(); }); @@ -339,7 +334,7 @@ describe('checkSubworkflowExecutePolicy()', () => { callerIds: 'xyz', }); - const check = PermissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id); + const check = permissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id); await expect(check).rejects.toThrow(); }); @@ -351,7 +346,7 @@ describe('checkSubworkflowExecutePolicy()', () => { const subworkflow = createSubworkflow({ policy: 'any' }); ownershipService.getWorkflowOwnerCached.mockResolvedValue(new User()); - const check = PermissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id); + const check = permissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id); await expect(check).resolves.not.toThrow(); }); @@ -367,7 +362,7 @@ describe('checkSubworkflowExecutePolicy()', () => { const subworkflow = createSubworkflow({ policy: 'workflowsFromSameOwner' }); - const check = PermissionChecker.checkSubworkflowExecutePolicy(subworkflow, uuid()); + const check = permissionChecker.checkSubworkflowExecutePolicy(subworkflow, uuid()); await expect(check).rejects.toThrow(toTargetCallErrorMsg(subworkflow.id)); }); @@ -382,7 +377,7 @@ describe('checkSubworkflowExecutePolicy()', () => { const subworkflow = createSubworkflow({ policy: 'workflowsFromSameOwner' }); - const check = PermissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id); + const check = permissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id); await expect(check).resolves.not.toThrow(); }); diff --git a/packages/cli/test/unit/UserManagementMailer.test.ts b/packages/cli/test/unit/UserManagementMailer.test.ts index 06ed1ab12d..8e9de8d3a9 100644 --- a/packages/cli/test/unit/UserManagementMailer.test.ts +++ b/packages/cli/test/unit/UserManagementMailer.test.ts @@ -22,7 +22,7 @@ describe('UserManagementMailer', () => { test('not be called when SMTP not set up', async () => { const userManagementMailer = new UserManagementMailer(); // NodeMailer.verifyConnection gets called only explicitly - await expect(async () => userManagementMailer.verifyConnection()).rejects.toThrow(); + await expect(async () => await userManagementMailer.verifyConnection()).rejects.toThrow(); expect(NodeMailer.prototype.verifyConnection).toHaveBeenCalledTimes(0); }); @@ -34,7 +34,7 @@ describe('UserManagementMailer', () => { const userManagementMailer = new UserManagementMailer(); // NodeMailer.verifyConnection gets called only explicitly - expect(async () => userManagementMailer.verifyConnection()).not.toThrow(); + expect(async () => await userManagementMailer.verifyConnection()).not.toThrow(); }); }); }); diff --git a/packages/cli/test/unit/WorkflowHelpers.test.ts b/packages/cli/test/unit/WorkflowHelpers.test.ts index 54f8b6e912..998e2b8b42 100644 --- a/packages/cli/test/unit/WorkflowHelpers.test.ts +++ b/packages/cli/test/unit/WorkflowHelpers.test.ts @@ -1,150 +1,8 @@ -import type { INode } from 'n8n-workflow'; import { type Workflow } from 'n8n-workflow'; -import { WorkflowEntity } from '@db/entities/WorkflowEntity'; -import { CredentialsEntity } from '@db/entities/CredentialsEntity'; -import { - getExecutionStartNode, - getNodesWithInaccessibleCreds, - validateWorkflowCredentialUsage, -} from '@/WorkflowHelpers'; +import { getExecutionStartNode } from '@/WorkflowHelpers'; import type { IWorkflowExecutionDataProcess } from '@/Interfaces'; -const FIRST_CREDENTIAL_ID = '1'; -const SECOND_CREDENTIAL_ID = '2'; -const THIRD_CREDENTIAL_ID = '3'; - -const NODE_WITH_NO_CRED = '0133467b-df4a-473d-9295-fdd9d01fa45a'; -const NODE_WITH_ONE_CRED = '4673f869-f2dc-4a33-b053-ca3193bc5226'; -const NODE_WITH_TWO_CRED = '9b4208bd-8f10-4a6a-ad3b-da47a326f7da'; - describe('WorkflowHelpers', () => { - describe('getNodesWithInaccessibleCreds', () => { - test('Should return an empty list for a workflow without nodes', () => { - const workflow = getWorkflow(); - const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, []); - expect(nodesWithInaccessibleCreds).toHaveLength(0); - }); - - test('Should return an empty list for a workflow with nodes without credentials', () => { - const workflow = getWorkflow({ addNodeWithoutCreds: true }); - const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, []); - expect(nodesWithInaccessibleCreds).toHaveLength(0); - }); - - test('Should return an element for a node with a credential without access', () => { - const workflow = getWorkflow({ addNodeWithOneCred: true }); - const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, []); - expect(nodesWithInaccessibleCreds).toHaveLength(1); - }); - - test('Should return an empty list for a node with a credential with access', () => { - const workflow = getWorkflow({ addNodeWithOneCred: true }); - const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, [ - FIRST_CREDENTIAL_ID, - ]); - expect(nodesWithInaccessibleCreds).toHaveLength(0); - }); - - test('Should return an element for a node with two credentials and mixed access', () => { - const workflow = getWorkflow({ addNodeWithTwoCreds: true }); - const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, [ - SECOND_CREDENTIAL_ID, - ]); - expect(nodesWithInaccessibleCreds).toHaveLength(1); - }); - - test('Should return one node for a workflow with two nodes and two credentials', () => { - const workflow = getWorkflow({ addNodeWithOneCred: true, addNodeWithTwoCreds: true }); - const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, [ - SECOND_CREDENTIAL_ID, - THIRD_CREDENTIAL_ID, - ]); - expect(nodesWithInaccessibleCreds).toHaveLength(1); - }); - - test('Should return one element for a workflows with two nodes and one credential', () => { - const workflow = getWorkflow({ - addNodeWithoutCreds: true, - addNodeWithOneCred: true, - addNodeWithTwoCreds: true, - }); - const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, [ - FIRST_CREDENTIAL_ID, - ]); - expect(nodesWithInaccessibleCreds).toHaveLength(1); - }); - - test('Should return one element for a workflows with two nodes and partial credential access', () => { - const workflow = getWorkflow({ addNodeWithOneCred: true, addNodeWithTwoCreds: true }); - const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, [ - FIRST_CREDENTIAL_ID, - SECOND_CREDENTIAL_ID, - ]); - expect(nodesWithInaccessibleCreds).toHaveLength(1); - }); - - test('Should return two elements for a workflows with two nodes and partial credential access', () => { - const workflow = getWorkflow({ addNodeWithOneCred: true, addNodeWithTwoCreds: true }); - const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, [ - SECOND_CREDENTIAL_ID, - ]); - expect(nodesWithInaccessibleCreds).toHaveLength(2); - }); - - test('Should return two elements for a workflows with two nodes and no credential access', () => { - const workflow = getWorkflow({ addNodeWithOneCred: true, addNodeWithTwoCreds: true }); - const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, []); - expect(nodesWithInaccessibleCreds).toHaveLength(2); - }); - }); - - describe('validateWorkflowCredentialUsage', () => { - it('Should throw error saving a workflow using credential without access', () => { - const newWorkflowVersion = getWorkflow({ addNodeWithOneCred: true }); - const previousWorkflowVersion = getWorkflow(); - expect(() => { - validateWorkflowCredentialUsage(newWorkflowVersion, previousWorkflowVersion, []); - }).toThrow(); - }); - - it('Should not throw error when saving a workflow using credential with access', () => { - const newWorkflowVersion = getWorkflow({ addNodeWithOneCred: true }); - const previousWorkflowVersion = getWorkflow(); - expect(() => { - validateWorkflowCredentialUsage(newWorkflowVersion, previousWorkflowVersion, [ - generateCredentialEntity(FIRST_CREDENTIAL_ID), - ]); - }).not.toThrow(); - }); - - it('Should not throw error when saving a workflow removing node without credential access', () => { - const newWorkflowVersion = getWorkflow(); - const previousWorkflowVersion = getWorkflow({ addNodeWithOneCred: true }); - expect(() => { - validateWorkflowCredentialUsage(newWorkflowVersion, previousWorkflowVersion, [ - generateCredentialEntity(FIRST_CREDENTIAL_ID), - ]); - }).not.toThrow(); - }); - - it('Should save fine when not making changes to workflow without access', () => { - const workflowWithOneCredential = getWorkflow({ addNodeWithOneCred: true }); - expect(() => { - validateWorkflowCredentialUsage(workflowWithOneCredential, workflowWithOneCredential, []); - }).not.toThrow(); - }); - - it('Should throw error saving a workflow adding node without credential access', () => { - const newWorkflowVersion = getWorkflow({ - addNodeWithOneCred: true, - addNodeWithTwoCreds: true, - }); - const previousWorkflowVersion = getWorkflow({ addNodeWithOneCred: true }); - expect(() => { - validateWorkflowCredentialUsage(newWorkflowVersion, previousWorkflowVersion, []); - }).toThrow(); - }); - }); describe('getExecutionStartNode', () => { it('Should return undefined', () => { const data = { @@ -186,77 +44,3 @@ describe('WorkflowHelpers', () => { }); }); }); - -function generateCredentialEntity(credentialId: string) { - const credentialEntity = new CredentialsEntity(); - credentialEntity.id = credentialId; - return credentialEntity; -} - -export function getWorkflow(options?: { - addNodeWithoutCreds?: boolean; - addNodeWithOneCred?: boolean; - addNodeWithTwoCreds?: boolean; -}) { - const workflow = new WorkflowEntity(); - - workflow.nodes = []; - - if (options?.addNodeWithoutCreds) { - workflow.nodes.push(nodeWithNoCredentials); - } - - if (options?.addNodeWithOneCred) { - workflow.nodes.push(nodeWithOneCredential); - } - - if (options?.addNodeWithTwoCreds) { - workflow.nodes.push(nodeWithTwoCredentials); - } - - return workflow; -} - -const nodeWithNoCredentials: INode = { - id: NODE_WITH_NO_CRED, - name: 'Node with no Credential', - typeVersion: 1, - type: 'n8n-nodes-base.fakeNode', - position: [0, 0], - credentials: {}, - parameters: {}, -}; - -const nodeWithOneCredential: INode = { - id: NODE_WITH_ONE_CRED, - name: 'Node with a single credential', - typeVersion: 1, - type: '', - position: [0, 0], - credentials: { - test: { - id: FIRST_CREDENTIAL_ID, - name: 'First fake credential', - }, - }, - parameters: {}, -}; - -const nodeWithTwoCredentials: INode = { - id: NODE_WITH_TWO_CRED, - name: 'Node with two credentials', - typeVersion: 1, - type: '', - position: [0, 0], - credentials: { - mcTest: { - id: SECOND_CREDENTIAL_ID, - name: 'Second fake credential', - }, - mcTest2: { - id: THIRD_CREDENTIAL_ID, - name: 'Third fake credential', - }, - }, - parameters: {}, -}; diff --git a/packages/cli/test/unit/WorkflowRunner.test.ts b/packages/cli/test/unit/WorkflowRunner.test.ts index 876ae1ac1c..0317793459 100644 --- a/packages/cli/test/unit/WorkflowRunner.test.ts +++ b/packages/cli/test/unit/WorkflowRunner.test.ts @@ -8,7 +8,6 @@ import config from '@/config'; import { mockInstance } from '../shared/mocking'; import * as testDb from '../integration/shared/testDb'; import { setupTestServer } from '../integration/shared/utils'; -import { getGlobalOwnerRole } from '../integration/shared/db/roles'; import { createUser } from '../integration/shared/db/users'; import { createWorkflow } from '../integration/shared/db/workflows'; import { createExecution } from '../integration/shared/db/executions'; @@ -25,8 +24,7 @@ const watchers = new Watchers(); const watchedWorkflowExecuteAfter = jest.spyOn(watchers, 'workflowExecuteAfter'); beforeAll(async () => { - const globalOwnerRole = await getGlobalOwnerRole(); - owner = await createUser({ globalRole: globalOwnerRole }); + owner = await createUser({ role: 'global:owner' }); mockInstance(Push); Container.set(Push, new Push()); diff --git a/packages/cli/test/unit/active-execution.service.test.ts b/packages/cli/test/unit/active-execution.service.test.ts new file mode 100644 index 0000000000..60a8fa48cf --- /dev/null +++ b/packages/cli/test/unit/active-execution.service.test.ts @@ -0,0 +1,127 @@ +import { mock, mockFn } from 'jest-mock-extended'; +import { ActiveExecutionService } from '@/executions/active-execution.service'; +import config from '@/config'; +import type { ExecutionRepository } from '@db/repositories/execution.repository'; +import type { ActiveExecutions } from '@/ActiveExecutions'; +import type { Job, Queue } from '@/Queue'; +import type { IExecutionBase, IExecutionsCurrentSummary } from '@/Interfaces'; +import type { WaitTracker } from '@/WaitTracker'; + +describe('ActiveExecutionsService', () => { + const queue = mock(); + const activeExecutions = mock(); + const executionRepository = mock(); + const waitTracker = mock(); + + const jobIds = ['j1', 'j2']; + const jobs = jobIds.map((executionId) => mock({ data: { executionId } })); + + const activeExecutionService = new ActiveExecutionService( + mock(), + queue, + activeExecutions, + executionRepository, + waitTracker, + ); + + const getEnv = mockFn<(typeof config)['getEnv']>(); + config.getEnv = getEnv; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('stop()', () => { + describe('in regular mode', () => { + getEnv.calledWith('executions.mode').mockReturnValue('regular'); + + it('should call `ActiveExecutions.stopExecution()`', async () => { + const execution = mock({ id: '123' }); + + await activeExecutionService.stop(execution); + + expect(activeExecutions.stopExecution).toHaveBeenCalledWith(execution.id); + }); + + it('should call `WaitTracker.stopExecution()` if `ActiveExecutions.stopExecution()` found no execution', async () => { + activeExecutions.stopExecution.mockResolvedValue(undefined); + const execution = mock({ id: '123' }); + + await activeExecutionService.stop(execution); + + expect(waitTracker.stopExecution).toHaveBeenCalledWith(execution.id); + }); + }); + + describe('in queue mode', () => { + it('should call `ActiveExecutions.stopExecution()`', async () => { + const execution = mock({ id: '123' }); + + await activeExecutionService.stop(execution); + + expect(activeExecutions.stopExecution).toHaveBeenCalledWith(execution.id); + }); + + it('should call `WaitTracker.stopExecution` if `ActiveExecutions.stopExecution()` found no execution', async () => { + activeExecutions.stopExecution.mockResolvedValue(undefined); + const execution = mock({ id: '123' }); + + await activeExecutionService.stop(execution); + + expect(waitTracker.stopExecution).toHaveBeenCalledWith(execution.id); + }); + }); + }); + + describe('findManyInQueueMode()', () => { + it('should query for active jobs, waiting jobs, and in-memory executions', async () => { + const sharedWorkflowIds = ['123']; + const filter = {}; + const executionIds = ['e1', 'e2']; + const summaries = executionIds.map((e) => mock({ id: e })); + + activeExecutions.getActiveExecutions.mockReturnValue(summaries); + queue.getJobs.mockResolvedValue(jobs); + executionRepository.findMultipleExecutions.mockResolvedValue([]); + executionRepository.getManyActive.mockResolvedValue([]); + + await activeExecutionService.findManyInQueueMode(filter, sharedWorkflowIds); + + expect(queue.getJobs).toHaveBeenCalledWith(['active', 'waiting']); + + expect(executionRepository.getManyActive).toHaveBeenCalledWith( + jobIds.concat(executionIds), + sharedWorkflowIds, + filter, + ); + }); + }); + + describe('findManyInRegularMode()', () => { + it('should return summaries of in-memory executions', async () => { + const sharedWorkflowIds = ['123']; + const filter = {}; + const executionIds = ['e1', 'e2']; + const summaries = executionIds.map((e) => + mock({ id: e, workflowId: '123', status: 'running' }), + ); + + activeExecutions.getActiveExecutions.mockReturnValue(summaries); + + const result = await activeExecutionService.findManyInRegularMode(filter, sharedWorkflowIds); + + expect(result).toEqual([ + expect.objectContaining({ + id: 'e1', + workflowId: '123', + status: 'running', + }), + expect.objectContaining({ + id: 'e2', + workflowId: '123', + status: 'running', + }), + ]); + }); + }); +}); diff --git a/packages/cli/test/unit/auth/jwt.test.ts b/packages/cli/test/unit/auth/jwt.test.ts new file mode 100644 index 0000000000..dd9c65116a --- /dev/null +++ b/packages/cli/test/unit/auth/jwt.test.ts @@ -0,0 +1,61 @@ +import { Container } from 'typedi'; +import { mock } from 'jest-mock-extended'; + +import config from '@/config'; +import { JwtService } from '@/services/jwt.service'; +import { License } from '@/License'; +import { Time } from '@/constants'; +import { issueJWT } from '@/auth/jwt'; + +import { mockInstance } from '../../shared/mocking'; + +import type { User } from '@db/entities/User'; + +mockInstance(License); + +describe('jwt.issueJWT', () => { + const jwtService = Container.get(JwtService); + + describe('when not setting userManagement.jwtSessionDuration', () => { + it('should default to expire in 7 days', () => { + const defaultInSeconds = 7 * Time.days.toSeconds; + const mockUser = mock({ password: 'passwordHash' }); + const { token, expiresIn } = issueJWT(mockUser); + + expect(expiresIn).toBe(defaultInSeconds); + const decodedToken = jwtService.verify(token); + if (decodedToken.exp === undefined || decodedToken.iat === undefined) { + fail('Expected exp and iat to be defined'); + } + + expect(decodedToken.exp - decodedToken.iat).toBe(defaultInSeconds); + }); + }); + + describe('when setting userManagement.jwtSessionDuration', () => { + const oldDuration = config.get('userManagement.jwtSessionDurationHours'); + const testDurationHours = 1; + const testDurationSeconds = testDurationHours * Time.hours.toSeconds; + + beforeEach(() => { + mockInstance(License); + config.set('userManagement.jwtSessionDurationHours', testDurationHours); + }); + + afterEach(() => { + config.set('userManagement.jwtSessionDuration', oldDuration); + }); + + it('should apply it to tokens', () => { + const mockUser = mock({ password: 'passwordHash' }); + const { token, expiresIn } = issueJWT(mockUser); + + expect(expiresIn).toBe(testDurationSeconds); + const decodedToken = jwtService.verify(token); + if (decodedToken.exp === undefined || decodedToken.iat === undefined) { + fail('Expected exp and iat to be defined on decodedToken'); + } + expect(decodedToken.exp - decodedToken.iat).toBe(testDurationSeconds); + }); + }); +}); diff --git a/packages/cli/test/unit/config/index.test.ts b/packages/cli/test/unit/config/index.test.ts new file mode 100644 index 0000000000..d49a4e0a3c --- /dev/null +++ b/packages/cli/test/unit/config/index.test.ts @@ -0,0 +1,9 @@ +describe('userManagement.jwtRefreshTimeoutHours', () => { + it("resets jwtRefreshTimeoutHours to 0 if it's greater than or equal to jwtSessionDurationHours", async () => { + process.env.N8N_USER_MANAGEMENT_JWT_DURATION_HOURS = '1'; + process.env.N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS = '1'; + const { default: config } = await import('@/config'); + + expect(config.getEnv('userManagement.jwtRefreshTimeoutHours')).toBe(0); + }); +}); diff --git a/packages/cli/test/unit/controllers/executions.controller.test.ts b/packages/cli/test/unit/controllers/executions.controller.test.ts new file mode 100644 index 0000000000..04263ea2c8 --- /dev/null +++ b/packages/cli/test/unit/controllers/executions.controller.test.ts @@ -0,0 +1,94 @@ +import { mock, mockFn } from 'jest-mock-extended'; +import config from '@/config'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ExecutionsController } from '@/executions/executions.controller'; +import { License } from '@/License'; +import { mockInstance } from '../../shared/mocking'; +import type { IExecutionBase } from '@/Interfaces'; +import type { ActiveExecutionService } from '@/executions/active-execution.service'; +import type { ExecutionRequest } from '@/executions/execution.types'; +import type { WorkflowSharingService } from '@/workflows/workflowSharing.service'; + +describe('ExecutionsController', () => { + const getEnv = mockFn<(typeof config)['getEnv']>(); + config.getEnv = getEnv; + + mockInstance(License); + const activeExecutionService = mock(); + const workflowSharingService = mock(); + + const req = mock({ query: { filter: '{}' } }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getActive()', () => { + workflowSharingService.getSharedWorkflowIds.mockResolvedValue(['123']); + + it('should call `ActiveExecutionService.findManyInQueueMode()`', async () => { + getEnv.calledWith('executions.mode').mockReturnValue('queue'); + + await new ExecutionsController( + mock(), + mock(), + workflowSharingService, + activeExecutionService, + mock(), + ).getActive(req); + + expect(activeExecutionService.findManyInQueueMode).toHaveBeenCalled(); + expect(activeExecutionService.findManyInRegularMode).not.toHaveBeenCalled(); + }); + + it('should call `ActiveExecutionService.findManyInRegularMode()`', async () => { + getEnv.calledWith('executions.mode').mockReturnValue('regular'); + + await new ExecutionsController( + mock(), + mock(), + workflowSharingService, + activeExecutionService, + mock(), + ).getActive(req); + + expect(activeExecutionService.findManyInQueueMode).not.toHaveBeenCalled(); + expect(activeExecutionService.findManyInRegularMode).toHaveBeenCalled(); + }); + }); + + describe('stop()', () => { + const req = mock({ params: { id: '999' } }); + const execution = mock(); + + it('should 404 when execution is not found or inaccessible for user', async () => { + activeExecutionService.findOne.mockResolvedValue(undefined); + + const promise = new ExecutionsController( + mock(), + mock(), + workflowSharingService, + activeExecutionService, + mock(), + ).stop(req); + + await expect(promise).rejects.toThrow(NotFoundError); + expect(activeExecutionService.findOne).toHaveBeenCalledWith('999', ['123']); + }); + + it('should call `ActiveExecutionService.stop()`', async () => { + getEnv.calledWith('executions.mode').mockReturnValue('regular'); + activeExecutionService.findOne.mockResolvedValue(execution); + + await new ExecutionsController( + mock(), + mock(), + workflowSharingService, + activeExecutionService, + mock(), + ).stop(req); + + expect(activeExecutionService.stop).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/test/unit/controllers/me.controller.test.ts b/packages/cli/test/unit/controllers/me.controller.test.ts index aaaea5a86a..3359e592aa 100644 --- a/packages/cli/test/unit/controllers/me.controller.test.ts +++ b/packages/cli/test/unit/controllers/me.controller.test.ts @@ -44,7 +44,7 @@ describe('MeController', () => { id: '123', password: 'password', authIdentities: [], - globalRoleId: '1', + role: 'global:owner', }); const reqBody = { email: 'valid@email.com', firstName: 'John', lastName: 'Potato' }; const req = mock({ user, body: reqBody }); @@ -79,7 +79,7 @@ describe('MeController', () => { id: '123', password: 'password', authIdentities: [], - globalRoleId: '1', + role: 'global:owner', }); const reqBody = { email: 'valid@email.com', firstName: 'John', lastName: 'Potato' }; const req = mock({ user, body: reqBody }); @@ -88,7 +88,7 @@ describe('MeController', () => { jest.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token'); // Add invalid data to the request payload - Object.assign(reqBody, { id: '0', globalRoleId: '42' }); + Object.assign(reqBody, { id: '0', role: '42' }); await controller.updateCurrentUser(req, res); @@ -99,7 +99,7 @@ describe('MeController', () => { expect(updatedUser.firstName).toBe(reqBody.firstName); expect(updatedUser.lastName).toBe(reqBody.lastName); expect(updatedUser.id).not.toBe('0'); - expect(updatedUser.globalRoleId).not.toBe('42'); + expect(updatedUser.role).not.toBe('42'); }); it('should throw BadRequestError if beforeUpdate hook throws BadRequestError', async () => { @@ -107,11 +107,11 @@ describe('MeController', () => { id: '123', password: 'password', authIdentities: [], - globalRoleId: '1', + role: 'global:owner', }); const reqBody = { email: 'valid@email.com', firstName: 'John', lastName: 'Potato' }; const req = mock({ user, body: reqBody }); - userService.findOneOrFail.mockResolvedValue(user); + // userService.findOneOrFail.mockResolvedValue(user); externalHooks.run.mockImplementationOnce(async (hookName) => { if (hookName === 'user.profile.beforeUpdate') { diff --git a/packages/cli/test/unit/controllers/oAuth1Credential.controller.test.ts b/packages/cli/test/unit/controllers/oAuth1Credential.controller.test.ts index 0ea169261c..59d9b8f636 100644 --- a/packages/cli/test/unit/controllers/oAuth1Credential.controller.test.ts +++ b/packages/cli/test/unit/controllers/oAuth1Credential.controller.test.ts @@ -34,7 +34,7 @@ describe('OAuth1CredentialController', () => { id: '123', password: 'password', authIdentities: [], - globalRoleId: '1', + role: 'global:owner', }); const credential = mock({ id: '1', diff --git a/packages/cli/test/unit/controllers/oAuth2Credential.controller.test.ts b/packages/cli/test/unit/controllers/oAuth2Credential.controller.test.ts index 3498f6a532..9acbe305be 100644 --- a/packages/cli/test/unit/controllers/oAuth2Credential.controller.test.ts +++ b/packages/cli/test/unit/controllers/oAuth2Credential.controller.test.ts @@ -38,7 +38,7 @@ describe('OAuth2CredentialController', () => { id: '123', password: 'password', authIdentities: [], - globalRoleId: '1', + role: 'global:owner', }); const credential = mock({ id: '1', diff --git a/packages/cli/test/unit/controllers/owner.controller.test.ts b/packages/cli/test/unit/controllers/owner.controller.test.ts index 90c6e02eb6..97191a7a69 100644 --- a/packages/cli/test/unit/controllers/owner.controller.test.ts +++ b/packages/cli/test/unit/controllers/owner.controller.test.ts @@ -76,7 +76,7 @@ describe('OwnerController', () => { it('should setup the instance owner successfully', async () => { const user = mock({ id: 'userId', - globalRole: { scope: 'global', name: 'owner' }, + role: 'global:owner', authIdentities: [], }); const req = mock({ diff --git a/packages/cli/test/unit/controllers/translation.controller.test.ts b/packages/cli/test/unit/controllers/translation.controller.test.ts index b56fdf2a58..e34237cd69 100644 --- a/packages/cli/test/unit/controllers/translation.controller.test.ts +++ b/packages/cli/test/unit/controllers/translation.controller.test.ts @@ -1,5 +1,4 @@ import { mock } from 'jest-mock-extended'; -import type { ICredentialTypes } from 'n8n-workflow'; import config from '@/config'; import type { TranslationRequest } from '@/controllers/translation.controller'; import { @@ -7,10 +6,11 @@ import { CREDENTIAL_TRANSLATIONS_DIR, } from '@/controllers/translation.controller'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import type { CredentialTypes } from '@/CredentialTypes'; describe('TranslationController', () => { const configGetSpy = jest.spyOn(config, 'getEnv'); - const credentialTypes = mock(); + const credentialTypes = mock(); const controller = new TranslationController(credentialTypes); describe('getCredentialTranslation', () => { diff --git a/packages/cli/test/unit/middleware/auth.test.ts b/packages/cli/test/unit/middleware/auth.test.ts new file mode 100644 index 0000000000..dfd6e0e8c1 --- /dev/null +++ b/packages/cli/test/unit/middleware/auth.test.ts @@ -0,0 +1,162 @@ +import { mock } from 'jest-mock-extended'; + +import config from '@/config'; +import { AUTH_COOKIE_NAME, Time } from '@/constants'; +import { License } from '@/License'; +import { issueJWT } from '@/auth/jwt'; +import { refreshExpiringCookie } from '@/middlewares'; + +import { mockInstance } from '../../shared/mocking'; + +import type { AuthenticatedRequest } from '@/requests'; +import type { NextFunction, Response } from 'express'; +import type { User } from '@/databases/entities/User'; + +mockInstance(License); + +jest.useFakeTimers(); + +describe('refreshExpiringCookie', () => { + const oldDuration = config.getEnv('userManagement.jwtSessionDurationHours'); + const oldTimeout = config.getEnv('userManagement.jwtRefreshTimeoutHours'); + let mockUser: User; + + beforeEach(() => { + mockUser = mock({ password: 'passwordHash' }); + }); + + afterEach(() => { + config.set('userManagement.jwtSessionDuration', oldDuration); + config.set('userManagement.jwtRefreshTimeoutHours', oldTimeout); + }); + + it('does not do anything if the user is not authorized', async () => { + const req = mock(); + const res = mock({ cookie: jest.fn() }); + const next = jest.fn(); + + await refreshExpiringCookie(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.cookie).not.toHaveBeenCalled(); + }); + + describe('with N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS=-1', () => { + it('does not refresh the cookie, ever', async () => { + config.set('userManagement.jwtSessionDurationHours', 1); + config.set('userManagement.jwtRefreshTimeoutHours', -1); + const { token } = issueJWT(mockUser); + + jest.advanceTimersByTime(1000 * 60 * 55); /* 55 minutes */ + + const req = mock({ + cookies: { [AUTH_COOKIE_NAME]: token }, + user: mockUser, + }); + const res = mock({ cookie: jest.fn() }); + const next = jest.fn(); + await refreshExpiringCookie(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.cookie).not.toHaveBeenCalled(); + }); + }); + + describe('with N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS=0', () => { + let token: string; + let req: AuthenticatedRequest; + let res: Response; + let next: NextFunction; + + beforeEach(() => { + // ARRANGE + config.set('userManagement.jwtSessionDurationHours', 1); + config.set('userManagement.jwtRefreshTimeoutHours', 0); + token = issueJWT(mockUser).token; + + req = mock({ + cookies: { [AUTH_COOKIE_NAME]: token }, + user: mockUser, + }); + res = mock({ cookie: jest.fn() }); + next = jest.fn(); + }); + + it('does not refresh the cookie when more than 1/4th of time is left', async () => { + // ARRANGE + jest.advanceTimersByTime(44 * Time.minutes.toMilliseconds); /* 44 minutes */ + + // ACT + await refreshExpiringCookie(req, res, next); + + // ASSERT + expect(next).toHaveBeenCalledTimes(1); + expect(res.cookie).not.toHaveBeenCalled(); + }); + + it('refreshes the cookie when 1/4th of time is left', async () => { + // ARRANGE + jest.advanceTimersByTime(46 * Time.minutes.toMilliseconds); /* 46 minutes */ + + // ACT + await refreshExpiringCookie(req, res, next); + + // ASSERT + expect(next).toHaveBeenCalledTimes(1); + expect(res.cookie).toHaveBeenCalledTimes(1); + }); + }); + + describe('with N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS=50', () => { + const jwtSessionDurationHours = 51; + let token: string; + let req: AuthenticatedRequest; + let res: Response; + let next: NextFunction; + + // ARRANGE + beforeEach(() => { + config.set('userManagement.jwtSessionDurationHours', jwtSessionDurationHours); + config.set('userManagement.jwtRefreshTimeoutHours', 50); + + token = issueJWT(mockUser).token; + req = mock({ + cookies: { [AUTH_COOKIE_NAME]: token }, + user: mockUser, + }); + res = mock({ cookie: jest.fn() }); + next = jest.fn(); + }); + + it('does not do anything if the cookie is still valid', async () => { + // ARRANGE + // cookie has 50.5 hours to live: 51 - 0.5 + jest.advanceTimersByTime(30 * Time.minutes.toMilliseconds); + + // ACT + await refreshExpiringCookie(req, res, next); + + // ASSERT + expect(next).toHaveBeenCalledTimes(1); + expect(res.cookie).not.toHaveBeenCalled(); + }); + + it('refreshes the cookie if it has less than 50 hours to live', async () => { + // ARRANGE + // cookie has 49.5 hours to live: 51 - 1.5 + jest.advanceTimersByTime(1.5 * Time.hours.toMilliseconds); + + // ACT + await refreshExpiringCookie(req, res, next); + + // ASSERT + expect(next).toHaveBeenCalledTimes(1); + expect(res.cookie).toHaveBeenCalledTimes(1); + expect(res.cookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME, expect.any(String), { + httpOnly: true, + maxAge: jwtSessionDurationHours * Time.hours.toMilliseconds, + sameSite: 'lax', + }); + }); + }); +}); diff --git a/packages/cli/test/unit/repositories/role.repository.test.ts b/packages/cli/test/unit/repositories/role.repository.test.ts deleted file mode 100644 index 40d95207ac..0000000000 --- a/packages/cli/test/unit/repositories/role.repository.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Container } from 'typedi'; -import { DataSource, EntityManager } from 'typeorm'; -import { mock } from 'jest-mock-extended'; -import type { RoleNames, RoleScopes } from '@db/entities/Role'; -import { Role } from '@db/entities/Role'; -import { RoleRepository } from '@db/repositories/role.repository'; -import { mockInstance } from '../../shared/mocking'; -import { randomInteger } from '../../integration/shared/random'; - -describe('RoleRepository', () => { - const entityManager = mockInstance(EntityManager); - const dataSource = mockInstance(DataSource, { manager: entityManager }); - dataSource.getMetadata.mockReturnValue(mock()); - Object.assign(entityManager, { connection: dataSource }); - const roleRepository = Container.get(RoleRepository); - - describe('findRole', () => { - test('should return the role when present', async () => { - entityManager.findOne.mockResolvedValueOnce(createRole('global', 'owner')); - const role = await roleRepository.findRole('global', 'owner'); - expect(role?.name).toEqual('owner'); - expect(role?.scope).toEqual('global'); - }); - - test('should return null otherwise', async () => { - entityManager.findOne.mockResolvedValueOnce(null); - const role = await roleRepository.findRole('global', 'owner'); - expect(role).toEqual(null); - }); - }); - - const createRole = (scope: RoleScopes, name: RoleNames) => - Object.assign(new Role(), { name, scope, id: `${randomInteger()}` }); -}); diff --git a/packages/cli/test/unit/services/communityPackages.service.test.ts b/packages/cli/test/unit/services/communityPackages.service.test.ts index 1d2a74e369..fe69d82629 100644 --- a/packages/cli/test/unit/services/communityPackages.service.test.ts +++ b/packages/cli/test/unit/services/communityPackages.service.test.ts @@ -173,7 +173,7 @@ describe('CommunityPackagesService', () => { mocked(exec).mockImplementation(erroringExecMock); - const call = async () => communityPackagesService.executeNpmCommand('ls'); + const call = async () => await communityPackagesService.executeNpmCommand('ls'); await expect(call).rejects.toThrowError(RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_FOUND); diff --git a/packages/cli/test/unit/services/events.service.test.ts b/packages/cli/test/unit/services/events.service.test.ts index bc1aeb6387..6504b691e8 100644 --- a/packages/cli/test/unit/services/events.service.test.ts +++ b/packages/cli/test/unit/services/events.service.test.ts @@ -17,8 +17,6 @@ import { UserService } from '@/services/user.service'; import { OwnershipService } from '@/services/ownership.service'; import { mockInstance } from '../../shared/mocking'; -jest.mock('@/UserManagement/UserManagementHelper', () => ({ getWorkflowOwner: jest.fn() })); - describe('EventsService', () => { const dbType = config.getEnv('database.type'); const fakeUser = mock({ id: 'abcde-fghij' }); diff --git a/packages/cli/test/unit/services/orchestration.service.test.ts b/packages/cli/test/unit/services/orchestration.service.test.ts index 8a71d6784e..6d6bd16822 100644 --- a/packages/cli/test/unit/services/orchestration.service.test.ts +++ b/packages/cli/test/unit/services/orchestration.service.test.ts @@ -1,6 +1,6 @@ import Container from 'typedi'; import config from '@/config'; -import { SingleMainSetup } from '@/services/orchestration/main/SingleMainSetup'; +import { OrchestrationService } from '@/services/orchestration.service'; import type { RedisServiceWorkerResponseObject } from '@/services/redis/RedisServiceCommands'; import { eventBus } from '@/eventbus'; import { RedisService } from '@/services/redis.service'; @@ -14,7 +14,7 @@ import { Push } from '@/push'; import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; import { mockInstance } from '../../shared/mocking'; -const os = Container.get(SingleMainSetup); +const os = Container.get(OrchestrationService); const handler = Container.get(OrchestrationHandlerMainService); mockInstance(ActiveWorkflowRunner); diff --git a/packages/cli/test/unit/services/ownership.service.test.ts b/packages/cli/test/unit/services/ownership.service.test.ts index 20be942c2f..3fed4b8ce7 100644 --- a/packages/cli/test/unit/services/ownership.service.test.ts +++ b/packages/cli/test/unit/services/ownership.service.test.ts @@ -1,181 +1,135 @@ import { OwnershipService } from '@/services/ownership.service'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; -import { Role } from '@db/entities/Role'; import { SharedWorkflow } from '@db/entities/SharedWorkflow'; import { User } from '@db/entities/User'; -import { RoleService } from '@/services/role.service'; import type { SharedCredentials } from '@db/entities/SharedCredentials'; import { mockInstance } from '../../shared/mocking'; import { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; import { UserRepository } from '@/databases/repositories/user.repository'; import { mock } from 'jest-mock-extended'; -import { - mockCredRole, - mockCredential, - mockUser, - mockInstanceOwnerRole, - wfOwnerRole, -} from '../shared/mockObjects'; +import { mockCredential, mockUser } from '../shared/mockObjects'; describe('OwnershipService', () => { - const roleService = mockInstance(RoleService); const userRepository = mockInstance(UserRepository); const sharedWorkflowRepository = mockInstance(SharedWorkflowRepository); - - const ownershipService = new OwnershipService( - mock(), - userRepository, - roleService, - sharedWorkflowRepository, - ); + const ownershipService = new OwnershipService(mock(), userRepository, sharedWorkflowRepository); beforeEach(() => { jest.clearAllMocks(); }); - describe('OwnershipService', () => { - const roleService = mockInstance(RoleService); - const userRepository = mockInstance(UserRepository); - const sharedWorkflowRepository = mockInstance(SharedWorkflowRepository); + describe('getWorkflowOwner()', () => { + test('should retrieve a workflow owner', async () => { + const mockOwner = new User(); + const mockNonOwner = new User(); - const ownershipService = new OwnershipService( - mock(), - userRepository, - roleService, - sharedWorkflowRepository, - ); + const sharedWorkflow = Object.assign(new SharedWorkflow(), { + role: 'workflow:owner', + user: mockOwner, + }); - beforeEach(() => { - jest.clearAllMocks(); + sharedWorkflowRepository.findOneOrFail.mockResolvedValueOnce(sharedWorkflow); + + const returnedOwner = await ownershipService.getWorkflowOwnerCached('some-workflow-id'); + + expect(returnedOwner).toBe(mockOwner); + expect(returnedOwner).not.toBe(mockNonOwner); }); - describe('getWorkflowOwner()', () => { - test('should retrieve a workflow owner', async () => { - roleService.findWorkflowOwnerRole.mockResolvedValueOnce(wfOwnerRole()); + test('should throw if no workflow owner found', async () => { + sharedWorkflowRepository.findOneOrFail.mockRejectedValue(new Error()); - const mockOwner = new User(); - const mockNonOwner = new User(); + await expect(ownershipService.getWorkflowOwnerCached('some-workflow-id')).rejects.toThrow(); + }); + }); - const sharedWorkflow = Object.assign(new SharedWorkflow(), { - role: new Role(), - user: mockOwner, - }); + describe('addOwnedByAndSharedWith()', () => { + test('should add `ownedBy` and `sharedWith` to credential', async () => { + const owner = mockUser(); + const editor = mockUser(); - sharedWorkflowRepository.findOneOrFail.mockResolvedValueOnce(sharedWorkflow); + const credential = mockCredential(); - const returnedOwner = await ownershipService.getWorkflowOwnerCached('some-workflow-id'); + credential.shared = [ + { role: 'credential:owner', user: owner }, + { role: 'credential:editor', user: editor }, + ] as SharedCredentials[]; - expect(returnedOwner).toBe(mockOwner); - expect(returnedOwner).not.toBe(mockNonOwner); + const { ownedBy, sharedWith } = ownershipService.addOwnedByAndSharedWith(credential); + + expect(ownedBy).toStrictEqual({ + id: owner.id, + email: owner.email, + firstName: owner.firstName, + lastName: owner.lastName, }); - test('should throw if no workflow owner role found', async () => { - roleService.findWorkflowOwnerRole.mockRejectedValueOnce(new Error()); - - await expect(ownershipService.getWorkflowOwnerCached('some-workflow-id')).rejects.toThrow(); - }); - - test('should throw if no workflow owner found', async () => { - roleService.findWorkflowOwnerRole.mockResolvedValueOnce(wfOwnerRole()); - - sharedWorkflowRepository.findOneOrFail.mockRejectedValue(new Error()); - - await expect(ownershipService.getWorkflowOwnerCached('some-workflow-id')).rejects.toThrow(); - }); + expect(sharedWith).toStrictEqual([ + { + id: editor.id, + email: editor.email, + firstName: editor.firstName, + lastName: editor.lastName, + }, + ]); }); - describe('addOwnedByAndSharedWith()', () => { - test('should add `ownedBy` and `sharedWith` to credential', async () => { - const owner = mockUser(); - const editor = mockUser(); + test('should add `ownedBy` and `sharedWith` to workflow', async () => { + const owner = mockUser(); + const editor = mockUser(); - const credential = mockCredential(); + const workflow = new WorkflowEntity(); - credential.shared = [ - { role: mockCredRole('owner'), user: owner }, - { role: mockCredRole('editor'), user: editor }, - ] as SharedCredentials[]; + workflow.shared = [ + { role: 'workflow:owner', user: owner }, + { role: 'workflow:editor', user: editor }, + ] as SharedWorkflow[]; - const { ownedBy, sharedWith } = ownershipService.addOwnedByAndSharedWith(credential); + const { ownedBy, sharedWith } = ownershipService.addOwnedByAndSharedWith(workflow); - expect(ownedBy).toStrictEqual({ - id: owner.id, - email: owner.email, - firstName: owner.firstName, - lastName: owner.lastName, - }); - - expect(sharedWith).toStrictEqual([ - { - id: editor.id, - email: editor.email, - firstName: editor.firstName, - lastName: editor.lastName, - }, - ]); + expect(ownedBy).toStrictEqual({ + id: owner.id, + email: owner.email, + firstName: owner.firstName, + lastName: owner.lastName, }); - test('should add `ownedBy` and `sharedWith` to workflow', async () => { - const owner = mockUser(); - const editor = mockUser(); - - const workflow = new WorkflowEntity(); - - workflow.shared = [ - { role: mockCredRole('owner'), user: owner }, - { role: mockCredRole('editor'), user: editor }, - ] as SharedWorkflow[]; - - const { ownedBy, sharedWith } = ownershipService.addOwnedByAndSharedWith(workflow); - - expect(ownedBy).toStrictEqual({ - id: owner.id, - email: owner.email, - firstName: owner.firstName, - lastName: owner.lastName, - }); - - expect(sharedWith).toStrictEqual([ - { - id: editor.id, - email: editor.email, - firstName: editor.firstName, - lastName: editor.lastName, - }, - ]); - }); - - test('should produce an empty sharedWith if no sharee', async () => { - const owner = mockUser(); - - const credential = mockCredential(); - - credential.shared = [{ role: mockCredRole('owner'), user: owner }] as SharedCredentials[]; - - const { ownedBy, sharedWith } = ownershipService.addOwnedByAndSharedWith(credential); - - expect(ownedBy).toStrictEqual({ - id: owner.id, - email: owner.email, - firstName: owner.firstName, - lastName: owner.lastName, - }); - - expect(sharedWith).toHaveLength(0); - }); + expect(sharedWith).toStrictEqual([ + { + id: editor.id, + email: editor.email, + firstName: editor.firstName, + lastName: editor.lastName, + }, + ]); }); - describe('getInstanceOwner()', () => { - test('should find owner using global owner role ID', async () => { - const instanceOwnerRole = mockInstanceOwnerRole(); - roleService.findGlobalOwnerRole.mockResolvedValue(instanceOwnerRole); + test('should produce an empty sharedWith if no sharee', async () => { + const owner = mockUser(); - await ownershipService.getInstanceOwner(); + const credential = mockCredential(); - expect(userRepository.findOneOrFail).toHaveBeenCalledWith({ - where: { globalRoleId: instanceOwnerRole.id }, - relations: ['globalRole'], - }); + credential.shared = [{ role: 'credential:owner', user: owner }] as SharedCredentials[]; + + const { ownedBy, sharedWith } = ownershipService.addOwnedByAndSharedWith(credential); + + expect(ownedBy).toStrictEqual({ + id: owner.id, + email: owner.email, + firstName: owner.firstName, + lastName: owner.lastName, + }); + + expect(sharedWith).toHaveLength(0); + }); + }); + + describe('getInstanceOwner()', () => { + test('should find owner using global owner role ID', async () => { + await ownershipService.getInstanceOwner(); + + expect(userRepository.findOneOrFail).toHaveBeenCalledWith({ + where: { role: 'global:owner' }, }); }); }); diff --git a/packages/cli/test/unit/services/role.service.test.ts b/packages/cli/test/unit/services/role.service.test.ts deleted file mode 100644 index 19c26ae4af..0000000000 --- a/packages/cli/test/unit/services/role.service.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; -import type { RoleNames, RoleScopes } from '@db/entities/Role'; -import { Role } from '@db/entities/Role'; -import { RoleService } from '@/services/role.service'; -import { RoleRepository } from '@db/repositories/role.repository'; -import { CacheService } from '@/services/cache/cache.service'; -import { SharedWorkflow } from '@db/entities/SharedWorkflow'; -import { mockInstance } from '../../shared/mocking'; -import { chooseRandomly } from '../../integration/shared/random'; -import config from '@/config'; - -const ROLE_PROPS: Array<{ name: RoleNames; scope: RoleScopes }> = [ - { name: 'owner', scope: 'global' }, - { name: 'member', scope: 'global' }, - { name: 'owner', scope: 'workflow' }, - { name: 'owner', scope: 'credential' }, - { name: 'user', scope: 'credential' }, - { name: 'editor', scope: 'workflow' }, -]; - -export const uppercaseInitial = (str: string) => str[0].toUpperCase() + str.slice(1); - -describe('RoleService', () => { - const sharedWorkflowRepository = mockInstance(SharedWorkflowRepository); - const roleRepository = mockInstance(RoleRepository); - const cacheService = mockInstance(CacheService); - const roleService = new RoleService(roleRepository, sharedWorkflowRepository, cacheService); - - const userId = '1'; - const workflowId = '42'; - - const { name, scope } = chooseRandomly(ROLE_PROPS); - - const display = { - name: uppercaseInitial(name), - scope: uppercaseInitial(scope), - }; - - beforeEach(() => { - config.load(config.default); - jest.clearAllMocks(); - }); - - [true, false].forEach((cacheEnabled) => { - const tag = ['cache', cacheEnabled ? 'enabled' : 'disabled'].join(' '); - - describe(`find${display.scope}${display.name}Role() [${tag}]`, () => { - test(`should return the ${scope} ${name} role if found`, async () => { - config.set('cache.enabled', cacheEnabled); - - const role = roleRepository.create({ name, scope }); - roleRepository.findRole.mockResolvedValueOnce(role); - const returnedRole = await roleRepository.findRole(scope, name); - - expect(returnedRole).toBe(role); - }); - }); - - describe(`findRoleByUserAndWorkflow() [${tag}]`, () => { - test('should return the role if a shared workflow is found', async () => { - config.set('cache.enabled', cacheEnabled); - - const sharedWorkflow = Object.assign(new SharedWorkflow(), { role: new Role() }); - sharedWorkflowRepository.findOne.mockResolvedValueOnce(sharedWorkflow); - const returnedRole = await roleService.findRoleByUserAndWorkflow(userId, workflowId); - - expect(returnedRole).toBe(sharedWorkflow.role); - }); - - test('should return undefined if no shared workflow is found', async () => { - config.set('cache.enabled', cacheEnabled); - - sharedWorkflowRepository.findOne.mockResolvedValueOnce(null); - const returnedRole = await roleService.findRoleByUserAndWorkflow(userId, workflowId); - - expect(returnedRole).toBeUndefined(); - }); - }); - }); -}); diff --git a/packages/cli/test/unit/services/user.service.test.ts b/packages/cli/test/unit/services/user.service.test.ts index 56eb26194b..dca81305af 100644 --- a/packages/cli/test/unit/services/user.service.test.ts +++ b/packages/cli/test/unit/services/user.service.test.ts @@ -6,14 +6,12 @@ import { User } from '@db/entities/User'; import { UserRepository } from '@db/repositories/user.repository'; import { UserService } from '@/services/user.service'; import { mockInstance } from '../../shared/mocking'; -import { RoleService } from '@/services/role.service'; import { v4 as uuid } from 'uuid'; describe('UserService', () => { config.set('userManagement.jwtSecret', 'random-secret'); mockInstance(Logger); - mockInstance(RoleService); const userRepository = mockInstance(UserRepository); const userService = Container.get(UserService); diff --git a/packages/cli/test/unit/services/workflowHistory.service.ee.test.ts b/packages/cli/test/unit/services/workflowHistory.service.ee.test.ts index 7a623188e6..05ccae7005 100644 --- a/packages/cli/test/unit/services/workflowHistory.service.ee.test.ts +++ b/packages/cli/test/unit/services/workflowHistory.service.ee.test.ts @@ -1,11 +1,11 @@ +import { mockClear } from 'jest-mock-extended'; import { User } from '@db/entities/User'; import { WorkflowHistoryRepository } from '@db/repositories/workflowHistory.repository'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { WorkflowHistoryService } from '@/workflows/workflowHistory/workflowHistory.service.ee'; -import { mockInstance } from '../../shared/mocking'; import { Logger } from '@/Logger'; -import { getWorkflow } from '../WorkflowHelpers.test'; -import { mockClear } from 'jest-mock-extended'; +import { mockInstance } from '../../shared/mocking'; +import { getWorkflow } from '../../integration/shared/workflow'; const workflowHistoryRepository = mockInstance(WorkflowHistoryRepository); const logger = mockInstance(Logger); diff --git a/packages/cli/test/unit/shared/mockObjects.ts b/packages/cli/test/unit/shared/mockObjects.ts index dee4d97150..baa6cf4740 100644 --- a/packages/cli/test/unit/shared/mockObjects.ts +++ b/packages/cli/test/unit/shared/mockObjects.ts @@ -1,5 +1,4 @@ import { User } from '@db/entities/User'; -import { Role } from '@db/entities/Role'; import { CredentialsEntity } from '@db/entities/CredentialsEntity'; import { @@ -9,20 +8,6 @@ import { randomName, } from '../../integration/shared/random'; -export const wfOwnerRole = () => - Object.assign(new Role(), { - scope: 'workflow', - name: 'owner', - id: randomInteger(), - }); - -export const mockCredRole = (name: 'owner' | 'editor'): Role => - Object.assign(new Role(), { - scope: 'credentials', - name, - id: randomInteger(), - }); - export const mockCredential = (): CredentialsEntity => Object.assign(new CredentialsEntity(), randomCredentialPayload()); @@ -33,10 +18,3 @@ export const mockUser = (): User => firstName: randomName(), lastName: randomName(), }); - -export const mockInstanceOwnerRole = () => - Object.assign(new Role(), { - scope: 'global', - name: 'owner', - id: randomInteger(), - }); diff --git a/packages/cli/test/unit/shutdown/Shutdown.service.test.ts b/packages/cli/test/unit/shutdown/Shutdown.service.test.ts index b253929cbb..ca85f78564 100644 --- a/packages/cli/test/unit/shutdown/Shutdown.service.test.ts +++ b/packages/cli/test/unit/shutdown/Shutdown.service.test.ts @@ -104,7 +104,7 @@ describe('ShutdownService', () => { }); it('should throw error if app is not shutting down', async () => { - await expect(async () => shutdownService.waitForShutdown()).rejects.toThrow( + await expect(async () => await shutdownService.waitForShutdown()).rejects.toThrow( 'App is not shutting down', ); }); diff --git a/packages/cli/test/unit/workflow-execution.service.test.ts b/packages/cli/test/unit/workflow-execution.service.test.ts new file mode 100644 index 0000000000..fd2848a46e --- /dev/null +++ b/packages/cli/test/unit/workflow-execution.service.test.ts @@ -0,0 +1,137 @@ +import type { INode } from 'n8n-workflow'; +import { WorkflowExecutionService } from '@/workflows/workflowExecution.service'; +import type { IWorkflowDb } from '@/Interfaces'; +import { mock } from 'jest-mock-extended'; + +const webhookNode: INode = { + name: 'Webhook', + type: 'n8n-nodes-base.webhook', + id: '111f1db0-e7be-44c5-9ce9-3e35362490f0', + parameters: {}, + typeVersion: 1, + position: [0, 0], + webhookId: 'de0f8dcb-7b64-4f22-b66d-d8f74d6aefb7', +}; + +const secondWebhookNode = { + ...webhookNode, + name: 'Webhook 2', + id: '222f1db0-e7be-44c5-9ce9-3e35362490f1', +}; + +const executeWorkflowTriggerNode: INode = { + name: 'Execute Workflow Trigger', + type: 'n8n-nodes-base.executeWorkflowTrigger', + id: '78d63bca-bb6c-4568-948f-8ed9aacb1fe9', + parameters: {}, + typeVersion: 1, + position: [0, 0], +}; + +const respondToWebhookNode: INode = { + name: 'Respond to Webhook', + type: 'n8n-nodes-base.respondToWebhook', + id: '66d63bca-bb6c-4568-948f-8ed9aacb1fe9', + parameters: {}, + typeVersion: 1, + position: [0, 0], +}; + +const hackerNewsNode: INode = { + name: 'Hacker News', + type: 'n8n-nodes-base.hackerNews', + id: '55d63bca-bb6c-4568-948f-8ed9aacb1fe9', + parameters: {}, + typeVersion: 1, + position: [0, 0], +}; + +describe('WorkflowExecutionService', () => { + let workflowExecutionService: WorkflowExecutionService; + + beforeAll(() => { + workflowExecutionService = new WorkflowExecutionService( + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + ); + }); + + describe('selectPinnedActivatorStarter()', () => { + const workflow = mock({ + nodes: [], + }); + + const pinData = { + [webhookNode.name]: [{ json: { key: 'value' } }], + [executeWorkflowTriggerNode.name]: [{ json: { key: 'value' } }], + }; + + afterEach(() => { + workflow.nodes = []; + }); + + it('should return `null` if no pindata', () => { + const node = workflowExecutionService.selectPinnedActivatorStarter(workflow, []); + + expect(node).toBeNull(); + }); + + it('should return `null` if no starter nodes', () => { + const node = workflowExecutionService.selectPinnedActivatorStarter(workflow); + + expect(node).toBeNull(); + }); + + it('should select webhook node if only choice', () => { + workflow.nodes.push(webhookNode); + + const node = workflowExecutionService.selectPinnedActivatorStarter(workflow, [], pinData); + + expect(node).toEqual(webhookNode); + }); + + it('should return `null` if no choice', () => { + workflow.nodes.push(hackerNewsNode); + + const node = workflowExecutionService.selectPinnedActivatorStarter(workflow, [], pinData); + + expect(node).toBeNull(); + }); + + it('should return ignore Respond to Webhook', () => { + workflow.nodes.push(respondToWebhookNode); + + const node = workflowExecutionService.selectPinnedActivatorStarter(workflow, [], pinData); + + expect(node).toBeNull(); + }); + + it('should select execute workflow trigger if only choice', () => { + workflow.nodes.push(executeWorkflowTriggerNode); + + const node = workflowExecutionService.selectPinnedActivatorStarter(workflow, [], pinData); + + expect(node).toEqual(executeWorkflowTriggerNode); + }); + + it('should favor webhook node over execute workflow trigger', () => { + workflow.nodes.push(webhookNode, executeWorkflowTriggerNode); + + const node = workflowExecutionService.selectPinnedActivatorStarter(workflow, [], pinData); + + expect(node).toEqual(webhookNode); + }); + + it('should favor first webhook node over second webhook node', () => { + workflow.nodes.push(webhookNode, secondWebhookNode); + + const node = workflowExecutionService.selectPinnedActivatorStarter(workflow, [], pinData); + + expect(node).toEqual(webhookNode); + }); + }); +}); diff --git a/packages/core/package.json b/packages/core/package.json index 2c336663fc..8d8052d2f1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "1.24.0", + "version": "1.25.0", "description": "Core functionality of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -50,13 +50,14 @@ "dependencies": { "@n8n/client-oauth2": "workspace:*", "aws4": "1.11.0", - "axios": "1.6.2", + "axios": "1.6.5", "concat-stream": "2.0.0", "cron": "1.7.2", "fast-glob": "3.2.12", "file-type": "16.5.4", "form-data": "4.0.0", "lodash": "4.17.21", + "luxon": "^3.4.4", "mime-types": "2.1.35", "n8n-workflow": "workspace:*", "oauth-1.0a": "2.2.6", diff --git a/packages/core/src/ActiveWorkflows.ts b/packages/core/src/ActiveWorkflows.ts index d42729b1f2..bcc5bb285b 100644 --- a/packages/core/src/ActiveWorkflows.ts +++ b/packages/core/src/ActiveWorkflows.ts @@ -209,8 +209,13 @@ export class ActiveWorkflows { const w = this.activeWorkflows[workflowId]; - w.triggerResponses?.forEach(async (r) => this.close(r, workflowId, 'trigger')); - w.pollResponses?.forEach(async (r) => this.close(r, workflowId, 'poller')); + for (const r of w.triggerResponses ?? []) { + await this.close(r, workflowId, 'trigger'); + } + + for (const r of w.pollResponses ?? []) { + await this.close(r, workflowId, 'poller'); + } delete this.activeWorkflows[workflowId]; @@ -219,10 +224,7 @@ export class ActiveWorkflows { async removeAllTriggerAndPollerBasedWorkflows() { for (const workflowId of Object.keys(this.activeWorkflows)) { - const w = this.activeWorkflows[workflowId]; - - w.triggerResponses?.forEach(async (r) => this.close(r, workflowId, 'trigger')); - w.pollResponses?.forEach(async (r) => this.close(r, workflowId, 'poller')); + await this.remove(workflowId); } } diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index ad8a071b8f..8aadee3454 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -111,20 +111,20 @@ export class BinaryDataService { } async toBuffer(bufferOrStream: Buffer | Readable) { - return toBuffer(bufferOrStream); + return await toBuffer(bufferOrStream); } async getAsStream(binaryDataId: string, chunkSize?: number) { const [mode, fileId] = binaryDataId.split(':'); - return this.getManager(mode).getAsStream(fileId, chunkSize); + return await this.getManager(mode).getAsStream(fileId, chunkSize); } async getAsBuffer(binaryData: IBinaryData) { if (binaryData.id) { const [mode, fileId] = binaryData.id.split(':'); - return this.getManager(mode).getAsBuffer(fileId); + return await this.getManager(mode).getAsBuffer(fileId); } return Buffer.from(binaryData.data, BINARY_ENCODING); @@ -139,7 +139,7 @@ export class BinaryDataService { async getMetadata(binaryDataId: string) { const [mode, fileId] = binaryDataId.split(':'); - return this.getManager(mode).getMetadata(fileId); + return await this.getManager(mode).getMetadata(fileId); } async deleteMany(ids: BinaryData.IdsForDeletion) { @@ -159,10 +159,14 @@ export class BinaryDataService { const returnInputData = (inputData as INodeExecutionData[][]).map( async (executionDataArray) => { if (executionDataArray) { - return Promise.all( + return await Promise.all( executionDataArray.map(async (executionData) => { if (executionData.binary) { - return this.duplicateBinaryDataInExecData(workflowId, executionId, executionData); + return await this.duplicateBinaryDataInExecData( + workflowId, + executionId, + executionData, + ); } return executionData; @@ -174,7 +178,7 @@ export class BinaryDataService { }, ); - return Promise.all(returnInputData); + return await Promise.all(returnInputData); } return inputData as INodeExecutionData[][]; @@ -217,13 +221,13 @@ export class BinaryDataService { const [_mode, fileId] = binaryDataId.split(':'); - return manager?.copyByFileId(workflowId, executionId, fileId).then((newFileId) => ({ + return await manager?.copyByFileId(workflowId, executionId, fileId).then((newFileId) => ({ newId: this.createBinaryDataId(newFileId), key, })); }); - return Promise.all(bdPromises).then((b) => { + return await Promise.all(bdPromises).then((b) => { return b.reduce((acc, curr) => { if (acc.binary && curr) { acc.binary[curr.key].id = curr.newId; diff --git a/packages/core/src/BinaryData/FileSystem.manager.ts b/packages/core/src/BinaryData/FileSystem.manager.ts index 1e076ad4c7..5b7250d9eb 100644 --- a/packages/core/src/BinaryData/FileSystem.manager.ts +++ b/packages/core/src/BinaryData/FileSystem.manager.ts @@ -61,13 +61,13 @@ export class FileSystemManager implements BinaryData.Manager { throw new FileNotFoundError(filePath); } - return fs.readFile(filePath); + return await fs.readFile(filePath); } async getMetadata(fileId: string): Promise { const filePath = this.resolvePath(`${fileId}.metadata`); - return jsonParse(await fs.readFile(filePath, { encoding: 'utf-8' })); + return await jsonParse(await fs.readFile(filePath, { encoding: 'utf-8' })); } async deleteMany(ids: BinaryData.IdsForDeletion) { diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts index 37a0f944d1..f42eba7f29 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ts @@ -34,11 +34,11 @@ export class ObjectStoreManager implements BinaryData.Manager { } async getAsBuffer(fileId: string) { - return this.objectStoreService.get(fileId, { mode: 'buffer' }); + return await this.objectStoreService.get(fileId, { mode: 'buffer' }); } async getAsStream(fileId: string) { - return this.objectStoreService.get(fileId, { mode: 'stream' }); + return await this.objectStoreService.get(fileId, { mode: 'stream' }); } async getMetadata(fileId: string): Promise { @@ -102,6 +102,6 @@ export class ObjectStoreManager implements BinaryData.Manager { } private async toBuffer(bufferOrStream: Buffer | Readable) { - return toBuffer(bufferOrStream); + return await toBuffer(bufferOrStream); } } diff --git a/packages/core/src/BinaryData/utils.ts b/packages/core/src/BinaryData/utils.ts index f1d692067b..3bf99ceaeb 100644 --- a/packages/core/src/BinaryData/utils.ts +++ b/packages/core/src/BinaryData/utils.ts @@ -34,7 +34,7 @@ export async function doesNotExist(dir: string) { export async function toBuffer(body: Buffer | Readable) { if (Buffer.isBuffer(body)) return body; - return new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { body .once('error', (cause) => { if ('code' in cause && cause.code === 'Z_DATA_ERROR') diff --git a/packages/core/src/Credentials.ts b/packages/core/src/Credentials.ts index 9bf58284df..7714df6898 100644 --- a/packages/core/src/Credentials.ts +++ b/packages/core/src/Credentials.ts @@ -41,9 +41,9 @@ export class Credentials extends ICredentials { throw new ApplicationError('No data is set so nothing can be returned.'); } - const decryptedData = this.cipher.decrypt(this.data); - try { + const decryptedData = this.cipher.decrypt(this.data); + return jsonParse(decryptedData); } catch (e) { throw new ApplicationError( diff --git a/packages/core/src/InstanceSettings.ts b/packages/core/src/InstanceSettings.ts index 4731ad91f2..e8ab9aa553 100644 --- a/packages/core/src/InstanceSettings.ts +++ b/packages/core/src/InstanceSettings.ts @@ -2,7 +2,7 @@ import path from 'path'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { createHash, randomBytes } from 'crypto'; import { Service } from 'typedi'; -import { jsonParse } from 'n8n-workflow'; +import { ApplicationError, jsonParse } from 'n8n-workflow'; interface ReadOnlySettings { encryptionKey: string; @@ -14,6 +14,8 @@ interface WritableSettings { type Settings = ReadOnlySettings & WritableSettings; +const inTest = process.env.NODE_ENV === 'test'; + @Service() export class InstanceSettings { private readonly userHome = this.getUserHome(); @@ -57,26 +59,44 @@ export class InstanceSettings { return process.env.N8N_USER_FOLDER ?? process.env[homeVarName] ?? process.cwd(); } + /** + * Load instance settings from the settings file. If missing, create a new + * settings file with an auto-generated encryption key. + */ private loadOrCreate(): Settings { - let settings: Settings; - const { settingsFile } = this; - if (existsSync(settingsFile)) { - const content = readFileSync(settingsFile, 'utf8'); - settings = jsonParse(content, { - errorMessage: `Error parsing n8n-config file "${settingsFile}". It does not seem to be valid JSON.`, + if (existsSync(this.settingsFile)) { + const content = readFileSync(this.settingsFile, 'utf8'); + + const settings = jsonParse(content, { + errorMessage: `Error parsing n8n-config file "${this.settingsFile}". It does not seem to be valid JSON.`, }); - } else { - // Ensure that the `.n8n` folder exists - mkdirSync(this.n8nFolder, { recursive: true }); - // If file doesn't exist, create new settings - const encryptionKey = process.env.N8N_ENCRYPTION_KEY ?? randomBytes(24).toString('base64'); - settings = { encryptionKey }; - this.save(settings); - // console.info(`UserSettings were generated and saved to: ${settingsFile}`); + + if (!inTest) console.info(`User settings loaded from: ${this.settingsFile}`); + + const { encryptionKey, tunnelSubdomain } = settings; + + if (process.env.N8N_ENCRYPTION_KEY && encryptionKey !== process.env.N8N_ENCRYPTION_KEY) { + throw new ApplicationError( + `Mismatching encryption keys. The encryption key in the settings file ${this.settingsFile} does not match the N8N_ENCRYPTION_KEY env var. Please make sure both keys match. More information: https://docs.n8n.io/hosting/environment-variables/configuration-methods/#encryption-key`, + ); + } + + return { encryptionKey, tunnelSubdomain }; } - const { encryptionKey, tunnelSubdomain } = settings; - return { encryptionKey, tunnelSubdomain }; + mkdirSync(this.n8nFolder, { recursive: true }); + + const encryptionKey = process.env.N8N_ENCRYPTION_KEY ?? randomBytes(24).toString('base64'); + + const settings: Settings = { encryptionKey }; + + this.save(settings); + + if (!inTest && !process.env.N8N_ENCRYPTION_KEY) { + console.info(`No encryption key found - Auto-generated and saved to: ${this.settingsFile}`); + } + + return settings; } private generateInstanceId() { diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index f539b30a94..db14c76437 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -151,6 +151,7 @@ import Container from 'typedi'; import type { BinaryData } from './BinaryData/types'; import merge from 'lodash/merge'; import { InstanceSettings } from './InstanceSettings'; +import { toUtcDate } from './utils'; axios.defaults.timeout = 300000; // Prevent axios from adding x-form-www-urlencoded headers by default @@ -753,7 +754,7 @@ export async function proxyRequestToAxios( } }; } else { - requestFn = async () => axios(axiosConfig); + requestFn = async () => await axios(axiosConfig); } try { @@ -968,14 +969,14 @@ export function getBinaryPath(binaryDataId: string): string { * Returns binary file metadata */ export async function getBinaryMetadata(binaryDataId: string): Promise { - return Container.get(BinaryDataService).getMetadata(binaryDataId); + return await Container.get(BinaryDataService).getMetadata(binaryDataId); } /** * Returns binary file stream for piping */ export async function getBinaryStream(binaryDataId: string, chunkSize?: number): Promise { - return Container.get(BinaryDataService).getAsStream(binaryDataId, chunkSize); + return await Container.get(BinaryDataService).getAsStream(binaryDataId, chunkSize); } export function assertBinaryData( @@ -1023,7 +1024,7 @@ export async function getBinaryDataBuffer( inputIndex: number, ): Promise { const binaryData = inputData.main[inputIndex]![itemIndex]!.binary![propertyName]!; - return Container.get(BinaryDataService).getAsBuffer(binaryData); + return await Container.get(BinaryDataService).getAsBuffer(binaryData); } /** @@ -1040,7 +1041,7 @@ export async function setBinaryDataBuffer( workflowId: string, executionId: string, ): Promise { - return Container.get(BinaryDataService).store( + return await Container.get(BinaryDataService).store( workflowId, executionId, bufferOrStream, @@ -1099,7 +1100,7 @@ export async function copyBinaryFile( returnData.fileName = path.parse(filePath).base; } - return Container.get(BinaryDataService).copyBinaryFile( + return await Container.get(BinaryDataService).copyBinaryFile( workflowId, executionId, returnData, @@ -1196,7 +1197,7 @@ async function prepareBinaryData( } } - return setBinaryDataBuffer(returnData, binaryData, workflowId, executionId); + return await setBinaryDataBuffer(returnData, binaryData, workflowId, executionId); } /** @@ -1288,7 +1289,7 @@ export async function requestOAuth2( }); } if (isN8nRequest) { - return this.helpers.httpRequest(newRequestOptions).catch(async (error: AxiosError) => { + return await this.helpers.httpRequest(newRequestOptions).catch(async (error: AxiosError) => { if (error.response?.status === 401) { Logger.debug( `OAuth2 token for "${credentialsType}" used by node "${node.name}" expired. Should revalidate.`, @@ -1345,7 +1346,7 @@ export async function requestOAuth2( }); } - return this.helpers.httpRequest(refreshedRequestOption); + return await this.helpers.httpRequest(refreshedRequestOption); } throw error; }); @@ -1355,7 +1356,7 @@ export async function requestOAuth2( ? 401 : oAuth2Options?.tokenExpiredStatusCode; - return this.helpers + return await this.helpers .request(newRequestOptions) .then((response) => { const requestOptions = newRequestOptions as any; @@ -1432,7 +1433,7 @@ export async function requestOAuth2( }); } - return this.helpers.request(newRequestOptions); + return await this.helpers.request(newRequestOptions); } // Unknown error so simply throw it @@ -1505,10 +1506,10 @@ export async function requestOAuth1( oauth.authorize(requestOptions as unknown as clientOAuth1.RequestOptions, token), ); if (isN8nRequest) { - return this.helpers.httpRequest(requestOptions as IHttpRequestOptions); + return await this.helpers.httpRequest(requestOptions as IHttpRequestOptions); } - return this.helpers.request(requestOptions).catch(async (error: IResponseError) => { + return await this.helpers.request(requestOptions).catch(async (error: IResponseError) => { // Unknown error so simply throw it throw error; }); @@ -2854,10 +2855,7 @@ const getRequestHelperFunctions = ( let contentBody: Exclude; - if ( - newResponse.body?.constructor.name === 'IncomingMessage' && - paginationOptions.binaryResult !== true - ) { + if (newResponse.body instanceof Readable && paginationOptions.binaryResult !== true) { const data = await this.helpers .binaryToBuffer(newResponse.body as Buffer | Readable) .then((body) => body.toString()); @@ -2953,10 +2951,7 @@ const getRequestHelperFunctions = ( // configured to stop on 404 response codes. For that reason we have to throw here // now an error manually if the response code is not a success one. let data = tempResponseData.body; - if ( - data?.constructor.name === 'IncomingMessage' && - paginationOptions.binaryResult !== true - ) { + if (data instanceof Readable && paginationOptions.binaryResult !== true) { data = await this.helpers .binaryToBuffer(tempResponseData.body as Buffer | Readable) .then((body) => body.toString()); @@ -2989,7 +2984,7 @@ const getRequestHelperFunctions = ( requestOptions, additionalCredentialOptions, ): Promise { - return httpRequestWithAuthentication.call( + return await httpRequestWithAuthentication.call( this, credentialsType, requestOptions, @@ -3001,7 +2996,7 @@ const getRequestHelperFunctions = ( }, request: async (uriOrObject, options) => - proxyRequestToAxios(workflow, additionalData, node, uriOrObject, options), + await proxyRequestToAxios(workflow, additionalData, node, uriOrObject, options), async requestWithAuthentication( this, @@ -3009,7 +3004,7 @@ const getRequestHelperFunctions = ( requestOptions, additionalCredentialOptions, ): Promise { - return requestWithAuthentication.call( + return await requestWithAuthentication.call( this, credentialsType, requestOptions, @@ -3025,7 +3020,7 @@ const getRequestHelperFunctions = ( credentialsType: string, requestOptions: OptionsWithUrl | RequestPromiseOptions, ): Promise { - return requestOAuth1.call(this, credentialsType, requestOptions); + return await requestOAuth1.call(this, credentialsType, requestOptions); }, async requestOAuth2( @@ -3034,7 +3029,7 @@ const getRequestHelperFunctions = ( requestOptions: OptionsWithUri | RequestPromiseOptions, oAuth2Options?: IOAuth2Options, ): Promise { - return requestOAuth2.call( + return await requestOAuth2.call( this, credentialsType, requestOptions, @@ -3144,7 +3139,7 @@ const getFileSystemHelperFunctions = (node: INode): FileSystemHelperFunctions => level: 'warning', }); } - return fsWriteFile(filePath, content, { encoding: 'binary', flag }); + return await fsWriteFile(filePath, content, { encoding: 'binary', flag }); }, }); @@ -3153,7 +3148,7 @@ const getNodeHelperFunctions = ( workflowId: string, ): NodeHelperFunctions => ({ copyBinaryFile: async (filePath, fileName, mimeType) => - copyBinaryFile(workflowId, executionId!, filePath, fileName, mimeType), + await copyBinaryFile(workflowId, executionId!, filePath, fileName, mimeType), }); const getBinaryHelperFunctions = ( @@ -3164,11 +3159,11 @@ const getBinaryHelperFunctions = ( getBinaryStream, getBinaryMetadata, binaryToBuffer: async (body: Buffer | Readable) => - Container.get(BinaryDataService).toBuffer(body), + await Container.get(BinaryDataService).toBuffer(body), prepareBinaryData: async (binaryData, filePath, mimeType) => - prepareBinaryData(binaryData, executionId!, workflowId, filePath, mimeType), + await prepareBinaryData(binaryData, executionId!, workflowId, filePath, mimeType), setBinaryDataBuffer: async (data, binaryData) => - setBinaryDataBuffer(data, binaryData, workflowId, executionId!), + await setBinaryDataBuffer(data, binaryData, workflowId, executionId!), copyBinaryFile: async () => { throw new ApplicationError('`copyBinaryFile` has been removed. Please upgrade this node.'); }, @@ -3218,7 +3213,8 @@ export function getExecutePollFunctions( }, getMode: () => mode, getActivationMode: () => activation, - getCredentials: async (type) => getCredentials(workflow, node, type, additionalData, mode), + getCredentials: async (type) => + await getCredentials(workflow, node, type, additionalData, mode), getNodeParameter: ( parameterName: string, fallbackValue?: any, @@ -3280,7 +3276,8 @@ export function getExecuteTriggerFunctions( }, getMode: () => mode, getActivationMode: () => activation, - getCredentials: async (type) => getCredentials(workflow, node, type, additionalData, mode), + getCredentials: async (type) => + await getCredentials(workflow, node, type, additionalData, mode), getNodeParameter: ( parameterName: string, fallbackValue?: any, @@ -3338,7 +3335,7 @@ export function getExecuteFunctions( ...executionCancellationFunctions(abortSignal), getMode: () => mode, getCredentials: async (type, itemIndex) => - getCredentials( + await getCredentials( workflow, node, type, @@ -3370,19 +3367,20 @@ export function getExecuteFunctions( workflowInfo: IExecuteWorkflowInfo, inputData?: INodeExecutionData[], ): Promise { - return additionalData + return await additionalData .executeWorkflow(workflowInfo, additionalData, { parentWorkflowId: workflow.id?.toString(), inputData, parentWorkflowSettings: workflow.settings, node, }) - .then(async (result) => - Container.get(BinaryDataService).duplicateBinaryData( - workflow.id, - additionalData.executionId!, - result, - ), + .then( + async (result) => + await Container.get(BinaryDataService).duplicateBinaryData( + workflow.id, + additionalData.executionId!, + result, + ), ); }, getContext(type: ContextType): IContextObject { @@ -3395,7 +3393,7 @@ export function getExecuteFunctions( // TODO: Not implemented yet, and maybe also not needed inputIndex?: number, ): Promise { - return getInputConnectionData.call( + return await getInputConnectionData.call( this, workflow, runExecutionData, @@ -3487,9 +3485,9 @@ export function getExecuteFunctions( return dataProxy.getDataProxy(); }, binaryToBuffer: async (body: Buffer | Readable) => - Container.get(BinaryDataService).toBuffer(body), + await Container.get(BinaryDataService).toBuffer(body), async putExecutionToWait(waitTill: Date): Promise { - runExecutionData.waitTill = waitTill; + runExecutionData.waitTill = toUtcDate(waitTill, getTimezone(workflow)); if (additionalData.setExecutionStatus) { additionalData.setExecutionStatus('waiting'); } @@ -3586,7 +3584,7 @@ export function getExecuteFunctions( assertBinaryData: (itemIndex, propertyName) => assertBinaryData(inputData, node, itemIndex, propertyName, 0), getBinaryDataBuffer: async (itemIndex, propertyName) => - getBinaryDataBuffer(inputData, itemIndex, propertyName, 0), + await getBinaryDataBuffer(inputData, itemIndex, propertyName, 0), returnJsonArray, normalizeItems, @@ -3637,7 +3635,7 @@ export function getExecuteSingleFunctions( return NodeHelpers.getContext(runExecutionData, type, node); }, getCredentials: async (type) => - getCredentials( + await getCredentials( workflow, node, type, @@ -3731,7 +3729,7 @@ export function getExecuteSingleFunctions( assertBinaryData: (propertyName, inputIndex = 0) => assertBinaryData(inputData, node, itemIndex, propertyName, inputIndex), getBinaryDataBuffer: async (propertyName, inputIndex = 0) => - getBinaryDataBuffer(inputData, itemIndex, propertyName, inputIndex), + await getBinaryDataBuffer(inputData, itemIndex, propertyName, inputIndex), }, }; })(workflow, runExecutionData, connectionInputData, inputData, node, itemIndex); @@ -3741,7 +3739,7 @@ export function getCredentialTestFunctions(): ICredentialTestFunctions { return { helpers: { request: async (uriOrObject: string | object, options?: object) => { - return proxyRequestToAxios(undefined, undefined, undefined, uriOrObject, options); + return await proxyRequestToAxios(undefined, undefined, undefined, uriOrObject, options); }, }, }; @@ -3760,7 +3758,7 @@ export function getLoadOptionsFunctions( return { ...getCommonWorkflowFunctions(workflow, node, additionalData), getCredentials: async (type) => - getCredentials(workflow, node, type, additionalData, 'internal'), + await getCredentials(workflow, node, type, additionalData, 'internal'), getCurrentNodeParameter: ( parameterPath: string, options?: IGetNodeParameterOptions, @@ -3837,7 +3835,8 @@ export function getExecuteHookFunctions( return ((workflow: Workflow, node: INode) => { return { ...getCommonWorkflowFunctions(workflow, node, additionalData), - getCredentials: async (type) => getCredentials(workflow, node, type, additionalData, mode), + getCredentials: async (type) => + await getCredentials(workflow, node, type, additionalData, mode), getMode: () => mode, getActivationMode: () => activation, getNodeParameter: ( @@ -3909,7 +3908,8 @@ export function getExecuteWebhookFunctions( } return additionalData.httpRequest.body; }, - getCredentials: async (type) => getCredentials(workflow, node, type, additionalData, mode), + getCredentials: async (type) => + await getCredentials(workflow, node, type, additionalData, mode), getHeaderData(): IncomingHttpHeaders { if (additionalData.httpRequest === undefined) { throw new ApplicationError('Request is missing'); @@ -3942,7 +3942,7 @@ export function getExecuteWebhookFunctions( }; const runIndex = 0; - return getInputConnectionData.call( + return await getInputConnectionData.call( this, workflow, runExecutionData, diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index bf2757b655..bcfeb47264 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -65,7 +65,7 @@ export class ObjectStoreService { async checkConnection() { if (this.isReady) return; - return this.request('HEAD', this.host, this.bucket.name); + return await this.request('HEAD', this.host, this.bucket.name); } /** @@ -74,7 +74,7 @@ export class ObjectStoreService { * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html */ async put(filename: string, buffer: Buffer, metadata: BinaryData.PreWriteMetadata = {}) { - if (this.isReadOnly) return this.blockWrite(filename); + if (this.isReadOnly) return await this.blockWrite(filename); const headers: Record = { 'Content-Length': buffer.length, @@ -86,7 +86,7 @@ export class ObjectStoreService { const path = `/${this.bucket.name}/${filename}`; - return this.request('PUT', this.host, path, { headers, body: buffer }); + return await this.request('PUT', this.host, path, { headers, body: buffer }); } /** @@ -131,7 +131,7 @@ export class ObjectStoreService { async deleteOne(fileId: string) { const path = `${this.bucket.name}/${fileId}`; - return this.request('DELETE', this.host, path); + return await this.request('DELETE', this.host, path); } /** @@ -156,7 +156,7 @@ export class ObjectStoreService { const path = `${this.bucket.name}/?delete`; - return this.request('POST', this.host, path, { headers, body }); + return await this.request('POST', this.host, path, { headers, body }); } /** diff --git a/packages/core/src/ObjectStore/utils.ts b/packages/core/src/ObjectStore/utils.ts index 1ecad915f9..6dc1e4df77 100644 --- a/packages/core/src/ObjectStore/utils.ts +++ b/packages/core/src/ObjectStore/utils.ts @@ -7,12 +7,12 @@ export function isStream(maybeStream: unknown): maybeStream is Stream { } export async function parseXml(xml: string): Promise { - return parseStringPromise(xml, { + return await (parseStringPromise(xml, { explicitArray: false, ignoreAttrs: true, tagNameProcessors: [firstCharLowerCase], valueProcessors: [parseNumbers, parseBooleans], - }) as Promise; + }) as Promise); } export function writeBlockedMessage(filename: string) { diff --git a/packages/core/src/WorkflowExecute.ts b/packages/core/src/WorkflowExecute.ts index 781aea9fdb..542704e6fe 100644 --- a/packages/core/src/WorkflowExecute.ts +++ b/packages/core/src/WorkflowExecute.ts @@ -308,7 +308,7 @@ export class WorkflowExecute { return; } - return this.additionalData.hooks.executeHookFunctions(hookName, parameters); + return await this.additionalData.hooks.executeHookFunctions(hookName, parameters); } moveNodeMetadata(): void { @@ -1114,6 +1114,12 @@ export class WorkflowExecute { item.error = undefined; } else if (item.json.error && Object.keys(item.json).length === 1) { errorData = item.json.error; + } else if ( + item.json.error && + item.json.message && + Object.keys(item.json).length === 2 + ) { + errorData = item.json.error; } if (errorData) { @@ -1657,14 +1663,19 @@ export class WorkflowExecute { })() .then(async () => { if (this.status === 'canceled' && executionError === undefined) { - return this.processSuccessExecution( + return await this.processSuccessExecution( startedAt, workflow, new WorkflowOperationError('Workflow has been canceled or timed out!'), closeFunction, ); } - return this.processSuccessExecution(startedAt, workflow, executionError, closeFunction); + return await this.processSuccessExecution( + startedAt, + workflow, + executionError, + closeFunction, + ); }) .catch(async (error) => { const fullRunData = this.getFullRunData(startedAt); @@ -1708,7 +1719,7 @@ export class WorkflowExecute { return fullRunData; }); - return returnPromise.then(resolve); + return await returnPromise.then(resolve); }); } diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts new file mode 100644 index 0000000000..991044ad41 --- /dev/null +++ b/packages/core/src/utils.ts @@ -0,0 +1,5 @@ +import { DateTime } from 'luxon'; + +export function toUtcDate(datetime: Date, tz: string) { + return DateTime.fromISO(datetime.toISOString().slice(0, -1), { zone: tz }).toUTC().toJSDate(); +} diff --git a/packages/core/test/InstanceSettings.test.ts b/packages/core/test/InstanceSettings.test.ts index 05899e9c89..414f875274 100644 --- a/packages/core/test/InstanceSettings.test.ts +++ b/packages/core/test/InstanceSettings.test.ts @@ -24,6 +24,12 @@ describe('InstanceSettings', () => { readSpy.mockReturnValue('{"encryptionKey":"test_key"'); expect(() => new InstanceSettings()).toThrowError(); }); + + it('should throw if the env and file keys do not match', () => { + readSpy.mockReturnValue(JSON.stringify({ encryptionKey: 'key_1' })); + process.env.N8N_ENCRYPTION_KEY = 'key_2'; + expect(() => new InstanceSettings()).toThrowError(); + }); }); describe('If the settings file does not exist', () => { diff --git a/packages/core/test/utils.test.ts b/packages/core/test/utils.test.ts new file mode 100644 index 0000000000..a155b72e1c --- /dev/null +++ b/packages/core/test/utils.test.ts @@ -0,0 +1,35 @@ +import { toUtcDate } from '@/utils'; + +describe('utils', () => { + describe('toUtcDate()', () => { + test('should convert to UTC date by adding', () => { + const originalDate = new Date('2020-01-01T00:00:00.000Z'); + const timezone = 'America/New_York'; // +5 to reach Z + + const utcDate = toUtcDate(originalDate, timezone); + + expect(utcDate).toBeInstanceOf(Date); + expect(utcDate.toISOString()).toBe('2020-01-01T05:00:00.000Z'); + }); + + test('should convert to UTC date by subtracting', () => { + const originalDate = new Date('2020-01-01T00:00:00.000Z'); + const timezone = 'Europe/Paris'; // -1 to reach Z + + const utcDate = toUtcDate(originalDate, timezone); + + expect(utcDate).toBeInstanceOf(Date); + expect(utcDate.toISOString()).toBe('2019-12-31T23:00:00.000Z'); + }); + + test('should convert to UTC date when already UTC', () => { + const originalDate = new Date('2020-01-01T00:00:00.000Z'); + const timezone = 'UTC'; // already at Z + + const utcDate = toUtcDate(originalDate, timezone); + + expect(utcDate).toBeInstanceOf(Date); + expect(utcDate.toISOString()).toBe('2020-01-01T00:00:00.000Z'); + }); + }); +}); diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 9addb27a53..f9de0a28f8 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -1,6 +1,6 @@ { "name": "n8n-design-system", - "version": "1.18.0", + "version": "1.19.0", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", "author": { @@ -74,6 +74,7 @@ "sanitize-html": "2.10.0", "vue": "^3.3.4", "vue-boring-avatars": "^1.3.0", + "vue-router": "^4.2.2", "xss": "^1.0.14" } } diff --git a/packages/design-system/src/components/ConditionalRouterLink/CondtionalRouterLink.vue b/packages/design-system/src/components/ConditionalRouterLink/CondtionalRouterLink.vue new file mode 100644 index 0000000000..d479bd5396 --- /dev/null +++ b/packages/design-system/src/components/ConditionalRouterLink/CondtionalRouterLink.vue @@ -0,0 +1,39 @@ + + + diff --git a/packages/design-system/src/components/ConditionalRouterLink/__tests__/ConditionalRouterLink.spec.ts b/packages/design-system/src/components/ConditionalRouterLink/__tests__/ConditionalRouterLink.spec.ts new file mode 100644 index 0000000000..853e06f8a9 --- /dev/null +++ b/packages/design-system/src/components/ConditionalRouterLink/__tests__/ConditionalRouterLink.spec.ts @@ -0,0 +1,67 @@ +import { render } from '@testing-library/vue'; +import { beforeAll, describe } from 'vitest'; +import { createRouter, createWebHistory } from 'vue-router'; +import CondtionalRouterLink from '../CondtionalRouterLink.vue'; + +const slots = { + default: 'Button', +}; + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/', + name: 'home', + redirect: '/home', + }, + ], +}); + +describe('CondtionalRouterLink', () => { + beforeAll(async () => { + await router.push('/'); + + await router.isReady(); + }); + + it("renders router-link when 'to' prop is passed", () => { + const wrapper = render(CondtionalRouterLink, { + props: { + to: { name: 'home' }, + }, + slots, + global: { + plugins: [router], + }, + }); + + expect(wrapper.html()).toMatchSnapshot(); + }); + + it("renders when 'href' attr is passed", () => { + const wrapper = render(CondtionalRouterLink, { + attrs: { + href: 'https://n8n.io', + target: '_blank', + }, + slots, + global: { + plugins: [router], + }, + }); + + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('renders only the slot when neither to nor href is given', () => { + const wrapper = render(CondtionalRouterLink, { + slots, + global: { + plugins: [router], + }, + }); + + expect(wrapper.html()).toMatchSnapshot(); + }); +}); diff --git a/packages/design-system/src/components/ConditionalRouterLink/__tests__/__snapshots__/ConditionalRouterLink.spec.ts.snap b/packages/design-system/src/components/ConditionalRouterLink/__tests__/__snapshots__/ConditionalRouterLink.spec.ts.snap new file mode 100644 index 0000000000..c316667540 --- /dev/null +++ b/packages/design-system/src/components/ConditionalRouterLink/__tests__/__snapshots__/ConditionalRouterLink.spec.ts.snap @@ -0,0 +1,7 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`CondtionalRouterLink > renders when 'href' attr is passed 1`] = `""`; + +exports[`CondtionalRouterLink > renders only the slot when neither to nor href is given 1`] = `"
Button
"`; + +exports[`CondtionalRouterLink > renders router-link when 'to' prop is passed 1`] = `""`; diff --git a/packages/design-system/src/components/ConditionalRouterLink/index.ts b/packages/design-system/src/components/ConditionalRouterLink/index.ts new file mode 100644 index 0000000000..4b8b5b65ad --- /dev/null +++ b/packages/design-system/src/components/ConditionalRouterLink/index.ts @@ -0,0 +1,3 @@ +import CondtionalRouterLink from './CondtionalRouterLink.vue'; + +export default CondtionalRouterLink; diff --git a/packages/design-system/src/components/N8nMenu/Menu.stories.ts b/packages/design-system/src/components/N8nMenu/Menu.stories.ts index 970d55b62f..bca1eff3bd 100644 --- a/packages/design-system/src/components/N8nMenu/Menu.stories.ts +++ b/packages/design-system/src/components/N8nMenu/Menu.stories.ts @@ -114,10 +114,9 @@ const menuItems = [ id: 'website', icon: 'globe', label: 'Website', - type: 'link', - properties: { + link: { href: 'https://www.n8n.io', - newWindow: true, + target: '_blank', }, position: 'bottom', }, @@ -140,10 +139,9 @@ const menuItems = [ id: 'quickstart', icon: 'video', label: 'Quickstart', - type: 'link', - properties: { + link: { href: 'https://www.youtube.com/watch?v=RpjQTGKm-ok', - newWindow: true, + target: '_blank', }, }, ], diff --git a/packages/design-system/src/components/N8nMenu/Menu.vue b/packages/design-system/src/components/N8nMenu/Menu.vue index 36e7dfbc98..9e4da3e1d6 100644 --- a/packages/design-system/src/components/N8nMenu/Menu.vue +++ b/packages/design-system/src/components/N8nMenu/Menu.vue @@ -59,6 +59,7 @@ import N8nMenuItem from '../N8nMenuItem'; import type { PropType } from 'vue'; import { defineComponent } from 'vue'; import type { IMenuItem, RouteObject } from '../../types'; +import { doesMenuItemMatchCurrentRoute } from '../N8nMenuItem/routerUtil'; export default defineComponent({ name: 'N8nMenu', @@ -128,14 +129,10 @@ export default defineComponent({ }, mounted() { if (this.mode === 'router') { - const found = this.items.find((item) => { - return ( - (Array.isArray(item.activateOnRouteNames) && - item.activateOnRouteNames.includes(this.currentRoute.name || '')) || - (Array.isArray(item.activateOnRoutePaths) && - item.activateOnRoutePaths.includes(this.currentRoute.path)) - ); - }); + const found = this.items.find((item) => + doesMenuItemMatchCurrentRoute(item, this.currentRoute), + ); + this.activeTab = found ? found.id : ''; } else { this.activeTab = this.items.length > 0 ? this.items[0].id : ''; @@ -145,19 +142,6 @@ export default defineComponent({ }, methods: { onSelect(item: IMenuItem): void { - if (item && item.type === 'link' && item.properties) { - const href: string = item.properties.href; - if (!href) { - return; - } - - if (item.properties.newWindow) { - window.open(href); - } else { - window.location.assign(item.properties.href); - } - } - if (this.mode === 'tabs') { this.activeTab = item.id; } diff --git a/packages/design-system/src/components/N8nMenuItem/MenuItem.stories.ts b/packages/design-system/src/components/N8nMenuItem/MenuItem.stories.ts index 5ad0429634..bb8df005a9 100644 --- a/packages/design-system/src/components/N8nMenuItem/MenuItem.stories.ts +++ b/packages/design-system/src/components/N8nMenuItem/MenuItem.stories.ts @@ -75,10 +75,9 @@ link.args = { id: 'website', icon: 'globe', label: 'Website', - type: 'link', - properties: { + link: { href: 'https://www.n8n.io', - newWindow: true, + target: '_blank', }, }, }; @@ -96,10 +95,9 @@ withChildren.args = { id: 'quickstart', icon: 'video', label: 'Quickstart', - type: 'link', - properties: { + link: { href: 'https://www.youtube.com/watch?v=RpjQTGKm-ok', - newWindow: true, + target: '_blank', }, }, ], diff --git a/packages/design-system/src/components/N8nMenuItem/MenuItem.vue b/packages/design-system/src/components/N8nMenuItem/MenuItem.vue index 290b363b3d..007325b1c3 100644 --- a/packages/design-system/src/components/N8nMenuItem/MenuItem.vue +++ b/packages/design-system/src/components/N8nMenuItem/MenuItem.vue @@ -40,37 +40,42 @@ :disabled="!compact" :show-after="tooltipDelay" > - - - {{ item.label }} - + - - - + + {{ item.label }} + + + + +
@@ -81,7 +86,9 @@ import N8nTooltip from '../N8nTooltip'; import N8nIcon from '../N8nIcon'; import type { PropType } from 'vue'; import { defineComponent } from 'vue'; +import ConditionalRouterLink from '../ConditionalRouterLink'; import type { IMenuItem, RouteObject } from '../../types'; +import { doesMenuItemMatchCurrentRoute } from './routerUtil'; export default defineComponent({ name: 'N8nMenuItem', @@ -90,6 +97,7 @@ export default defineComponent({ ElMenuItem, N8nIcon, N8nTooltip, + ConditionalRouterLink, }, props: { item: { @@ -115,9 +123,11 @@ export default defineComponent({ }, activeTab: { type: String, + default: undefined, }, handleSelect: { type: Function as PropType<(item: IMenuItem) => void>, + default: undefined, }, }, computed: { @@ -151,18 +161,7 @@ export default defineComponent({ }, isActive(item: IMenuItem): boolean { if (this.mode === 'router') { - if (item.activateOnRoutePaths) { - return ( - Array.isArray(item.activateOnRoutePaths) && - item.activateOnRoutePaths.includes(this.currentRoute.path) - ); - } else if (item.activateOnRouteNames) { - return ( - Array.isArray(item.activateOnRouteNames) && - item.activateOnRouteNames.includes(this.currentRoute.name || '') - ); - } - return false; + return doesMenuItemMatchCurrentRoute(item, this.currentRoute); } else { return item.id === this.activeTab; } @@ -278,6 +277,7 @@ export default defineComponent({ align-items: center; justify-content: flex-end; flex: 1; + margin-left: 20px; } .label { diff --git a/packages/design-system/src/components/N8nMenuItem/routerUtil.ts b/packages/design-system/src/components/N8nMenuItem/routerUtil.ts new file mode 100644 index 0000000000..64627db787 --- /dev/null +++ b/packages/design-system/src/components/N8nMenuItem/routerUtil.ts @@ -0,0 +1,42 @@ +import type { IMenuItem, RouteObject } from '@/types'; +import type { RouteLocationRaw } from 'vue-router'; + +/** + * Checks if the given menu item matches the current route. + */ +export function doesMenuItemMatchCurrentRoute(item: IMenuItem, currentRoute: RouteObject) { + let activateOnRouteNames: string[] = []; + if (Array.isArray(item.activateOnRouteNames)) { + activateOnRouteNames = item.activateOnRouteNames; + } else if (item.route && isNamedRouteLocation(item.route.to)) { + activateOnRouteNames = [item.route.to.name]; + } + + let activateOnRoutePaths: string[] = []; + if (Array.isArray(item.activateOnRoutePaths)) { + activateOnRoutePaths = item.activateOnRoutePaths; + } else if (item.route && isPathRouteLocation(item.route.to)) { + activateOnRoutePaths = [item.route.to.path]; + } + + return ( + activateOnRouteNames.includes(currentRoute.name ?? '') || + activateOnRoutePaths.includes(currentRoute.path) + ); +} + +function isPathRouteLocation(routeLocation?: RouteLocationRaw): routeLocation is { path: string } { + return ( + typeof routeLocation === 'object' && + 'path' in routeLocation && + typeof routeLocation.path === 'string' + ); +} + +function isNamedRouteLocation(routeLocation?: RouteLocationRaw): routeLocation is { name: string } { + return ( + typeof routeLocation === 'object' && + 'name' in routeLocation && + typeof routeLocation.name === 'string' + ); +} diff --git a/packages/design-system/src/components/N8nRadioButtons/RadioButtons.vue b/packages/design-system/src/components/N8nRadioButtons/RadioButtons.vue index b4cdb732b5..83acba928e 100644 --- a/packages/design-system/src/components/N8nRadioButtons/RadioButtons.vue +++ b/packages/design-system/src/components/N8nRadioButtons/RadioButtons.vue @@ -10,7 +10,7 @@ :active="modelValue === option.value" :size="size" :disabled="disabled || option.disabled" - @click.prevent.stop="onClick(option)" + @click.prevent.stop="onClick(option, $event)" /> @@ -47,12 +47,13 @@ export default defineComponent({ type: Boolean, }, }, + emits: ['update:modelValue'], methods: { - onClick(option: { label: string; value: string; disabled?: boolean }) { + onClick(option: { label: string; value: string; disabled?: boolean }, event: MouseEvent) { if (this.disabled || option.disabled) { return; } - this.$emit('update:modelValue', option.value); + this.$emit('update:modelValue', option.value, event); }, }, }); diff --git a/packages/design-system/src/components/N8nTooltip/Tooltip.vue b/packages/design-system/src/components/N8nTooltip/Tooltip.vue index a338ea8744..fa280f4d9f 100644 --- a/packages/design-system/src/components/N8nTooltip/Tooltip.vue +++ b/packages/design-system/src/components/N8nTooltip/Tooltip.vue @@ -3,7 +3,7 @@ - +