diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index feb39f1f4f..4e77fbd580 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -1,6 +1,8 @@ name: Chromatic on: + schedule: + - cron: '0 0 * * *' workflow_dispatch: pull_request_review: types: [submitted] @@ -70,7 +72,7 @@ jobs: exitZeroOnChanges: false - name: Success comment - if: steps.chromatic_tests.outcome == 'success' + if: steps.chromatic_tests.outcome == 'success' && github.ref != 'refs/heads/master' uses: peter-evans/create-or-update-comment@v4.0.0 with: issue-number: ${{ github.event.pull_request.number }} @@ -80,7 +82,7 @@ jobs: :white_check_mark: No visual regressions found. - name: Fail comment - if: steps.chromatic_tests.outcome != 'success' + if: steps.chromatic_tests.outcome != 'success' && github.ref != 'refs/heads/master' uses: peter-evans/create-or-update-comment@v4.0.0 with: issue-number: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/ci-master.yml b/.github/workflows/ci-master.yml index 5e828a7022..b6972e1932 100644 --- a/.github/workflows/ci-master.yml +++ b/.github/workflows/ci-master.yml @@ -47,6 +47,7 @@ jobs: nodeVersion: ${{ matrix.node-version }} cacheKey: ${{ github.sha }}-base:build collectCoverage: ${{ matrix.node-version == '20.x' }} + ignoreTurboCache: ${{ matrix.node-version == '20.x' }} secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/ci-postgres-mysql.yml b/.github/workflows/ci-postgres-mysql.yml index bca99ebb65..f6e5e773cb 100644 --- a/.github/workflows/ci-postgres-mysql.yml +++ b/.github/workflows/ci-postgres-mysql.yml @@ -106,7 +106,7 @@ jobs: - name: Test MariaDB working-directory: packages/cli - run: pnpm test:mariadb --testTimeout 20000 + run: pnpm test:mariadb --testTimeout 30000 postgres: name: Postgres diff --git a/.github/workflows/units-tests-reusable.yml b/.github/workflows/units-tests-reusable.yml index 60bf593e82..62eca74b15 100644 --- a/.github/workflows/units-tests-reusable.yml +++ b/.github/workflows/units-tests-reusable.yml @@ -22,6 +22,10 @@ on: required: false default: false type: boolean + ignoreTurboCache: + required: false + default: false + type: boolean secrets: CODECOV_TOKEN: description: 'Codecov upload token.' @@ -32,6 +36,7 @@ jobs: name: Unit tests runs-on: ubuntu-latest env: + TURBO_FORCE: ${{ inputs.ignoreTurboCache }} COVERAGE_ENABLED: ${{ inputs.collectCoverage }} steps: - uses: actions/checkout@v4.1.1 @@ -49,7 +54,6 @@ jobs: run: pnpm install --frozen-lockfile - name: Setup build cache - if: inputs.collectCoverage != true uses: rharkor/caching-for-turbo@v1.5 - name: Build @@ -74,6 +78,6 @@ jobs: - name: Upload coverage to Codecov if: inputs.collectCoverage - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v5.1.2 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/LICENSE.md b/LICENSE.md index aab68b6d93..f85f59baa9 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -3,9 +3,11 @@ Portions of this software are licensed as follows: - Content of branches other than the main branch (i.e. "master") are not licensed. -- Source code files that contain ".ee." in their filename are NOT licensed under the Sustainable Use License. - To use source code files that contain ".ee." in their filename you must hold a valid n8n Enterprise License - specifically allowing you access to such source code files and as defined in "LICENSE_EE.md". +- Source code files that contain ".ee." in their filename or ".ee" in their dirname are NOT licensed under + the Sustainable Use License. + To use source code files that contain ".ee." in their filename or ".ee" in their dirname you must hold a + valid n8n Enterprise License specifically allowing you access to such source code files and as defined + in "LICENSE_EE.md". - All third party components incorporated into the n8n Software are licensed under the original license provided by the owner of the applicable component. - Content outside of the above mentioned files or restrictions is available under the "Sustainable Use diff --git a/README.md b/README.md index c41a5e5ac8..8d01e943d3 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,16 @@ n8n is a workflow automation platform that gives technical teams the flexibility Try n8n instantly with [npx](https://docs.n8n.io/hosting/installation/npm/) (requires [Node.js](https://nodejs.org/en/)): -`npx n8n` +``` +npx n8n +``` Or deploy with [Docker](https://docs.n8n.io/hosting/installation/docker/): -`docker run -it --rm --name n8n -p 5678:5678 docker.n8n.io/n8n-io/n8n` +``` +docker volume create n8n_data +docker run -it --rm --name n8n -p 5678:5678 -v n8n_data:/home/node/.n8n docker.n8n.io/n8nio/n8n +``` Access the editor at http://localhost:5678 diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000000..362ebcb760 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,60 @@ +codecov: + max_report_age: off + require_ci_to_pass: true + +coverage: + status: + patch: false + project: + default: + threshold: 0.5% + +github_checks: + annotations: false + +flags: + tests: + paths: + - "**" + carryforward: true + +component_management: + default_rules: + statuses: + - type: project + target: auto + branches: + - "!master" + individual_components: + - component_id: backend_packages + name: Backend + paths: + - packages/@n8n/api-types/** + - packages/@n8n/config/** + - packages/@n8n/client-oauth2/** + - packages/@n8n/di/** + - packages/@n8n/imap/** + - packages/@n8n/permissions/** + - packages/@n8n/task-runner/** + - packages/workflow/** + - packages/core/** + - packages/cli/** + - component_id: frontend_packages + name: Frontend + paths: + - packages/@n8n/chat/** + - packages/@n8n/codemirror-lang/** + - packages/design-system/** + - packages/editor-ui/** + - component_id: nodes_packages + name: Nodes + paths: + - packages/node-dev/** + - packages/nodes-base/** + - packages/@n8n/json-schema-to-zod/** + - packages/@n8n/nodes-langchain/** + +ignore: + - (?s:.*/[^\/]*\.spec\.ts.*)\Z + - (?s:.*/[^\/]*\.test\.ts.*)\Z + - (?s:.*/[^\/]*e2e[^\/]*\.ts.*)\Z diff --git a/cypress/composables/ndv.ts b/cypress/composables/ndv.ts index 4819e7fccc..90146ab374 100644 --- a/cypress/composables/ndv.ts +++ b/cypress/composables/ndv.ts @@ -2,7 +2,7 @@ * Getters */ -import { getVisibleSelect } from '../utils'; +import { getVisibleSelect } from '../utils/popper'; export function getCredentialSelect(eq = 0) { return cy.getByTestId('node-credentials-select').eq(eq); diff --git a/cypress/composables/projects.ts b/cypress/composables/projects.ts index 3e1b2fd46a..09d7a341ce 100644 --- a/cypress/composables/projects.ts +++ b/cypress/composables/projects.ts @@ -29,7 +29,11 @@ export const getAddProjectButton = () => { return cy.get('@button'); }; - +export const getAddFirstProjectButton = () => cy.getByTestId('add-first-project-button'); +export const getIconPickerButton = () => cy.getByTestId('icon-picker-button'); +export const getIconPickerTab = (tab: string) => cy.getByTestId('icon-picker-tabs').contains(tab); +export const getIconPickerIcons = () => cy.getByTestId('icon-picker-icon'); +export const getIconPickerEmojis = () => cy.getByTestId('icon-picker-emoji'); // export const getAddProjectButton = () => // cy.getByTestId('universal-add').should('contain', 'Add project').should('be.visible'); export const getProjectTabs = () => cy.getByTestId('project-tabs').find('a'); diff --git a/cypress/composables/workflow.ts b/cypress/composables/workflow.ts index bc27048219..29b871f560 100644 --- a/cypress/composables/workflow.ts +++ b/cypress/composables/workflow.ts @@ -1,4 +1,5 @@ import { getManualChatModal } from './modals/chat-modal'; +import { clickGetBackToCanvas, getParameterInputByName } from './ndv'; import { ROUTES } from '../constants'; /** @@ -127,7 +128,7 @@ export function navigateToNewWorkflowPage(preventNodeViewUnload = true) { }); } -export function addSupplementalNodeToParent( +function connectNodeToParent( nodeName: string, endpointType: EndpointType, parentNodeName: string, @@ -141,6 +142,15 @@ export function addSupplementalNodeToParent( } else { getNodeCreatorItems().contains(nodeName).click(); } +} + +export function addSupplementalNodeToParent( + nodeName: string, + endpointType: EndpointType, + parentNodeName: string, + exactMatch = false, +) { + connectNodeToParent(nodeName, endpointType, parentNodeName, exactMatch); getConnectionBySourceAndTarget(parentNodeName, nodeName).should('exist'); } @@ -160,6 +170,15 @@ export function addToolNodeToParent(nodeName: string, parentNodeName: string) { addSupplementalNodeToParent(nodeName, 'ai_tool', parentNodeName); } +export function addVectorStoreToolToParent(nodeName: string, parentNodeName: string) { + connectNodeToParent(nodeName, 'ai_tool', parentNodeName, false); + getParameterInputByName('mode') + .find('input') + .should('have.value', 'Retrieve Documents (As Tool for AI Agent)'); + clickGetBackToCanvas(); + getConnectionBySourceAndTarget(nodeName, parentNodeName).should('exist'); +} + export function addOutputParserNodeToParent(nodeName: string, parentNodeName: string) { addSupplementalNodeToParent(nodeName, 'ai_outputParser', parentNodeName); } diff --git a/cypress/e2e/14-mapping.cy.ts b/cypress/e2e/14-mapping.cy.ts index e19959453f..4ebce02d9d 100644 --- a/cypress/e2e/14-mapping.cy.ts +++ b/cypress/e2e/14-mapping.cy.ts @@ -41,7 +41,9 @@ describe('Data mapping', () => { ndv.actions.mapDataFromHeader(1, 'value'); ndv.getters.inlineExpressionEditorInput().should('have.text', '{{ $json.timestamp }}'); ndv.getters.inlineExpressionEditorInput().type('{esc}'); - ndv.getters.parameterExpressionPreview('value').should('include.text', '2024'); + ndv.getters + .parameterExpressionPreview('value') + .should('include.text', new Date().getFullYear()); ndv.actions.mapDataFromHeader(2, 'value'); ndv.getters @@ -113,6 +115,8 @@ describe('Data mapping', () => { }); it('maps expressions from json view', () => { + // ADO-3063 - followup to make this viewport global + cy.viewport('macbook-16'); cy.fixture('Test_workflow_3.json').then((data) => { cy.get('body').paste(JSON.stringify(data)); }); @@ -121,17 +125,17 @@ describe('Data mapping', () => { workflowPage.actions.openNode('Set'); ndv.actions.switchInputMode('JSON'); + ndv.getters.inputDataContainer().should('exist'); + ndv.getters .inputDataContainer() - .should('exist') .find('.json-data') .should( 'have.text', '[{"input": [{"count": 0,"with space": "!!","with.dot": "!!","with"quotes": "!!"}]},{"input": [{"count": 1}]}]', - ) - .find('span') - .contains('"count"') - .realMouseDown(); + ); + + ndv.getters.inputDataContainer().find('span').contains('"count"').realMouseDown(); ndv.actions.mapToParameter('value'); ndv.getters.inlineExpressionEditorInput().should('have.text', '{{ $json.input[0].count }}'); diff --git a/cypress/e2e/39-projects.cy.ts b/cypress/e2e/39-projects.cy.ts index 327bff4f93..efd18bca74 100644 --- a/cypress/e2e/39-projects.cy.ts +++ b/cypress/e2e/39-projects.cy.ts @@ -15,7 +15,7 @@ import { NDV, MainSidebar, } from '../pages'; -import { clearNotifications } from '../pages/notifications'; +import { clearNotifications, successToast } from '../pages/notifications'; import { getVisibleDropdown, getVisibleModalOverlay, getVisibleSelect } from '../utils'; const workflowsPage = new WorkflowsPage(); @@ -830,4 +830,23 @@ describe('Projects', { disableAutoLogin: true }, () => { .should('not.have.length'); }); }); + + it('should set and update project icon', () => { + const DEFAULT_ICON = 'fa-layer-group'; + const NEW_PROJECT_NAME = 'Test Project'; + + cy.signinAsAdmin(); + cy.visit(workflowsPage.url); + projects.createProject(NEW_PROJECT_NAME); + // New project should have default icon + projects.getIconPickerButton().find('svg').should('have.class', DEFAULT_ICON); + // Choose another icon + projects.getIconPickerButton().click(); + projects.getIconPickerTab('Emojis').click(); + projects.getIconPickerEmojis().first().click(); + // Project should be updated with new icon + successToast().contains('Project icon updated successfully'); + projects.getIconPickerButton().should('contain', '😀'); + projects.getMenuItems().contains(NEW_PROJECT_NAME).should('contain', '😀'); + }); }); diff --git a/cypress/e2e/4-node-creator.cy.ts b/cypress/e2e/4-node-creator.cy.ts index a2cd5968d1..e841605863 100644 --- a/cypress/e2e/4-node-creator.cy.ts +++ b/cypress/e2e/4-node-creator.cy.ts @@ -1,10 +1,12 @@ +import { clickGetBackToCanvas } from '../composables/ndv'; import { addNodeToCanvas, addRetrieverNodeToParent, addVectorStoreNodeToParent, + addVectorStoreToolToParent, getNodeCreatorItems, } from '../composables/workflow'; -import { IF_NODE_NAME } from '../constants'; +import { AGENT_NODE_NAME, IF_NODE_NAME, MANUAL_CHAT_TRIGGER_NODE_NAME } from '../constants'; import { NodeCreator } from '../pages/features/node-creator'; import { NDV } from '../pages/ndv'; import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; @@ -536,7 +538,7 @@ describe('Node Creator', () => { }); }); - it('should add node directly for sub-connection', () => { + it('should add node directly for sub-connection as vector store', () => { addNodeToCanvas('Question and Answer Chain', true); addRetrieverNodeToParent('Vector Store Retriever', 'Question and Answer Chain'); cy.realPress('Escape'); @@ -544,4 +546,12 @@ describe('Node Creator', () => { cy.realPress('Escape'); WorkflowPage.getters.canvasNodes().should('have.length', 4); }); + + it('should add node directly for sub-connection as tool', () => { + addNodeToCanvas(MANUAL_CHAT_TRIGGER_NODE_NAME, true); + addNodeToCanvas(AGENT_NODE_NAME, true, true); + clickGetBackToCanvas(); + + addVectorStoreToolToParent('In-Memory Vector Store', AGENT_NODE_NAME); + }); }); diff --git a/cypress/e2e/48-subworkflow-inputs.cy.ts b/cypress/e2e/48-subworkflow-inputs.cy.ts new file mode 100644 index 0000000000..0e2755b9f0 --- /dev/null +++ b/cypress/e2e/48-subworkflow-inputs.cy.ts @@ -0,0 +1,288 @@ +import { clickGetBackToCanvas, getOutputTableHeaders } from '../composables/ndv'; +import { + clickZoomToFit, + navigateToNewWorkflowPage, + openNode, + pasteWorkflow, + saveWorkflowOnButtonClick, +} from '../composables/workflow'; +import SUB_WORKFLOW_INPUTS from '../fixtures/Test_Subworkflow-Inputs.json'; +import { NDV, WorkflowsPage, WorkflowPage } from '../pages'; +import { errorToast, successToast } from '../pages/notifications'; +import { getVisiblePopper } from '../utils'; + +const ndv = new NDV(); +const workflowsPage = new WorkflowsPage(); +const workflow = new WorkflowPage(); + +const DEFAULT_WORKFLOW_NAME = 'My workflow'; +const DEFAULT_SUBWORKFLOW_NAME_1 = 'My Sub-Workflow 1'; +const DEFAULT_SUBWORKFLOW_NAME_2 = 'My Sub-Workflow 2'; + +type FieldRow = readonly string[]; + +const exampleFields = [ + ['aNumber', 'Number'], + ['aString', 'String'], + ['aArray', 'Array'], + ['aObject', 'Object'], + ['aAny', 'Allow Any Type'], + // bool last since it's not an inputField so we'll skip it for some cases + ['aBool', 'Boolean'], +] as const; + +/** + * Populate multiValue fixedCollections. Only supports fixedCollections for which all fields can be defined via keyboard typing + * + * @param items - 2D array of items to populate, i.e. [["myField1", "String"], [""] + * @param collectionName - name of the fixedCollection to populate + * @param offset - amount of 'parameter-input's before the fixedCollection under test + * @returns + */ +function populateFixedCollection( + items: readonly FieldRow[], + collectionName: string, + offset: number, +) { + if (items.length === 0) return; + const n = items[0].length; + for (const [i, params] of items.entries()) { + ndv.actions.addItemToFixedCollection(collectionName); + for (const [j, param] of params.entries()) { + ndv.getters + .fixedCollectionParameter(collectionName) + .getByTestId('parameter-input') + .eq(offset + i * n + j) + .type(`${param}{downArrow}{enter}`); + } + } +} + +function makeExample(type: TypeField) { + switch (type) { + case 'String': + return '"example"'; + case 'Number': + return '42'; + case 'Boolean': + return 'true'; + case 'Array': + return '["example", 123, null]'; + case 'Object': + return '{{}"example": [123]}'; + case 'Allow Any Type': + return 'null'; + } +} + +type TypeField = 'Allow Any Type' | 'String' | 'Number' | 'Boolean' | 'Array' | 'Object'; +function populateFields(items: ReadonlyArray) { + populateFixedCollection(items, 'workflowInputs', 1); +} + +function navigateWorkflowSelectionDropdown(index: number, expectedText: string) { + ndv.getters.resourceLocator('workflowId').should('be.visible'); + ndv.getters.resourceLocatorInput('workflowId').click(); + + getVisiblePopper().findChildByTestId('rlc-item').eq(0).should('exist'); + getVisiblePopper() + .findChildByTestId('rlc-item') + .eq(index) + .find('span') + .should('have.text', expectedText) + .click(); +} + +function populateMapperFields(values: readonly string[], offset: number) { + for (const [i, value] of values.entries()) { + cy.getByTestId('parameter-input') + .eq(offset + i) + .type(value); + + // Click on a parent to dismiss the pop up hiding the field below. + cy.getByTestId('parameter-input') + .eq(offset + i) + .parent() + .parent() + .click('topLeft'); + } +} + +// This function starts off in the Child Workflow Input Trigger, assuming we just defined the input fields +// It then navigates back to the parent and validates output +function validateAndReturnToParent(targetChild: string, offset: number, fields: string[]) { + ndv.actions.execute(); + + // + 1 to account for formatting-only column + getOutputTableHeaders().should('have.length', fields.length + 1); + for (const [i, name] of fields.entries()) { + getOutputTableHeaders().eq(i).should('have.text', name); + } + + clickGetBackToCanvas(); + saveWorkflowOnButtonClick(); + + cy.visit(workflowsPage.url); + + workflowsPage.getters.workflowCardContent(DEFAULT_WORKFLOW_NAME).click(); + + openNode('Execute Workflow'); + + // Note that outside of e2e tests this will be pre-selected correctly. + // Due to our workaround to remain in the same tab we need to select the correct tab manually + navigateWorkflowSelectionDropdown(offset, targetChild); + + // This fails, pointing to `usePushConnection` `const triggerNode = subWorkflow?.nodes.find` being `undefined.find()`I + ndv.actions.execute(); + + getOutputTableHeaders().should('have.length', fields.length + 1); + for (const [i, name] of fields.entries()) { + getOutputTableHeaders().eq(i).should('have.text', name); + } + + // todo: verify the fields appear and show the correct types + + // todo: fill in the input fields (and mock previous node data in the json fixture to match) + + // todo: validate the actual output data +} + +function setWorkflowInputFieldValue(index: number, value: string) { + ndv.actions.addItemToFixedCollection('workflowInputs'); + ndv.actions.typeIntoFixedCollectionItem('workflowInputs', index, value); +} + +describe('Sub-workflow creation and typed usage', () => { + beforeEach(() => { + navigateToNewWorkflowPage(); + pasteWorkflow(SUB_WORKFLOW_INPUTS); + saveWorkflowOnButtonClick(); + clickZoomToFit(); + + openNode('Execute Workflow'); + + // Prevent sub-workflow from opening in new window + cy.window().then((win) => { + cy.stub(win, 'open').callsFake((url) => { + cy.visit(url); + }); + }); + navigateWorkflowSelectionDropdown(0, 'Create a new sub-workflow'); + // ************************** + // NAVIGATE TO CHILD WORKFLOW + // ************************** + + openNode('Workflow Input Trigger'); + }); + + it('works with type-checked values', () => { + populateFields(exampleFields); + + validateAndReturnToParent( + DEFAULT_SUBWORKFLOW_NAME_1, + 1, + exampleFields.map((f) => f[0]), + ); + + const values = [ + '-1', // number fields don't support `=` switch to expression, so let's test the Fixed case with it + ...exampleFields.slice(1).map((x) => `={{}{{} $json.a${x[0]}`), // }} are added automatically + ]; + + // this matches with the pinned data provided in the fixture + populateMapperFields(values, 2); + + ndv.actions.execute(); + + // todo: + // - validate output lines up + // - change input to need casts + // - run + // - confirm error + // - switch `attemptToConvertTypes` flag + // - confirm success and changed output + // - change input to be invalid despite cast + // - run + // - confirm error + // - switch type option flags + // - run + // - confirm success + // - turn off attempt to cast flag + // - confirm a value was not cast + }); + + it('works with Fields input source into JSON input source', () => { + ndv.getters.nodeOutputHint().should('exist'); + + populateFields(exampleFields); + + validateAndReturnToParent( + DEFAULT_SUBWORKFLOW_NAME_1, + 1, + exampleFields.map((f) => f[0]), + ); + + cy.window().then((win) => { + cy.stub(win, 'open').callsFake((url) => { + cy.visit(url); + }); + }); + navigateWorkflowSelectionDropdown(0, 'Create a new sub-workflow'); + + openNode('Workflow Input Trigger'); + + cy.getByTestId('parameter-input').eq(0).click(); + + // Todo: Check if there's a better way to interact with option dropdowns + // This PR would add this child testId + getVisiblePopper() + .getByTestId('parameter-input') + .eq(0) + .type('Using JSON Example{downArrow}{enter}'); + + const exampleJson = + '{{}' + exampleFields.map((x) => `"${x[0]}": ${makeExample(x[1])}`).join(',') + '}'; + cy.getByTestId('parameter-input-jsonExample') + .find('.cm-line') + .eq(0) + .type(`{selectAll}{backspace}${exampleJson}{enter}`); + + // first one doesn't work for some reason, might need to wait for something? + ndv.actions.execute(); + + validateAndReturnToParent( + DEFAULT_SUBWORKFLOW_NAME_2, + 2, + exampleFields.map((f) => f[0]), + ); + + // test for either InputSource mode and options combinations: + // + we're showing the notice in the output panel + // + we start with no fields + // + Test Step works and we create the fields + // + create field of each type (string, number, boolean, object, array, any) + // + exit ndv + // + save + // + go back to parent workflow + // - verify fields appear [needs Ivan's PR] + // - link fields [needs Ivan's PR] + // + run parent + // - verify output with `null` defaults exists + // + }); + + it('should show node issue when no fields are defined in manual mode', () => { + ndv.getters.nodeExecuteButton().should('be.disabled'); + ndv.actions.close(); + // Executing the workflow should show an error toast + workflow.actions.executeWorkflow(); + errorToast().should('contain', 'The workflow has issues'); + openNode('Workflow Input Trigger'); + // Add a field to the workflowInputs fixedCollection + setWorkflowInputFieldValue(0, 'test'); + // Executing the workflow should not show error now + ndv.actions.close(); + workflow.actions.executeWorkflow(); + successToast().should('contain', 'Workflow executed successfully'); + }); +}); diff --git a/cypress/fixtures/Test_Subworkflow-Inputs.json b/cypress/fixtures/Test_Subworkflow-Inputs.json new file mode 100644 index 0000000000..aeb4d601fd --- /dev/null +++ b/cypress/fixtures/Test_Subworkflow-Inputs.json @@ -0,0 +1,70 @@ +{ + "meta": { + "instanceId": "4d0676b62208d810ef035130bbfc9fd3afdc78d963ea8ccb9514dc89066efc94" + }, + "nodes": [ + { + "parameters": {}, + "id": "bb7f8bb3-840a-464c-a7de-d3a80538c2be", + "name": "When clicking ‘Test workflow’", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0] + }, + { + "parameters": { + "workflowId": {}, + "workflowInputs": { + "mappingMode": "defineBelow", + "value": {}, + "matchingColumns": [], + "schema": [], + "ignoreTypeMismatchErrors": false, + "attemptToConvertTypes": false, + "convertFieldsToString": true + }, + "options": {} + }, + "type": "n8n-nodes-base.executeWorkflow", + "typeVersion": 1.2, + "position": [500, 240], + "id": "6b6e2e34-c6ab-4083-b8e3-6b0d56be5453", + "name": "Execute Workflow" + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "Execute Workflow", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": { + "When clicking ‘Test workflow’": [ + { + "aaString": "A String", + "aaNumber": 1, + "aaArray": [1, true, "3"], + "aaObject": { + "aKey": -1 + }, + "aaAny": {} + }, + { + "aaString": "Another String", + "aaNumber": 2, + "aaArray": [], + "aaObject": { + "aDifferentKey": -1 + }, + "aaAny": [] + } + ] + } +} diff --git a/cypress/pages/ndv.ts b/cypress/pages/ndv.ts index 4550da8e2a..1926ef0ad1 100644 --- a/cypress/pages/ndv.ts +++ b/cypress/pages/ndv.ts @@ -320,6 +320,11 @@ export class NDV extends BasePage { addItemToFixedCollection: (paramName: string) => { this.getters.fixedCollectionParameter(paramName).getByTestId('fixed-collection-add').click(); }, + typeIntoFixedCollectionItem: (fixedCollectionName: string, index: number, content: string) => { + this.getters.fixedCollectionParameter(fixedCollectionName).within(() => { + cy.getByTestId('parameter-input').eq(index).type(content); + }); + }, dragMainPanelToLeft: () => { cy.drag('[data-test-id=panel-drag-button]', [-1000, 0], { moveTwice: true }); }, diff --git a/package.json b/package.json index 063accd855..90e48e9bd8 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,6 @@ "ws": ">=8.17.1" }, "patchedDependencies": { - "typedi@0.10.0": "patches/typedi@0.10.0.patch", "pkce-challenge@3.0.0": "patches/pkce-challenge@3.0.0.patch", "pyodide@0.23.4": "patches/pyodide@0.23.4.patch", "@types/express-serve-static-core@4.17.43": "patches/@types__express-serve-static-core@4.17.43.patch", diff --git a/packages/@n8n/api-types/package.json b/packages/@n8n/api-types/package.json index c14e189922..299986a5c9 100644 --- a/packages/@n8n/api-types/package.json +++ b/packages/@n8n/api-types/package.json @@ -27,6 +27,6 @@ "dependencies": { "xss": "catalog:", "zod": "catalog:", - "zod-class": "0.0.15" + "zod-class": "0.0.16" } } diff --git a/packages/@n8n/api-types/src/dto/ai/__tests__/ai-apply-suggestion-request.dto.test.ts b/packages/@n8n/api-types/src/dto/ai/__tests__/ai-apply-suggestion-request.dto.test.ts new file mode 100644 index 0000000000..568900e409 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/ai/__tests__/ai-apply-suggestion-request.dto.test.ts @@ -0,0 +1,36 @@ +import { AiApplySuggestionRequestDto } from '../ai-apply-suggestion-request.dto'; + +describe('AiApplySuggestionRequestDto', () => { + it('should validate a valid suggestion application request', () => { + const validRequest = { + sessionId: 'session-123', + suggestionId: 'suggestion-456', + }; + + const result = AiApplySuggestionRequestDto.safeParse(validRequest); + + expect(result.success).toBe(true); + }); + + it('should fail if sessionId is missing', () => { + const invalidRequest = { + suggestionId: 'suggestion-456', + }; + + const result = AiApplySuggestionRequestDto.safeParse(invalidRequest); + + expect(result.success).toBe(false); + expect(result.error?.issues[0].path).toEqual(['sessionId']); + }); + + it('should fail if suggestionId is missing', () => { + const invalidRequest = { + sessionId: 'session-123', + }; + + const result = AiApplySuggestionRequestDto.safeParse(invalidRequest); + + expect(result.success).toBe(false); + expect(result.error?.issues[0].path).toEqual(['suggestionId']); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/ai/__tests__/ai-ask-request.dto.test.ts b/packages/@n8n/api-types/src/dto/ai/__tests__/ai-ask-request.dto.test.ts new file mode 100644 index 0000000000..a87eb5f3a4 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/ai/__tests__/ai-ask-request.dto.test.ts @@ -0,0 +1,252 @@ +import { AiAskRequestDto } from '../ai-ask-request.dto'; + +describe('AiAskRequestDto', () => { + const validRequest = { + question: 'How can I improve this workflow?', + context: { + schema: [ + { + nodeName: 'TestNode', + schema: { + type: 'string', + key: 'testKey', + value: 'testValue', + path: '/test/path', + }, + }, + ], + inputSchema: { + nodeName: 'InputNode', + schema: { + type: 'object', + key: 'inputKey', + value: [ + { + type: 'string', + key: 'nestedKey', + value: 'nestedValue', + path: '/nested/path', + }, + ], + path: '/input/path', + }, + }, + pushRef: 'push-123', + ndvPushRef: 'ndv-push-456', + }, + forNode: 'TestWorkflowNode', + }; + + it('should validate a valid AI ask request', () => { + const result = AiAskRequestDto.safeParse(validRequest); + + expect(result.success).toBe(true); + }); + + it('should fail if question is missing', () => { + const invalidRequest = { + ...validRequest, + question: undefined, + }; + + const result = AiAskRequestDto.safeParse(invalidRequest); + + expect(result.success).toBe(false); + expect(result.error?.issues[0].path).toEqual(['question']); + }); + + it('should fail if context is invalid', () => { + const invalidRequest = { + ...validRequest, + context: { + ...validRequest.context, + schema: [ + { + nodeName: 'TestNode', + schema: { + type: 'invalid-type', // Invalid type + value: 'testValue', + path: '/test/path', + }, + }, + ], + }, + }; + + const result = AiAskRequestDto.safeParse(invalidRequest); + + expect(result.success).toBe(false); + }); + + it('should fail if forNode is missing', () => { + const invalidRequest = { + ...validRequest, + forNode: undefined, + }; + + const result = AiAskRequestDto.safeParse(invalidRequest); + + expect(result.success).toBe(false); + expect(result.error?.issues[0].path).toEqual(['forNode']); + }); + + it('should validate all possible schema types', () => { + const allTypesRequest = { + question: 'Test all possible types', + context: { + schema: [ + { + nodeName: 'AllTypesNode', + schema: { + type: 'object', + key: 'typesRoot', + value: [ + { type: 'string', key: 'stringType', value: 'string', path: '/types/string' }, + { type: 'number', key: 'numberType', value: 'number', path: '/types/number' }, + { type: 'boolean', key: 'booleanType', value: 'boolean', path: '/types/boolean' }, + { type: 'bigint', key: 'bigintType', value: 'bigint', path: '/types/bigint' }, + { type: 'symbol', key: 'symbolType', value: 'symbol', path: '/types/symbol' }, + { type: 'array', key: 'arrayType', value: [], path: '/types/array' }, + { type: 'object', key: 'objectType', value: [], path: '/types/object' }, + { + type: 'function', + key: 'functionType', + value: 'function', + path: '/types/function', + }, + { type: 'null', key: 'nullType', value: 'null', path: '/types/null' }, + { + type: 'undefined', + key: 'undefinedType', + value: 'undefined', + path: '/types/undefined', + }, + ], + path: '/types/root', + }, + }, + ], + inputSchema: { + nodeName: 'InputNode', + schema: { + type: 'object', + key: 'simpleInput', + value: [ + { + type: 'string', + key: 'simpleKey', + value: 'simpleValue', + path: '/simple/path', + }, + ], + path: '/simple/input/path', + }, + }, + pushRef: 'push-types-123', + ndvPushRef: 'ndv-push-types-456', + }, + forNode: 'TypeCheckNode', + }; + + const result = AiAskRequestDto.safeParse(allTypesRequest); + expect(result.success).toBe(true); + }); + + it('should fail with invalid type', () => { + const invalidTypeRequest = { + question: 'Test invalid type', + context: { + schema: [ + { + nodeName: 'InvalidTypeNode', + schema: { + type: 'invalid-type', // This should fail + key: 'invalidKey', + value: 'invalidValue', + path: '/invalid/path', + }, + }, + ], + inputSchema: { + nodeName: 'InputNode', + schema: { + type: 'object', + key: 'simpleInput', + value: [ + { + type: 'string', + key: 'simpleKey', + value: 'simpleValue', + path: '/simple/path', + }, + ], + path: '/simple/input/path', + }, + }, + pushRef: 'push-invalid-123', + ndvPushRef: 'ndv-push-invalid-456', + }, + forNode: 'InvalidTypeNode', + }; + + const result = AiAskRequestDto.safeParse(invalidTypeRequest); + expect(result.success).toBe(false); + }); + + it('should validate multiple schema entries', () => { + const multiSchemaRequest = { + question: 'Multiple schema test', + context: { + schema: [ + { + nodeName: 'FirstNode', + schema: { + type: 'string', + key: 'firstKey', + value: 'firstValue', + path: '/first/path', + }, + }, + { + nodeName: 'SecondNode', + schema: { + type: 'object', + key: 'secondKey', + value: [ + { + type: 'number', + key: 'nestedKey', + value: 'nestedValue', + path: '/second/nested/path', + }, + ], + path: '/second/path', + }, + }, + ], + inputSchema: { + nodeName: 'InputNode', + schema: { + type: 'object', + key: 'simpleInput', + value: [ + { + type: 'string', + key: 'simpleKey', + value: 'simpleValue', + path: '/simple/path', + }, + ], + path: '/simple/input/path', + }, + }, + pushRef: 'push-multi-123', + ndvPushRef: 'ndv-push-multi-456', + }, + forNode: 'MultiSchemaNode', + }; + + const result = AiAskRequestDto.safeParse(multiSchemaRequest); + expect(result.success).toBe(true); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/ai/__tests__/ai-chat-request.dto.test.ts b/packages/@n8n/api-types/src/dto/ai/__tests__/ai-chat-request.dto.test.ts new file mode 100644 index 0000000000..ce1ccffac5 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/ai/__tests__/ai-chat-request.dto.test.ts @@ -0,0 +1,34 @@ +import { AiChatRequestDto } from '../ai-chat-request.dto'; + +describe('AiChatRequestDto', () => { + it('should validate a request with a payload and session ID', () => { + const validRequest = { + payload: { someKey: 'someValue' }, + sessionId: 'session-123', + }; + + const result = AiChatRequestDto.safeParse(validRequest); + + expect(result.success).toBe(true); + }); + + it('should validate a request with only a payload', () => { + const validRequest = { + payload: { complexObject: { nested: 'value' } }, + }; + + const result = AiChatRequestDto.safeParse(validRequest); + + expect(result.success).toBe(true); + }); + + it('should fail if payload is missing', () => { + const invalidRequest = { + sessionId: 'session-123', + }; + + const result = AiChatRequestDto.safeParse(invalidRequest); + + expect(result.success).toBe(false); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/ai/__tests__/ai-free-credits-request.dto.test.ts b/packages/@n8n/api-types/src/dto/ai/__tests__/ai-free-credits-request.dto.test.ts new file mode 100644 index 0000000000..2b61eeaee9 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/ai/__tests__/ai-free-credits-request.dto.test.ts @@ -0,0 +1,32 @@ +import { nanoId } from 'minifaker'; + +import { AiFreeCreditsRequestDto } from '../ai-free-credits-request.dto'; +import 'minifaker/locales/en'; + +describe('AiChatRequestDto', () => { + it('should succeed if projectId is a valid nanoid', () => { + const validRequest = { + projectId: nanoId.nanoid(), + }; + + const result = AiFreeCreditsRequestDto.safeParse(validRequest); + + expect(result.success).toBe(true); + }); + + it('should succeed if no projectId is sent', () => { + const result = AiFreeCreditsRequestDto.safeParse({}); + + expect(result.success).toBe(true); + }); + + it('should fail is projectId invalid value', () => { + const validRequest = { + projectId: '', + }; + + const result = AiFreeCreditsRequestDto.safeParse(validRequest); + + expect(result.success).toBe(false); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/ai/ai-apply-suggestion-request.dto.ts b/packages/@n8n/api-types/src/dto/ai/ai-apply-suggestion-request.dto.ts new file mode 100644 index 0000000000..cc808dfd24 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/ai/ai-apply-suggestion-request.dto.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class AiApplySuggestionRequestDto extends Z.class({ + sessionId: z.string(), + suggestionId: z.string(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/ai/ai-ask-request.dto.ts b/packages/@n8n/api-types/src/dto/ai/ai-ask-request.dto.ts new file mode 100644 index 0000000000..9039243e05 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/ai/ai-ask-request.dto.ts @@ -0,0 +1,53 @@ +import type { AiAssistantSDK, SchemaType } from '@n8n_io/ai-assistant-sdk'; +import { z } from 'zod'; +import { Z } from 'zod-class'; + +// Note: This is copied from the sdk, since this type is not exported +type Schema = { + type: SchemaType; + key?: string; + value: string | Schema[]; + path: string; +}; + +// Create a lazy validator to handle the recursive type +const schemaValidator: z.ZodType = z.lazy(() => + z.object({ + type: z.enum([ + 'string', + 'number', + 'boolean', + 'bigint', + 'symbol', + 'array', + 'object', + 'function', + 'null', + 'undefined', + ]), + key: z.string().optional(), + value: z.union([z.string(), z.lazy(() => schemaValidator.array())]), + path: z.string(), + }), +); + +export class AiAskRequestDto + extends Z.class({ + question: z.string(), + context: z.object({ + schema: z.array( + z.object({ + nodeName: z.string(), + schema: schemaValidator, + }), + ), + inputSchema: z.object({ + nodeName: z.string(), + schema: schemaValidator, + }), + pushRef: z.string(), + ndvPushRef: z.string(), + }), + forNode: z.string(), + }) + implements AiAssistantSDK.AskAiRequestPayload {} diff --git a/packages/@n8n/api-types/src/dto/ai/ai-chat-request.dto.ts b/packages/@n8n/api-types/src/dto/ai/ai-chat-request.dto.ts new file mode 100644 index 0000000000..59e7a26aa3 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/ai/ai-chat-request.dto.ts @@ -0,0 +1,10 @@ +import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk'; +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class AiChatRequestDto + extends Z.class({ + payload: z.object({}).passthrough(), // Allow any object shape + sessionId: z.string().optional(), + }) + implements AiAssistantSDK.ChatRequestPayload {} diff --git a/packages/@n8n/api-types/src/dto/ai/ai-free-credits-request.dto.ts b/packages/@n8n/api-types/src/dto/ai/ai-free-credits-request.dto.ts new file mode 100644 index 0000000000..9f9120d417 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/ai/ai-free-credits-request.dto.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class AiFreeCreditsRequestDto extends Z.class({ + projectId: z.string().min(1).optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/auth/__tests__/login-request.dto.test.ts b/packages/@n8n/api-types/src/dto/auth/__tests__/login-request.dto.test.ts new file mode 100644 index 0000000000..f222f1d93e --- /dev/null +++ b/packages/@n8n/api-types/src/dto/auth/__tests__/login-request.dto.test.ts @@ -0,0 +1,93 @@ +import { LoginRequestDto } from '../login-request.dto'; + +describe('LoginRequestDto', () => { + describe('Valid requests', () => { + test.each([ + { + name: 'complete valid login request', + request: { + email: 'test@example.com', + password: 'securePassword123', + mfaCode: '123456', + }, + }, + { + name: 'login request without optional MFA', + request: { + email: 'test@example.com', + password: 'securePassword123', + }, + }, + { + name: 'login request with both mfaCode and mfaRecoveryCode', + request: { + email: 'test@example.com', + password: 'securePassword123', + mfaCode: '123456', + mfaRecoveryCode: 'recovery-code-123', + }, + }, + { + name: 'login request with only mfaRecoveryCode', + request: { + email: 'test@example.com', + password: 'securePassword123', + mfaRecoveryCode: 'recovery-code-123', + }, + }, + ])('should validate $name', ({ request }) => { + const result = LoginRequestDto.safeParse(request); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'invalid email', + request: { + email: 'invalid-email', + password: 'securePassword123', + }, + expectedErrorPath: ['email'], + }, + { + name: 'empty password', + request: { + email: 'test@example.com', + password: '', + }, + expectedErrorPath: ['password'], + }, + { + name: 'missing email', + request: { + password: 'securePassword123', + }, + expectedErrorPath: ['email'], + }, + { + name: 'missing password', + request: { + email: 'test@example.com', + }, + expectedErrorPath: ['password'], + }, + { + name: 'whitespace in email and password', + request: { + email: ' test@example.com ', + password: ' securePassword123 ', + }, + expectedErrorPath: ['email'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = LoginRequestDto.safeParse(request); + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/auth/__tests__/resolve-signup-token-query.dto.test.ts b/packages/@n8n/api-types/src/dto/auth/__tests__/resolve-signup-token-query.dto.test.ts new file mode 100644 index 0000000000..218fe9107a --- /dev/null +++ b/packages/@n8n/api-types/src/dto/auth/__tests__/resolve-signup-token-query.dto.test.ts @@ -0,0 +1,87 @@ +import { ResolveSignupTokenQueryDto } from '../resolve-signup-token-query.dto'; + +describe('ResolveSignupTokenQueryDto', () => { + const validUuid = '123e4567-e89b-12d3-a456-426614174000'; + + describe('Valid requests', () => { + test.each([ + { + name: 'standard UUID', + request: { + inviterId: validUuid, + inviteeId: validUuid, + }, + }, + ])('should validate $name', ({ request }) => { + const result = ResolveSignupTokenQueryDto.safeParse(request); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'invalid inviterId UUID', + request: { + inviterId: 'not-a-valid-uuid', + inviteeId: validUuid, + }, + expectedErrorPath: ['inviterId'], + }, + { + name: 'invalid inviteeId UUID', + request: { + inviterId: validUuid, + inviteeId: 'not-a-valid-uuid', + }, + expectedErrorPath: ['inviteeId'], + }, + { + name: 'missing inviterId', + request: { + inviteeId: validUuid, + }, + expectedErrorPath: ['inviterId'], + }, + { + name: 'missing inviteeId', + request: { + inviterId: validUuid, + }, + expectedErrorPath: ['inviteeId'], + }, + { + name: 'UUID with invalid characters', + request: { + inviterId: '123e4567-e89b-12d3-a456-42661417400G', + inviteeId: validUuid, + }, + expectedErrorPath: ['inviterId'], + }, + { + name: 'UUID too long', + request: { + inviterId: '123e4567-e89b-12d3-a456-426614174001234', + inviteeId: validUuid, + }, + expectedErrorPath: ['inviterId'], + }, + { + name: 'UUID too short', + request: { + inviterId: '123e4567-e89b-12d3-a456', + inviteeId: validUuid, + }, + expectedErrorPath: ['inviterId'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = ResolveSignupTokenQueryDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/auth/login-request.dto.ts b/packages/@n8n/api-types/src/dto/auth/login-request.dto.ts new file mode 100644 index 0000000000..894263992c --- /dev/null +++ b/packages/@n8n/api-types/src/dto/auth/login-request.dto.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class LoginRequestDto extends Z.class({ + email: z.string().email(), + password: z.string().min(1), + mfaCode: z.string().optional(), + mfaRecoveryCode: z.string().optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/auth/resolve-signup-token-query.dto.ts b/packages/@n8n/api-types/src/dto/auth/resolve-signup-token-query.dto.ts new file mode 100644 index 0000000000..768202ff04 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/auth/resolve-signup-token-query.dto.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class ResolveSignupTokenQueryDto extends Z.class({ + inviterId: z.string().uuid(), + inviteeId: z.string().uuid(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/credentials/__tests__/credentials-get-many-request.dto.test.ts b/packages/@n8n/api-types/src/dto/credentials/__tests__/credentials-get-many-request.dto.test.ts new file mode 100644 index 0000000000..0fa074b97d --- /dev/null +++ b/packages/@n8n/api-types/src/dto/credentials/__tests__/credentials-get-many-request.dto.test.ts @@ -0,0 +1,55 @@ +import { CredentialsGetManyRequestQuery } from '../credentials-get-many-request.dto'; + +describe('CredentialsGetManyRequestQuery', () => { + describe('should pass validation', () => { + it('with empty object', () => { + const data = {}; + + const result = CredentialsGetManyRequestQuery.safeParse(data); + + expect(result.success).toBe(true); + }); + + test.each([ + { field: 'includeScopes', value: 'true' }, + { field: 'includeScopes', value: 'false' }, + { field: 'includeData', value: 'true' }, + { field: 'includeData', value: 'false' }, + ])('with $field set to $value', ({ field, value }) => { + const data = { [field]: value }; + + const result = CredentialsGetManyRequestQuery.safeParse(data); + + expect(result.success).toBe(true); + }); + + it('with both parameters set', () => { + const data = { + includeScopes: 'true', + includeData: 'true', + }; + + const result = CredentialsGetManyRequestQuery.safeParse(data); + + expect(result.success).toBe(true); + }); + }); + + describe('should fail validation', () => { + test.each([ + { field: 'includeScopes', value: true }, + { field: 'includeScopes', value: false }, + { field: 'includeScopes', value: 'invalid' }, + { field: 'includeData', value: true }, + { field: 'includeData', value: false }, + { field: 'includeData', value: 'invalid' }, + ])('with invalid value $value for $field', ({ field, value }) => { + const data = { [field]: value }; + + const result = CredentialsGetManyRequestQuery.safeParse(data); + + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe(field); + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/credentials/__tests__/credentials-get-one-request.dto.test.ts b/packages/@n8n/api-types/src/dto/credentials/__tests__/credentials-get-one-request.dto.test.ts new file mode 100644 index 0000000000..274b00b759 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/credentials/__tests__/credentials-get-one-request.dto.test.ts @@ -0,0 +1,52 @@ +import { CredentialsGetOneRequestQuery } from '../credentials-get-one-request.dto'; + +describe('CredentialsGetManyRequestQuery', () => { + describe('should pass validation', () => { + it('with empty object', () => { + const data = {}; + + const result = CredentialsGetOneRequestQuery.safeParse(data); + + expect(result.success).toBe(true); + // defaults to false + expect(result.data?.includeData).toBe(false); + }); + + test.each([ + { field: 'includeData', value: 'true' }, + { field: 'includeData', value: 'false' }, + ])('with $field set to $value', ({ field, value }) => { + const data = { [field]: value }; + + const result = CredentialsGetOneRequestQuery.safeParse(data); + + expect(result.success).toBe(true); + }); + + it('with both parameters set', () => { + const data = { + includeScopes: 'true', + includeData: 'true', + }; + + const result = CredentialsGetOneRequestQuery.safeParse(data); + + expect(result.success).toBe(true); + }); + }); + + describe('should fail validation', () => { + test.each([ + { field: 'includeData', value: true }, + { field: 'includeData', value: false }, + { field: 'includeData', value: 'invalid' }, + ])('with invalid value $value for $field', ({ field, value }) => { + const data = { [field]: value }; + + const result = CredentialsGetOneRequestQuery.safeParse(data); + + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe(field); + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/credentials/credentials-get-many-request.dto.ts b/packages/@n8n/api-types/src/dto/credentials/credentials-get-many-request.dto.ts new file mode 100644 index 0000000000..47332ca7f9 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/credentials/credentials-get-many-request.dto.ts @@ -0,0 +1,22 @@ +import { Z } from 'zod-class'; + +import { booleanFromString } from '../../schemas/booleanFromString'; + +export class CredentialsGetManyRequestQuery extends Z.class({ + /** + * Adds the `scopes` field to each credential which includes all scopes the + * requesting user has in relation to the credential, e.g. + * ['credential:read', 'credential:update'] + */ + includeScopes: booleanFromString.optional(), + + /** + * Adds the decrypted `data` field to each credential. + * + * It only does this for credentials for which the user has the + * `credential:update` scope. + * + * This switches `includeScopes` to true to be able to check for the scopes + */ + includeData: booleanFromString.optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/credentials/credentials-get-one-request.dto.ts b/packages/@n8n/api-types/src/dto/credentials/credentials-get-one-request.dto.ts new file mode 100644 index 0000000000..ad790014e8 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/credentials/credentials-get-one-request.dto.ts @@ -0,0 +1,13 @@ +import { Z } from 'zod-class'; + +import { booleanFromString } from '../../schemas/booleanFromString'; + +export class CredentialsGetOneRequestQuery extends Z.class({ + /** + * Adds the decrypted `data` field to each credential. + * + * It only does this for credentials for which the user has the + * `credential:update` scope. + */ + includeData: booleanFromString.optional().default('false'), +}) {} diff --git a/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/action-result-request.dto.test.ts b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/action-result-request.dto.test.ts new file mode 100644 index 0000000000..eb451e5b09 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/action-result-request.dto.test.ts @@ -0,0 +1,81 @@ +import { ActionResultRequestDto } from '../action-result-request.dto'; + +describe('ActionResultRequestDto', () => { + const baseValidRequest = { + path: '/test/path', + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + handler: 'testHandler', + currentNodeParameters: {}, + }; + + describe('Valid requests', () => { + test.each([ + { + name: 'minimal valid request', + request: baseValidRequest, + }, + { + name: 'request with payload', + request: { + ...baseValidRequest, + payload: { key: 'value' }, + }, + }, + { + name: 'request with credentials', + request: { + ...baseValidRequest, + credentials: { testCredential: { id: 'cred1', name: 'Test Cred' } }, + }, + }, + { + name: 'request with current node parameters', + request: { + ...baseValidRequest, + currentNodeParameters: { param1: 'value1' }, + }, + }, + ])('should validate $name', ({ request }) => { + const result = ActionResultRequestDto.safeParse(request); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'missing path', + request: { + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + handler: 'testHandler', + }, + expectedErrorPath: ['path'], + }, + { + name: 'missing handler', + request: { + path: '/test/path', + currentNodeParameters: {}, + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + }, + expectedErrorPath: ['handler'], + }, + { + name: 'invalid node version', + request: { + ...baseValidRequest, + nodeTypeAndVersion: { name: 'TestNode', version: 0 }, + }, + expectedErrorPath: ['nodeTypeAndVersion', 'version'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = ActionResultRequestDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/options-request.dto.test.ts b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/options-request.dto.test.ts new file mode 100644 index 0000000000..28c5534cc7 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/options-request.dto.test.ts @@ -0,0 +1,90 @@ +import { OptionsRequestDto } from '../options-request.dto'; + +describe('OptionsRequestDto', () => { + const baseValidRequest = { + path: '/test/path', + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + currentNodeParameters: {}, + }; + + describe('Valid requests', () => { + test.each([ + { + name: 'minimal valid request', + request: baseValidRequest, + }, + { + name: 'request with method name', + request: { + ...baseValidRequest, + methodName: 'testMethod', + }, + }, + { + name: 'request with load options', + request: { + ...baseValidRequest, + loadOptions: { + routing: { + operations: { someOperation: 'test' }, + output: { someOutput: 'test' }, + request: { someRequest: 'test' }, + }, + }, + }, + }, + { + name: 'request with credentials', + request: { + ...baseValidRequest, + credentials: { testCredential: { id: 'cred1', name: 'Test Cred' } }, + }, + }, + { + name: 'request with current node parameters', + request: { + ...baseValidRequest, + currentNodeParameters: { param1: 'value1' }, + }, + }, + ])('should validate $name', ({ request }) => { + const result = OptionsRequestDto.safeParse(request); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'missing path', + request: { + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + }, + expectedErrorPath: ['path'], + }, + { + name: 'missing node type and version', + request: { + path: '/test/path', + }, + expectedErrorPath: ['nodeTypeAndVersion'], + }, + { + name: 'invalid node version', + request: { + ...baseValidRequest, + nodeTypeAndVersion: { name: 'TestNode', version: 0 }, + }, + expectedErrorPath: ['nodeTypeAndVersion', 'version'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = OptionsRequestDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/resource-locator-request.dto.test.ts b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/resource-locator-request.dto.test.ts new file mode 100644 index 0000000000..d64f31dec2 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/resource-locator-request.dto.test.ts @@ -0,0 +1,95 @@ +import { ResourceLocatorRequestDto } from '../resource-locator-request.dto'; + +describe('ResourceLocatorRequestDto', () => { + const baseValidRequest = { + path: '/test/path', + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + methodName: 'testMethod', + currentNodeParameters: {}, + }; + + describe('Valid requests', () => { + test.each([ + { + name: 'minimal valid request', + request: baseValidRequest, + }, + { + name: 'request with filter', + request: { + ...baseValidRequest, + filter: 'testFilter', + }, + }, + { + name: 'request with pagination token', + request: { + ...baseValidRequest, + paginationToken: 'token123', + }, + }, + { + name: 'request with credentials', + request: { + ...baseValidRequest, + credentials: { testCredential: { id: 'cred1', name: 'Test Cred' } }, + }, + }, + { + name: 'request with current node parameters', + request: { + ...baseValidRequest, + currentNodeParameters: { param1: 'value1' }, + }, + }, + { + name: 'request with a semver node version', + request: { + ...baseValidRequest, + nodeTypeAndVersion: { name: 'TestNode', version: 1.1 }, + }, + }, + ])('should validate $name', ({ request }) => { + const result = ResourceLocatorRequestDto.safeParse(request); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'missing path', + request: { + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + methodName: 'testMethod', + }, + expectedErrorPath: ['path'], + }, + { + name: 'missing method name', + request: { + path: '/test/path', + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + currentNodeParameters: {}, + }, + expectedErrorPath: ['methodName'], + }, + { + name: 'invalid node version', + request: { + ...baseValidRequest, + nodeTypeAndVersion: { name: 'TestNode', version: 0 }, + }, + expectedErrorPath: ['nodeTypeAndVersion', 'version'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = ResourceLocatorRequestDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/resource-mapper-fields-request.dto.test.ts b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/resource-mapper-fields-request.dto.test.ts new file mode 100644 index 0000000000..2370177ab0 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/__tests__/resource-mapper-fields-request.dto.test.ts @@ -0,0 +1,74 @@ +import { ResourceMapperFieldsRequestDto } from '../resource-mapper-fields-request.dto'; + +describe('ResourceMapperFieldsRequestDto', () => { + const baseValidRequest = { + path: '/test/path', + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + methodName: 'testMethod', + currentNodeParameters: {}, + }; + + describe('Valid requests', () => { + test.each([ + { + name: 'minimal valid request', + request: baseValidRequest, + }, + { + name: 'request with credentials', + request: { + ...baseValidRequest, + credentials: { testCredential: { id: 'cred1', name: 'Test Cred' } }, + }, + }, + { + name: 'request with current node parameters', + request: { + ...baseValidRequest, + currentNodeParameters: { param1: 'value1' }, + }, + }, + ])('should validate $name', ({ request }) => { + const result = ResourceMapperFieldsRequestDto.safeParse(request); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'missing path', + request: { + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + methodName: 'testMethod', + }, + expectedErrorPath: ['path'], + }, + { + name: 'missing method name', + request: { + path: '/test/path', + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + currentNodeParameters: {}, + }, + expectedErrorPath: ['methodName'], + }, + { + name: 'invalid node version', + request: { + ...baseValidRequest, + nodeTypeAndVersion: { name: 'TestNode', version: 0 }, + }, + expectedErrorPath: ['nodeTypeAndVersion', 'version'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = ResourceMapperFieldsRequestDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/dynamic-node-parameters/action-result-request.dto.ts b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/action-result-request.dto.ts new file mode 100644 index 0000000000..d6f867af6d --- /dev/null +++ b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/action-result-request.dto.ts @@ -0,0 +1,11 @@ +import type { IDataObject } from 'n8n-workflow'; +import { z } from 'zod'; + +import { BaseDynamicParametersRequestDto } from './base-dynamic-parameters-request.dto'; + +export class ActionResultRequestDto extends BaseDynamicParametersRequestDto.extend({ + handler: z.string(), + payload: z + .union([z.object({}).catchall(z.any()) satisfies z.ZodType, z.string()]) + .optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/dynamic-node-parameters/base-dynamic-parameters-request.dto.ts b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/base-dynamic-parameters-request.dto.ts new file mode 100644 index 0000000000..66b9cd7629 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/base-dynamic-parameters-request.dto.ts @@ -0,0 +1,18 @@ +import type { INodeCredentials, INodeParameters, INodeTypeNameVersion } from 'n8n-workflow'; +import { z } from 'zod'; +import { Z } from 'zod-class'; + +import { nodeVersionSchema } from '../../schemas/nodeVersion.schema'; + +export class BaseDynamicParametersRequestDto extends Z.class({ + path: z.string(), + nodeTypeAndVersion: z.object({ + name: z.string(), + version: nodeVersionSchema, + }) satisfies z.ZodType, + currentNodeParameters: z.record(z.string(), z.any()) satisfies z.ZodType, + methodName: z.string().optional(), + credentials: z.record(z.string(), z.any()).optional() satisfies z.ZodType< + INodeCredentials | undefined + >, +}) {} diff --git a/packages/@n8n/api-types/src/dto/dynamic-node-parameters/options-request.dto.ts b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/options-request.dto.ts new file mode 100644 index 0000000000..b9d34ef75d --- /dev/null +++ b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/options-request.dto.ts @@ -0,0 +1,18 @@ +import type { ILoadOptions } from 'n8n-workflow'; +import { z } from 'zod'; + +import { BaseDynamicParametersRequestDto } from './base-dynamic-parameters-request.dto'; + +export class OptionsRequestDto extends BaseDynamicParametersRequestDto.extend({ + loadOptions: z + .object({ + routing: z + .object({ + operations: z.any().optional(), + output: z.any().optional(), + request: z.any().optional(), + }) + .optional(), + }) + .optional() as z.ZodType, +}) {} diff --git a/packages/@n8n/api-types/src/dto/dynamic-node-parameters/resource-locator-request.dto.ts b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/resource-locator-request.dto.ts new file mode 100644 index 0000000000..ac8e8df274 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/resource-locator-request.dto.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +import { BaseDynamicParametersRequestDto } from './base-dynamic-parameters-request.dto'; + +export class ResourceLocatorRequestDto extends BaseDynamicParametersRequestDto.extend({ + methodName: z.string(), + filter: z.string().optional(), + paginationToken: z.string().optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/dynamic-node-parameters/resource-mapper-fields-request.dto.ts b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/resource-mapper-fields-request.dto.ts new file mode 100644 index 0000000000..3c6d00eb3c --- /dev/null +++ b/packages/@n8n/api-types/src/dto/dynamic-node-parameters/resource-mapper-fields-request.dto.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +import { BaseDynamicParametersRequestDto } from './base-dynamic-parameters-request.dto'; + +export class ResourceMapperFieldsRequestDto extends BaseDynamicParametersRequestDto.extend({ + methodName: z.string(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 97d5d38459..f16758a89c 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -1,6 +1,39 @@ +export { AiAskRequestDto } from './ai/ai-ask-request.dto'; +export { AiChatRequestDto } from './ai/ai-chat-request.dto'; +export { AiApplySuggestionRequestDto } from './ai/ai-apply-suggestion-request.dto'; +export { AiFreeCreditsRequestDto } from './ai/ai-free-credits-request.dto'; + +export { LoginRequestDto } from './auth/login-request.dto'; +export { ResolveSignupTokenQueryDto } from './auth/resolve-signup-token-query.dto'; + +export { OptionsRequestDto } from './dynamic-node-parameters/options-request.dto'; +export { ResourceLocatorRequestDto } from './dynamic-node-parameters/resource-locator-request.dto'; +export { ResourceMapperFieldsRequestDto } from './dynamic-node-parameters/resource-mapper-fields-request.dto'; +export { ActionResultRequestDto } from './dynamic-node-parameters/action-result-request.dto'; + +export { InviteUsersRequestDto } from './invitation/invite-users-request.dto'; +export { AcceptInvitationRequestDto } from './invitation/accept-invitation-request.dto'; + +export { OwnerSetupRequestDto } from './owner/owner-setup-request.dto'; +export { DismissBannerRequestDto } from './owner/dismiss-banner-request.dto'; + +export { ForgotPasswordRequestDto } from './password-reset/forgot-password-request.dto'; +export { ResolvePasswordTokenQueryDto } from './password-reset/resolve-password-token-query.dto'; +export { ChangePasswordRequestDto } from './password-reset/change-password-request.dto'; + +export { SamlAcsDto } from './saml/saml-acs.dto'; +export { SamlPreferences } from './saml/saml-preferences.dto'; +export { SamlToggleDto } from './saml/saml-toggle.dto'; + export { PasswordUpdateRequestDto } from './user/password-update-request.dto'; export { RoleChangeRequestDto } from './user/role-change-request.dto'; export { SettingsUpdateRequestDto } from './user/settings-update-request.dto'; export { UserUpdateRequestDto } from './user/user-update-request.dto'; + export { CommunityRegisteredRequestDto } from './license/community-registered-request.dto'; + export { VariableListRequestDto } from './variables/variables-list-request.dto'; +export { CredentialsGetOneRequestQuery } from './credentials/credentials-get-one-request.dto'; +export { CredentialsGetManyRequestQuery } from './credentials/credentials-get-many-request.dto'; + +export { ImportWorkflowFromUrlDto } from './workflows/import-workflow-from-url.dto'; diff --git a/packages/@n8n/api-types/src/dto/invitation/__tests__/accept-invitation-request.dto.test.ts b/packages/@n8n/api-types/src/dto/invitation/__tests__/accept-invitation-request.dto.test.ts new file mode 100644 index 0000000000..f78de8ab6d --- /dev/null +++ b/packages/@n8n/api-types/src/dto/invitation/__tests__/accept-invitation-request.dto.test.ts @@ -0,0 +1,94 @@ +import { AcceptInvitationRequestDto } from '../accept-invitation-request.dto'; + +describe('AcceptInvitationRequestDto', () => { + const validUuid = '123e4567-e89b-12d3-a456-426614174000'; + + describe('Valid requests', () => { + test.each([ + { + name: 'complete valid invitation acceptance', + request: { + inviterId: validUuid, + firstName: 'John', + lastName: 'Doe', + password: 'SecurePassword123', + }, + }, + ])('should validate $name', ({ request }) => { + const result = AcceptInvitationRequestDto.safeParse(request); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'missing inviterId', + request: { + firstName: 'John', + lastName: 'Doe', + password: 'SecurePassword123', + }, + expectedErrorPath: ['inviterId'], + }, + { + name: 'invalid inviterId', + request: { + inviterId: 'not-a-valid-uuid', + firstName: 'John', + lastName: 'Doe', + password: 'SecurePassword123', + }, + expectedErrorPath: ['inviterId'], + }, + { + name: 'missing first name', + request: { + inviterId: validUuid, + firstName: '', + lastName: 'Doe', + password: 'SecurePassword123', + }, + expectedErrorPath: ['firstName'], + }, + { + name: 'missing last name', + request: { + inviterId: validUuid, + firstName: 'John', + lastName: '', + password: 'SecurePassword123', + }, + expectedErrorPath: ['lastName'], + }, + { + name: 'password too short', + request: { + inviterId: validUuid, + firstName: 'John', + lastName: 'Doe', + password: 'short', + }, + expectedErrorPath: ['password'], + }, + { + name: 'password without number', + request: { + inviterId: validUuid, + firstName: 'John', + lastName: 'Doe', + password: 'NoNumberPassword', + }, + expectedErrorPath: ['password'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = AcceptInvitationRequestDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/invitation/__tests__/invite-users-request.dto.test.ts b/packages/@n8n/api-types/src/dto/invitation/__tests__/invite-users-request.dto.test.ts new file mode 100644 index 0000000000..f47a138ed5 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/invitation/__tests__/invite-users-request.dto.test.ts @@ -0,0 +1,60 @@ +import { InviteUsersRequestDto } from '../invite-users-request.dto'; + +describe('InviteUsersRequestDto', () => { + describe('Valid requests', () => { + test.each([ + { + name: 'empty array', + request: [], + }, + { + name: 'single user invitation with default role', + request: [{ email: 'user@example.com' }], + }, + { + name: 'multiple user invitations with different roles', + request: [ + { email: 'user1@example.com', role: 'global:member' }, + { email: 'user2@example.com', role: 'global:admin' }, + ], + }, + ])('should validate $name', ({ request }) => { + const result = InviteUsersRequestDto.safeParse(request); + expect(result.success).toBe(true); + }); + + it('should default role to global:member', () => { + const result = InviteUsersRequestDto.safeParse([{ email: 'user@example.com' }]); + expect(result.success).toBe(true); + expect(result.data?.[0].role).toBe('global:member'); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'invalid email', + request: [{ email: 'invalid-email' }], + expectedErrorPath: [0, 'email'], + }, + { + name: 'invalid role', + request: [ + { + email: 'user@example.com', + role: 'invalid-role', + }, + ], + expectedErrorPath: [0, 'role'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = InviteUsersRequestDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/invitation/accept-invitation-request.dto.ts b/packages/@n8n/api-types/src/dto/invitation/accept-invitation-request.dto.ts new file mode 100644 index 0000000000..7c93e708ba --- /dev/null +++ b/packages/@n8n/api-types/src/dto/invitation/accept-invitation-request.dto.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +import { passwordSchema } from '../../schemas/password.schema'; + +export class AcceptInvitationRequestDto extends Z.class({ + inviterId: z.string().uuid(), + firstName: z.string().min(1, 'First name is required'), + lastName: z.string().min(1, 'Last name is required'), + password: passwordSchema, +}) {} diff --git a/packages/@n8n/api-types/src/dto/invitation/invite-users-request.dto.ts b/packages/@n8n/api-types/src/dto/invitation/invite-users-request.dto.ts new file mode 100644 index 0000000000..9693234c64 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/invitation/invite-users-request.dto.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +const roleSchema = z.enum(['global:member', 'global:admin']); + +const invitedUserSchema = z.object({ + email: z.string().email(), + role: roleSchema.default('global:member'), +}); + +const invitationsSchema = z.array(invitedUserSchema); + +export class InviteUsersRequestDto extends Array> { + static safeParse(data: unknown) { + return invitationsSchema.safeParse(data); + } +} diff --git a/packages/@n8n/api-types/src/dto/owner/__tests__/dismiss-banner-request.dto.test.ts b/packages/@n8n/api-types/src/dto/owner/__tests__/dismiss-banner-request.dto.test.ts new file mode 100644 index 0000000000..97371de16a --- /dev/null +++ b/packages/@n8n/api-types/src/dto/owner/__tests__/dismiss-banner-request.dto.test.ts @@ -0,0 +1,64 @@ +import { bannerNameSchema } from '../../../schemas/bannerName.schema'; +import { DismissBannerRequestDto } from '../dismiss-banner-request.dto'; + +describe('DismissBannerRequestDto', () => { + describe('Valid requests', () => { + test.each( + bannerNameSchema.options.map((banner) => ({ + name: `valid banner: ${banner}`, + request: { banner }, + })), + )('should validate $name', ({ request }) => { + const result = DismissBannerRequestDto.safeParse(request); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'invalid banner string', + request: { + banner: 'not-a-valid-banner', + }, + expectedErrorPath: ['banner'], + }, + { + name: 'non-string banner', + request: { + banner: 123, + }, + expectedErrorPath: ['banner'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = DismissBannerRequestDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); + + describe('Optional banner', () => { + test('should validate empty request', () => { + const result = DismissBannerRequestDto.safeParse({}); + expect(result.success).toBe(true); + }); + }); + + describe('Exhaustive banner name check', () => { + test('should have all banner names defined', () => { + const expectedBanners = [ + 'V1', + 'TRIAL_OVER', + 'TRIAL', + 'NON_PRODUCTION_LICENSE', + 'EMAIL_CONFIRMATION', + ]; + + expect(bannerNameSchema.options).toEqual(expectedBanners); + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/owner/__tests__/owner-setup-request.dto.test.ts b/packages/@n8n/api-types/src/dto/owner/__tests__/owner-setup-request.dto.test.ts new file mode 100644 index 0000000000..facf808ec3 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/owner/__tests__/owner-setup-request.dto.test.ts @@ -0,0 +1,93 @@ +import { OwnerSetupRequestDto } from '../owner-setup-request.dto'; + +describe('OwnerSetupRequestDto', () => { + describe('Valid requests', () => { + test.each([ + { + name: 'complete valid setup request', + request: { + email: 'owner@example.com', + firstName: 'John', + lastName: 'Doe', + password: 'SecurePassword123', + }, + }, + ])('should validate $name', ({ request }) => { + const result = OwnerSetupRequestDto.safeParse(request); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'invalid email', + request: { + email: 'invalid-email', + firstName: 'John', + lastName: 'Doe', + password: 'SecurePassword123', + }, + expectedErrorPath: ['email'], + }, + { + name: 'missing first name', + request: { + email: 'owner@example.com', + firstName: '', + lastName: 'Doe', + password: 'SecurePassword123', + }, + expectedErrorPath: ['firstName'], + }, + { + name: 'missing last name', + request: { + email: 'owner@example.com', + firstName: 'John', + lastName: '', + password: 'SecurePassword123', + }, + expectedErrorPath: ['lastName'], + }, + { + name: 'password too short', + request: { + email: 'owner@example.com', + firstName: 'John', + lastName: 'Doe', + password: 'short', + }, + expectedErrorPath: ['password'], + }, + { + name: 'password without number', + request: { + email: 'owner@example.com', + firstName: 'John', + lastName: 'Doe', + password: 'NoNumberPassword', + }, + expectedErrorPath: ['password'], + }, + { + name: 'password without uppercase letter', + request: { + email: 'owner@example.com', + firstName: 'John', + lastName: 'Doe', + password: 'nouppercasepassword123', + }, + expectedErrorPath: ['password'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = OwnerSetupRequestDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/owner/dismiss-banner-request.dto.ts b/packages/@n8n/api-types/src/dto/owner/dismiss-banner-request.dto.ts new file mode 100644 index 0000000000..1f42381e7a --- /dev/null +++ b/packages/@n8n/api-types/src/dto/owner/dismiss-banner-request.dto.ts @@ -0,0 +1,7 @@ +import { Z } from 'zod-class'; + +import { bannerNameSchema } from '../../schemas/bannerName.schema'; + +export class DismissBannerRequestDto extends Z.class({ + banner: bannerNameSchema.optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/owner/owner-setup-request.dto.ts b/packages/@n8n/api-types/src/dto/owner/owner-setup-request.dto.ts new file mode 100644 index 0000000000..ccaa06b18e --- /dev/null +++ b/packages/@n8n/api-types/src/dto/owner/owner-setup-request.dto.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +import { passwordSchema } from '../../schemas/password.schema'; + +export class OwnerSetupRequestDto extends Z.class({ + email: z.string().email(), + firstName: z.string().min(1, 'First name is required'), + lastName: z.string().min(1, 'Last name is required'), + password: passwordSchema, +}) {} diff --git a/packages/@n8n/api-types/src/dto/password-reset/__tests__/change-password-request.dto.test.ts b/packages/@n8n/api-types/src/dto/password-reset/__tests__/change-password-request.dto.test.ts new file mode 100644 index 0000000000..86b230ba5a --- /dev/null +++ b/packages/@n8n/api-types/src/dto/password-reset/__tests__/change-password-request.dto.test.ts @@ -0,0 +1,114 @@ +import { ChangePasswordRequestDto } from '../change-password-request.dto'; + +describe('ChangePasswordRequestDto', () => { + describe('Valid requests', () => { + test.each([ + { + name: 'valid password reset with token', + request: { + token: 'valid-reset-token-with-sufficient-length', + password: 'newSecurePassword123', + }, + }, + { + name: 'valid password reset with MFA code', + request: { + token: 'another-valid-reset-token', + password: 'newSecurePassword123', + mfaCode: '123456', + }, + }, + ])('should validate $name', ({ request }) => { + const result = ChangePasswordRequestDto.safeParse(request); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'missing token', + request: { password: 'newSecurePassword123' }, + expectedErrorPath: ['token'], + }, + { + name: 'empty token', + request: { token: '', password: 'newSecurePassword123' }, + expectedErrorPath: ['token'], + }, + { + name: 'short token', + request: { token: 'short', password: 'newSecurePassword123' }, + expectedErrorPath: ['token'], + }, + { + name: 'missing password', + request: { token: 'valid-reset-token' }, + expectedErrorPath: ['password'], + }, + { + name: 'password too short', + request: { + token: 'valid-reset-token', + password: 'short', + }, + expectedErrorPath: ['password'], + }, + { + name: 'password too long', + request: { + token: 'valid-reset-token', + password: 'a'.repeat(65), + }, + expectedErrorPath: ['password'], + }, + { + name: 'password without number', + request: { + token: 'valid-reset-token', + password: 'NoNumberPassword', + }, + expectedErrorPath: ['password'], + }, + { + name: 'password without uppercase letter', + request: { + token: 'valid-reset-token', + password: 'nouppercasepassword123', + }, + expectedErrorPath: ['password'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = ChangePasswordRequestDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + + describe('Edge cases', () => { + test('should handle optional MFA code correctly', () => { + const validRequest = { + token: 'valid-reset-token', + password: 'newSecurePassword123', + mfaCode: undefined, + }; + + const result = ChangePasswordRequestDto.safeParse(validRequest); + expect(result.success).toBe(true); + }); + + test('should handle token with special characters', () => { + const validRequest = { + token: 'valid-reset-token-with-special-!@#$%^&*()_+', + password: 'newSecurePassword123', + }; + + const result = ChangePasswordRequestDto.safeParse(validRequest); + expect(result.success).toBe(true); + }); + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/password-reset/__tests__/forgot-password-request.dto.test.ts b/packages/@n8n/api-types/src/dto/password-reset/__tests__/forgot-password-request.dto.test.ts new file mode 100644 index 0000000000..891d52fdad --- /dev/null +++ b/packages/@n8n/api-types/src/dto/password-reset/__tests__/forgot-password-request.dto.test.ts @@ -0,0 +1,47 @@ +import { ForgotPasswordRequestDto } from '../forgot-password-request.dto'; + +describe('ForgotPasswordRequestDto', () => { + describe('Valid requests', () => { + test.each([ + { + name: 'valid email', + request: { email: 'test@example.com' }, + }, + { + name: 'email with subdomain', + request: { email: 'user@sub.example.com' }, + }, + ])('should validate $name', ({ request }) => { + const result = ForgotPasswordRequestDto.safeParse(request); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'invalid email format', + request: { email: 'invalid-email' }, + expectedErrorPath: ['email'], + }, + { + name: 'missing email', + request: {}, + expectedErrorPath: ['email'], + }, + { + name: 'empty email', + request: { email: '' }, + expectedErrorPath: ['email'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = ForgotPasswordRequestDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/password-reset/__tests__/resolve-password-token-query.dto.test.ts b/packages/@n8n/api-types/src/dto/password-reset/__tests__/resolve-password-token-query.dto.test.ts new file mode 100644 index 0000000000..a2f5881ac8 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/password-reset/__tests__/resolve-password-token-query.dto.test.ts @@ -0,0 +1,42 @@ +import { ResolvePasswordTokenQueryDto } from '../resolve-password-token-query.dto'; + +describe('ResolvePasswordTokenQueryDto', () => { + describe('Valid requests', () => { + test.each([ + { + name: 'valid token', + request: { token: 'valid-reset-token' }, + }, + { + name: 'long token', + request: { token: 'x'.repeat(50) }, + }, + ])('should validate $name', ({ request }) => { + const result = ResolvePasswordTokenQueryDto.safeParse(request); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'missing token', + request: {}, + expectedErrorPath: ['token'], + }, + { + name: 'empty token', + request: { token: '' }, + expectedErrorPath: ['token'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = ResolvePasswordTokenQueryDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/password-reset/change-password-request.dto.ts b/packages/@n8n/api-types/src/dto/password-reset/change-password-request.dto.ts new file mode 100644 index 0000000000..33ef47b3f1 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/password-reset/change-password-request.dto.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +import { passwordSchema } from '../../schemas/password.schema'; +import { passwordResetTokenSchema } from '../../schemas/passwordResetToken.schema'; + +export class ChangePasswordRequestDto extends Z.class({ + token: passwordResetTokenSchema, + password: passwordSchema, + mfaCode: z.string().optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/password-reset/forgot-password-request.dto.ts b/packages/@n8n/api-types/src/dto/password-reset/forgot-password-request.dto.ts new file mode 100644 index 0000000000..f6ab3cfac5 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/password-reset/forgot-password-request.dto.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class ForgotPasswordRequestDto extends Z.class({ + email: z.string().email(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/password-reset/resolve-password-token-query.dto.ts b/packages/@n8n/api-types/src/dto/password-reset/resolve-password-token-query.dto.ts new file mode 100644 index 0000000000..88385df244 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/password-reset/resolve-password-token-query.dto.ts @@ -0,0 +1,7 @@ +import { Z } from 'zod-class'; + +import { passwordResetTokenSchema } from '../../schemas/passwordResetToken.schema'; + +export class ResolvePasswordTokenQueryDto extends Z.class({ + token: passwordResetTokenSchema, +}) {} diff --git a/packages/@n8n/api-types/src/dto/saml/__tests__/saml-preferences.dto.test.ts b/packages/@n8n/api-types/src/dto/saml/__tests__/saml-preferences.dto.test.ts new file mode 100644 index 0000000000..6d11483347 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/saml/__tests__/saml-preferences.dto.test.ts @@ -0,0 +1,155 @@ +import { SamlPreferences } from '../saml-preferences.dto'; + +describe('SamlPreferences', () => { + describe('Valid requests', () => { + test.each([ + { + name: 'valid minimal configuration', + request: { + mapping: { + email: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + userPrincipalName: 'johndoe', + }, + metadata: 'metadata', + metadataUrl: 'https://example.com/metadata', + loginEnabled: true, + loginLabel: 'Login with SAML', + }, + }, + { + name: 'valid full configuration', + request: { + mapping: { + email: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + userPrincipalName: 'johndoe', + }, + metadata: 'metadata', + metadataUrl: 'https://example.com/metadata', + ignoreSSL: true, + loginBinding: 'post', + loginEnabled: true, + loginLabel: 'Login with SAML', + authnRequestsSigned: true, + wantAssertionsSigned: true, + wantMessageSigned: true, + acsBinding: 'redirect', + signatureConfig: { + prefix: 'ds', + location: { + reference: '/samlp:Response/saml:Issuer', + action: 'after', + }, + }, + relayState: 'https://example.com/relay', + }, + }, + ])('should validate $name', ({ request }) => { + const result = SamlPreferences.safeParse(request); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'invalid loginBinding', + request: { + loginBinding: 'invalid', + }, + expectedErrorPath: ['loginBinding'], + }, + { + name: 'invalid acsBinding', + request: { + acsBinding: 'invalid', + }, + expectedErrorPath: ['acsBinding'], + }, + { + name: 'invalid signatureConfig location action', + request: { + signatureConfig: { + prefix: 'ds', + location: { + reference: '/samlp:Response/saml:Issuer', + action: 'invalid', + }, + }, + }, + expectedErrorPath: ['signatureConfig', 'location', 'action'], + }, + { + name: 'missing signatureConfig location reference', + request: { + signatureConfig: { + prefix: 'ds', + location: { + action: 'after', + }, + }, + }, + expectedErrorPath: ['signatureConfig', 'location', 'reference'], + }, + { + name: 'invalid mapping email', + request: { + mapping: { + email: 123, + firstName: 'John', + lastName: 'Doe', + userPrincipalName: 'johndoe', + }, + }, + expectedErrorPath: ['mapping', 'email'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = SamlPreferences.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + + describe('Edge cases', () => { + test('should handle optional fields correctly', () => { + const validRequest = { + mapping: undefined, + metadata: undefined, + metadataUrl: undefined, + loginEnabled: undefined, + loginLabel: undefined, + }; + + const result = SamlPreferences.safeParse(validRequest); + expect(result.success).toBe(true); + }); + + test('should handle default values correctly', () => { + const validRequest = {}; + + const result = SamlPreferences.safeParse(validRequest); + expect(result.success).toBe(true); + expect(result.data?.ignoreSSL).toBe(false); + expect(result.data?.loginBinding).toBe('redirect'); + expect(result.data?.authnRequestsSigned).toBe(false); + expect(result.data?.wantAssertionsSigned).toBe(true); + expect(result.data?.wantMessageSigned).toBe(true); + expect(result.data?.acsBinding).toBe('post'); + expect(result.data?.signatureConfig).toEqual({ + prefix: 'ds', + location: { + reference: '/samlp:Response/saml:Issuer', + action: 'after', + }, + }); + expect(result.data?.relayState).toBe(''); + }); + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/saml/saml-acs.dto.ts b/packages/@n8n/api-types/src/dto/saml/saml-acs.dto.ts new file mode 100644 index 0000000000..2bfbece7d6 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/saml/saml-acs.dto.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class SamlAcsDto extends Z.class({ + RelayState: z.string().optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/saml/saml-preferences.dto.ts b/packages/@n8n/api-types/src/dto/saml/saml-preferences.dto.ts new file mode 100644 index 0000000000..e07504c1b3 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/saml/saml-preferences.dto.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +const SamlLoginBindingSchema = z.enum(['redirect', 'post']); + +/** Schema for configuring the signature in SAML requests/responses. */ +const SignatureConfigSchema = z.object({ + prefix: z.string().default('ds'), + location: z.object({ + reference: z.string(), + action: z.enum(['before', 'after', 'prepend', 'append']), + }), +}); + +export class SamlPreferences extends Z.class({ + /** Mapping of SAML attributes to user fields. */ + mapping: z + .object({ + email: z.string(), + firstName: z.string(), + lastName: z.string(), + userPrincipalName: z.string(), + }) + .optional(), + /** SAML metadata in XML format. */ + metadata: z.string().optional(), + metadataUrl: z.string().optional(), + + ignoreSSL: z.boolean().default(false), + loginBinding: SamlLoginBindingSchema.default('redirect'), + /** Whether SAML login is enabled. */ + loginEnabled: z.boolean().optional(), + /** Label for the SAML login button. on the Auth screen */ + loginLabel: z.string().optional(), + + authnRequestsSigned: z.boolean().default(false), + wantAssertionsSigned: z.boolean().default(true), + wantMessageSigned: z.boolean().default(true), + + acsBinding: SamlLoginBindingSchema.default('post'), + signatureConfig: SignatureConfigSchema.default({ + prefix: 'ds', + location: { + reference: '/samlp:Response/saml:Issuer', + action: 'after', + }, + }), + + relayState: z.string().default(''), +}) {} diff --git a/packages/@n8n/api-types/src/dto/saml/saml-toggle.dto.ts b/packages/@n8n/api-types/src/dto/saml/saml-toggle.dto.ts new file mode 100644 index 0000000000..be07933d06 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/saml/saml-toggle.dto.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class SamlToggleDto extends Z.class({ + loginEnabled: z.boolean(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/workflows/__tests__/import-workflow-from-url.dto.test.ts b/packages/@n8n/api-types/src/dto/workflows/__tests__/import-workflow-from-url.dto.test.ts new file mode 100644 index 0000000000..3c17e873e0 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/workflows/__tests__/import-workflow-from-url.dto.test.ts @@ -0,0 +1,63 @@ +import { ImportWorkflowFromUrlDto } from '../import-workflow-from-url.dto'; + +describe('ImportWorkflowFromUrlDto', () => { + describe('Valid requests', () => { + test('should validate $name', () => { + const result = ImportWorkflowFromUrlDto.safeParse({ + url: 'https://example.com/workflow.json', + }); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'invalid URL (not ending with .json)', + url: 'https://example.com/workflow', + expectedErrorPath: ['url'], + }, + { + name: 'invalid URL (missing protocol)', + url: 'example.com/workflow.json', + expectedErrorPath: ['url'], + }, + { + name: 'invalid URL (not a URL)', + url: 'not-a-url', + expectedErrorPath: ['url'], + }, + { + name: 'missing URL', + url: undefined, + expectedErrorPath: ['url'], + }, + { + name: 'null URL', + url: null, + expectedErrorPath: ['url'], + }, + { + name: 'invalid URL (ends with .json but not a valid URL)', + url: 'not-a-url.json', + expectedErrorPath: ['url'], + }, + { + name: 'valid URL with query parameters', + url: 'https://example.com/workflow.json?param=value', + }, + { + name: 'valid URL with fragments', + url: 'https://example.com/workflow.json#section', + }, + ])('should fail validation for $name', ({ url, expectedErrorPath }) => { + const result = ImportWorkflowFromUrlDto.safeParse({ url }); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/workflows/import-workflow-from-url.dto.ts b/packages/@n8n/api-types/src/dto/workflows/import-workflow-from-url.dto.ts new file mode 100644 index 0000000000..310e620fde --- /dev/null +++ b/packages/@n8n/api-types/src/dto/workflows/import-workflow-from-url.dto.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class ImportWorkflowFromUrlDto extends Z.class({ + url: z.string().url().endsWith('.json'), +}) {} diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index 1fe0fcd857..3ce856d6ad 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -163,6 +163,10 @@ export interface FrontendSettings { pruneTime: number; licensePruneTime: number; }; + aiCredits: { + enabled: boolean; + credits: number; + }; pruning?: { isEnabled: boolean; maxAge: number; diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index d0067f7fff..a003d54201 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -7,3 +7,6 @@ export type * from './user'; export type { Collaborator } from './push/collaboration'; export type { SendWorkerStatusMessage } from './push/worker'; + +export type { BannerName } from './schemas/bannerName.schema'; +export { passwordSchema } from './schemas/password.schema'; diff --git a/packages/@n8n/api-types/src/schemas/__tests__/nodeVersion.schema.test.ts b/packages/@n8n/api-types/src/schemas/__tests__/nodeVersion.schema.test.ts new file mode 100644 index 0000000000..098db82096 --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/__tests__/nodeVersion.schema.test.ts @@ -0,0 +1,28 @@ +import { nodeVersionSchema } from '../nodeVersion.schema'; + +describe('nodeVersionSchema', () => { + describe('valid versions', () => { + test.each([ + [1, 'single digit'], + [2, 'single digit'], + [1.0, 'major.minor with zero minor'], + [1.2, 'major.minor'], + [10.5, 'major.minor with double digits'], + ])('should accept %s as a valid version (%s)', (version) => { + const validated = nodeVersionSchema.parse(version); + expect(validated).toBe(version); + }); + }); + + describe('invalid versions', () => { + test.each([ + ['not-a-number', 'non-number input'], + ['1.2.3', 'more than two parts'], + ['1.a', 'non-numeric characters'], + ['1.2.3', 'more than two parts as string'], + ])('should reject %s as an invalid version (%s)', (version) => { + const check = () => nodeVersionSchema.parse(version); + expect(check).toThrowError(); + }); + }); +}); diff --git a/packages/@n8n/api-types/src/schemas/__tests__/paswword.schema.test.ts b/packages/@n8n/api-types/src/schemas/__tests__/paswword.schema.test.ts new file mode 100644 index 0000000000..c3bcd5f4c8 --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/__tests__/paswword.schema.test.ts @@ -0,0 +1,54 @@ +import { passwordSchema } from '../password.schema'; + +describe('passwordSchema', () => { + test('should throw on empty password', () => { + const check = () => passwordSchema.parse(''); + + expect(check).toThrowError('Password must be 8 to 64 characters long'); + }); + + test('should return same password if valid', () => { + const validPassword = 'abcd1234X'; + + const validated = passwordSchema.parse(validPassword); + + expect(validated).toBe(validPassword); + }); + + test('should require at least one uppercase letter', () => { + const invalidPassword = 'abcd1234'; + + const failingCheck = () => passwordSchema.parse(invalidPassword); + + expect(failingCheck).toThrowError('Password must contain at least 1 uppercase letter.'); + }); + + test('should require at least one number', () => { + const validPassword = 'abcd1234X'; + const invalidPassword = 'abcdEFGH'; + + const validated = passwordSchema.parse(validPassword); + + expect(validated).toBe(validPassword); + + const check = () => passwordSchema.parse(invalidPassword); + + expect(check).toThrowError('Password must contain at least 1 number.'); + }); + + test('should require a minimum length of 8 characters', () => { + const invalidPassword = 'a'.repeat(7); + + const check = () => passwordSchema.parse(invalidPassword); + + expect(check).toThrowError('Password must be 8 to 64 characters long.'); + }); + + test('should require a maximum length of 64 characters', () => { + const invalidPassword = 'a'.repeat(65); + + const check = () => passwordSchema.parse(invalidPassword); + + expect(check).toThrowError('Password must be 8 to 64 characters long.'); + }); +}); diff --git a/packages/@n8n/api-types/src/schemas/bannerName.schema.ts b/packages/@n8n/api-types/src/schemas/bannerName.schema.ts new file mode 100644 index 0000000000..445bc31d1a --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/bannerName.schema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const bannerNameSchema = z.enum([ + 'V1', + 'TRIAL_OVER', + 'TRIAL', + 'NON_PRODUCTION_LICENSE', + 'EMAIL_CONFIRMATION', +]); + +export type BannerName = z.infer; diff --git a/packages/@n8n/api-types/src/schemas/booleanFromString.ts b/packages/@n8n/api-types/src/schemas/booleanFromString.ts new file mode 100644 index 0000000000..bcc9e8133c --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/booleanFromString.ts @@ -0,0 +1,3 @@ +import { z } from 'zod'; + +export const booleanFromString = z.enum(['true', 'false']).transform((value) => value === 'true'); diff --git a/packages/@n8n/api-types/src/schemas/nodeVersion.schema.ts b/packages/@n8n/api-types/src/schemas/nodeVersion.schema.ts new file mode 100644 index 0000000000..3edb8cc5fe --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/nodeVersion.schema.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +export const nodeVersionSchema = z + .number() + .min(1) + .refine( + (val) => { + const parts = String(val).split('.'); + return ( + (parts.length === 1 && !isNaN(Number(parts[0]))) || + (parts.length === 2 && !isNaN(Number(parts[0])) && !isNaN(Number(parts[1]))) + ); + }, + { + message: 'Invalid node version. Must be in format: major.minor', + }, + ); diff --git a/packages/@n8n/api-types/src/schemas/password.schema.ts b/packages/@n8n/api-types/src/schemas/password.schema.ts new file mode 100644 index 0000000000..3c60470af7 --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/password.schema.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +// TODO: Delete these from `cli` after all password-validation code starts using this schema +const minLength = 8; +const maxLength = 64; + +export const passwordSchema = z + .string() + .min(minLength, `Password must be ${minLength} to ${maxLength} characters long.`) + .max(maxLength, `Password must be ${minLength} to ${maxLength} characters long.`) + .refine((password) => /\d/.test(password), { + message: 'Password must contain at least 1 number.', + }) + .refine((password) => /[A-Z]/.test(password), { + message: 'Password must contain at least 1 uppercase letter.', + }); diff --git a/packages/@n8n/api-types/src/schemas/passwordResetToken.schema.ts b/packages/@n8n/api-types/src/schemas/passwordResetToken.schema.ts new file mode 100644 index 0000000000..b7c55bb886 --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/passwordResetToken.schema.ts @@ -0,0 +1,3 @@ +import { z } from 'zod'; + +export const passwordResetTokenSchema = z.string().min(10, 'Token too short'); diff --git a/packages/@n8n/config/package.json b/packages/@n8n/config/package.json index c4368a75c5..c601e82d77 100644 --- a/packages/@n8n/config/package.json +++ b/packages/@n8n/config/package.json @@ -21,7 +21,7 @@ "dist/**/*" ], "dependencies": { - "reflect-metadata": "0.2.2", - "typedi": "catalog:" + "@n8n/di": "workspace:*", + "reflect-metadata": "catalog:" } } diff --git a/packages/@n8n/config/src/configs/aiAssistant.config.ts b/packages/@n8n/config/src/configs/aiAssistant.config.ts new file mode 100644 index 0000000000..ff8a3986f2 --- /dev/null +++ b/packages/@n8n/config/src/configs/aiAssistant.config.ts @@ -0,0 +1,8 @@ +import { Config, Env } from '../decorators'; + +@Config +export class AiAssistantConfig { + /** Base URL of the AI assistant service */ + @Env('N8N_AI_ASSISTANT_BASE_URL') + baseUrl: string = ''; +} diff --git a/packages/@n8n/config/src/configs/logging.config.ts b/packages/@n8n/config/src/configs/logging.config.ts index ef4661c115..733278c3e3 100644 --- a/packages/@n8n/config/src/configs/logging.config.ts +++ b/packages/@n8n/config/src/configs/logging.config.ts @@ -9,6 +9,7 @@ export const LOG_SCOPES = [ 'multi-main-setup', 'pruning', 'pubsub', + 'push', 'redis', 'scaling', 'waiting-executions', @@ -70,10 +71,13 @@ export class LoggingConfig { * - `external-secrets` * - `license` * - `multi-main-setup` + * - `pruning` * - `pubsub` + * - `push` * - `redis` * - `scaling` * - `waiting-executions` + * - `task-runner` * * @example * `N8N_LOG_SCOPES=license` diff --git a/packages/@n8n/config/src/decorators.ts b/packages/@n8n/config/src/decorators.ts index cafdf3fcd4..57eb1500e2 100644 --- a/packages/@n8n/config/src/decorators.ts +++ b/packages/@n8n/config/src/decorators.ts @@ -1,6 +1,6 @@ import 'reflect-metadata'; +import { Container, Service } from '@n8n/di'; import { readFileSync } from 'fs'; -import { Container, Service } from 'typedi'; // eslint-disable-next-line @typescript-eslint/ban-types type Class = Function; @@ -35,7 +35,7 @@ export const Config: ClassDecorator = (ConfigClass: Class) => { for (const [key, { type, envName }] of classMetadata) { if (typeof type === 'function' && globalMetadata.has(type)) { - config[key] = Container.get(type); + config[key] = Container.get(type as Constructable); } else if (envName) { const value = readEnv(envName); if (value === undefined) continue; diff --git a/packages/@n8n/config/src/index.ts b/packages/@n8n/config/src/index.ts index a5144d4196..945b5f1237 100644 --- a/packages/@n8n/config/src/index.ts +++ b/packages/@n8n/config/src/index.ts @@ -1,3 +1,4 @@ +import { AiAssistantConfig } from './configs/aiAssistant.config'; import { CacheConfig } from './configs/cache.config'; import { CredentialsConfig } from './configs/credentials.config'; import { DatabaseConfig } from './configs/database.config'; @@ -121,4 +122,7 @@ export class GlobalConfig { @Nested diagnostics: DiagnosticsConfig; + + @Nested + aiAssistant: AiAssistantConfig; } diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index 771d915ee4..9fd0a35d5a 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -1,6 +1,6 @@ +import { Container } from '@n8n/di'; import fs from 'fs'; import { mock } from 'jest-mock-extended'; -import { Container } from 'typedi'; import { GlobalConfig } from '../src/index'; @@ -289,6 +289,9 @@ describe('GlobalConfig', () => { apiHost: 'https://ph.n8n.io', }, }, + aiAssistant: { + baseUrl: '', + }, }; it('should use all default values when no env variables are defined', () => { diff --git a/packages/@n8n/config/test/decorators.test.ts b/packages/@n8n/config/test/decorators.test.ts index c5c90b70a0..a346b442e0 100644 --- a/packages/@n8n/config/test/decorators.test.ts +++ b/packages/@n8n/config/test/decorators.test.ts @@ -1,4 +1,4 @@ -import { Container } from 'typedi'; +import { Container } from '@n8n/di'; import { Config, Env } from '../src/decorators'; diff --git a/packages/@n8n/config/tsconfig.json b/packages/@n8n/config/tsconfig.json index 15e134d6b4..f26ea23c24 100644 --- a/packages/@n8n/config/tsconfig.json +++ b/packages/@n8n/config/tsconfig.json @@ -9,5 +9,6 @@ "baseUrl": "src", "tsBuildInfoFile": "dist/typecheck.tsbuildinfo" }, - "include": ["src/**/*.ts", "test/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts"], + "references": [{ "path": "../di/tsconfig.build.json" }] } diff --git a/packages/@n8n/di/.eslintrc.js b/packages/@n8n/di/.eslintrc.js new file mode 100644 index 0000000000..1c42fddcdc --- /dev/null +++ b/packages/@n8n/di/.eslintrc.js @@ -0,0 +1,7 @@ +const sharedOptions = require('@n8n_io/eslint-config/shared'); + +/** @type {import('@types/eslint').ESLint.ConfigData} */ +module.exports = { + extends: ['@n8n_io/eslint-config/base'], + ...sharedOptions(__dirname), +}; diff --git a/packages/@n8n/di/README.md b/packages/@n8n/di/README.md new file mode 100644 index 0000000000..0f6999f225 --- /dev/null +++ b/packages/@n8n/di/README.md @@ -0,0 +1,52 @@ +## @n8n/di + +`@n8n/di` is a dependency injection (DI) container library, based on [`typedi`](https://github.com/typestack/typedi). + +n8n no longer uses `typedi` because: + +- `typedi` is no longer officially maintained +- Need for future-proofing, e.g. stage-3 decorators +- Small enough that it is worth the maintenance burden +- Easier to customize, e.g. to simplify unit tests + +### Usage + +```typescript +// from https://github.com/typestack/typedi/blob/develop/README.md +import { Container, Service } from 'typedi'; + +@Service() +class ExampleInjectedService { + printMessage() { + console.log('I am alive!'); + } +} + +@Service() +class ExampleService { + constructor( + // because we annotated ExampleInjectedService with the @Service() + // decorator TypeDI will automatically inject an instance of + // ExampleInjectedService here when the ExampleService class is requested + // from TypeDI. + public injectedService: ExampleInjectedService + ) {} +} + +const serviceInstance = Container.get(ExampleService); +// we request an instance of ExampleService from TypeDI + +serviceInstance.injectedService.printMessage(); +// logs "I am alive!" to the console +``` + +Requires enabling these flags in `tsconfig.json`: + +```json +{ + "compilerOptions": { + "experimentalDecorators": true, + "emitDecoratorMetadata": true + } +} +``` diff --git a/packages/@n8n/di/jest.config.js b/packages/@n8n/di/jest.config.js new file mode 100644 index 0000000000..d6c48554a7 --- /dev/null +++ b/packages/@n8n/di/jest.config.js @@ -0,0 +1,2 @@ +/** @type {import('jest').Config} */ +module.exports = require('../../../jest.config'); diff --git a/packages/@n8n/di/package.json b/packages/@n8n/di/package.json new file mode 100644 index 0000000000..07b702757f --- /dev/null +++ b/packages/@n8n/di/package.json @@ -0,0 +1,26 @@ +{ + "name": "@n8n/di", + "version": "0.1.0", + "scripts": { + "clean": "rimraf dist .turbo", + "dev": "pnpm watch", + "typecheck": "tsc --noEmit", + "build": "tsc -p tsconfig.build.json", + "format": "biome format --write .", + "format:check": "biome ci .", + "lint": "eslint .", + "lintfix": "eslint . --fix", + "watch": "tsc -p tsconfig.build.json --watch", + "test": "jest", + "test:dev": "jest --watch" + }, + "main": "dist/di.js", + "module": "src/di.ts", + "types": "dist/di.d.ts", + "files": [ + "dist/**/*" + ], + "dependencies": { + "reflect-metadata": "catalog:" + } +} diff --git a/packages/@n8n/di/src/__tests__/di.test.ts b/packages/@n8n/di/src/__tests__/di.test.ts new file mode 100644 index 0000000000..736ff06152 --- /dev/null +++ b/packages/@n8n/di/src/__tests__/di.test.ts @@ -0,0 +1,287 @@ +import { Container, Service } from '../di'; + +@Service() +class SimpleService { + getValue() { + return 'simple'; + } +} + +@Service() +class DependentService { + constructor(readonly simple: SimpleService) {} + + getValue() { + return this.simple.getValue() + '-dependent'; + } +} + +class CustomFactory { + getValue() { + return 'factory-made'; + } +} + +@Service({ factory: () => new CustomFactory() }) +class FactoryService { + getValue() { + return 'should-not-be-called'; + } +} + +abstract class AbstractService { + abstract getValue(): string; +} + +@Service() +class ConcreteService extends AbstractService { + getValue(): string { + return 'concrete'; + } +} + +describe('DI Container', () => { + beforeEach(() => { + jest.clearAllMocks(); + Container.reset(); + }); + + describe('basic functionality', () => { + it('should create a simple instance', () => { + const instance = Container.get(SimpleService); + expect(instance).toBeInstanceOf(SimpleService); + expect(instance.getValue()).toBe('simple'); + }); + + it('should return same instance on multiple gets', () => { + const instance1 = Container.get(SimpleService); + const instance2 = Container.get(SimpleService); + expect(instance1).toBe(instance2); + }); + + it('should handle classes with no dependencies (empty constructor)', () => { + @Service() + class EmptyConstructorService {} + + const instance = Container.get(EmptyConstructorService); + expect(instance).toBeInstanceOf(EmptyConstructorService); + }); + + it('should throw when trying to resolve an undecorated class', () => { + class UnDecoratedService {} + + expect(() => Container.get(UnDecoratedService)).toThrow(); + }); + }); + + describe('dependency injection', () => { + it('should inject dependencies correctly', () => { + const dependent = Container.get(DependentService); + expect(dependent).toBeInstanceOf(DependentService); + expect(dependent.getValue()).toBe('simple-dependent'); + expect(dependent.simple).toBeInstanceOf(SimpleService); + }); + + it('should handle deep dependency chains', () => { + @Service() + class ServiceC { + getValue() { + return 'C'; + } + } + + @Service() + class ServiceB { + constructor(private c: ServiceC) {} + + getValue() { + return this.c.getValue() + 'B'; + } + } + + @Service() + class ServiceA { + constructor(private b: ServiceB) {} + + getValue() { + return this.b.getValue() + 'A'; + } + } + + const instance = Container.get(ServiceA); + expect(instance.getValue()).toBe('CBA'); + }); + + it('should return undefined for non-decorated dependencies in resolution chain', () => { + class NonDecoratedDep {} + + @Service() + class ServiceWithNonDecoratedDep { + constructor(readonly dep: NonDecoratedDep) {} + } + + const instance = Container.get(ServiceWithNonDecoratedDep); + expect(instance).toBeInstanceOf(ServiceWithNonDecoratedDep); + expect(instance.dep).toBeUndefined(); + }); + }); + + describe('factory handling', () => { + it('should use factory when provided', () => { + const instance = Container.get(FactoryService); + expect(instance).toBeInstanceOf(CustomFactory); + expect(instance.getValue()).toBe('factory-made'); + }); + + it('should preserve factory metadata when setting instance', () => { + const customInstance = new CustomFactory(); + Container.set(FactoryService, customInstance); + const instance = Container.get(FactoryService); + expect(instance).toBe(customInstance); + }); + + it('should preserve factory when resetting container', () => { + const factoryInstance1 = Container.get(FactoryService); + Container.reset(); + const factoryInstance2 = Container.get(FactoryService); + + expect(factoryInstance1).not.toBe(factoryInstance2); + expect(factoryInstance2.getValue()).toBe('factory-made'); + }); + + it('should throw error when factory throws', () => { + @Service({ + factory: () => { + throw new Error('Factory error'); + }, + }) + class ErrorFactoryService {} + + expect(() => Container.get(ErrorFactoryService)).toThrow('Factory error'); + }); + }); + + describe('instance management', () => { + it('should allow manual instance setting', () => { + const customInstance = new SimpleService(); + Container.set(SimpleService, customInstance); + const instance = Container.get(SimpleService); + expect(instance).toBe(customInstance); + }); + }); + + describe('abstract classes', () => { + it('should throw when trying to instantiate an abstract class directly', () => { + @Service() + abstract class TestAbstractClass { + abstract doSomething(): void; + + // Add a concrete method to make the class truly abstract at runtime + constructor() { + if (this.constructor === TestAbstractClass) { + throw new TypeError('Abstract class "TestAbstractClass" cannot be instantiated'); + } + } + } + + expect(() => Container.get(TestAbstractClass)).toThrow( + '[DI] TestAbstractClass is an abstract class, and cannot be instantiated', + ); + }); + + it('should allow setting an implementation for an abstract class', () => { + const concrete = new ConcreteService(); + Container.set(AbstractService, concrete); + + const instance = Container.get(AbstractService); + expect(instance).toBe(concrete); + expect(instance.getValue()).toBe('concrete'); + }); + + it('should allow factory for abstract class', () => { + @Service({ factory: () => new ConcreteService() }) + abstract class FactoryAbstractService { + abstract getValue(): string; + } + + const instance = Container.get(FactoryAbstractService); + expect(instance).toBeInstanceOf(ConcreteService); + expect(instance.getValue()).toBe('concrete'); + }); + }); + + describe('inheritance', () => { + it('should handle inheritance in injectable classes', () => { + @Service() + class BaseService { + getValue() { + return 'base'; + } + } + + @Service() + class DerivedService extends BaseService { + getValue() { + return 'derived-' + super.getValue(); + } + } + + const instance = Container.get(DerivedService); + expect(instance.getValue()).toBe('derived-base'); + }); + + it('should maintain separate instances for base and derived classes', () => { + @Service() + class BaseService { + getValue() { + return 'base'; + } + } + + @Service() + class DerivedService extends BaseService {} + + const baseInstance = Container.get(BaseService); + const derivedInstance = Container.get(DerivedService); + + expect(baseInstance).not.toBe(derivedInstance); + expect(baseInstance).toBeInstanceOf(BaseService); + expect(derivedInstance).toBeInstanceOf(DerivedService); + }); + }); + + describe('type registration checking', () => { + it('should return true for registered classes', () => { + expect(Container.has(SimpleService)).toBe(true); + }); + + it('should return false for unregistered classes', () => { + class UnregisteredService {} + expect(Container.has(UnregisteredService)).toBe(false); + }); + + it('should return true for abstract classes with implementations', () => { + const concrete = new ConcreteService(); + Container.set(AbstractService, concrete); + expect(Container.has(AbstractService)).toBe(true); + }); + + it('should return true for factory-provided services before instantiation', () => { + expect(Container.has(FactoryService)).toBe(true); + }); + + it('should maintain registration after reset', () => { + expect(Container.has(SimpleService)).toBe(true); + Container.reset(); + expect(Container.has(SimpleService)).toBe(true); + }); + + it('should return true after manual instance setting', () => { + class ManualService {} + expect(Container.has(ManualService)).toBe(false); + + Container.set(ManualService, new ManualService()); + expect(Container.has(ManualService)).toBe(true); + }); + }); +}); diff --git a/packages/@n8n/di/src/di.ts b/packages/@n8n/di/src/di.ts new file mode 100644 index 0000000000..a4acb98474 --- /dev/null +++ b/packages/@n8n/di/src/di.ts @@ -0,0 +1,142 @@ +import 'reflect-metadata'; + +/** + * Represents a class constructor type that can be instantiated with 'new' + * @template T The type of instance the constructor creates + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type Constructable = new (...args: any[]) => T; + +type AbstractConstructable = abstract new (...args: unknown[]) => T; + +type ServiceIdentifier = Constructable | AbstractConstructable; + +interface Metadata { + instance?: T; + factory?: () => T; +} + +interface Options { + factory?: () => T; +} + +const instances = new Map(); + +/** + * Decorator that marks a class as available for dependency injection. + * @param options Configuration options for the injectable class + * @param options.factory Optional factory function to create instances of this class + * @returns A class decorator to be applied to the target class + */ +// eslint-disable-next-line @typescript-eslint/ban-types +export function Service(): Function; +// eslint-disable-next-line @typescript-eslint/ban-types +export function Service(options: Options): Function; +export function Service({ factory }: Options = {}) { + return function (target: Constructable) { + instances.set(target, { factory }); + return target; + }; +} + +class DIError extends Error { + constructor(message: string) { + super(`[DI] ${message}`); + } +} + +class ContainerClass { + /** Stack to track types being resolved to detect circular dependencies */ + private readonly resolutionStack: ServiceIdentifier[] = []; + + /** + * Checks if a type is registered in the container + * @template T The type to check for + * @param type The constructor of the type to check + * @returns True if the type is registered (has metadata), false otherwise + */ + has(type: ServiceIdentifier): boolean { + return instances.has(type); + } + + /** + * Retrieves or creates an instance of the specified type from the container + * @template T The type of instance to retrieve + * @param type The constructor of the type to retrieve + * @returns An instance of the specified type with all dependencies injected + * @throws {DIError} If circular dependencies are detected or if the type is not injectable + */ + get(type: ServiceIdentifier): T { + const { resolutionStack } = this; + const metadata = instances.get(type) as Metadata; + if (!metadata) { + // Special case: Allow undefined returns for non-decorated constructor params + // when resolving a dependency chain (i.e., resolutionStack not empty) + if (resolutionStack.length) return undefined as T; + throw new DIError(`${type.name} is not decorated with ${Service.name}`); + } + + if (metadata?.instance) return metadata.instance as T; + + // Check for circular dependencies before proceeding with instantiation + if (resolutionStack.includes(type)) { + throw new DIError( + `Circular dependency detected. ${resolutionStack.map((t) => t.name).join(' -> ')}`, + ); + } + + // Add current type to resolution stack before resolving dependencies + resolutionStack.push(type); + + try { + let instance: T; + + if (metadata?.factory) { + instance = metadata.factory(); + } else { + const paramTypes = (Reflect.getMetadata('design:paramtypes', type) ?? + []) as Constructable[]; + const dependencies = paramTypes.map(

(paramType: Constructable

) => + this.get(paramType), + ); + // Create new instance with resolved dependencies + instance = new (type as Constructable)(...dependencies) as T; + } + + instances.set(type, { ...metadata, instance }); + return instance; + } catch (error) { + if (error instanceof TypeError && error.message.toLowerCase().includes('abstract')) { + throw new DIError(`${type.name} is an abstract class, and cannot be instantiated`); + } + throw error; + } finally { + resolutionStack.pop(); + } + } + + /** + * Manually sets an instance for a specific type in the container + * @template T The type of instance being set + * @param type The constructor of the type to set. This can also be an abstract class + * @param instance The instance to store in the container + */ + set(type: ServiceIdentifier, instance: T): void { + // Preserve any existing metadata (like factory) when setting new instance + const metadata = instances.get(type) ?? {}; + instances.set(type, { ...metadata, instance }); + } + + /** Clears all instantiated instances from the container while preserving type registrations */ + reset(): void { + for (const metadata of instances.values()) { + delete metadata.instance; + } + } +} + +/** + * Global dependency injection container instance + * Used to retrieve and manage class instances and their dependencies + */ +export const Container = new ContainerClass(); diff --git a/packages/@n8n/di/tsconfig.build.json b/packages/@n8n/di/tsconfig.build.json new file mode 100644 index 0000000000..59065a1e2b --- /dev/null +++ b/packages/@n8n/di/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": ["./tsconfig.json", "../../../tsconfig.build.json"], + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/build.tsbuildinfo" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/__tests__/**"] +} diff --git a/packages/@n8n/di/tsconfig.json b/packages/@n8n/di/tsconfig.json new file mode 100644 index 0000000000..efe662ed2a --- /dev/null +++ b/packages/@n8n/di/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "types": ["node", "jest"], + "baseUrl": "src", + "tsBuildInfoFile": "dist/typecheck.tsbuildinfo", + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.ts index aaa2ca37d9..97113643f3 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.ts @@ -15,15 +15,15 @@ import { getConnectionHintNoticeField } from '@utils/sharedFields'; export class ToolVectorStore implements INodeType { description: INodeTypeDescription = { - displayName: 'Vector Store Tool', + displayName: 'Vector Store Question Answer Tool', name: 'toolVectorStore', icon: 'fa:database', iconColor: 'black', group: ['transform'], version: [1], - description: 'Retrieve context from vector store', + description: 'Answer questions with a vector store', defaults: { - name: 'Vector Store Tool', + name: 'Answer questions with a vector store', }, codex: { categories: ['AI'], @@ -60,20 +60,23 @@ export class ToolVectorStore implements INodeType { properties: [ getConnectionHintNoticeField([NodeConnectionType.AiAgent]), { - displayName: 'Name', + displayName: 'Data Name', name: 'name', type: 'string', default: '', - placeholder: 'e.g. company_knowledge_base', + placeholder: 'e.g. users_info', validateType: 'string-alphanumeric', - description: 'Name of the vector store', + description: + 'Name of the data in vector store. This will be used to fill this tool description: Useful for when you need to answer questions about [name]. Whenever you need information about [data description], you should ALWAYS use this. Input should be a fully formed question.', }, { - displayName: 'Description', + displayName: 'Description of Data', name: 'description', type: 'string', default: '', - placeholder: 'Retrieves data about [insert information about your data here]...', + placeholder: "[Describe your data here, e.g. a user's name, email, etc.]", + description: + 'Describe the data in vector store. This will be used to fill this tool description: Useful for when you need to answer questions about [name]. Whenever you need information about [data description], you should ALWAYS use this. Input should be a fully formed question.', typeOptions: { rows: 3, }, diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts index 227481b65c..de7abf6a8b 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts @@ -1,567 +1,42 @@ -import type { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager'; -import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; -import type { JSONSchema7 } from 'json-schema'; -import get from 'lodash/get'; -import isObject from 'lodash/isObject'; -import type { SetField, SetNodeOptions } from 'n8n-nodes-base/dist/nodes/Set/v2/helpers/interfaces'; -import * as manual from 'n8n-nodes-base/dist/nodes/Set/v2/manual.mode'; -import type { - IExecuteWorkflowInfo, - INodeExecutionData, - INodeType, - INodeTypeDescription, - IWorkflowBase, - ISupplyDataFunctions, - SupplyData, - ExecutionError, - ExecuteWorkflowData, - IDataObject, - INodeParameterResourceLocator, - ITaskMetadata, -} from 'n8n-workflow'; -import { NodeConnectionType, NodeOperationError, jsonParse } from 'n8n-workflow'; +import type { IVersionedNodeType, INodeTypeBaseDescription } from 'n8n-workflow'; +import { VersionedNodeType } from 'n8n-workflow'; -import { jsonSchemaExampleField, schemaTypeField, inputSchemaField } from '@utils/descriptions'; -import { convertJsonSchemaToZod, generateSchema } from '@utils/schemaParsing'; -import { getConnectionHintNoticeField } from '@utils/sharedFields'; +import { ToolWorkflowV1 } from './v1/ToolWorkflowV1.node'; +import { ToolWorkflowV2 } from './v2/ToolWorkflowV2.node'; -import type { DynamicZodObject } from '../../../types/zod.types'; - -export class ToolWorkflow implements INodeType { - description: INodeTypeDescription = { - displayName: 'Call n8n Workflow Tool', - name: 'toolWorkflow', - icon: 'fa:network-wired', - iconColor: 'black', - group: ['transform'], - version: [1, 1.1, 1.2, 1.3], - description: 'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.', - defaults: { - name: 'Call n8n Workflow Tool', - }, - codex: { - categories: ['AI'], - subcategories: { - AI: ['Tools'], - Tools: ['Recommended Tools'], - }, - resources: { - primaryDocumentation: [ - { - url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolworkflow/', - }, - ], - }, - }, - // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node - inputs: [], - // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: [NodeConnectionType.AiTool], - outputNames: ['Tool'], - properties: [ - getConnectionHintNoticeField([NodeConnectionType.AiAgent]), - { - displayName: - 'See an example of a workflow to suggest meeting slots using AI here.', - name: 'noticeTemplateExample', - type: 'notice', - default: '', - }, - { - displayName: 'Name', - name: 'name', - type: 'string', - default: '', - placeholder: 'My_Color_Tool', - displayOptions: { - show: { - '@version': [1], - }, +export class ToolWorkflow extends VersionedNodeType { + constructor() { + const baseDescription: INodeTypeBaseDescription = { + displayName: 'Call n8n Sub-Workflow Tool', + name: 'toolWorkflow', + icon: 'fa:network-wired', + group: ['transform'], + description: + 'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.', + codex: { + categories: ['AI'], + subcategories: { + AI: ['Tools'], + Tools: ['Recommended Tools'], }, - }, - { - displayName: 'Name', - name: 'name', - type: 'string', - default: '', - placeholder: 'e.g. My_Color_Tool', - validateType: 'string-alphanumeric', - description: - 'The name of the function to be called, could contain letters, numbers, and underscores only', - displayOptions: { - show: { - '@version': [{ _cnd: { gte: 1.1 } }], - }, - }, - }, - { - displayName: 'Description', - name: 'description', - type: 'string', - default: '', - placeholder: - 'Call this tool to get a random color. The input should be a string with comma separted names of colors to exclude.', - typeOptions: { - rows: 3, - }, - }, - - { - displayName: - 'This tool will call the workflow you define below, and look in the last node for the response. The workflow needs to start with an Execute Workflow trigger', - name: 'executeNotice', - type: 'notice', - default: '', - }, - - { - displayName: 'Source', - name: 'source', - type: 'options', - options: [ - { - name: 'Database', - value: 'database', - description: 'Load the workflow from the database by ID', - }, - { - name: 'Define Below', - value: 'parameter', - description: 'Pass the JSON code of a workflow', - }, - ], - default: 'database', - description: 'Where to get the workflow to execute from', - }, - - // ---------------------------------- - // source:database - // ---------------------------------- - { - displayName: 'Workflow ID', - name: 'workflowId', - type: 'string', - displayOptions: { - show: { - source: ['database'], - '@version': [{ _cnd: { lte: 1.1 } }], - }, - }, - default: '', - required: true, - description: 'The workflow to execute', - hint: 'Can be found in the URL of the workflow', - }, - - { - displayName: 'Workflow', - name: 'workflowId', - type: 'workflowSelector', - displayOptions: { - show: { - source: ['database'], - '@version': [{ _cnd: { gte: 1.2 } }], - }, - }, - default: '', - required: true, - }, - - // ---------------------------------- - // source:parameter - // ---------------------------------- - { - displayName: 'Workflow JSON', - name: 'workflowJson', - type: 'json', - typeOptions: { - rows: 10, - }, - displayOptions: { - show: { - source: ['parameter'], - }, - }, - default: '\n\n\n\n\n\n\n\n\n', - required: true, - description: 'The workflow JSON code to execute', - }, - // ---------------------------------- - // For all - // ---------------------------------- - { - displayName: 'Field to Return', - name: 'responsePropertyName', - type: 'string', - default: 'response', - required: true, - hint: 'The field in the last-executed node of the workflow that contains the response', - description: - 'Where to find the data that this tool should return. n8n will look in the output of the last-executed node of the workflow for a field with this name, and return its value.', - displayOptions: { - show: { - '@version': [{ _cnd: { lt: 1.3 } }], - }, - }, - }, - { - displayName: 'Extra Workflow Inputs', - name: 'fields', - placeholder: 'Add Value', - type: 'fixedCollection', - description: - "These will be output by the 'execute workflow' trigger of the workflow being called", - typeOptions: { - multipleValues: true, - sortable: true, - }, - default: {}, - options: [ - { - name: 'values', - displayName: 'Values', - values: [ - { - displayName: 'Name', - name: 'name', - type: 'string', - default: '', - placeholder: 'e.g. fieldName', - description: - 'Name of the field to set the value of. Supports dot-notation. Example: data.person[0].name.', - requiresDataPath: 'single', - }, - { - displayName: 'Type', - name: 'type', - type: 'options', - description: 'The field value type', - // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items - options: [ - { - name: 'String', - value: 'stringValue', - }, - { - name: 'Number', - value: 'numberValue', - }, - { - name: 'Boolean', - value: 'booleanValue', - }, - { - name: 'Array', - value: 'arrayValue', - }, - { - name: 'Object', - value: 'objectValue', - }, - ], - default: 'stringValue', - }, - { - displayName: 'Value', - name: 'stringValue', - type: 'string', - default: '', - displayOptions: { - show: { - type: ['stringValue'], - }, - }, - validateType: 'string', - ignoreValidationDuringExecution: true, - }, - { - displayName: 'Value', - name: 'numberValue', - type: 'string', - default: '', - displayOptions: { - show: { - type: ['numberValue'], - }, - }, - validateType: 'number', - ignoreValidationDuringExecution: true, - }, - { - displayName: 'Value', - name: 'booleanValue', - type: 'options', - default: 'true', - options: [ - { - name: 'True', - value: 'true', - }, - { - name: 'False', - value: 'false', - }, - ], - displayOptions: { - show: { - type: ['booleanValue'], - }, - }, - validateType: 'boolean', - ignoreValidationDuringExecution: true, - }, - { - displayName: 'Value', - name: 'arrayValue', - type: 'string', - default: '', - placeholder: 'e.g. [ arrayItem1, arrayItem2, arrayItem3 ]', - displayOptions: { - show: { - type: ['arrayValue'], - }, - }, - validateType: 'array', - ignoreValidationDuringExecution: true, - }, - { - displayName: 'Value', - name: 'objectValue', - type: 'json', - default: '={}', - typeOptions: { - rows: 2, - }, - displayOptions: { - show: { - type: ['objectValue'], - }, - }, - validateType: 'object', - ignoreValidationDuringExecution: true, - }, - ], - }, - ], - }, - // ---------------------------------- - // Output Parsing - // ---------------------------------- - { - displayName: 'Specify Input Schema', - name: 'specifyInputSchema', - type: 'boolean', - description: - 'Whether to specify the schema for the function. This would require the LLM to provide the input in the correct format and would validate it against the schema.', - noDataExpression: true, - default: false, - }, - { ...schemaTypeField, displayOptions: { show: { specifyInputSchema: [true] } } }, - jsonSchemaExampleField, - inputSchemaField, - ], - }; - - async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { - const workflowProxy = this.getWorkflowDataProxy(0); - - const name = this.getNodeParameter('name', itemIndex) as string; - const description = this.getNodeParameter('description', itemIndex) as string; - - let subExecutionId: string | undefined; - let subWorkflowId: string | undefined; - - const useSchema = this.getNodeParameter('specifyInputSchema', itemIndex) as boolean; - let tool: DynamicTool | DynamicStructuredTool | undefined = undefined; - - const runFunction = async ( - query: string | IDataObject, - runManager?: CallbackManagerForToolRun, - ): Promise => { - const source = this.getNodeParameter('source', itemIndex) as string; - const workflowInfo: IExecuteWorkflowInfo = {}; - if (source === 'database') { - // Read workflow from database - const nodeVersion = this.getNode().typeVersion; - if (nodeVersion <= 1.1) { - workflowInfo.id = this.getNodeParameter('workflowId', itemIndex) as string; - } else { - const { value } = this.getNodeParameter( - 'workflowId', - itemIndex, - {}, - ) as INodeParameterResourceLocator; - workflowInfo.id = value as string; - } - - subWorkflowId = workflowInfo.id; - } else if (source === 'parameter') { - // Read workflow from parameter - const workflowJson = this.getNodeParameter('workflowJson', itemIndex) as string; - try { - workflowInfo.code = JSON.parse(workflowJson) as IWorkflowBase; - - // subworkflow is same as parent workflow - subWorkflowId = workflowProxy.$workflow.id; - } catch (error) { - throw new NodeOperationError( - this.getNode(), - `The provided workflow is not valid JSON: "${(error as Error).message}"`, + resources: { + primaryDocumentation: [ { - itemIndex, + url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolworkflow/', }, - ); - } - } - - const rawData: IDataObject = { query }; - - const workflowFieldsJson = this.getNodeParameter('fields.values', itemIndex, [], { - rawExpressions: true, - }) as SetField[]; - - // Copied from Set Node v2 - for (const entry of workflowFieldsJson) { - if (entry.type === 'objectValue' && (entry.objectValue as string).startsWith('=')) { - rawData[entry.name] = (entry.objectValue as string).replace(/^=+/, ''); - } - } - - const options: SetNodeOptions = { - include: 'all', - }; - - const newItem = await manual.execute.call( - this, - { json: { query } }, - itemIndex, - options, - rawData, - this.getNode(), - ); - - const items = [newItem] as INodeExecutionData[]; - - let receivedData: ExecuteWorkflowData; - try { - receivedData = await this.executeWorkflow(workflowInfo, items, runManager?.getChild(), { - parentExecution: { - executionId: workflowProxy.$execution.id, - workflowId: workflowProxy.$workflow.id, - }, - }); - subExecutionId = receivedData.executionId; - } catch (error) { - // Make sure a valid error gets returned that can by json-serialized else it will - // not show up in the frontend - throw new NodeOperationError(this.getNode(), error as Error); - } - - const response: string | undefined = get(receivedData, 'data[0][0].json') as - | string - | undefined; - if (response === undefined) { - throw new NodeOperationError( - this.getNode(), - 'There was an error: "The workflow did not return a response"', - ); - } - - return response; + ], + }, + }, + defaultVersion: 2, }; - const toolHandler = async ( - query: string | IDataObject, - runManager?: CallbackManagerForToolRun, - ): Promise => { - const { index } = this.addInputData(NodeConnectionType.AiTool, [[{ json: { query } }]]); - - let response: string = ''; - let executionError: ExecutionError | undefined; - try { - response = await runFunction(query, runManager); - } catch (error) { - // TODO: Do some more testing. Issues here should actually fail the workflow - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - executionError = error; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - response = `There was an error: "${error.message}"`; - } - - if (typeof response === 'number') { - response = (response as number).toString(); - } - - if (isObject(response)) { - response = JSON.stringify(response, null, 2); - } - - if (typeof response !== 'string') { - // TODO: Do some more testing. Issues here should actually fail the workflow - executionError = new NodeOperationError(this.getNode(), 'Wrong output type returned', { - description: `The response property should be a string, but it is an ${typeof response}`, - }); - response = `There was an error: "${executionError.message}"`; - } - - let metadata: ITaskMetadata | undefined; - if (subExecutionId && subWorkflowId) { - metadata = { - subExecution: { - executionId: subExecutionId, - workflowId: subWorkflowId, - }, - }; - } - - if (executionError) { - void this.addOutputData(NodeConnectionType.AiTool, index, executionError, metadata); - } else { - // Output always needs to be an object - // so we try to parse the response as JSON and if it fails we just return the string wrapped in an object - const json = jsonParse(response, { fallbackValue: { response } }); - void this.addOutputData(NodeConnectionType.AiTool, index, [[{ json }]], metadata); - } - return response; - }; - - const functionBase = { - name, - description, - func: toolHandler, - }; - - if (useSchema) { - try { - // We initialize these even though one of them will always be empty - // it makes it easier to navigate the ternary operator - const jsonExample = this.getNodeParameter('jsonSchemaExample', itemIndex, '') as string; - const inputSchema = this.getNodeParameter('inputSchema', itemIndex, '') as string; - - const schemaType = this.getNodeParameter('schemaType', itemIndex) as 'fromJson' | 'manual'; - const jsonSchema = - schemaType === 'fromJson' - ? generateSchema(jsonExample) - : jsonParse(inputSchema); - - const zodSchema = convertJsonSchemaToZod(jsonSchema); - - tool = new DynamicStructuredTool({ - schema: zodSchema, - ...functionBase, - }); - } catch (error) { - throw new NodeOperationError( - this.getNode(), - 'Error during parsing of JSON Schema. \n ' + error, - ); - } - } else { - tool = new DynamicTool(functionBase); - } - - return { - response: tool, + const nodeVersions: IVersionedNodeType['nodeVersions'] = { + 1: new ToolWorkflowV1(baseDescription), + 1.1: new ToolWorkflowV1(baseDescription), + 1.2: new ToolWorkflowV1(baseDescription), + 1.3: new ToolWorkflowV1(baseDescription), + 2: new ToolWorkflowV2(baseDescription), }; + super(nodeVersions, baseDescription); } } diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/ToolWorkflowV1.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/ToolWorkflowV1.node.ts new file mode 100644 index 0000000000..4c33c86b4e --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/ToolWorkflowV1.node.ts @@ -0,0 +1,241 @@ +import type { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager'; +import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; +import type { JSONSchema7 } from 'json-schema'; +import get from 'lodash/get'; +import isObject from 'lodash/isObject'; +import type { SetField, SetNodeOptions } from 'n8n-nodes-base/dist/nodes/Set/v2/helpers/interfaces'; +import * as manual from 'n8n-nodes-base/dist/nodes/Set/v2/manual.mode'; +import type { + IExecuteWorkflowInfo, + INodeExecutionData, + INodeType, + INodeTypeDescription, + IWorkflowBase, + ISupplyDataFunctions, + SupplyData, + ExecutionError, + ExecuteWorkflowData, + IDataObject, + INodeParameterResourceLocator, + ITaskMetadata, + INodeTypeBaseDescription, +} from 'n8n-workflow'; +import { NodeConnectionType, NodeOperationError, jsonParse } from 'n8n-workflow'; + +import { versionDescription } from './versionDescription'; +import type { DynamicZodObject } from '../../../../types/zod.types'; +import { convertJsonSchemaToZod, generateSchema } from '../../../../utils/schemaParsing'; + +export class ToolWorkflowV1 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + ...versionDescription, + }; + } + + async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { + const workflowProxy = this.getWorkflowDataProxy(0); + + const name = this.getNodeParameter('name', itemIndex) as string; + const description = this.getNodeParameter('description', itemIndex) as string; + + let subExecutionId: string | undefined; + let subWorkflowId: string | undefined; + + const useSchema = this.getNodeParameter('specifyInputSchema', itemIndex) as boolean; + let tool: DynamicTool | DynamicStructuredTool | undefined = undefined; + + const runFunction = async ( + query: string | IDataObject, + runManager?: CallbackManagerForToolRun, + ): Promise => { + const source = this.getNodeParameter('source', itemIndex) as string; + const workflowInfo: IExecuteWorkflowInfo = {}; + if (source === 'database') { + // Read workflow from database + const nodeVersion = this.getNode().typeVersion; + if (nodeVersion <= 1.1) { + workflowInfo.id = this.getNodeParameter('workflowId', itemIndex) as string; + } else { + const { value } = this.getNodeParameter( + 'workflowId', + itemIndex, + {}, + ) as INodeParameterResourceLocator; + workflowInfo.id = value as string; + } + + subWorkflowId = workflowInfo.id; + } else if (source === 'parameter') { + // Read workflow from parameter + const workflowJson = this.getNodeParameter('workflowJson', itemIndex) as string; + try { + workflowInfo.code = JSON.parse(workflowJson) as IWorkflowBase; + + // subworkflow is same as parent workflow + subWorkflowId = workflowProxy.$workflow.id; + } catch (error) { + throw new NodeOperationError( + this.getNode(), + `The provided workflow is not valid JSON: "${(error as Error).message}"`, + { + itemIndex, + }, + ); + } + } + + const rawData: IDataObject = { query }; + + const workflowFieldsJson = this.getNodeParameter('fields.values', itemIndex, [], { + rawExpressions: true, + }) as SetField[]; + + // Copied from Set Node v2 + for (const entry of workflowFieldsJson) { + if (entry.type === 'objectValue' && (entry.objectValue as string).startsWith('=')) { + rawData[entry.name] = (entry.objectValue as string).replace(/^=+/, ''); + } + } + + const options: SetNodeOptions = { + include: 'all', + }; + + const newItem = await manual.execute.call( + this, + { json: { query } }, + itemIndex, + options, + rawData, + this.getNode(), + ); + + const items = [newItem] as INodeExecutionData[]; + + let receivedData: ExecuteWorkflowData; + try { + receivedData = await this.executeWorkflow(workflowInfo, items, runManager?.getChild(), { + parentExecution: { + executionId: workflowProxy.$execution.id, + workflowId: workflowProxy.$workflow.id, + }, + }); + subExecutionId = receivedData.executionId; + } catch (error) { + // Make sure a valid error gets returned that can by json-serialized else it will + // not show up in the frontend + throw new NodeOperationError(this.getNode(), error as Error); + } + + const response: string | undefined = get(receivedData, 'data[0][0].json') as + | string + | undefined; + if (response === undefined) { + throw new NodeOperationError( + this.getNode(), + 'There was an error: "The workflow did not return a response"', + ); + } + + return response; + }; + + const toolHandler = async ( + query: string | IDataObject, + runManager?: CallbackManagerForToolRun, + ): Promise => { + const { index } = this.addInputData(NodeConnectionType.AiTool, [[{ json: { query } }]]); + + let response: string = ''; + let executionError: ExecutionError | undefined; + try { + response = await runFunction(query, runManager); + } catch (error) { + // TODO: Do some more testing. Issues here should actually fail the workflow + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + executionError = error; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + response = `There was an error: "${error.message}"`; + } + + if (typeof response === 'number') { + response = (response as number).toString(); + } + + if (isObject(response)) { + response = JSON.stringify(response, null, 2); + } + + if (typeof response !== 'string') { + // TODO: Do some more testing. Issues here should actually fail the workflow + executionError = new NodeOperationError(this.getNode(), 'Wrong output type returned', { + description: `The response property should be a string, but it is an ${typeof response}`, + }); + response = `There was an error: "${executionError.message}"`; + } + + let metadata: ITaskMetadata | undefined; + if (subExecutionId && subWorkflowId) { + metadata = { + subExecution: { + executionId: subExecutionId, + workflowId: subWorkflowId, + }, + }; + } + + if (executionError) { + void this.addOutputData(NodeConnectionType.AiTool, index, executionError, metadata); + } else { + // Output always needs to be an object + // so we try to parse the response as JSON and if it fails we just return the string wrapped in an object + const json = jsonParse(response, { fallbackValue: { response } }); + void this.addOutputData(NodeConnectionType.AiTool, index, [[{ json }]], metadata); + } + return response; + }; + + const functionBase = { + name, + description, + func: toolHandler, + }; + + if (useSchema) { + try { + // We initialize these even though one of them will always be empty + // it makes it easier to navigate the ternary operator + const jsonExample = this.getNodeParameter('jsonSchemaExample', itemIndex, '') as string; + const inputSchema = this.getNodeParameter('inputSchema', itemIndex, '') as string; + + const schemaType = this.getNodeParameter('schemaType', itemIndex) as 'fromJson' | 'manual'; + const jsonSchema = + schemaType === 'fromJson' + ? generateSchema(jsonExample) + : jsonParse(inputSchema); + + const zodSchema = convertJsonSchemaToZod(jsonSchema); + + tool = new DynamicStructuredTool({ + schema: zodSchema, + ...functionBase, + }); + } catch (error) { + throw new NodeOperationError( + this.getNode(), + 'Error during parsing of JSON Schema. \n ' + error, + ); + } + } else { + tool = new DynamicTool(functionBase); + } + + return { + response: tool, + }; + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/versionDescription.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/versionDescription.ts new file mode 100644 index 0000000000..da7a0e9815 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/versionDescription.ts @@ -0,0 +1,345 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +/* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import type { INodeTypeDescription } from 'n8n-workflow'; +import { NodeConnectionType } from 'n8n-workflow'; + +import { + inputSchemaField, + jsonSchemaExampleField, + schemaTypeField, +} from '../../../../utils/descriptions'; +import { getConnectionHintNoticeField } from '../../../../utils/sharedFields'; + +export const versionDescription: INodeTypeDescription = { + displayName: 'Call n8n Workflow Tool', + name: 'toolWorkflow', + icon: 'fa:network-wired', + iconColor: 'black', + group: ['transform'], + version: [1, 1.1, 1.2, 1.3], + description: 'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.', + defaults: { + name: 'Call n8n Workflow Tool', + }, + codex: { + categories: ['AI'], + subcategories: { + AI: ['Tools'], + Tools: ['Recommended Tools'], + }, + resources: { + primaryDocumentation: [ + { + url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolworkflow/', + }, + ], + }, + }, + // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node + inputs: [], + // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong + outputs: [NodeConnectionType.AiTool], + outputNames: ['Tool'], + properties: [ + getConnectionHintNoticeField([NodeConnectionType.AiAgent]), + { + displayName: + 'See an example of a workflow to suggest meeting slots using AI here.', + name: 'noticeTemplateExample', + type: 'notice', + default: '', + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + placeholder: 'My_Color_Tool', + displayOptions: { + show: { + '@version': [1], + }, + }, + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + placeholder: 'e.g. My_Color_Tool', + validateType: 'string-alphanumeric', + description: + 'The name of the function to be called, could contain letters, numbers, and underscores only', + displayOptions: { + show: { + '@version': [{ _cnd: { gte: 1.1 } }], + }, + }, + }, + { + displayName: 'Description', + name: 'description', + type: 'string', + default: '', + placeholder: + 'Call this tool to get a random color. The input should be a string with comma separted names of colors to exclude.', + typeOptions: { + rows: 3, + }, + }, + + { + displayName: + 'This tool will call the workflow you define below, and look in the last node for the response. The workflow needs to start with an Execute Workflow trigger', + name: 'executeNotice', + type: 'notice', + default: '', + }, + + { + displayName: 'Source', + name: 'source', + type: 'options', + options: [ + { + name: 'Database', + value: 'database', + description: 'Load the workflow from the database by ID', + }, + { + name: 'Define Below', + value: 'parameter', + description: 'Pass the JSON code of a workflow', + }, + ], + default: 'database', + description: 'Where to get the workflow to execute from', + }, + + // ---------------------------------- + // source:database + // ---------------------------------- + { + displayName: 'Workflow ID', + name: 'workflowId', + type: 'string', + displayOptions: { + show: { + source: ['database'], + '@version': [{ _cnd: { lte: 1.1 } }], + }, + }, + default: '', + required: true, + description: 'The workflow to execute', + hint: 'Can be found in the URL of the workflow', + }, + + { + displayName: 'Workflow', + name: 'workflowId', + type: 'workflowSelector', + displayOptions: { + show: { + source: ['database'], + '@version': [{ _cnd: { gte: 1.2 } }], + }, + }, + default: '', + required: true, + }, + + // ---------------------------------- + // source:parameter + // ---------------------------------- + { + displayName: 'Workflow JSON', + name: 'workflowJson', + type: 'json', + typeOptions: { + rows: 10, + }, + displayOptions: { + show: { + source: ['parameter'], + }, + }, + default: '\n\n\n\n\n\n\n\n\n', + required: true, + description: 'The workflow JSON code to execute', + }, + // ---------------------------------- + // For all + // ---------------------------------- + { + displayName: 'Field to Return', + name: 'responsePropertyName', + type: 'string', + default: 'response', + required: true, + hint: 'The field in the last-executed node of the workflow that contains the response', + description: + 'Where to find the data that this tool should return. n8n will look in the output of the last-executed node of the workflow for a field with this name, and return its value.', + displayOptions: { + show: { + '@version': [{ _cnd: { lt: 1.3 } }], + }, + }, + }, + { + displayName: 'Extra Workflow Inputs', + name: 'fields', + placeholder: 'Add Value', + type: 'fixedCollection', + description: + "These will be output by the 'execute workflow' trigger of the workflow being called", + typeOptions: { + multipleValues: true, + sortable: true, + }, + default: {}, + options: [ + { + name: 'values', + displayName: 'Values', + values: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + placeholder: 'e.g. fieldName', + description: + 'Name of the field to set the value of. Supports dot-notation. Example: data.person[0].name.', + requiresDataPath: 'single', + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + description: 'The field value type', + // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items + options: [ + { + name: 'String', + value: 'stringValue', + }, + { + name: 'Number', + value: 'numberValue', + }, + { + name: 'Boolean', + value: 'booleanValue', + }, + { + name: 'Array', + value: 'arrayValue', + }, + { + name: 'Object', + value: 'objectValue', + }, + ], + default: 'stringValue', + }, + { + displayName: 'Value', + name: 'stringValue', + type: 'string', + default: '', + displayOptions: { + show: { + type: ['stringValue'], + }, + }, + validateType: 'string', + ignoreValidationDuringExecution: true, + }, + { + displayName: 'Value', + name: 'numberValue', + type: 'string', + default: '', + displayOptions: { + show: { + type: ['numberValue'], + }, + }, + validateType: 'number', + ignoreValidationDuringExecution: true, + }, + { + displayName: 'Value', + name: 'booleanValue', + type: 'options', + default: 'true', + options: [ + { + name: 'True', + value: 'true', + }, + { + name: 'False', + value: 'false', + }, + ], + displayOptions: { + show: { + type: ['booleanValue'], + }, + }, + validateType: 'boolean', + ignoreValidationDuringExecution: true, + }, + { + displayName: 'Value', + name: 'arrayValue', + type: 'string', + default: '', + placeholder: 'e.g. [ arrayItem1, arrayItem2, arrayItem3 ]', + displayOptions: { + show: { + type: ['arrayValue'], + }, + }, + validateType: 'array', + ignoreValidationDuringExecution: true, + }, + { + displayName: 'Value', + name: 'objectValue', + type: 'json', + default: '={}', + typeOptions: { + rows: 2, + }, + displayOptions: { + show: { + type: ['objectValue'], + }, + }, + validateType: 'object', + ignoreValidationDuringExecution: true, + }, + ], + }, + ], + }, + // ---------------------------------- + // Output Parsing + // ---------------------------------- + { + displayName: 'Specify Input Schema', + name: 'specifyInputSchema', + type: 'boolean', + description: + 'Whether to specify the schema for the function. This would require the LLM to provide the input in the correct format and would validate it against the schema.', + noDataExpression: true, + default: false, + }, + { ...schemaTypeField, displayOptions: { show: { specifyInputSchema: [true] } } }, + jsonSchemaExampleField, + inputSchemaField, + ], +}; diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.node.ts new file mode 100644 index 0000000000..22ca31e4da --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.node.ts @@ -0,0 +1,42 @@ +import { loadWorkflowInputMappings } from 'n8n-nodes-base/dist/utils/workflowInputsResourceMapping/GenericFunctions'; +import type { + INodeTypeBaseDescription, + ISupplyDataFunctions, + SupplyData, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { WorkflowToolService } from './utils/WorkflowToolService'; +import { versionDescription } from './versionDescription'; + +export class ToolWorkflowV2 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + ...versionDescription, + }; + } + + methods = { + localResourceMapping: { + loadWorkflowInputMappings, + }, + }; + + async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { + const workflowToolService = new WorkflowToolService(this); + const name = this.getNodeParameter('name', itemIndex) as string; + const description = this.getNodeParameter('description', itemIndex) as string; + + const tool = await workflowToolService.createTool({ + name, + description, + itemIndex, + }); + + return { response: tool }; + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.test.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.test.ts new file mode 100644 index 0000000000..73aa24c6b7 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.test.ts @@ -0,0 +1,235 @@ +/* eslint-disable @typescript-eslint/dot-notation */ // Disabled to allow access to private methods +import { DynamicTool } from '@langchain/core/tools'; +import { NodeOperationError } from 'n8n-workflow'; +import type { + ISupplyDataFunctions, + INodeExecutionData, + IWorkflowDataProxyData, + ExecuteWorkflowData, + INode, +} from 'n8n-workflow'; + +import { WorkflowToolService } from './utils/WorkflowToolService'; + +// Mock ISupplyDataFunctions interface +function createMockContext(overrides?: Partial): ISupplyDataFunctions { + return { + getNodeParameter: jest.fn(), + getWorkflowDataProxy: jest.fn(), + getNode: jest.fn(), + executeWorkflow: jest.fn(), + addInputData: jest.fn(), + addOutputData: jest.fn(), + getCredentials: jest.fn(), + getCredentialsProperties: jest.fn(), + getInputData: jest.fn(), + getMode: jest.fn(), + getRestApiUrl: jest.fn(), + getTimezone: jest.fn(), + getWorkflow: jest.fn(), + getWorkflowStaticData: jest.fn(), + logger: { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + }, + ...overrides, + } as ISupplyDataFunctions; +} + +describe('WorkflowTool::WorkflowToolService', () => { + let context: ISupplyDataFunctions; + let service: WorkflowToolService; + + beforeEach(() => { + // Prepare essential mocks + context = createMockContext(); + jest.spyOn(context, 'getNode').mockReturnValue({ + parameters: { workflowInputs: { schema: [] } }, + } as unknown as INode); + service = new WorkflowToolService(context); + }); + + describe('createTool', () => { + it('should create a basic dynamic tool when schema is not used', async () => { + const toolParams = { + name: 'TestTool', + description: 'Test Description', + itemIndex: 0, + }; + + const result = await service.createTool(toolParams); + + expect(result).toBeInstanceOf(DynamicTool); + expect(result).toHaveProperty('name', 'TestTool'); + expect(result).toHaveProperty('description', 'Test Description'); + }); + + it('should create a tool that can handle successful execution', async () => { + const toolParams = { + name: 'TestTool', + description: 'Test Description', + itemIndex: 0, + }; + + const TEST_RESPONSE = { msg: 'test response' }; + + const mockExecuteWorkflowResponse: ExecuteWorkflowData = { + data: [[{ json: TEST_RESPONSE }]], + executionId: 'test-execution', + }; + + jest.spyOn(context, 'executeWorkflow').mockResolvedValueOnce(mockExecuteWorkflowResponse); + jest.spyOn(context, 'addInputData').mockReturnValue({ index: 0 }); + jest.spyOn(context, 'getNodeParameter').mockReturnValue('database'); + jest.spyOn(context, 'getWorkflowDataProxy').mockReturnValue({ + $execution: { id: 'exec-id' }, + $workflow: { id: 'workflow-id' }, + } as unknown as IWorkflowDataProxyData); + + const tool = await service.createTool(toolParams); + const result = await tool.func('test query'); + + expect(result).toBe(JSON.stringify(TEST_RESPONSE, null, 2)); + expect(context.addOutputData).toHaveBeenCalled(); + }); + + it('should handle errors during tool execution', async () => { + const toolParams = { + name: 'TestTool', + description: 'Test Description', + itemIndex: 0, + }; + + jest + .spyOn(context, 'executeWorkflow') + .mockRejectedValueOnce(new Error('Workflow execution failed')); + jest.spyOn(context, 'addInputData').mockReturnValue({ index: 0 }); + jest.spyOn(context, 'getNodeParameter').mockReturnValue('database'); + + const tool = await service.createTool(toolParams); + const result = await tool.func('test query'); + + expect(result).toContain('There was an error'); + expect(context.addOutputData).toHaveBeenCalled(); + }); + }); + + describe('handleToolResponse', () => { + it('should handle number response', () => { + const result = service['handleToolResponse'](42); + + expect(result).toBe('42'); + }); + + it('should handle object response', () => { + const obj = { test: 'value' }; + + const result = service['handleToolResponse'](obj); + + expect(result).toBe(JSON.stringify(obj, null, 2)); + }); + + it('should handle string response', () => { + const result = service['handleToolResponse']('test response'); + + expect(result).toBe('test response'); + }); + + it('should throw error for invalid response type', () => { + expect(() => service['handleToolResponse'](undefined)).toThrow(NodeOperationError); + }); + }); + + describe('executeSubWorkflow', () => { + it('should successfully execute workflow and return response', async () => { + const workflowInfo = { id: 'test-workflow' }; + const items: INodeExecutionData[] = []; + const workflowProxyMock = { + $execution: { id: 'exec-id' }, + $workflow: { id: 'workflow-id' }, + } as unknown as IWorkflowDataProxyData; + + const TEST_RESPONSE = { msg: 'test response' }; + + const mockResponse: ExecuteWorkflowData = { + data: [[{ json: TEST_RESPONSE }]], + executionId: 'test-execution', + }; + + jest.spyOn(context, 'executeWorkflow').mockResolvedValueOnce(mockResponse); + + const result = await service['executeSubWorkflow'](workflowInfo, items, workflowProxyMock); + + expect(result.response).toBe(TEST_RESPONSE); + expect(result.subExecutionId).toBe('test-execution'); + }); + + it('should throw error when workflow execution fails', async () => { + jest.spyOn(context, 'executeWorkflow').mockRejectedValueOnce(new Error('Execution failed')); + + await expect(service['executeSubWorkflow']({}, [], {} as never)).rejects.toThrow( + NodeOperationError, + ); + }); + + it('should throw error when workflow returns no response', async () => { + const mockResponse: ExecuteWorkflowData = { + data: [], + executionId: 'test-execution', + }; + + jest.spyOn(context, 'executeWorkflow').mockResolvedValueOnce(mockResponse); + + await expect(service['executeSubWorkflow']({}, [], {} as never)).rejects.toThrow(); + }); + }); + + describe('getSubWorkflowInfo', () => { + it('should handle database source correctly', async () => { + const source = 'database'; + const itemIndex = 0; + const workflowProxyMock = { + $workflow: { id: 'proxy-id' }, + } as unknown as IWorkflowDataProxyData; + + jest.spyOn(context, 'getNodeParameter').mockReturnValueOnce({ value: 'workflow-id' }); + + const result = await service['getSubWorkflowInfo'](source, itemIndex, workflowProxyMock); + + expect(result.workflowInfo).toHaveProperty('id', 'workflow-id'); + expect(result.subWorkflowId).toBe('workflow-id'); + }); + + it('should handle parameter source correctly', async () => { + const source = 'parameter'; + const itemIndex = 0; + const workflowProxyMock = { + $workflow: { id: 'proxy-id' }, + } as unknown as IWorkflowDataProxyData; + const mockWorkflow = { id: 'test-workflow' }; + + jest.spyOn(context, 'getNodeParameter').mockReturnValueOnce(JSON.stringify(mockWorkflow)); + + const result = await service['getSubWorkflowInfo'](source, itemIndex, workflowProxyMock); + + expect(result.workflowInfo.code).toEqual(mockWorkflow); + expect(result.subWorkflowId).toBe('proxy-id'); + }); + + it('should throw error for invalid JSON in parameter source', async () => { + const source = 'parameter'; + const itemIndex = 0; + const workflowProxyMock = { + $workflow: { id: 'proxy-id' }, + } as unknown as IWorkflowDataProxyData; + + jest.spyOn(context, 'getNodeParameter').mockReturnValueOnce('invalid json'); + + await expect( + service['getSubWorkflowInfo'](source, itemIndex, workflowProxyMock), + ).rejects.toThrow(NodeOperationError); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/FromAIParser.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/FromAIParser.ts new file mode 100644 index 0000000000..4b9b6ed58e --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/FromAIParser.ts @@ -0,0 +1,284 @@ +import type { ISupplyDataFunctions } from 'n8n-workflow'; +import { jsonParse, NodeOperationError } from 'n8n-workflow'; +import { z } from 'zod'; + +type AllowedTypes = 'string' | 'number' | 'boolean' | 'json'; +export interface FromAIArgument { + key: string; + description?: string; + type?: AllowedTypes; + defaultValue?: string | number | boolean | Record; +} + +// TODO: We copied this class from the core package, once the new nodes context work is merged, this should be available in root node context and this file can be removed. +// Please apply any changes to both files + +/** + * AIParametersParser + * + * This class encapsulates the logic for parsing node parameters, extracting $fromAI calls, + * generating Zod schemas, and creating LangChain tools. + */ +export class AIParametersParser { + private ctx: ISupplyDataFunctions; + + /** + * Constructs an instance of AIParametersParser. + * @param ctx The execution context. + */ + constructor(ctx: ISupplyDataFunctions) { + this.ctx = ctx; + } + + /** + * Generates a Zod schema based on the provided FromAIArgument placeholder. + * @param placeholder The FromAIArgument object containing key, type, description, and defaultValue. + * @returns A Zod schema corresponding to the placeholder's type and constraints. + */ + generateZodSchema(placeholder: FromAIArgument): z.ZodTypeAny { + let schema: z.ZodTypeAny; + + switch (placeholder.type?.toLowerCase()) { + case 'string': + schema = z.string(); + break; + case 'number': + schema = z.number(); + break; + case 'boolean': + schema = z.boolean(); + break; + case 'json': + schema = z.record(z.any()); + break; + default: + schema = z.string(); + } + + if (placeholder.description) { + schema = schema.describe(`${schema.description ?? ''} ${placeholder.description}`.trim()); + } + + if (placeholder.defaultValue !== undefined) { + schema = schema.default(placeholder.defaultValue); + } + + return schema; + } + + /** + * Recursively traverses the nodeParameters object to find all $fromAI calls. + * @param payload The current object or value being traversed. + * @param collectedArgs The array collecting FromAIArgument objects. + */ + traverseNodeParameters(payload: unknown, collectedArgs: FromAIArgument[]) { + if (typeof payload === 'string') { + const fromAICalls = this.extractFromAICalls(payload); + fromAICalls.forEach((call) => collectedArgs.push(call)); + } else if (Array.isArray(payload)) { + payload.forEach((item: unknown) => this.traverseNodeParameters(item, collectedArgs)); + } else if (typeof payload === 'object' && payload !== null) { + Object.values(payload).forEach((value) => this.traverseNodeParameters(value, collectedArgs)); + } + } + + /** + * Extracts all $fromAI calls from a given string + * @param str The string to search for $fromAI calls. + * @returns An array of FromAIArgument objects. + * + * This method uses a regular expression to find the start of each $fromAI function call + * in the input string. It then employs a character-by-character parsing approach to + * accurately extract the arguments of each call, handling nested parentheses and quoted strings. + * + * The parsing process: + * 1. Finds the starting position of a $fromAI call using regex. + * 2. Iterates through characters, keeping track of parentheses depth and quote status. + * 3. Handles escaped characters within quotes to avoid premature quote closing. + * 4. Builds the argument string until the matching closing parenthesis is found. + * 5. Parses the extracted argument string into a FromAIArgument object. + * 6. Repeats the process for all $fromAI calls in the input string. + * + */ + extractFromAICalls(str: string): FromAIArgument[] { + const args: FromAIArgument[] = []; + // Regular expression to match the start of a $fromAI function call + const pattern = /\$fromAI\s*\(\s*/gi; + let match: RegExpExecArray | null; + + while ((match = pattern.exec(str)) !== null) { + const startIndex = match.index + match[0].length; + let current = startIndex; + let inQuotes = false; + let quoteChar = ''; + let parenthesesCount = 1; + let argsString = ''; + + // Parse the arguments string, handling nested parentheses and quotes + while (current < str.length && parenthesesCount > 0) { + const char = str[current]; + + if (inQuotes) { + // Handle characters inside quotes, including escaped characters + if (char === '\\' && current + 1 < str.length) { + argsString += char + str[current + 1]; + current += 2; + continue; + } + + if (char === quoteChar) { + inQuotes = false; + quoteChar = ''; + } + argsString += char; + } else { + // Handle characters outside quotes + if (['"', "'", '`'].includes(char)) { + inQuotes = true; + quoteChar = char; + } else if (char === '(') { + parenthesesCount++; + } else if (char === ')') { + parenthesesCount--; + } + + // Only add characters if we're still inside the main parentheses + if (parenthesesCount > 0 || char !== ')') { + argsString += char; + } + } + + current++; + } + + // If parentheses are balanced, parse the arguments + if (parenthesesCount === 0) { + try { + const parsedArgs = this.parseArguments(argsString); + args.push(parsedArgs); + } catch (error) { + // If parsing fails, throw an ApplicationError with details + throw new NodeOperationError( + this.ctx.getNode(), + `Failed to parse $fromAI arguments: ${argsString}: ${error}`, + ); + } + } else { + // Log an error if parentheses are unbalanced + throw new NodeOperationError( + this.ctx.getNode(), + `Unbalanced parentheses while parsing $fromAI call: ${str.slice(startIndex)}`, + ); + } + } + + return args; + } + + /** + * Parses the arguments of a single $fromAI function call. + * @param argsString The string containing the function arguments. + * @returns A FromAIArgument object. + */ + parseArguments(argsString: string): FromAIArgument { + // Split arguments by commas not inside quotes + const args: string[] = []; + let currentArg = ''; + let inQuotes = false; + let quoteChar = ''; + let escapeNext = false; + + for (let i = 0; i < argsString.length; i++) { + const char = argsString[i]; + + if (escapeNext) { + currentArg += char; + escapeNext = false; + continue; + } + + if (char === '\\') { + escapeNext = true; + continue; + } + + if (['"', "'", '`'].includes(char)) { + if (!inQuotes) { + inQuotes = true; + quoteChar = char; + currentArg += char; + } else if (char === quoteChar) { + inQuotes = false; + quoteChar = ''; + currentArg += char; + } else { + currentArg += char; + } + continue; + } + + if (char === ',' && !inQuotes) { + args.push(currentArg.trim()); + currentArg = ''; + continue; + } + + currentArg += char; + } + + if (currentArg) { + args.push(currentArg.trim()); + } + + // Remove surrounding quotes if present + const cleanArgs = args.map((arg) => { + const trimmed = arg.trim(); + if ( + (trimmed.startsWith("'") && trimmed.endsWith("'")) || + (trimmed.startsWith('`') && trimmed.endsWith('`')) || + (trimmed.startsWith('"') && trimmed.endsWith('"')) + ) { + return trimmed + .slice(1, -1) + .replace(/\\'/g, "'") + .replace(/\\`/g, '`') + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\'); + } + return trimmed; + }); + + const type = cleanArgs?.[2] || 'string'; + + if (!['string', 'number', 'boolean', 'json'].includes(type.toLowerCase())) { + throw new NodeOperationError(this.ctx.getNode(), `Invalid type: ${type}`); + } + + return { + key: cleanArgs[0] || '', + description: cleanArgs[1], + type: (cleanArgs?.[2] ?? 'string') as AllowedTypes, + defaultValue: this.parseDefaultValue(cleanArgs[3]), + }; + } + + /** + * Parses the default value, preserving its original type. + * @param value The default value as a string. + * @returns The parsed default value in its appropriate type. + */ + parseDefaultValue( + value: string | undefined, + ): string | number | boolean | Record | undefined { + if (value === undefined || value === '') return undefined; + const lowerValue = value.toLowerCase(); + if (lowerValue === 'true') return true; + if (lowerValue === 'false') return false; + if (!isNaN(Number(value))) return Number(value); + try { + return jsonParse(value); + } catch { + return value; + } + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts new file mode 100644 index 0000000000..2ce3c43556 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts @@ -0,0 +1,313 @@ +import type { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager'; +import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; +import get from 'lodash/get'; +import isObject from 'lodash/isObject'; +import type { SetField, SetNodeOptions } from 'n8n-nodes-base/dist/nodes/Set/v2/helpers/interfaces'; +import * as manual from 'n8n-nodes-base/dist/nodes/Set/v2/manual.mode'; +import { getCurrentWorkflowInputData } from 'n8n-nodes-base/dist/utils/workflowInputsResourceMapping/GenericFunctions'; +import type { + ExecuteWorkflowData, + ExecutionError, + IDataObject, + IExecuteWorkflowInfo, + INodeExecutionData, + INodeParameterResourceLocator, + ISupplyDataFunctions, + ITaskMetadata, + IWorkflowBase, + IWorkflowDataProxyData, + ResourceMapperValue, +} from 'n8n-workflow'; +import { jsonParse, NodeConnectionType, NodeOperationError } from 'n8n-workflow'; +import { z } from 'zod'; + +import type { FromAIArgument } from './FromAIParser'; +import { AIParametersParser } from './FromAIParser'; + +/** + Main class for creating the Workflow tool + Processes the node parameters and creates AI Agent tool capable of executing n8n workflows +*/ +export class WorkflowToolService { + // Determines if we should use input schema when creating the tool + private useSchema: boolean; + + // Sub-workflow id, pulled from referenced sub-workflow + private subWorkflowId: string | undefined; + + // Sub-workflow execution id, will be set after the sub-workflow is executed + private subExecutionId: string | undefined; + + constructor(private context: ISupplyDataFunctions) { + const subWorkflowInputs = this.context.getNode().parameters + .workflowInputs as ResourceMapperValue; + this.useSchema = (subWorkflowInputs?.schema ?? []).length > 0; + } + + // Creates the tool based on the provided parameters + async createTool({ + name, + description, + itemIndex, + }: { + name: string; + description: string; + itemIndex: number; + }): Promise { + // Handler for the tool execution, will be called when the tool is executed + // This function will execute the sub-workflow and return the response + const toolHandler = async ( + query: string | IDataObject, + runManager?: CallbackManagerForToolRun, + ): Promise => { + const { index } = this.context.addInputData(NodeConnectionType.AiTool, [ + [{ json: { query } }], + ]); + + try { + const response = await this.runFunction(query, itemIndex, runManager); + const processedResponse = this.handleToolResponse(response); + + // Once the sub-workflow is executed, add the output data to the context + // This will be used to link the sub-workflow execution in the parent workflow + let metadata: ITaskMetadata | undefined; + if (this.subExecutionId && this.subWorkflowId) { + metadata = { + subExecution: { + executionId: this.subExecutionId, + workflowId: this.subWorkflowId, + }, + }; + } + const json = jsonParse(processedResponse, { + fallbackValue: { response: processedResponse }, + }); + void this.context.addOutputData(NodeConnectionType.AiTool, index, [[{ json }]], metadata); + + return processedResponse; + } catch (error) { + const executionError = error as ExecutionError; + const errorResponse = `There was an error: "${executionError.message}"`; + void this.context.addOutputData(NodeConnectionType.AiTool, index, executionError); + return errorResponse; + } + }; + + // Create structured tool if input schema is provided + return this.useSchema + ? await this.createStructuredTool(name, description, toolHandler) + : new DynamicTool({ name, description, func: toolHandler }); + } + + private handleToolResponse(response: unknown): string { + if (typeof response === 'number') { + return response.toString(); + } + + if (isObject(response)) { + return JSON.stringify(response, null, 2); + } + + if (typeof response !== 'string') { + throw new NodeOperationError(this.context.getNode(), 'Wrong output type returned', { + description: `The response property should be a string, but it is an ${typeof response}`, + }); + } + + return response; + } + + /** + * Executes specified sub-workflow with provided inputs + */ + private async executeSubWorkflow( + workflowInfo: IExecuteWorkflowInfo, + items: INodeExecutionData[], + workflowProxy: IWorkflowDataProxyData, + runManager?: CallbackManagerForToolRun, + ): Promise<{ response: string; subExecutionId: string }> { + let receivedData: ExecuteWorkflowData; + try { + receivedData = await this.context.executeWorkflow( + workflowInfo, + items, + runManager?.getChild(), + { + parentExecution: { + executionId: workflowProxy.$execution.id, + workflowId: workflowProxy.$workflow.id, + }, + }, + ); + // Set sub-workflow execution id so it can be used in other places + this.subExecutionId = receivedData.executionId; + } catch (error) { + throw new NodeOperationError(this.context.getNode(), error as Error); + } + + const response: string | undefined = get(receivedData, 'data[0][0].json') as string | undefined; + if (response === undefined) { + throw new NodeOperationError( + this.context.getNode(), + 'There was an error: "The workflow did not return a response"', + ); + } + + return { response, subExecutionId: receivedData.executionId }; + } + + /** + * Gets the sub-workflow info based on the source and executes it. + * This function will be called as part of the tool execution (from the toolHandler) + */ + private async runFunction( + query: string | IDataObject, + itemIndex: number, + runManager?: CallbackManagerForToolRun, + ): Promise { + const source = this.context.getNodeParameter('source', itemIndex) as string; + const workflowProxy = this.context.getWorkflowDataProxy(0); + + const { workflowInfo } = await this.getSubWorkflowInfo(source, itemIndex, workflowProxy); + const rawData = this.prepareRawData(query, itemIndex); + const items = await this.prepareWorkflowItems(query, itemIndex, rawData); + + this.subWorkflowId = workflowInfo.id; + + const { response } = await this.executeSubWorkflow( + workflowInfo, + items, + workflowProxy, + runManager, + ); + return response; + } + + /** + * Gets the sub-workflow info based on the source (database or parameter) + */ + private async getSubWorkflowInfo( + source: string, + itemIndex: number, + workflowProxy: IWorkflowDataProxyData, + ): Promise<{ + workflowInfo: IExecuteWorkflowInfo; + subWorkflowId: string; + }> { + const workflowInfo: IExecuteWorkflowInfo = {}; + let subWorkflowId: string; + + if (source === 'database') { + const { value } = this.context.getNodeParameter( + 'workflowId', + itemIndex, + {}, + ) as INodeParameterResourceLocator; + workflowInfo.id = value as string; + subWorkflowId = workflowInfo.id; + } else if (source === 'parameter') { + const workflowJson = this.context.getNodeParameter('workflowJson', itemIndex) as string; + try { + workflowInfo.code = JSON.parse(workflowJson) as IWorkflowBase; + // subworkflow is same as parent workflow + subWorkflowId = workflowProxy.$workflow.id; + } catch (error) { + throw new NodeOperationError( + this.context.getNode(), + `The provided workflow is not valid JSON: "${(error as Error).message}"`, + { itemIndex }, + ); + } + } + + return { workflowInfo, subWorkflowId: subWorkflowId! }; + } + + private prepareRawData(query: string | IDataObject, itemIndex: number): IDataObject { + const rawData: IDataObject = { query }; + const workflowFieldsJson = this.context.getNodeParameter('fields.values', itemIndex, [], { + rawExpressions: true, + }) as SetField[]; + + // Copied from Set Node v2 + for (const entry of workflowFieldsJson) { + if (entry.type === 'objectValue' && (entry.objectValue as string).startsWith('=')) { + rawData[entry.name] = (entry.objectValue as string).replace(/^=+/, ''); + } + } + + return rawData; + } + + /** + * Prepares the sub-workflow items for execution + */ + private async prepareWorkflowItems( + query: string | IDataObject, + itemIndex: number, + rawData: IDataObject, + ): Promise { + const options: SetNodeOptions = { include: 'all' }; + let jsonData = typeof query === 'object' ? query : { query }; + + if (this.useSchema) { + const currentWorkflowInputs = getCurrentWorkflowInputData.call(this.context); + jsonData = currentWorkflowInputs[itemIndex].json; + } + + const newItem = await manual.execute.call( + this.context, + { json: jsonData }, + itemIndex, + options, + rawData, + this.context.getNode(), + ); + + return [newItem] as INodeExecutionData[]; + } + + /** + * Create structured tool by parsing the sub-workflow input schema + */ + private async createStructuredTool( + name: string, + description: string, + func: (query: string | IDataObject, runManager?: CallbackManagerForToolRun) => Promise, + ): Promise { + const fromAIParser = new AIParametersParser(this.context); + const collectedArguments = await this.extractFromAIParameters(fromAIParser); + + // If there are no `fromAI` arguments, fallback to creating a simple tool + if (collectedArguments.length === 0) { + return new DynamicTool({ name, description, func }); + } + + // Otherwise, prepare Zod schema and create a structured tool + const schema = this.createZodSchema(collectedArguments, fromAIParser); + return new DynamicStructuredTool({ schema, name, description, func }); + } + + private async extractFromAIParameters( + fromAIParser: AIParametersParser, + ): Promise { + const collectedArguments: FromAIArgument[] = []; + fromAIParser.traverseNodeParameters(this.context.getNode().parameters, collectedArguments); + + const uniqueArgsMap = new Map(); + for (const arg of collectedArguments) { + uniqueArgsMap.set(arg.key, arg); + } + + return Array.from(uniqueArgsMap.values()); + } + + private createZodSchema(args: FromAIArgument[], parser: AIParametersParser): z.ZodObject { + const schemaObj = args.reduce((acc: Record, placeholder) => { + acc[placeholder.key] = parser.generateZodSchema(placeholder); + return acc; + }, {}); + + return z.object(schemaObj).required(); + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/versionDescription.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/versionDescription.ts new file mode 100644 index 0000000000..469a7d6d4c --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/versionDescription.ts @@ -0,0 +1,151 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +/* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import { NodeConnectionType, type INodeTypeDescription } from 'n8n-workflow'; + +import { getConnectionHintNoticeField } from '../../../../utils/sharedFields'; + +export const versionDescription: INodeTypeDescription = { + displayName: 'Call n8n Workflow Tool', + name: 'toolWorkflow', + icon: 'fa:network-wired', + group: ['transform'], + description: 'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.', + defaults: { + name: 'Call n8n Workflow Tool', + }, + version: [2], + inputs: [], + outputs: [NodeConnectionType.AiTool], + outputNames: ['Tool'], + properties: [ + getConnectionHintNoticeField([NodeConnectionType.AiAgent]), + { + displayName: + 'See an example of a workflow to suggest meeting slots using AI here.', + name: 'noticeTemplateExample', + type: 'notice', + default: '', + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + placeholder: 'e.g. My_Color_Tool', + validateType: 'string-alphanumeric', + description: + 'The name of the function to be called, could contain letters, numbers, and underscores only', + }, + { + displayName: 'Description', + name: 'description', + type: 'string', + default: '', + placeholder: + 'Call this tool to get a random color. The input should be a string with comma separated names of colors to exclude.', + typeOptions: { + rows: 3, + }, + }, + + { + displayName: + 'This tool will call the workflow you define below, and look in the last node for the response. The workflow needs to start with an Execute Workflow trigger', + name: 'executeNotice', + type: 'notice', + default: '', + }, + + { + displayName: 'Source', + name: 'source', + type: 'options', + options: [ + { + name: 'Database', + value: 'database', + description: 'Load the workflow from the database by ID', + }, + { + name: 'Define Below', + value: 'parameter', + description: 'Pass the JSON code of a workflow', + }, + ], + default: 'database', + description: 'Where to get the workflow to execute from', + }, + + // ---------------------------------- + // source:database + // ---------------------------------- + { + displayName: 'Workflow', + name: 'workflowId', + type: 'workflowSelector', + displayOptions: { + show: { + source: ['database'], + }, + }, + default: '', + required: true, + }, + // ----------------------------------------------- + // Resource mapper for workflow inputs + // ----------------------------------------------- + { + displayName: 'Workflow Inputs', + name: 'workflowInputs', + type: 'resourceMapper', + noDataExpression: true, + default: { + mappingMode: 'defineBelow', + value: null, + }, + required: true, + typeOptions: { + loadOptionsDependsOn: ['workflowId.value'], + resourceMapper: { + localResourceMapperMethod: 'loadWorkflowInputMappings', + valuesLabel: 'Workflow Inputs', + mode: 'map', + fieldWords: { + singular: 'workflow input', + plural: 'workflow inputs', + }, + addAllFields: true, + multiKeyMatch: false, + supportAutoMap: false, + }, + }, + displayOptions: { + show: { + source: ['database'], + }, + hide: { + workflowId: [''], + }, + }, + }, + // ---------------------------------- + // source:parameter + // ---------------------------------- + { + displayName: 'Workflow JSON', + name: 'workflowJson', + type: 'json', + typeOptions: { + rows: 10, + }, + displayOptions: { + show: { + source: ['parameter'], + }, + }, + default: '\n\n\n\n\n\n\n\n\n', + required: true, + description: 'The workflow JSON code to execute', + }, + ], +}; diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePGVector/VectorStorePGVector.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePGVector/VectorStorePGVector.node.ts index 6d5da1615b..d9d5ee611a 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePGVector/VectorStorePGVector.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePGVector/VectorStorePGVector.node.ts @@ -228,7 +228,7 @@ export class VectorStorePGVector extends createVectorStoreNode({ testedBy: 'postgresConnectionTest', }, ], - operationModes: ['load', 'insert', 'retrieve'], + operationModes: ['load', 'insert', 'retrieve', 'retrieve-as-tool'], }, sharedFields, insertFields, 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 711425df55..5a11acea24 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 @@ -65,7 +65,7 @@ export class VectorStorePinecone extends createVectorStoreNode({ required: true, }, ], - operationModes: ['load', 'insert', 'retrieve', 'update'], + operationModes: ['load', 'insert', 'retrieve', 'update', 'retrieve-as-tool'], }, methods: { listSearch: { pineconeIndexSearch } }, retrieveFields, 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 b1b80fea5a..a462ff8cf6 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 @@ -55,7 +55,7 @@ export class VectorStoreSupabase extends createVectorStoreNode({ required: true, }, ], - operationModes: ['load', 'insert', 'retrieve', 'update'], + operationModes: ['load', 'insert', 'retrieve', 'update', 'retrieve-as-tool'], }, methods: { listSearch: { supabaseTableNameSearch }, diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZep/VectorStoreZep.node.test.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZep/VectorStoreZep.node.test.ts new file mode 100644 index 0000000000..e5694ce096 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZep/VectorStoreZep.node.test.ts @@ -0,0 +1,73 @@ +import { ZepVectorStore } from '@langchain/community/vectorstores/zep'; +import { ZepCloudVectorStore } from '@langchain/community/vectorstores/zep_cloud'; +import { mock } from 'jest-mock-extended'; +import type { ISupplyDataFunctions } from 'n8n-workflow'; + +import { VectorStoreZep } from './VectorStoreZep.node'; + +describe('VectorStoreZep', () => { + const vectorStore = new VectorStoreZep(); + const helpers = mock(); + const executeFunctions = mock({ helpers }); + + beforeEach(() => { + jest.resetAllMocks(); + + executeFunctions.addInputData.mockReturnValue({ index: 0 }); + }); + + it('should get vector store cloud client', async () => { + executeFunctions.getNodeParameter.mockImplementation((paramName: string) => { + switch (paramName) { + case 'mode': + return 'retrieve'; + case 'collectionName': + return 'test-collection'; + case 'options': + return {}; + default: + return undefined; + } + }); + + executeFunctions.getCredentials.mockResolvedValue( + mock({ + apiKey: 'some-key', + cloud: true, + }), + ); + + const { response } = await vectorStore.supplyData.call(executeFunctions, 0); + + expect(response).toBeDefined(); + expect(response).toBeInstanceOf(ZepCloudVectorStore); + }); + + it('should get vector store self-hosted client', async () => { + executeFunctions.getNodeParameter.mockImplementation((paramName: string) => { + switch (paramName) { + case 'mode': + return 'retrieve'; + case 'collectionName': + return 'test-collection'; + case 'options': + return {}; + default: + return undefined; + } + }); + + executeFunctions.getCredentials.mockResolvedValue( + mock({ + apiKey: 'some-key', + apiUrl: 'https://example.com', + cloud: false, + }), + ); + + const { response } = await vectorStore.supplyData.call(executeFunctions, 0); + + expect(response).toBeDefined(); + expect(response).toBeInstanceOf(ZepVectorStore); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZep/VectorStoreZep.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZep/VectorStoreZep.node.ts index d6e8914ae5..1372d54f6e 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZep/VectorStoreZep.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZep/VectorStoreZep.node.ts @@ -1,5 +1,5 @@ -import type { IZepConfig } from '@langchain/community/vectorstores/zep'; import { ZepVectorStore } from '@langchain/community/vectorstores/zep'; +import { ZepCloudVectorStore } from '@langchain/community/vectorstores/zep_cloud'; import type { IDataObject, INodeProperties } from 'n8n-workflow'; import { NodeOperationError } from 'n8n-workflow'; @@ -84,17 +84,21 @@ export class VectorStoreZep extends createVectorStoreNode({ const credentials = await context.getCredentials<{ apiKey?: string; apiUrl: string; + cloud: boolean; }>('zepApi'); - const zepConfig: IZepConfig = { - apiUrl: credentials.apiUrl, + const zepConfig = { apiKey: credentials.apiKey, collectionName, embeddingDimensions: options.embeddingDimensions ?? 1536, metadata: filter, }; - return new ZepVectorStore(embeddings, zepConfig); + if (credentials.cloud) { + return new ZepCloudVectorStore(embeddings, zepConfig); + } else { + return new ZepVectorStore(embeddings, { ...zepConfig, apiUrl: credentials.apiUrl }); + } }, async populateVectorStore(context, embeddings, documents, itemIndex) { const collectionName = context.getNodeParameter('collectionName', itemIndex) as string; @@ -107,10 +111,10 @@ export class VectorStoreZep extends createVectorStoreNode({ const credentials = await context.getCredentials<{ apiKey?: string; apiUrl: string; + cloud: boolean; }>('zepApi'); const zepConfig = { - apiUrl: credentials.apiUrl, apiKey: credentials.apiKey, collectionName, embeddingDimensions: options.embeddingDimensions ?? 1536, @@ -118,7 +122,14 @@ export class VectorStoreZep extends createVectorStoreNode({ }; try { - await ZepVectorStore.fromDocuments(documents, embeddings, zepConfig); + if (credentials.cloud) { + await ZepCloudVectorStore.fromDocuments(documents, embeddings, zepConfig); + } else { + await ZepVectorStore.fromDocuments(documents, embeddings, { + ...zepConfig, + apiUrl: credentials.apiUrl, + }); + } } catch (error) { const errorCode = (error as IDataObject).code as number; const responseData = (error as IDataObject).responseData as string; diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode.test.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode.test.ts new file mode 100644 index 0000000000..26036dce80 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode.test.ts @@ -0,0 +1,161 @@ +import type { DocumentInterface } from '@langchain/core/documents'; +import type { Embeddings } from '@langchain/core/embeddings'; +import type { VectorStore } from '@langchain/core/vectorstores'; +import { mock } from 'jest-mock-extended'; +import type { DynamicTool } from 'langchain/tools'; +import type { ISupplyDataFunctions, NodeParameterValueType } from 'n8n-workflow'; + +import type { VectorStoreNodeConstructorArgs } from './createVectorStoreNode'; +import { createVectorStoreNode } from './createVectorStoreNode'; + +jest.mock('@utils/logWrapper', () => ({ + logWrapper: jest.fn().mockImplementation((val: DynamicTool) => ({ logWrapped: val })), +})); + +const DEFAULT_PARAMETERS = { + options: {}, + topK: 1, +}; + +const MOCK_DOCUMENTS: Array<[DocumentInterface, number]> = [ + [ + { + pageContent: 'first page', + metadata: { + id: 123, + }, + }, + 0, + ], + [ + { + pageContent: 'second page', + metadata: { + id: 567, + }, + }, + 0, + ], +]; + +const MOCK_SEARCH_VALUE = 'search value'; +const MOCK_EMBEDDED_SEARCH_VALUE = [1, 2, 3]; + +describe('createVectorStoreNode', () => { + const vectorStore = mock({ + similaritySearchVectorWithScore: jest.fn().mockResolvedValue(MOCK_DOCUMENTS), + }); + + const vectorStoreNodeArgs = mock({ + sharedFields: [], + insertFields: [], + loadFields: [], + retrieveFields: [], + updateFields: [], + getVectorStoreClient: jest.fn().mockReturnValue(vectorStore), + }); + + const embeddings = mock({ + embedQuery: jest.fn().mockResolvedValue(MOCK_EMBEDDED_SEARCH_VALUE), + }); + + const context = mock({ + getNodeParameter: jest.fn(), + getInputConnectionData: jest.fn().mockReturnValue(embeddings), + }); + + describe('retrieve mode', () => { + it('supplies vector store as data', async () => { + // ARRANGE + const parameters: Record = { + ...DEFAULT_PARAMETERS, + mode: 'retrieve', + }; + context.getNodeParameter.mockImplementation( + (parameterName: string): NodeParameterValueType | object => parameters[parameterName], + ); + + // ACT + const VectorStoreNodeType = createVectorStoreNode(vectorStoreNodeArgs); + const nodeType = new VectorStoreNodeType(); + const data = await nodeType.supplyData.call(context, 1); + const wrappedVectorStore = (data.response as { logWrapped: VectorStore }).logWrapped; + + // ASSERT + expect(wrappedVectorStore).toEqual(vectorStore); + expect(vectorStoreNodeArgs.getVectorStoreClient).toHaveBeenCalled(); + }); + }); + + describe('retrieve-as-tool mode', () => { + it('supplies DynamicTool that queries vector store and returns documents with metadata', async () => { + // ARRANGE + const parameters: Record = { + ...DEFAULT_PARAMETERS, + mode: 'retrieve-as-tool', + description: 'tool description', + toolName: 'tool name', + includeDocumentMetadata: true, + }; + context.getNodeParameter.mockImplementation( + (parameterName: string): NodeParameterValueType | object => parameters[parameterName], + ); + + // ACT + const VectorStoreNodeType = createVectorStoreNode(vectorStoreNodeArgs); + const nodeType = new VectorStoreNodeType(); + const data = await nodeType.supplyData.call(context, 1); + const tool = (data.response as { logWrapped: DynamicTool }).logWrapped; + const output = await tool?.func(MOCK_SEARCH_VALUE); + + // ASSERT + expect(tool?.getName()).toEqual(parameters.toolName); + expect(tool?.description).toEqual(parameters.toolDescription); + expect(embeddings.embedQuery).toHaveBeenCalledWith(MOCK_SEARCH_VALUE); + expect(vectorStore.similaritySearchVectorWithScore).toHaveBeenCalledWith( + MOCK_EMBEDDED_SEARCH_VALUE, + parameters.topK, + parameters.filter, + ); + expect(output).toEqual([ + { type: 'text', text: JSON.stringify(MOCK_DOCUMENTS[0][0]) }, + { type: 'text', text: JSON.stringify(MOCK_DOCUMENTS[1][0]) }, + ]); + }); + + it('supplies DynamicTool that queries vector store and returns documents without metadata', async () => { + // ARRANGE + const parameters: Record = { + ...DEFAULT_PARAMETERS, + mode: 'retrieve-as-tool', + description: 'tool description', + toolName: 'tool name', + includeDocumentMetadata: false, + }; + context.getNodeParameter.mockImplementation( + (parameterName: string): NodeParameterValueType | object => parameters[parameterName], + ); + + // ACT + const VectorStoreNodeType = createVectorStoreNode(vectorStoreNodeArgs); + const nodeType = new VectorStoreNodeType(); + const data = await nodeType.supplyData.call(context, 1); + const tool = (data.response as { logWrapped: DynamicTool }).logWrapped; + const output = await tool?.func(MOCK_SEARCH_VALUE); + + // ASSERT + expect(tool?.getName()).toEqual(parameters.toolName); + expect(tool?.description).toEqual(parameters.toolDescription); + expect(embeddings.embedQuery).toHaveBeenCalledWith(MOCK_SEARCH_VALUE); + expect(vectorStore.similaritySearchVectorWithScore).toHaveBeenCalledWith( + MOCK_EMBEDDED_SEARCH_VALUE, + parameters.topK, + parameters.filter, + ); + expect(output).toEqual([ + { type: 'text', text: JSON.stringify({ pageContent: MOCK_DOCUMENTS[0][0].pageContent }) }, + { type: 'text', text: JSON.stringify({ pageContent: MOCK_DOCUMENTS[1][0].pageContent }) }, + ]); + }); + }); +}); 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 84f1d550e5..b7c0de3922 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode.ts @@ -3,6 +3,7 @@ import type { Document } from '@langchain/core/documents'; import type { Embeddings } from '@langchain/core/embeddings'; import type { VectorStore } from '@langchain/core/vectorstores'; +import { DynamicTool } from 'langchain/tools'; import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; import type { IExecuteFunctions, @@ -28,9 +29,14 @@ import { getConnectionHintNoticeField } from '@utils/sharedFields'; import { processDocument } from './processDocuments'; -type NodeOperationMode = 'insert' | 'load' | 'retrieve' | 'update'; +type NodeOperationMode = 'insert' | 'load' | 'retrieve' | 'update' | 'retrieve-as-tool'; -const DEFAULT_OPERATION_MODES: NodeOperationMode[] = ['load', 'insert', 'retrieve']; +const DEFAULT_OPERATION_MODES: NodeOperationMode[] = [ + 'load', + 'insert', + 'retrieve', + 'retrieve-as-tool', +]; interface NodeMeta { displayName: string; @@ -43,7 +49,7 @@ interface NodeMeta { operationModes?: NodeOperationMode[]; } -interface VectorStoreNodeConstructorArgs { +export interface VectorStoreNodeConstructorArgs { meta: NodeMeta; methods?: { listSearch?: { @@ -102,10 +108,18 @@ function getOperationModeOptions(args: VectorStoreNodeConstructorArgs): INodePro action: 'Add documents to vector store', }, { - name: 'Retrieve Documents (For Agent/Chain)', + name: 'Retrieve Documents (As Vector Store for AI Agent)', value: 'retrieve', - description: 'Retrieve documents from vector store to be used with AI nodes', - action: 'Retrieve documents for AI processing', + description: 'Retrieve documents from vector store to be used as vector store with AI nodes', + action: 'Retrieve documents for AI processing as Vector Store', + outputConnectionType: NodeConnectionType.AiVectorStore, + }, + { + name: 'Retrieve Documents (As Tool for AI Agent)', + value: 'retrieve-as-tool', + description: 'Retrieve documents from vector store to be used as tool with AI nodes', + action: 'Retrieve documents for AI processing as Tool', + outputConnectionType: NodeConnectionType.AiTool, }, { name: 'Update Documents', @@ -136,7 +150,8 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) => codex: { categories: ['AI'], subcategories: { - AI: ['Vector Stores', 'Root Nodes'], + AI: ['Vector Stores', 'Tools', 'Root Nodes'], + Tools: ['Other Tools'], }, resources: { primaryDocumentation: [ @@ -153,6 +168,10 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) => const mode = parameters?.mode; const inputs = [{ displayName: "Embedding", type: "${NodeConnectionType.AiEmbedding}", required: true, maxConnections: 1}] + if (mode === 'retrieve-as-tool') { + return inputs; + } + if (['insert', 'load', 'update'].includes(mode)) { inputs.push({ displayName: "", type: "${NodeConnectionType.Main}"}) } @@ -166,6 +185,11 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) => outputs: `={{ ((parameters) => { const mode = parameters?.mode ?? 'retrieve'; + + if (mode === 'retrieve-as-tool') { + return [{ displayName: "Tool", type: "${NodeConnectionType.AiTool}"}] + } + if (mode === 'retrieve') { return [{ displayName: "Vector Store", type: "${NodeConnectionType.AiVectorStore}"}] } @@ -189,6 +213,37 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) => }, }, }, + { + displayName: 'Name', + name: 'toolName', + type: 'string', + default: '', + required: true, + description: 'Name of the vector store', + placeholder: 'e.g. company_knowledge_base', + validateType: 'string-alphanumeric', + displayOptions: { + show: { + mode: ['retrieve-as-tool'], + }, + }, + }, + { + displayName: 'Description', + name: 'toolDescription', + type: 'string', + default: '', + required: true, + typeOptions: { rows: 2 }, + description: + 'Explain to the LLM what this tool does, a good, specific description would allow LLMs to produce expected results much more often', + placeholder: `e.g. ${args.meta.description}`, + displayOptions: { + show: { + mode: ['retrieve-as-tool'], + }, + }, + }, ...args.sharedFields, ...transformDescriptionForOperationMode(args.insertFields ?? [], 'insert'), // Prompt and topK are always used for the load operation @@ -214,7 +269,19 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) => description: 'Number of top results to fetch from vector store', displayOptions: { show: { - mode: ['load'], + mode: ['load', 'retrieve-as-tool'], + }, + }, + }, + { + displayName: 'Include Metadata', + name: 'includeDocumentMetadata', + type: 'boolean', + default: true, + description: 'Whether or not to include document metadata', + displayOptions: { + show: { + mode: ['load', 'retrieve-as-tool'], }, }, }, @@ -271,10 +338,16 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) => filter, ); + const includeDocumentMetadata = this.getNodeParameter( + 'includeDocumentMetadata', + itemIndex, + true, + ) as boolean; + const serializedDocs = docs.map(([doc, score]) => { const document = { - metadata: doc.metadata, pageContent: doc.pageContent, + ...(includeDocumentMetadata ? { metadata: doc.metadata } : {}), }; return { @@ -381,12 +454,12 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) => throw new NodeOperationError( this.getNode(), - 'Only the "load" and "insert" operation modes are supported with execute', + 'Only the "load", "update" and "insert" operation modes are supported with execute', ); } async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { - const mode = this.getNodeParameter('mode', 0) as 'load' | 'insert' | 'retrieve'; + const mode = this.getNodeParameter('mode', 0) as NodeOperationMode; const filter = getMetadataFiltersValues(this, itemIndex); const embeddings = (await this.getInputConnectionData( NodeConnectionType.AiEmbedding, @@ -400,9 +473,54 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) => }; } + if (mode === 'retrieve-as-tool') { + const toolDescription = this.getNodeParameter('toolDescription', itemIndex) as string; + const toolName = this.getNodeParameter('toolName', itemIndex) as string; + const topK = this.getNodeParameter('topK', itemIndex, 4) as number; + const includeDocumentMetadata = this.getNodeParameter( + 'includeDocumentMetadata', + itemIndex, + true, + ) as boolean; + + const vectorStoreTool = new DynamicTool({ + name: toolName, + description: toolDescription, + func: async (input) => { + const vectorStore = await args.getVectorStoreClient( + this, + filter, + embeddings, + itemIndex, + ); + const embeddedPrompt = await embeddings.embedQuery(input); + const documents = await vectorStore.similaritySearchVectorWithScore( + embeddedPrompt, + topK, + filter, + ); + return documents + .map((document) => { + if (includeDocumentMetadata) { + return { type: 'text', text: JSON.stringify(document[0]) }; + } + return { + type: 'text', + text: JSON.stringify({ pageContent: document[0].pageContent }), + }; + }) + .filter((document) => !!document); + }, + }); + + return { + response: logWrapper(vectorStoreTool, this), + }; + } + throw new NodeOperationError( this.getNode(), - 'Only the "retrieve" operation mode is supported to supply data', + 'Only the "retrieve" and "retrieve-as-tool" operation mode is supported to supply data', ); } }; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/transport/index.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/transport/index.ts index 36a5773245..619869bf9d 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/transport/index.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/transport/index.ts @@ -18,14 +18,22 @@ export async function apiRequest( endpoint: string, parameters?: RequestParameters, ) { - const { body, qs, uri, option, headers } = parameters ?? {}; + const { body, qs, option, headers } = parameters ?? {}; + + const credentials = await this.getCredentials('openAiApi'); + + let uri = `https://api.openai.com/v1${endpoint}`; + + if (credentials.url) { + uri = `${credentials?.url}${endpoint}`; + } const options = { headers, method, body, qs, - uri: uri ?? `https://api.openai.com/v1${endpoint}`, + uri, json: true, }; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/transport/test/transport.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/transport/test/transport.test.ts new file mode 100644 index 0000000000..399dad8562 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/transport/test/transport.test.ts @@ -0,0 +1,62 @@ +import type { IExecuteFunctions } from 'n8n-workflow'; + +import { apiRequest } from '../index'; + +const mockedExecutionContext = { + getCredentials: jest.fn(), + helpers: { + requestWithAuthentication: jest.fn(), + }, +}; + +describe('apiRequest', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should call requestWithAuthentication with credentials URL if one is provided', async () => { + mockedExecutionContext.getCredentials.mockResolvedValue({ + url: 'http://www.test/url/v1', + }); + + // Act + await apiRequest.call(mockedExecutionContext as unknown as IExecuteFunctions, 'GET', '/test', { + headers: { 'Content-Type': 'application/json' }, + }); + + // Assert + + expect(mockedExecutionContext.getCredentials).toHaveBeenCalledWith('openAiApi'); + expect(mockedExecutionContext.helpers.requestWithAuthentication).toHaveBeenCalledWith( + 'openAiApi', + { + headers: { 'Content-Type': 'application/json' }, + method: 'GET', + uri: 'http://www.test/url/v1/test', + json: true, + }, + ); + }); + + it('should call requestWithAuthentication with default URL if credentials URL is not provided', async () => { + mockedExecutionContext.getCredentials.mockResolvedValue({}); + + // Act + await apiRequest.call(mockedExecutionContext as unknown as IExecuteFunctions, 'GET', '/test', { + headers: { 'Content-Type': 'application/json' }, + }); + + // Assert + + expect(mockedExecutionContext.getCredentials).toHaveBeenCalledWith('openAiApi'); + expect(mockedExecutionContext.helpers.requestWithAuthentication).toHaveBeenCalledWith( + 'openAiApi', + { + headers: { 'Content-Type': 'application/json' }, + method: 'GET', + uri: 'https://api.openai.com/v1/test', + json: true, + }, + ); + }); +}); diff --git a/packages/@n8n/nodes-langchain/utils/descriptions.ts b/packages/@n8n/nodes-langchain/utils/descriptions.ts index e629bab812..a90b27bb6c 100644 --- a/packages/@n8n/nodes-langchain/utils/descriptions.ts +++ b/packages/@n8n/nodes-langchain/utils/descriptions.ts @@ -66,7 +66,7 @@ export const inputSchemaField: INodeProperties = { }; export const promptTypeOptions: INodeProperties = { - displayName: 'Prompt Source (User Message)', + displayName: 'Source for Prompt (User Message)', name: 'promptType', type: 'options', options: [ @@ -98,7 +98,7 @@ export const textInput: INodeProperties = { }; export const textFromPreviousNode: INodeProperties = { - displayName: 'Text From Previous Node', + displayName: 'Prompt (User Message)', name: 'text', type: 'string', required: true, diff --git a/packages/@n8n/permissions/src/combineScopes.ts b/packages/@n8n/permissions/src/combineScopes.ee.ts similarity index 98% rename from packages/@n8n/permissions/src/combineScopes.ts rename to packages/@n8n/permissions/src/combineScopes.ee.ts index 23da64d837..96a43b940c 100644 --- a/packages/@n8n/permissions/src/combineScopes.ts +++ b/packages/@n8n/permissions/src/combineScopes.ee.ts @@ -1,4 +1,4 @@ -import type { Scope, ScopeLevels, GlobalScopes, MaskLevels } from './types'; +import type { Scope, ScopeLevels, GlobalScopes, MaskLevels } from './types.ee'; export function combineScopes(userScopes: GlobalScopes, masks?: MaskLevels): Set; export function combineScopes(userScopes: ScopeLevels, masks?: MaskLevels): Set; diff --git a/packages/@n8n/permissions/src/constants.ts b/packages/@n8n/permissions/src/constants.ee.ts similarity index 100% rename from packages/@n8n/permissions/src/constants.ts rename to packages/@n8n/permissions/src/constants.ee.ts diff --git a/packages/@n8n/permissions/src/hasScope.ts b/packages/@n8n/permissions/src/hasScope.ee.ts similarity index 90% rename from packages/@n8n/permissions/src/hasScope.ts rename to packages/@n8n/permissions/src/hasScope.ee.ts index d449283490..81bcbc5175 100644 --- a/packages/@n8n/permissions/src/hasScope.ts +++ b/packages/@n8n/permissions/src/hasScope.ee.ts @@ -1,5 +1,5 @@ -import { combineScopes } from './combineScopes'; -import type { Scope, ScopeLevels, GlobalScopes, ScopeOptions, MaskLevels } from './types'; +import { combineScopes } from './combineScopes.ee'; +import type { Scope, ScopeLevels, GlobalScopes, ScopeOptions, MaskLevels } from './types.ee'; export function hasScope( scope: Scope | Scope[], diff --git a/packages/@n8n/permissions/src/index.ts b/packages/@n8n/permissions/src/index.ts index f04f2e4ef6..ae20358303 100644 --- a/packages/@n8n/permissions/src/index.ts +++ b/packages/@n8n/permissions/src/index.ts @@ -1,4 +1,4 @@ -export type * from './types'; -export * from './constants'; -export * from './hasScope'; -export * from './combineScopes'; +export type * from './types.ee'; +export * from './constants.ee'; +export * from './hasScope.ee'; +export * from './combineScopes.ee'; diff --git a/packages/@n8n/permissions/src/types.ts b/packages/@n8n/permissions/src/types.ee.ts similarity index 96% rename from packages/@n8n/permissions/src/types.ts rename to packages/@n8n/permissions/src/types.ee.ts index b36fb792ae..db74668fbe 100644 --- a/packages/@n8n/permissions/src/types.ts +++ b/packages/@n8n/permissions/src/types.ee.ts @@ -1,4 +1,4 @@ -import type { RESOURCES } from './constants'; +import type { RESOURCES } from './constants.ee'; export type Resource = keyof typeof RESOURCES; diff --git a/packages/@n8n/permissions/test/hasScope.test.ts b/packages/@n8n/permissions/test/hasScope.test.ts index 0e43bc8dc6..b3362e4ea6 100644 --- a/packages/@n8n/permissions/test/hasScope.test.ts +++ b/packages/@n8n/permissions/test/hasScope.test.ts @@ -1,5 +1,5 @@ -import { hasScope } from '@/hasScope'; -import type { Scope } from '@/types'; +import { hasScope } from '@/hasScope.ee'; +import type { Scope } from '@/types.ee'; const ownerPermissions: Scope[] = [ 'workflow:create', diff --git a/packages/@n8n/task-runner/package.json b/packages/@n8n/task-runner/package.json index 212909990e..9f2b0f0960 100644 --- a/packages/@n8n/task-runner/package.json +++ b/packages/@n8n/task-runner/package.json @@ -35,6 +35,7 @@ }, "dependencies": { "@n8n/config": "workspace:*", + "@n8n/di": "workspace:*", "@sentry/node": "catalog:", "acorn": "8.14.0", "acorn-walk": "8.3.4", @@ -42,7 +43,6 @@ "n8n-core": "workspace:*", "n8n-workflow": "workspace:*", "nanoid": "catalog:", - "typedi": "catalog:", "ws": "^8.18.0" }, "devDependencies": { diff --git a/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts b/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts index dbb9403894..5ebd965e87 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts @@ -1,4 +1,3 @@ -import { mock } from 'jest-mock-extended'; import { DateTime } from 'luxon'; import type { IBinaryData } from 'n8n-workflow'; import { setGlobalState, type CodeExecutionMode, type IDataObject } from 'n8n-workflow'; @@ -18,11 +17,12 @@ import { type DataRequestResponse, type InputDataChunkDefinition, } from '@/runner-types'; -import type { Task } from '@/task-runner'; +import type { TaskParams } from '@/task-runner'; import { newDataRequestResponse, - newTaskWithSettings, + newTaskParamsWithSettings, + newTaskState, withPairedItem, wrapIntoJson, } from './test-data'; @@ -64,12 +64,12 @@ describe('JsTaskRunner', () => { taskData, runner = defaultTaskRunner, }: { - task: Task; + task: TaskParams; taskData: DataRequestResponse; runner?: JsTaskRunner; }) => { jest.spyOn(runner, 'requestData').mockResolvedValue(taskData); - return await runner.executeTask(task, mock()); + return await runner.executeTask(task, new AbortController().signal); }; afterEach(() => { @@ -88,7 +88,7 @@ describe('JsTaskRunner', () => { runner?: JsTaskRunner; }) => { return await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code, nodeMode: 'runOnceForAllItems', ...settings, @@ -112,7 +112,7 @@ describe('JsTaskRunner', () => { chunk?: InputDataChunkDefinition; }) => { return await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code, nodeMode: 'runOnceForEachItem', chunk, @@ -128,7 +128,7 @@ describe('JsTaskRunner', () => { 'should make an rpc call for console log in %s mode', async (nodeMode) => { jest.spyOn(defaultTaskRunner, 'makeRpcCall').mockResolvedValue(undefined); - const task = newTaskWithSettings({ + const task = newTaskParamsWithSettings({ code: "console.log('Hello', 'world!'); return {}", nodeMode, }); @@ -139,13 +139,14 @@ describe('JsTaskRunner', () => { }); expect(defaultTaskRunner.makeRpcCall).toHaveBeenCalledWith(task.taskId, 'logNodeOutput', [ - 'Hello world!', + "'Hello'", + "'world!'", ]); }, ); it('should not throw when using unsupported console methods', async () => { - const task = newTaskWithSettings({ + const task = newTaskParamsWithSettings({ code: ` console.warn('test'); console.error('test'); @@ -173,6 +174,44 @@ describe('JsTaskRunner', () => { }), ).resolves.toBeDefined(); }); + + it('should not throw when trying to log the context object', async () => { + const task = newTaskParamsWithSettings({ + code: ` + console.log(this); + return {json: {}} + `, + nodeMode: 'runOnceForAllItems', + }); + + await expect( + execTaskWithParams({ + task, + taskData: newDataRequestResponse([wrapIntoJson({})]), + }), + ).resolves.toBeDefined(); + }); + + it('should log the context object as [[ExecutionContext]]', async () => { + const rpcCallSpy = jest.spyOn(defaultTaskRunner, 'makeRpcCall').mockResolvedValue(undefined); + + const task = newTaskParamsWithSettings({ + code: ` + console.log(this); + return {json: {}} + `, + nodeMode: 'runOnceForAllItems', + }); + + await execTaskWithParams({ + task, + taskData: newDataRequestResponse([wrapIntoJson({})]), + }); + + expect(rpcCallSpy).toHaveBeenCalledWith(task.taskId, 'logNodeOutput', [ + '[[ExecutionContext]]', + ]); + }); }); describe('built-in methods and variables available in the context', () => { @@ -297,7 +336,7 @@ describe('JsTaskRunner', () => { describe('$env', () => { it('should have the env available in context when access has not been blocked', async () => { const outcome = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: 'return { val: $env.VAR1 }', nodeMode: 'runOnceForAllItems', }), @@ -316,7 +355,7 @@ describe('JsTaskRunner', () => { it('should be possible to access env if it has been blocked', async () => { await expect( execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: 'return { val: $env.VAR1 }', nodeMode: 'runOnceForAllItems', }), @@ -333,7 +372,7 @@ describe('JsTaskRunner', () => { it('should not be possible to iterate $env', async () => { const outcome = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: 'return Object.values($env).concat(Object.keys($env))', nodeMode: 'runOnceForAllItems', }), @@ -352,7 +391,7 @@ describe('JsTaskRunner', () => { it("should not expose task runner's env variables even if no env state is received", async () => { process.env.N8N_RUNNERS_TASK_BROKER_URI = 'http://127.0.0.1:5679'; const outcome = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: 'return { val: $env.N8N_RUNNERS_TASK_BROKER_URI }', nodeMode: 'runOnceForAllItems', }), @@ -373,7 +412,7 @@ describe('JsTaskRunner', () => { }; const outcome = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: 'return { val: $now.toSeconds() }', nodeMode: 'runOnceForAllItems', }), @@ -390,7 +429,7 @@ describe('JsTaskRunner', () => { }); const outcome = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: 'return { val: $now.toSeconds() }', nodeMode: 'runOnceForAllItems', }), @@ -405,7 +444,7 @@ describe('JsTaskRunner', () => { describe("$getWorkflowStaticData('global')", () => { it('should have the global workflow static data available in runOnceForAllItems', async () => { const outcome = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: 'return { val: $getWorkflowStaticData("global") }', nodeMode: 'runOnceForAllItems', }), @@ -421,7 +460,7 @@ describe('JsTaskRunner', () => { it('should have the global workflow static data available in runOnceForEachItem', async () => { const outcome = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: 'return { val: $getWorkflowStaticData("global") }', nodeMode: 'runOnceForEachItem', }), @@ -441,7 +480,7 @@ describe('JsTaskRunner', () => { "does not return static data if it hasn't been modified in %s", async (mode) => { const outcome = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: ` const staticData = $getWorkflowStaticData("global"); return { val: staticData }; @@ -463,7 +502,7 @@ describe('JsTaskRunner', () => { 'returns the updated static data in %s', async (mode) => { const outcome = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: ` const staticData = $getWorkflowStaticData("global"); staticData.newKey = 'newValue'; @@ -502,7 +541,7 @@ describe('JsTaskRunner', () => { it('should have the node workflow static data available in runOnceForAllItems', async () => { const outcome = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: 'return { val: $getWorkflowStaticData("node") }', nodeMode: 'runOnceForAllItems', }), @@ -514,7 +553,7 @@ describe('JsTaskRunner', () => { it('should have the node workflow static data available in runOnceForEachItem', async () => { const outcome = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: 'return { val: $getWorkflowStaticData("node") }', nodeMode: 'runOnceForEachItem', }), @@ -530,7 +569,7 @@ describe('JsTaskRunner', () => { "does not return static data if it hasn't been modified in %s", async (mode) => { const outcome = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: ` const staticData = $getWorkflowStaticData("node"); return { val: staticData }; @@ -548,7 +587,7 @@ describe('JsTaskRunner', () => { 'returns the updated static data in %s', async (mode) => { const outcome = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: ` const staticData = $getWorkflowStaticData("node"); staticData.newKey = 'newValue'; @@ -623,7 +662,7 @@ describe('JsTaskRunner', () => { // Act await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: `await ${group.invocation}; return []`, nodeMode: 'runOnceForAllItems', }), @@ -633,6 +672,7 @@ describe('JsTaskRunner', () => { ), }); + // Assert expect(rpcCallSpy).toHaveBeenCalledWith('1', group.method, group.expectedParams); }); @@ -644,7 +684,7 @@ describe('JsTaskRunner', () => { // Act await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: `await ${group.invocation}; return {}`, nodeMode: 'runOnceForEachItem', }), @@ -661,26 +701,22 @@ describe('JsTaskRunner', () => { describe('unsupported methods', () => { for (const unsupportedFunction of UNSUPPORTED_HELPER_FUNCTIONS) { it(`should throw an error if ${unsupportedFunction} is used in runOnceForAllItems`, async () => { - // Act - + // Act & Assert await expect( - async () => - await executeForAllItems({ - code: `${unsupportedFunction}()`, - inputItems, - }), + executeForAllItems({ + code: `${unsupportedFunction}()`, + inputItems, + }), ).rejects.toThrow(UnsupportedFunctionError); }); it(`should throw an error if ${unsupportedFunction} is used in runOnceForEachItem`, async () => { - // Act - + // Act & Assert await expect( - async () => - await executeForEachItem({ - code: `${unsupportedFunction}()`, - inputItems, - }), + executeForEachItem({ + code: `${unsupportedFunction}()`, + inputItems, + }), ).rejects.toThrow(UnsupportedFunctionError); }); } @@ -689,7 +725,7 @@ describe('JsTaskRunner', () => { it('should allow access to Node.js Buffers', async () => { const outcomeAll = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: 'return { val: Buffer.from("test-buffer").toString() }', nodeMode: 'runOnceForAllItems', }), @@ -701,7 +737,7 @@ describe('JsTaskRunner', () => { expect(outcomeAll.result).toEqual([wrapIntoJson({ val: 'test-buffer' })]); const outcomePer = await execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: 'return { val: Buffer.from("test-buffer").toString() }', nodeMode: 'runOnceForEachItem', }), @@ -1169,7 +1205,7 @@ describe('JsTaskRunner', () => { async (nodeMode) => { await expect( execTaskWithParams({ - task: newTaskWithSettings({ + task: newTaskParamsWithSettings({ code: 'unknown', nodeMode, }), @@ -1182,12 +1218,13 @@ describe('JsTaskRunner', () => { it('sends serializes an error correctly', async () => { const runner = createRunnerWithOpts({}); const taskId = '1'; - const task = newTaskWithSettings({ + const task = newTaskState(taskId); + const taskSettings: JSExecSettings = { code: 'unknown; return []', nodeMode: 'runOnceForAllItems', continueOnFail: false, workflowMode: 'manual', - }); + }; runner.runningTasks.set(taskId, task); const sendSpy = jest.spyOn(runner.ws, 'send').mockImplementation(() => {}); @@ -1196,7 +1233,7 @@ describe('JsTaskRunner', () => { .spyOn(runner, 'requestData') .mockResolvedValue(newDataRequestResponse([wrapIntoJson({ a: 1 })])); - await runner.receivedSettings(taskId, task.settings); + await runner.receivedSettings(taskId, taskSettings); expect(sendSpy).toHaveBeenCalled(); const calledWith = sendSpy.mock.calls[0][0] as string; @@ -1268,11 +1305,7 @@ describe('JsTaskRunner', () => { const emitSpy = jest.spyOn(runner, 'emit'); jest.spyOn(runner, 'executeTask').mockResolvedValue({ result: [] }); - runner.runningTasks.set(taskId, { - taskId, - active: true, - cancelled: false, - }); + runner.runningTasks.set(taskId, newTaskState(taskId)); jest.advanceTimersByTime(idleTimeout * 1000 - 100); expect(emitSpy).not.toHaveBeenCalledWith('runner:reached-idle-timeout'); @@ -1299,15 +1332,13 @@ describe('JsTaskRunner', () => { const runner = createRunnerWithOpts({}, { idleTimeout }); const taskId = '123'; const emitSpy = jest.spyOn(runner, 'emit'); + const task = newTaskState(taskId); - runner.runningTasks.set(taskId, { - taskId, - active: true, - cancelled: false, - }); + runner.runningTasks.set(taskId, task); jest.advanceTimersByTime(idleTimeout * 1000); expect(emitSpy).not.toHaveBeenCalledWith('runner:reached-idle-timeout'); + task.cleanup(); }); }); }); diff --git a/packages/@n8n/task-runner/src/js-task-runner/__tests__/task-runner.test.ts b/packages/@n8n/task-runner/src/js-task-runner/__tests__/task-runner.test.ts index e12770f770..9db385eb7f 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/__tests__/task-runner.test.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/__tests__/task-runner.test.ts @@ -1,32 +1,47 @@ import { WebSocket } from 'ws'; -import { TaskRunner } from '@/task-runner'; +import { newTaskState } from '@/js-task-runner/__tests__/test-data'; +import { TimeoutError } from '@/js-task-runner/errors/timeout-error'; +import { TaskRunner, type TaskRunnerOpts } from '@/task-runner'; +import type { TaskStatus } from '@/task-state'; class TestRunner extends TaskRunner {} jest.mock('ws'); describe('TestRunner', () => { + let runner: TestRunner; + + const newTestRunner = (opts: Partial = {}) => + new TestRunner({ + taskType: 'test-task', + maxConcurrency: 5, + idleTimeout: 60, + grantToken: 'test-token', + maxPayloadSize: 1024, + taskBrokerUri: 'http://localhost:8080', + timezone: 'America/New_York', + taskTimeout: 60, + healthcheckServer: { + enabled: false, + host: 'localhost', + port: 8081, + }, + ...opts, + }); + + afterEach(() => { + runner?.clearIdleTimer(); + }); + describe('constructor', () => { afterEach(() => { jest.clearAllMocks(); }); it('should correctly construct WebSocket URI with provided taskBrokerUri', () => { - const runner = new TestRunner({ - taskType: 'test-task', - maxConcurrency: 5, - idleTimeout: 60, - grantToken: 'test-token', - maxPayloadSize: 1024, + runner = newTestRunner({ taskBrokerUri: 'http://localhost:8080', - timezone: 'America/New_York', - taskTimeout: 60, - healthcheckServer: { - enabled: false, - host: 'localhost', - port: 8081, - }, }); expect(WebSocket).toHaveBeenCalledWith( @@ -38,25 +53,11 @@ describe('TestRunner', () => { maxPayload: 1024, }), ); - - runner.clearIdleTimer(); }); it('should handle different taskBrokerUri formats correctly', () => { - const runner = new TestRunner({ - taskType: 'test-task', - maxConcurrency: 5, - idleTimeout: 60, - grantToken: 'test-token', - maxPayloadSize: 1024, + runner = newTestRunner({ taskBrokerUri: 'https://example.com:3000/path', - timezone: 'America/New_York', - taskTimeout: 60, - healthcheckServer: { - enabled: false, - host: 'localhost', - port: 8081, - }, }); expect(WebSocket).toHaveBeenCalledWith( @@ -68,56 +69,175 @@ describe('TestRunner', () => { maxPayload: 1024, }), ); - - runner.clearIdleTimer(); }); it('should throw an error if taskBrokerUri is invalid', () => { - expect( - () => - new TestRunner({ - taskType: 'test-task', - maxConcurrency: 5, - idleTimeout: 60, - grantToken: 'test-token', - maxPayloadSize: 1024, - taskBrokerUri: 'not-a-valid-uri', - timezone: 'America/New_York', - taskTimeout: 60, - healthcheckServer: { - enabled: false, - host: 'localhost', - port: 8081, - }, - }), + expect(() => + newTestRunner({ + taskBrokerUri: 'not-a-valid-uri', + }), ).toThrowError(/Invalid URL/); }); }); - describe('taskCancelled', () => { - it('should reject pending requests when task is cancelled', () => { - const runner = new TestRunner({ + describe('sendOffers', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + it('should not send offers if canSendOffers is false', () => { + runner = newTestRunner({ taskType: 'test-task', - maxConcurrency: 5, - idleTimeout: 60, - grantToken: 'test-token', - maxPayloadSize: 1024, - taskBrokerUri: 'http://localhost:8080', - timezone: 'America/New_York', - taskTimeout: 60, - healthcheckServer: { - enabled: false, - host: 'localhost', - port: 8081, - }, + maxConcurrency: 2, + }); + const sendSpy = jest.spyOn(runner, 'send'); + expect(runner.canSendOffers).toBe(false); + + runner.sendOffers(); + + expect(sendSpy).toHaveBeenCalledTimes(0); + }); + + it('should enable sending of offer on runnerregistered message', () => { + runner = newTestRunner({ + taskType: 'test-task', + maxConcurrency: 2, + }); + runner.onMessage({ + type: 'broker:runnerregistered', }); - const taskId = 'test-task'; - runner.runningTasks.set(taskId, { - taskId, - active: false, - cancelled: false, + expect(runner.canSendOffers).toBe(true); + }); + + it('should send maxConcurrency offers when there are no offers', () => { + runner = newTestRunner({ + taskType: 'test-task', + maxConcurrency: 2, }); + runner.onMessage({ + type: 'broker:runnerregistered', + }); + + const sendSpy = jest.spyOn(runner, 'send'); + + runner.sendOffers(); + runner.sendOffers(); + + expect(sendSpy).toHaveBeenCalledTimes(2); + expect(sendSpy.mock.calls).toEqual([ + [ + { + type: 'runner:taskoffer', + taskType: 'test-task', + offerId: expect.any(String), + validFor: expect.any(Number), + }, + ], + [ + { + type: 'runner:taskoffer', + taskType: 'test-task', + offerId: expect.any(String), + validFor: expect.any(Number), + }, + ], + ]); + }); + + it('should send up to maxConcurrency offers when there is a running task', () => { + runner = newTestRunner({ + taskType: 'test-task', + maxConcurrency: 2, + }); + runner.onMessage({ + type: 'broker:runnerregistered', + }); + const taskState = newTaskState('test-task'); + runner.runningTasks.set('test-task', taskState); + const sendSpy = jest.spyOn(runner, 'send'); + + runner.sendOffers(); + + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(sendSpy.mock.calls).toEqual([ + [ + { + type: 'runner:taskoffer', + taskType: 'test-task', + offerId: expect.any(String), + validFor: expect.any(Number), + }, + ], + ]); + taskState.cleanup(); + }); + + it('should delete stale offers and send new ones', () => { + runner = newTestRunner({ + taskType: 'test-task', + maxConcurrency: 2, + }); + runner.onMessage({ + type: 'broker:runnerregistered', + }); + + const sendSpy = jest.spyOn(runner, 'send'); + + runner.sendOffers(); + expect(sendSpy).toHaveBeenCalledTimes(2); + sendSpy.mockClear(); + + jest.advanceTimersByTime(6000); + runner.sendOffers(); + expect(sendSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe('taskCancelled', () => { + test.each<[TaskStatus, string]>([ + ['aborting:cancelled', 'cancelled'], + ['aborting:timeout', 'timeout'], + ])('should not do anything if task status is %s', async (status, reason) => { + runner = newTestRunner(); + + const taskId = 'test-task'; + const task = newTaskState(taskId); + task.status = status; + + runner.runningTasks.set(taskId, task); + + await runner.taskCancelled(taskId, reason); + + expect(runner.runningTasks.size).toBe(1); + expect(task.status).toBe(status); + }); + + it('should delete task if task is waiting for settings when task is cancelled', async () => { + runner = newTestRunner(); + + const taskId = 'test-task'; + const task = newTaskState(taskId); + const taskCleanupSpy = jest.spyOn(task, 'cleanup'); + runner.runningTasks.set(taskId, task); + + await runner.taskCancelled(taskId, 'test-reason'); + + expect(runner.runningTasks.size).toBe(0); + expect(taskCleanupSpy).toHaveBeenCalled(); + }); + + it('should reject pending requests when task is cancelled', async () => { + runner = newTestRunner(); + + const taskId = 'test-task'; + const task = newTaskState(taskId); + task.status = 'running'; + runner.runningTasks.set(taskId, task); const dataRequestReject = jest.fn(); const nodeTypesRequestReject = jest.fn(); @@ -136,7 +256,71 @@ describe('TestRunner', () => { reject: nodeTypesRequestReject, }); - runner.taskCancelled(taskId, 'test-reason'); + await runner.taskCancelled(taskId, 'test-reason'); + + expect(dataRequestReject).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Task cancelled: test-reason', + }), + ); + + expect(nodeTypesRequestReject).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Task cancelled: test-reason', + }), + ); + + expect(runner.dataRequests.size).toBe(0); + expect(runner.nodeTypesRequests.size).toBe(0); + }); + }); + + describe('taskTimedOut', () => { + it('should error task if task is waiting for settings', async () => { + runner = newTestRunner(); + + const taskId = 'test-task'; + const task = newTaskState(taskId); + task.status = 'waitingForSettings'; + runner.runningTasks.set(taskId, task); + const sendSpy = jest.spyOn(runner, 'send'); + + await runner.taskTimedOut(taskId); + + expect(runner.runningTasks.size).toBe(0); + expect(sendSpy).toHaveBeenCalledWith({ + type: 'runner:taskerror', + taskId, + error: expect.any(TimeoutError), + }); + }); + + it('should reject pending requests when task is running', async () => { + runner = newTestRunner(); + + const taskId = 'test-task'; + const task = newTaskState(taskId); + task.status = 'running'; + runner.runningTasks.set(taskId, task); + + const dataRequestReject = jest.fn(); + const nodeTypesRequestReject = jest.fn(); + + runner.dataRequests.set('data-req', { + taskId, + requestId: 'data-req', + resolve: jest.fn(), + reject: dataRequestReject, + }); + + runner.nodeTypesRequests.set('node-req', { + taskId, + requestId: 'node-req', + resolve: jest.fn(), + reject: nodeTypesRequestReject, + }); + + await runner.taskCancelled(taskId, 'test-reason'); expect(dataRequestReject).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/@n8n/task-runner/src/js-task-runner/__tests__/test-data.ts b/packages/@n8n/task-runner/src/js-task-runner/__tests__/test-data.ts index f13939e51e..85a1235dc6 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/__tests__/test-data.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/__tests__/test-data.ts @@ -4,22 +4,21 @@ import { nanoid } from 'nanoid'; import type { JSExecSettings } from '@/js-task-runner/js-task-runner'; import type { DataRequestResponse } from '@/runner-types'; -import type { Task } from '@/task-runner'; +import type { TaskParams } from '@/task-runner'; +import { TaskState } from '@/task-state'; /** * Creates a new task with the given settings */ -export const newTaskWithSettings = ( +export const newTaskParamsWithSettings = ( settings: Partial & Pick, -): Task => ({ +): TaskParams => ({ taskId: '1', settings: { workflowMode: 'manual', continueOnFail: false, ...settings, }, - active: true, - cancelled: false, }); /** @@ -167,3 +166,13 @@ export const withPairedItem = (index: number, data: INodeExecutionData): INodeEx item: index, }, }); + +/** + * Creates a new task state with the given taskId + */ +export const newTaskState = (taskId: string) => + new TaskState({ + taskId, + timeoutInS: 60, + onTimeout: () => {}, + }); diff --git a/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts b/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts index 04e05fb30a..ab2cc3a304 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts @@ -15,21 +15,23 @@ import type { EnvProviderState, IExecuteData, INodeTypeDescription, + IWorkflowDataProxyData, } from 'n8n-workflow'; import * as a from 'node:assert'; +import { inspect } from 'node:util'; import { runInNewContext, type Context } from 'node:vm'; import type { MainConfig } from '@/config/main-config'; import { UnsupportedFunctionError } from '@/js-task-runner/errors/unsupported-function.error'; -import { - EXPOSED_RPC_METHODS, - UNSUPPORTED_HELPER_FUNCTIONS, - type DataRequestResponse, - type InputDataChunkDefinition, - type PartialAdditionalData, - type TaskResultData, +import { EXPOSED_RPC_METHODS, UNSUPPORTED_HELPER_FUNCTIONS } from '@/runner-types'; +import type { + DataRequestResponse, + InputDataChunkDefinition, + PartialAdditionalData, + TaskResultData, } from '@/runner-types'; -import { type Task, TaskRunner } from '@/task-runner'; +import type { TaskParams } from '@/task-runner'; +import { noOp, TaskRunner } from '@/task-runner'; import { BuiltInsParser } from './built-ins-parser/built-ins-parser'; import { BuiltInsParserState } from './built-ins-parser/built-ins-parser-state'; @@ -42,8 +44,8 @@ import { createRequireResolver } from './require-resolver'; import { validateRunForAllItemsOutput, validateRunForEachItemOutput } from './result-validation'; import { DataRequestResponseReconstruct } from '../data-request/data-request-response-reconstruct'; -export interface RPCCallObject { - [name: string]: ((...args: unknown[]) => Promise) | RPCCallObject; +export interface RpcCallObject { + [name: string]: ((...args: unknown[]) => Promise) | RpcCallObject; } export interface JSExecSettings { @@ -103,8 +105,11 @@ export class JsTaskRunner extends TaskRunner { }); } - async executeTask(task: Task, signal: AbortSignal): Promise { - const settings = task.settings; + async executeTask( + taskParams: TaskParams, + abortSignal: AbortSignal, + ): Promise { + const { taskId, settings } = taskParams; a.ok(settings, 'JS Code not sent to runner'); this.validateTaskSettings(settings); @@ -115,13 +120,13 @@ export class JsTaskRunner extends TaskRunner { : BuiltInsParserState.newNeedsAllDataState(); const dataResponse = await this.requestData( - task.taskId, + taskId, neededBuiltIns.toDataRequestParams(settings.chunk), ); const data = this.reconstructTaskData(dataResponse, settings.chunk); - await this.requestNodeTypeIfNeeded(neededBuiltIns, data.workflow, task.taskId); + await this.requestNodeTypeIfNeeded(neededBuiltIns, data.workflow, taskId); const workflowParams = data.workflow; const workflow = new Workflow({ @@ -129,29 +134,12 @@ export class JsTaskRunner extends TaskRunner { nodeTypes: this.nodeTypes, }); - const noOp = () => {}; - const customConsole = { - // all except `log` are dummy methods that disregard without throwing, following existing Code node behavior - ...Object.keys(console).reduce void>>((acc, name) => { - acc[name] = noOp; - return acc; - }, {}), - // Send log output back to the main process. It will take care of forwarding - // it to the UI or printing to console. - log: (...args: unknown[]) => { - const logOutput = args - .map((arg) => (typeof arg === 'object' && arg !== null ? JSON.stringify(arg) : arg)) - .join(' '); - void this.makeRpcCall(task.taskId, 'logNodeOutput', [logOutput]); - }, - }; - workflow.staticData = ObservableObject.create(workflow.staticData); const result = settings.nodeMode === 'runOnceForAllItems' - ? await this.runForAllItems(task.taskId, settings, data, workflow, customConsole, signal) - : await this.runForEachItem(task.taskId, settings, data, workflow, customConsole, signal); + ? await this.runForAllItems(taskId, settings, data, workflow, abortSignal) + : await this.runForEachItem(taskId, settings, data, workflow, abortSignal); return { result, @@ -200,22 +188,14 @@ export class JsTaskRunner extends TaskRunner { settings: JSExecSettings, data: JsTaskData, workflow: Workflow, - customConsole: CustomConsole, signal: AbortSignal, ): Promise { const dataProxy = this.createDataProxy(data, workflow, data.itemIndex); const inputItems = data.connectionInputData; - const context: Context = { - require: this.requireResolver, - module: {}, - console: customConsole, + const context = this.buildContext(taskId, workflow, data.node, dataProxy, { items: inputItems, - $getWorkflowStaticData: (type: 'global' | 'node') => workflow.getStaticData(type, data.node), - ...this.getNativeVariables(), - ...dataProxy, - ...this.buildRpcCallObject(taskId), - }; + }); try { const result = await new Promise((resolve, reject) => { @@ -264,7 +244,6 @@ export class JsTaskRunner extends TaskRunner { settings: JSExecSettings, data: JsTaskData, workflow: Workflow, - customConsole: CustomConsole, signal: AbortSignal, ): Promise { const inputItems = data.connectionInputData; @@ -279,17 +258,7 @@ export class JsTaskRunner extends TaskRunner { for (let index = chunkStartIdx; index < chunkEndIdx; index++) { const item = inputItems[index]; const dataProxy = this.createDataProxy(data, workflow, index); - const context: Context = { - require: this.requireResolver, - module: {}, - console: customConsole, - item, - $getWorkflowStaticData: (type: 'global' | 'node') => - workflow.getStaticData(type, data.node), - ...this.getNativeVariables(), - ...dataProxy, - ...this.buildRpcCallObject(taskId), - }; + const context = this.buildContext(taskId, workflow, data.node, dataProxy, { item }); try { let result = await new Promise((resolve, reject) => { @@ -449,7 +418,7 @@ export class JsTaskRunner extends TaskRunner { } private buildRpcCallObject(taskId: string) { - const rpcObject: RPCCallObject = {}; + const rpcObject: RpcCallObject = {}; for (const rpcMethod of EXPOSED_RPC_METHODS) { set( @@ -467,4 +436,52 @@ export class JsTaskRunner extends TaskRunner { return rpcObject; } + + private buildCustomConsole(taskId: string): CustomConsole { + return { + // all except `log` are dummy methods that disregard without throwing, following existing Code node behavior + ...Object.keys(console).reduce void>>((acc, name) => { + acc[name] = noOp; + return acc; + }, {}), + + // Send log output back to the main process. It will take care of forwarding + // it to the UI or printing to console. + log: (...args: unknown[]) => { + const formattedLogArgs = args.map((arg) => inspect(arg)); + void this.makeRpcCall(taskId, 'logNodeOutput', formattedLogArgs); + }, + }; + } + + /** + * Builds the 'global' context object that is passed to the script + * + * @param taskId The ID of the task. Needed for RPC calls + * @param workflow The workflow that is being executed. Needed for static data + * @param node The node that is being executed. Needed for static data + * @param dataProxy The data proxy object that provides access to built-ins + * @param additionalProperties Additional properties to add to the context + */ + private buildContext( + taskId: string, + workflow: Workflow, + node: INode, + dataProxy: IWorkflowDataProxyData, + additionalProperties: Record = {}, + ): Context { + const context: Context = { + [inspect.custom]: () => '[[ExecutionContext]]', + require: this.requireResolver, + module: {}, + console: this.buildCustomConsole(taskId), + $getWorkflowStaticData: (type: 'global' | 'node') => workflow.getStaticData(type, node), + ...this.getNativeVariables(), + ...dataProxy, + ...this.buildRpcCallObject(taskId), + ...additionalProperties, + }; + + return context; + } } diff --git a/packages/@n8n/task-runner/src/start.ts b/packages/@n8n/task-runner/src/start.ts index 391b6ba156..536b550d17 100644 --- a/packages/@n8n/task-runner/src/start.ts +++ b/packages/@n8n/task-runner/src/start.ts @@ -1,7 +1,7 @@ import './polyfills'; +import { Container } from '@n8n/di'; import type { ErrorReporter } from 'n8n-core'; import { ensureError, setGlobalState } from 'n8n-workflow'; -import Container from 'typedi'; import { MainConfig } from './config/main-config'; import type { HealthCheckServer } from './health-check-server'; @@ -56,7 +56,7 @@ void (async function start() { if (config.sentryConfig.sentryDsn) { const { ErrorReporter } = await import('n8n-core'); - errorReporter = new ErrorReporter(); + errorReporter = Container.get(ErrorReporter); await errorReporter.init('task_runner', config.sentryConfig.sentryDsn); } diff --git a/packages/@n8n/task-runner/src/task-runner.ts b/packages/@n8n/task-runner/src/task-runner.ts index 4254aad99c..dd048bcf7e 100644 --- a/packages/@n8n/task-runner/src/task-runner.ts +++ b/packages/@n8n/task-runner/src/task-runner.ts @@ -5,19 +5,14 @@ import { EventEmitter } from 'node:events'; import { type MessageEvent, WebSocket } from 'ws'; import type { BaseRunnerConfig } from '@/config/base-runner-config'; +import { TimeoutError } from '@/js-task-runner/errors/timeout-error'; import type { BrokerMessage, RunnerMessage } from '@/message-types'; import { TaskRunnerNodeTypes } from '@/node-types'; import type { TaskResultData } from '@/runner-types'; +import { TaskState } from '@/task-state'; import { TaskCancelledError } from './js-task-runner/errors/task-cancelled-error'; -export interface Task { - taskId: string; - settings?: T; - active: boolean; - cancelled: boolean; -} - export interface TaskOffer { offerId: string; validUntil: bigint; @@ -49,6 +44,14 @@ const OFFER_VALID_EXTRA_MS = 100; /** Converts milliseconds to nanoseconds */ const msToNs = (ms: number) => BigInt(ms * 1_000_000); +export const noOp = () => {}; + +/** Params the task receives when it is executed */ +export interface TaskParams { + taskId: string; + settings: T; +} + export interface TaskRunnerOpts extends BaseRunnerConfig { taskType: string; name?: string; @@ -61,7 +64,7 @@ export abstract class TaskRunner extends EventEmitter { canSendOffers = false; - runningTasks: Map = new Map(); + runningTasks: Map = new Map(); offerInterval: NodeJS.Timeout | undefined; @@ -89,10 +92,9 @@ export abstract class TaskRunner extends EventEmitter { /** How long (in seconds) a runner may be idle for before exit. */ private readonly idleTimeout: number; - protected taskCancellations = new Map(); - constructor(opts: TaskRunnerOpts) { super(); + this.taskType = opts.taskType; this.name = opts.name ?? 'Node.js Task Runner SDK'; this.maxConcurrency = opts.maxConcurrency; @@ -174,9 +176,11 @@ export abstract class TaskRunner extends EventEmitter { sendOffers() { this.deleteStaleOffers(); - const offersToSend = - this.maxConcurrency - - (Object.values(this.openOffers).length + Object.values(this.runningTasks).length); + if (!this.canSendOffers) { + return; + } + + const offersToSend = this.maxConcurrency - (this.openOffers.size + this.runningTasks.size); for (let i = 0; i < offersToSend; i++) { // Add a bit of randomness so that not all offers expire at the same time @@ -217,7 +221,7 @@ export abstract class TaskRunner extends EventEmitter { this.offerAccepted(message.offerId, message.taskId); break; case 'broker:taskcancel': - this.taskCancelled(message.taskId, message.reason); + void this.taskCancelled(message.taskId, message.reason); break; case 'broker:tasksettings': void this.receivedSettings(message.taskId, message.settings); @@ -255,11 +259,12 @@ export abstract class TaskRunner extends EventEmitter { } hasOpenTasks() { - return Object.values(this.runningTasks).length < this.maxConcurrency; + return this.runningTasks.size < this.maxConcurrency; } offerAccepted(offerId: string, taskId: string) { if (!this.hasOpenTasks()) { + this.openOffers.delete(offerId); this.send({ type: 'runner:taskrejected', taskId, @@ -267,6 +272,7 @@ export abstract class TaskRunner extends EventEmitter { }); return; } + const offer = this.openOffers.get(offerId); if (!offer) { this.send({ @@ -280,11 +286,14 @@ export abstract class TaskRunner extends EventEmitter { } this.resetIdleTimer(); - this.runningTasks.set(taskId, { + const taskState = new TaskState({ taskId, - active: false, - cancelled: false, + timeoutInS: this.taskTimeout, + onTimeout: () => { + void this.taskTimedOut(taskId); + }, }); + this.runningTasks.set(taskId, taskState); this.send({ type: 'runner:taskaccepted', @@ -292,99 +301,103 @@ export abstract class TaskRunner extends EventEmitter { }); } - taskCancelled(taskId: string, reason: string) { - const task = this.runningTasks.get(taskId); - if (!task) { + async taskCancelled(taskId: string, reason: string) { + const taskState = this.runningTasks.get(taskId); + if (!taskState) { return; } - task.cancelled = true; - for (const [requestId, request] of this.dataRequests.entries()) { - if (request.taskId === taskId) { - request.reject(new TaskCancelledError(reason)); - this.dataRequests.delete(requestId); - } - } + await taskState.caseOf({ + // If the cancelled task hasn't received settings yet, we can finish it + waitingForSettings: () => this.finishTask(taskState), - for (const [requestId, request] of this.nodeTypesRequests.entries()) { - if (request.taskId === taskId) { - request.reject(new TaskCancelledError(reason)); - this.nodeTypesRequests.delete(requestId); - } - } + // If the task has already timed out or is already cancelled, we can + // ignore the cancellation + 'aborting:timeout': noOp, + 'aborting:cancelled': noOp, - const controller = this.taskCancellations.get(taskId); - if (controller) { - controller.abort(); - this.taskCancellations.delete(taskId); - } - - if (!task.active) this.runningTasks.delete(taskId); - - this.sendOffers(); + running: () => { + taskState.status = 'aborting:cancelled'; + taskState.abortController.abort('cancelled'); + this.cancelTaskRequests(taskId, reason); + }, + }); } - taskErrored(taskId: string, error: unknown) { - this.send({ - type: 'runner:taskerror', - taskId, - error, - }); - this.runningTasks.delete(taskId); - this.sendOffers(); - } + async taskTimedOut(taskId: string) { + const taskState = this.runningTasks.get(taskId); + if (!taskState) { + return; + } - taskDone(taskId: string, data: RunnerMessage.ToBroker.TaskDone['data']) { - this.send({ - type: 'runner:taskdone', - taskId, - data, + await taskState.caseOf({ + // If we are still waiting for settings for the task, we can error the + // task immediately + waitingForSettings: () => { + try { + this.send({ + type: 'runner:taskerror', + taskId, + error: new TimeoutError(this.taskTimeout), + }); + } finally { + this.finishTask(taskState); + } + }, + + // This should never happen, the timeout timer should only fire once + 'aborting:timeout': TaskState.throwUnexpectedTaskStatus, + + // If we are currently executing the task, abort the execution and + // mark the task as timed out + running: () => { + taskState.status = 'aborting:timeout'; + taskState.abortController.abort('timeout'); + this.cancelTaskRequests(taskId, 'timeout'); + }, + + // If the task is already cancelling, we can ignore the timeout + 'aborting:cancelled': noOp, }); - this.runningTasks.delete(taskId); - this.sendOffers(); } async receivedSettings(taskId: string, settings: unknown) { - const task = this.runningTasks.get(taskId); - if (!task) { - return; - } - if (task.cancelled) { - this.runningTasks.delete(taskId); + const taskState = this.runningTasks.get(taskId); + if (!taskState) { return; } - const controller = new AbortController(); - this.taskCancellations.set(taskId, controller); + await taskState.caseOf({ + // These states should never happen, as they are handled already in + // the other lifecycle methods and the task should be removed from the + // running tasks + 'aborting:cancelled': TaskState.throwUnexpectedTaskStatus, + 'aborting:timeout': TaskState.throwUnexpectedTaskStatus, + running: TaskState.throwUnexpectedTaskStatus, - const taskTimeout = setTimeout(() => { - if (!task.cancelled) { - controller.abort(); - this.taskCancellations.delete(taskId); - } - }, this.taskTimeout * 1_000); + waitingForSettings: async () => { + taskState.status = 'running'; - task.settings = settings; - task.active = true; - try { - const data = await this.executeTask(task, controller.signal); - this.taskDone(taskId, data); - } catch (error) { - if (!task.cancelled) this.taskErrored(taskId, error); - } finally { - clearTimeout(taskTimeout); - this.taskCancellations.delete(taskId); - this.resetIdleTimer(); - } + await this.executeTask( + { + taskId, + settings, + }, + taskState.abortController.signal, + ) + .then(async (data) => await this.taskExecutionSucceeded(taskState, data)) + .catch(async (error) => await this.taskExecutionFailed(taskState, error)); + }, + }); } // eslint-disable-next-line @typescript-eslint/naming-convention - async executeTask(_task: Task, _signal: AbortSignal): Promise { + async executeTask(_taskParams: TaskParams, _signal: AbortSignal): Promise { throw new ApplicationError('Unimplemented'); } async requestNodeTypes( - taskId: Task['taskId'], + taskId: TaskState['taskId'], requestParams: RunnerMessage.ToBroker.NodeTypesRequest['requestParams'], ) { const requestId = nanoid(); @@ -413,12 +426,12 @@ export abstract class TaskRunner extends EventEmitter { } async requestData( - taskId: Task['taskId'], + taskId: TaskState['taskId'], requestParams: RunnerMessage.ToBroker.TaskDataRequest['requestParams'], ): Promise { const requestId = nanoid(); - const p = new Promise((resolve, reject) => { + const dataRequestPromise = new Promise((resolve, reject) => { this.dataRequests.set(requestId, { requestId, taskId, @@ -435,7 +448,7 @@ export abstract class TaskRunner extends EventEmitter { }); try { - return await p; + return await dataRequestPromise; } finally { this.dataRequests.delete(requestId); } @@ -452,15 +465,15 @@ export abstract class TaskRunner extends EventEmitter { }); }); - this.send({ - type: 'runner:rpc', - callId, - taskId, - name, - params, - }); - try { + this.send({ + type: 'runner:rpc', + callId, + taskId, + name, + params, + }); + const returnValue = await dataPromise; return isSerializedBuffer(returnValue) ? toBuffer(returnValue) : returnValue; @@ -523,4 +536,86 @@ export abstract class TaskRunner extends EventEmitter { await new Promise((resolve) => setTimeout(resolve, 100)); } } + + private async taskExecutionSucceeded(taskState: TaskState, data: TaskResultData) { + try { + const sendData = () => { + this.send({ + type: 'runner:taskdone', + taskId: taskState.taskId, + data, + }); + }; + + await taskState.caseOf({ + waitingForSettings: TaskState.throwUnexpectedTaskStatus, + + 'aborting:cancelled': noOp, + + // If the task timed out but we ended up reaching this point, we + // might as well send the data + 'aborting:timeout': sendData, + running: sendData, + }); + } finally { + this.finishTask(taskState); + } + } + + private async taskExecutionFailed(taskState: TaskState, error: unknown) { + try { + const sendError = () => { + this.send({ + type: 'runner:taskerror', + taskId: taskState.taskId, + error, + }); + }; + + await taskState.caseOf({ + waitingForSettings: TaskState.throwUnexpectedTaskStatus, + + 'aborting:cancelled': noOp, + + 'aborting:timeout': () => { + console.warn(`Task ${taskState.taskId} timed out`); + + sendError(); + }, + + running: sendError, + }); + } finally { + this.finishTask(taskState); + } + } + + /** + * Cancels all node type and data requests made by the given task + */ + private cancelTaskRequests(taskId: string, reason: string) { + for (const [requestId, request] of this.dataRequests.entries()) { + if (request.taskId === taskId) { + request.reject(new TaskCancelledError(reason)); + this.dataRequests.delete(requestId); + } + } + + for (const [requestId, request] of this.nodeTypesRequests.entries()) { + if (request.taskId === taskId) { + request.reject(new TaskCancelledError(reason)); + this.nodeTypesRequests.delete(requestId); + } + } + } + + /** + * Finishes task by removing it from the running tasks and sending new offers + */ + private finishTask(taskState: TaskState) { + taskState.cleanup(); + this.runningTasks.delete(taskState.taskId); + this.sendOffers(); + this.resetIdleTimer(); + } } diff --git a/packages/@n8n/task-runner/src/task-state.ts b/packages/@n8n/task-runner/src/task-state.ts new file mode 100644 index 0000000000..4c2c0e44a8 --- /dev/null +++ b/packages/@n8n/task-runner/src/task-state.ts @@ -0,0 +1,118 @@ +import * as a from 'node:assert'; + +export type TaskStatus = + | 'waitingForSettings' + | 'running' + | 'aborting:cancelled' + | 'aborting:timeout'; + +export type TaskStateOpts = { + taskId: string; + timeoutInS: number; + onTimeout: () => void; +}; + +/** + * The state of a task. The task can be in one of the following states: + * - waitingForSettings: The task is waiting for settings from the broker + * - running: The task is currently running + * - aborting:cancelled: The task was canceled by the broker and is being aborted + * - aborting:timeout: The task took too long to complete and is being aborted + * + * The task is discarded once it reaches an end state. + * + * The class only holds the state, and does not have any logic. + * + * The task has the following lifecycle: + * + * ┌───┐ + * └───┘ + * │ + * broker:taskofferaccept : create task state + * │ + * ▼ + * ┌────────────────────┐ broker:taskcancel / timeout + * │ waitingForSettings ├──────────────────────────────────┐ + * └────────┬───────────┘ │ + * │ │ + * broker:tasksettings │ + * │ │ + * ▼ │ + * ┌───────────────┐ ┌────────────────────┐ │ + * │ running │ │ aborting:timeout │ │ + * │ │ timeout │ │ │ + * ┌───────┤- execute task ├───────────►│- fire abort signal │ │ + * │ └──────┬────────┘ └──────────┬─────────┘ │ + * │ │ │ │ + * │ broker:taskcancel │ │ + * Task execution │ Task execution │ + * resolves / rejects │ resolves / rejects │ + * │ ▼ │ │ + * │ ┌─────────────────────┐ │ │ + * │ │ aborting:cancelled │ │ │ + * │ │ │ │ │ + * │ │- fire abort signal │ │ │ + * │ └──────────┬──────────┘ │ │ + * │ Task execution │ │ + * │ resolves / rejects │ │ + * │ │ │ │ + * │ ▼ │ │ + * │ ┌──┐ │ │ + * └─────────────►│ │◄────────────────────────────┴─────────────┘ + * └──┘ + */ +export class TaskState { + status: TaskStatus = 'waitingForSettings'; + + readonly taskId: string; + + /** Controller for aborting the execution of the task */ + readonly abortController = new AbortController(); + + /** Timeout timer for the task */ + private timeoutTimer: NodeJS.Timeout | undefined; + + constructor(opts: TaskStateOpts) { + this.taskId = opts.taskId; + this.timeoutTimer = setTimeout(opts.onTimeout, opts.timeoutInS * 1000); + } + + /** Cleans up any resources before the task can be removed */ + cleanup() { + clearTimeout(this.timeoutTimer); + this.timeoutTimer = undefined; + } + + /** Custom JSON serialization for the task state for logging purposes */ + toJSON() { + return `[Task ${this.taskId} (${this.status})]`; + } + + /** + * Executes the function matching the current task status + * + * @example + * ```ts + * taskState.caseOf({ + * waitingForSettings: () => {...}, + * running: () => {...}, + * aborting:cancelled: () => {...}, + * aborting:timeout: () => {...}, + * }); + * ``` + */ + async caseOf( + conditions: Record void | Promise | never>, + ) { + if (!conditions[this.status]) { + TaskState.throwUnexpectedTaskStatus(this); + } + + return await conditions[this.status](this); + } + + /** Throws an error that the task status is unexpected */ + static throwUnexpectedTaskStatus = (taskState: TaskState) => { + a.fail(`Unexpected task status: ${JSON.stringify(taskState)}`); + }; +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 8e9ff0f7ca..6a22ce1ee7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -89,12 +89,13 @@ "@n8n/api-types": "workspace:*", "@n8n/client-oauth2": "workspace:*", "@n8n/config": "workspace:*", + "@n8n/di": "workspace:*", "@n8n/localtunnel": "3.0.0", "@n8n/n8n-nodes-langchain": "workspace:*", "@n8n/permissions": "workspace:*", "@n8n/task-runner": "workspace:*", "@n8n/typeorm": "0.3.20-12", - "@n8n_io/ai-assistant-sdk": "1.12.0", + "@n8n_io/ai-assistant-sdk": "1.13.0", "@n8n_io/license-sdk": "2.13.1", "@oclif/core": "4.0.7", "@rudderstack/rudder-sdk-node": "2.0.9", @@ -104,7 +105,6 @@ "bcryptjs": "2.4.3", "bull": "4.12.1", "cache-manager": "5.2.3", - "callsites": "3.1.0", "change-case": "4.1.2", "class-transformer": "0.5.1", "class-validator": "0.14.0", @@ -149,13 +149,13 @@ "p-cancelable": "2.1.1", "p-lazy": "3.1.0", "pg": "8.12.0", - "picocolors": "1.0.1", + "picocolors": "catalog:", "pkce-challenge": "3.0.0", "posthog-node": "3.2.1", "prom-client": "13.2.0", "psl": "1.9.0", "raw-body": "2.5.1", - "reflect-metadata": "0.2.2", + "reflect-metadata": "catalog:", "replacestream": "4.0.3", "samlify": "2.8.9", "semver": "7.5.4", @@ -166,10 +166,8 @@ "sshpk": "1.17.0", "swagger-ui-express": "5.0.1", "syslog-client": "1.1.1", - "typedi": "catalog:", "uuid": "catalog:", "validator": "13.7.0", - "winston": "3.14.2", "ws": "8.17.1", "xml2js": "catalog:", "xmllint-wasm": "3.0.1", diff --git a/packages/cli/src/__tests__/project.test-data.ts b/packages/cli/src/__tests__/project.test-data.ts new file mode 100644 index 0000000000..3ffac36fc8 --- /dev/null +++ b/packages/cli/src/__tests__/project.test-data.ts @@ -0,0 +1,19 @@ +import { nanoId, date, firstName, lastName, email } from 'minifaker'; +import 'minifaker/locales/en'; + +import type { Project, ProjectType } from '@/databases/entities/project'; + +type RawProjectData = Pick; + +const projectName = `${firstName()} ${lastName()} <${email}>`; + +export const createRawProjectData = (payload: Partial): Project => { + return { + createdAt: date(), + updatedAt: date(), + id: nanoId.nanoid(), + name: projectName, + type: 'personal' as ProjectType, + ...payload, + } as Project; +}; diff --git a/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts b/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts index e7d94d3e34..4c3af0e696 100644 --- a/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts +++ b/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts @@ -1,3 +1,4 @@ +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import type { IWorkflowBase } from 'n8n-workflow'; import type { @@ -8,17 +9,16 @@ import type { INodeExecutionData, } from 'n8n-workflow'; import type PCancelable from 'p-cancelable'; -import Container from 'typedi'; import { ActiveExecutions } from '@/active-executions'; import { CredentialsHelper } from '@/credentials-helper'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; -import { VariablesService } from '@/environments/variables/variables.service.ee'; +import { VariablesService } from '@/environments.ee/variables/variables.service.ee'; import { EventService } from '@/events/event.service'; import { ExternalHooks } from '@/external-hooks'; -import { SecretsHelper } from '@/secrets-helpers'; +import { SecretsHelper } from '@/secrets-helpers.ee'; import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; import { SubworkflowPolicyChecker } from '@/subworkflows/subworkflow-policy-checker.service'; import { Telemetry } from '@/telemetry'; diff --git a/packages/cli/src/__tests__/workflow-runner.test.ts b/packages/cli/src/__tests__/workflow-runner.test.ts index 683343b44c..7330f85997 100644 --- a/packages/cli/src/__tests__/workflow-runner.test.ts +++ b/packages/cli/src/__tests__/workflow-runner.test.ts @@ -1,3 +1,4 @@ +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import { DirectedGraph, WorkflowExecute } from 'n8n-core'; import * as core from 'n8n-core'; @@ -18,7 +19,6 @@ import { type IWorkflowExecuteHooks, } from 'n8n-workflow'; import PCancelable from 'p-cancelable'; -import Container from 'typedi'; import { ActiveExecutions } from '@/active-executions'; import config from '@/config'; diff --git a/packages/cli/src/abstract-server.ts b/packages/cli/src/abstract-server.ts index f4a8a5b2cc..a9340b0a87 100644 --- a/packages/cli/src/abstract-server.ts +++ b/packages/cli/src/abstract-server.ts @@ -1,18 +1,18 @@ import { GlobalConfig } from '@n8n/config'; +import { Container, Service } from '@n8n/di'; import compression from 'compression'; import express from 'express'; import { engine as expressHandlebars } from 'express-handlebars'; import { readFile } from 'fs/promises'; import type { Server } from 'http'; import isbot from 'isbot'; -import { Container, Service } from 'typedi'; +import { Logger } from 'n8n-core'; import config from '@/config'; import { N8N_VERSION, TEMPLATES_DIR, inDevelopment, inTest } from '@/constants'; import * as Db from '@/db'; import { OnShutdown } from '@/decorators/on-shutdown'; import { ExternalHooks } from '@/external-hooks'; -import { Logger } from '@/logging/logger.service'; import { rawBodyReader, bodyParser, corsMiddleware } from '@/middlewares'; import { send, sendErrorResponse } from '@/response-helper'; import { LiveWebhooks } from '@/webhooks/live-webhooks'; diff --git a/packages/cli/src/activation-errors.service.ts b/packages/cli/src/activation-errors.service.ts index 9ddcad9317..1d1bb349aa 100644 --- a/packages/cli/src/activation-errors.service.ts +++ b/packages/cli/src/activation-errors.service.ts @@ -1,4 +1,4 @@ -import { Service } from 'typedi'; +import { Service } from '@n8n/di'; import { CacheService } from '@/services/cache/cache.service'; diff --git a/packages/cli/src/active-executions.ts b/packages/cli/src/active-executions.ts index bc18eade16..9056271f64 100644 --- a/packages/cli/src/active-executions.ts +++ b/packages/cli/src/active-executions.ts @@ -1,3 +1,5 @@ +import { Service } from '@n8n/di'; +import { Logger } from 'n8n-core'; import type { IDeferredPromise, IExecuteResponsePromiseData, @@ -8,7 +10,6 @@ import type { import { createDeferredPromise, ExecutionCancelledError, sleep } from 'n8n-workflow'; import { strict as assert } from 'node:assert'; import type PCancelable from 'p-cancelable'; -import { Service } from 'typedi'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { ExecutionNotFoundError } from '@/errors/execution-not-found-error'; @@ -18,7 +19,6 @@ import type { IExecutionDb, IExecutionsCurrentSummary, } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { isWorkflowIdValid } from '@/utils'; import { ConcurrencyControlService } from './concurrency/concurrency-control.service'; diff --git a/packages/cli/src/active-workflow-manager.ts b/packages/cli/src/active-workflow-manager.ts index 6ef3753af7..a002bc4054 100644 --- a/packages/cli/src/active-workflow-manager.ts +++ b/packages/cli/src/active-workflow-manager.ts @@ -1,8 +1,10 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { Service } from '@n8n/di'; import { ActiveWorkflows, ErrorReporter, InstanceSettings, + Logger, PollContext, TriggerContext, } from 'n8n-core'; @@ -27,7 +29,6 @@ import { WebhookPathTakenError, ApplicationError, } from 'n8n-workflow'; -import { Service } from 'typedi'; import { ActivationErrorsService } from '@/activation-errors.service'; import { ActiveExecutions } from '@/active-executions'; @@ -42,7 +43,6 @@ import { OnShutdown } from '@/decorators/on-shutdown'; import { ExecutionService } from '@/executions/execution.service'; import { ExternalHooks } from '@/external-hooks'; import type { IWorkflowDb } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { NodeTypes } from '@/node-types'; import { Publisher } from '@/scaling/pubsub/publisher.service'; import { ActiveWorkflowsService } from '@/services/active-workflows.service'; diff --git a/packages/cli/src/auth/auth.service.ts b/packages/cli/src/auth/auth.service.ts index 492e22ab53..190f788ba2 100644 --- a/packages/cli/src/auth/auth.service.ts +++ b/packages/cli/src/auth/auth.service.ts @@ -1,8 +1,9 @@ import { GlobalConfig } from '@n8n/config'; +import { Container, Service } from '@n8n/di'; import { createHash } from 'crypto'; import type { NextFunction, Response } from 'express'; import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken'; -import Container, { Service } from 'typedi'; +import { Logger } from 'n8n-core'; import config from '@/config'; import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES, Time } from '@/constants'; @@ -12,7 +13,6 @@ import { UserRepository } from '@/databases/repositories/user.repository'; import { AuthError } from '@/errors/response-errors/auth.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import type { AuthenticatedRequest } from '@/requests'; import { JwtService } from '@/services/jwt.service'; import { UrlService } from '@/services/url.service'; diff --git a/packages/cli/src/auth/jwt.ts b/packages/cli/src/auth/jwt.ts index 63255f5f66..0e35263462 100644 --- a/packages/cli/src/auth/jwt.ts +++ b/packages/cli/src/auth/jwt.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import type { Response } from 'express'; -import { Container } from 'typedi'; import type { User } from '@/databases/entities/user'; diff --git a/packages/cli/src/auth/methods/email.ts b/packages/cli/src/auth/methods/email.ts index 723365057f..61f344afaf 100644 --- a/packages/cli/src/auth/methods/email.ts +++ b/packages/cli/src/auth/methods/email.ts @@ -1,10 +1,10 @@ -import { Container } from 'typedi'; +import { Container } from '@n8n/di'; import type { User } from '@/databases/entities/user'; import { UserRepository } from '@/databases/repositories/user.repository'; import { AuthError } from '@/errors/response-errors/auth.error'; import { EventService } from '@/events/event.service'; -import { isLdapLoginEnabled } from '@/ldap/helpers.ee'; +import { isLdapLoginEnabled } from '@/ldap.ee/helpers.ee'; import { PasswordUtility } from '@/services/password.utility'; export const handleEmailLogin = async ( diff --git a/packages/cli/src/auth/methods/ldap.ts b/packages/cli/src/auth/methods/ldap.ts index b8fea25989..5466d0d8cb 100644 --- a/packages/cli/src/auth/methods/ldap.ts +++ b/packages/cli/src/auth/methods/ldap.ts @@ -1,4 +1,4 @@ -import { Container } from 'typedi'; +import { Container } from '@n8n/di'; import type { User } from '@/databases/entities/user'; import { EventService } from '@/events/event.service'; @@ -10,8 +10,8 @@ import { mapLdapAttributesToUser, createLdapAuthIdentity, updateLdapUserOnLocalDb, -} from '@/ldap/helpers.ee'; -import { LdapService } from '@/ldap/ldap.service.ee'; +} from '@/ldap.ee/helpers.ee'; +import { LdapService } from '@/ldap.ee/ldap.service.ee'; export const handleLdapLogin = async ( loginId: string, diff --git a/packages/cli/src/collaboration/collaboration.service.ts b/packages/cli/src/collaboration/collaboration.service.ts index ece93bd5b2..9b2f3dec8e 100644 --- a/packages/cli/src/collaboration/collaboration.service.ts +++ b/packages/cli/src/collaboration/collaboration.service.ts @@ -1,8 +1,8 @@ import type { PushPayload } from '@n8n/api-types'; +import { Service } from '@n8n/di'; import { ErrorReporter } from 'n8n-core'; import type { Workflow } from 'n8n-workflow'; import { ApplicationError } from 'n8n-workflow'; -import { Service } from 'typedi'; import { CollaborationState } from '@/collaboration/collaboration.state'; import type { User } from '@/databases/entities/user'; @@ -99,6 +99,6 @@ export class CollaborationService { collaborators: activeCollaborators, }; - this.push.sendToUsers('collaboratorsChanged', msgData, userIds); + this.push.sendToUsers({ type: 'collaboratorsChanged', data: msgData }, userIds); } } diff --git a/packages/cli/src/collaboration/collaboration.state.ts b/packages/cli/src/collaboration/collaboration.state.ts index 556dee2ace..f2a3b31f24 100644 --- a/packages/cli/src/collaboration/collaboration.state.ts +++ b/packages/cli/src/collaboration/collaboration.state.ts @@ -1,6 +1,6 @@ import type { Iso8601DateTimeString } from '@n8n/api-types'; +import { Service } from '@n8n/di'; import type { Workflow } from 'n8n-workflow'; -import { Service } from 'typedi'; import { Time } from '@/constants'; import type { User } from '@/databases/entities/user'; diff --git a/packages/cli/src/commands/audit.ts b/packages/cli/src/commands/audit.ts index e98bb8bce0..1bd76bc4ed 100644 --- a/packages/cli/src/commands/audit.ts +++ b/packages/cli/src/commands/audit.ts @@ -1,7 +1,7 @@ import { SecurityConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import { Flags } from '@oclif/core'; import { ApplicationError } from 'n8n-workflow'; -import { Container } from 'typedi'; import { RISK_CATEGORIES } from '@/security-audit/constants'; import { SecurityAuditService } from '@/security-audit/security-audit.service'; diff --git a/packages/cli/src/commands/base-command.ts b/packages/cli/src/commands/base-command.ts index 286fec1de6..56799c5089 100644 --- a/packages/cli/src/commands/base-command.ts +++ b/packages/cli/src/commands/base-command.ts @@ -1,15 +1,16 @@ import 'reflect-metadata'; import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import { Command, Errors } from '@oclif/core'; import { BinaryDataService, InstanceSettings, + Logger, ObjectStoreService, DataDeduplicationService, ErrorReporter, } from 'n8n-core'; import { ApplicationError, ensureError, sleep } from 'n8n-workflow'; -import { Container } from 'typedi'; import type { AbstractServer } from '@/abstract-server'; import config from '@/config'; @@ -22,14 +23,13 @@ import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus' import { TelemetryEventRelay } from '@/events/relays/telemetry.event-relay'; import { initExpressionEvaluator } from '@/expression-evaluator'; import { ExternalHooks } from '@/external-hooks'; -import { ExternalSecretsManager } from '@/external-secrets/external-secrets-manager.ee'; +import { ExternalSecretsManager } from '@/external-secrets.ee/external-secrets-manager.ee'; import { License } from '@/license'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; -import { Logger } from '@/logging/logger.service'; import { NodeTypes } from '@/node-types'; import { PostHogClient } from '@/posthog'; import { ShutdownService } from '@/shutdown/shutdown.service'; -import { WorkflowHistoryManager } from '@/workflows/workflow-history/workflow-history-manager.ee'; +import { WorkflowHistoryManager } from '@/workflows/workflow-history.ee/workflow-history-manager.ee'; export abstract class BaseCommand extends Command { protected logger = Container.get(Logger); diff --git a/packages/cli/src/commands/db/__tests__/revert.test.ts b/packages/cli/src/commands/db/__tests__/revert.test.ts index ce3911b2b6..8fdabafbec 100644 --- a/packages/cli/src/commands/db/__tests__/revert.test.ts +++ b/packages/cli/src/commands/db/__tests__/revert.test.ts @@ -1,10 +1,10 @@ import type { Migration, MigrationExecutor } from '@n8n/typeorm'; import { type DataSource } from '@n8n/typeorm'; import { mock } from 'jest-mock-extended'; +import { Logger } from 'n8n-core'; import { main } from '@/commands/db/revert'; import type { IrreversibleMigration, ReversibleMigration } from '@/databases/types'; -import { Logger } from '@/logging/logger.service'; import { mockInstance } from '@test/mocking'; const logger = mockInstance(Logger); diff --git a/packages/cli/src/commands/db/revert.ts b/packages/cli/src/commands/db/revert.ts index 4510044405..57780d4620 100644 --- a/packages/cli/src/commands/db/revert.ts +++ b/packages/cli/src/commands/db/revert.ts @@ -1,14 +1,14 @@ +import { Container } from '@n8n/di'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import type { DataSourceOptions as ConnectionOptions } from '@n8n/typeorm'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { MigrationExecutor, DataSource as Connection } from '@n8n/typeorm'; import { Command, Flags } from '@oclif/core'; -import { Container } from 'typedi'; +import { Logger } from 'n8n-core'; import { getConnectionOptions } from '@/databases/config'; import type { Migration } from '@/databases/types'; import { wrapMigration } from '@/databases/utils/migration-helpers'; -import { Logger } from '@/logging/logger.service'; // This function is extracted to make it easier to unit test it. // Mocking turned into a mess due to this command using typeorm and the db diff --git a/packages/cli/src/commands/execute-batch.ts b/packages/cli/src/commands/execute-batch.ts index 0b19e25652..883cd7068d 100644 --- a/packages/cli/src/commands/execute-batch.ts +++ b/packages/cli/src/commands/execute-batch.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-loop-func */ +import { Container } from '@n8n/di'; import { Flags } from '@oclif/core'; import fs from 'fs'; import { diff } from 'json-diff'; @@ -7,7 +8,6 @@ import type { IRun, ITaskData, IWorkflowExecutionDataProcess } from 'n8n-workflo import { ApplicationError, jsonParse } from 'n8n-workflow'; import os from 'os'; import { sep } from 'path'; -import { Container } from 'typedi'; import { ActiveExecutions } from '@/active-executions'; import type { User } from '@/databases/entities/user'; diff --git a/packages/cli/src/commands/execute.ts b/packages/cli/src/commands/execute.ts index fd49a2b619..786b91f7fc 100644 --- a/packages/cli/src/commands/execute.ts +++ b/packages/cli/src/commands/execute.ts @@ -1,7 +1,7 @@ +import { Container } from '@n8n/di'; import { Flags } from '@oclif/core'; import type { IWorkflowBase, IWorkflowExecutionDataProcess } from 'n8n-workflow'; import { ApplicationError, ExecutionBaseError } from 'n8n-workflow'; -import { Container } from 'typedi'; import { ActiveExecutions } from '@/active-executions'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; diff --git a/packages/cli/src/commands/export/credentials.ts b/packages/cli/src/commands/export/credentials.ts index 4644c2556c..ad0b5784c1 100644 --- a/packages/cli/src/commands/export/credentials.ts +++ b/packages/cli/src/commands/export/credentials.ts @@ -1,9 +1,9 @@ +import { Container } from '@n8n/di'; import { Flags } from '@oclif/core'; import fs from 'fs'; import { Credentials } from 'n8n-core'; import { ApplicationError } from 'n8n-workflow'; import path from 'path'; -import Container from 'typedi'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; import type { ICredentialsDb, ICredentialsDecryptedDb } from '@/interfaces'; diff --git a/packages/cli/src/commands/export/workflow.ts b/packages/cli/src/commands/export/workflow.ts index 8e129eec4d..3fa0f1e046 100644 --- a/packages/cli/src/commands/export/workflow.ts +++ b/packages/cli/src/commands/export/workflow.ts @@ -1,8 +1,8 @@ +import { Container } from '@n8n/di'; import { Flags } from '@oclif/core'; import fs from 'fs'; import { ApplicationError } from 'n8n-workflow'; import path from 'path'; -import Container from 'typedi'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; diff --git a/packages/cli/src/commands/import/credentials.ts b/packages/cli/src/commands/import/credentials.ts index 5f9e06f256..aae6c7d23e 100644 --- a/packages/cli/src/commands/import/credentials.ts +++ b/packages/cli/src/commands/import/credentials.ts @@ -1,3 +1,4 @@ +import { Container } from '@n8n/di'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import type { EntityManager } from '@n8n/typeorm'; import { Flags } from '@oclif/core'; @@ -6,7 +7,6 @@ import fs from 'fs'; import { Cipher } from 'n8n-core'; import type { ICredentialsEncrypted } from 'n8n-workflow'; import { ApplicationError, jsonParse } from 'n8n-workflow'; -import { Container } from 'typedi'; import { UM_FIX_INSTRUCTION } from '@/constants'; import { CredentialsEntity } from '@/databases/entities/credentials-entity'; diff --git a/packages/cli/src/commands/import/workflow.ts b/packages/cli/src/commands/import/workflow.ts index 2548ca9523..d8813485a1 100644 --- a/packages/cli/src/commands/import/workflow.ts +++ b/packages/cli/src/commands/import/workflow.ts @@ -1,8 +1,8 @@ +import { Container } from '@n8n/di'; import { Flags } from '@oclif/core'; import glob from 'fast-glob'; import fs from 'fs'; import { ApplicationError, jsonParse } from 'n8n-workflow'; -import { Container } from 'typedi'; import { UM_FIX_INSTRUCTION } from '@/constants'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; diff --git a/packages/cli/src/commands/ldap/reset.ts b/packages/cli/src/commands/ldap/reset.ts index f9f1f3d0bb..edbf988434 100644 --- a/packages/cli/src/commands/ldap/reset.ts +++ b/packages/cli/src/commands/ldap/reset.ts @@ -1,8 +1,8 @@ +import { Container } from '@n8n/di'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In } from '@n8n/typeorm'; import { Flags } from '@oclif/core'; import { ApplicationError } from 'n8n-workflow'; -import Container from 'typedi'; import { UM_FIX_INSTRUCTION } from '@/constants'; import { CredentialsService } from '@/credentials/credentials.service'; @@ -14,7 +14,7 @@ import { SettingsRepository } from '@/databases/repositories/settings.repository import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; import { UserRepository } from '@/databases/repositories/user.repository'; -import { LDAP_DEFAULT_CONFIGURATION, LDAP_FEATURE_NAME } from '@/ldap/constants'; +import { LDAP_DEFAULT_CONFIGURATION, LDAP_FEATURE_NAME } from '@/ldap.ee/constants'; import { WorkflowService } from '@/workflows/workflow.service'; import { BaseCommand } from '../base-command'; diff --git a/packages/cli/src/commands/license/clear.ts b/packages/cli/src/commands/license/clear.ts index 427a150ebb..03a2ea4dd4 100644 --- a/packages/cli/src/commands/license/clear.ts +++ b/packages/cli/src/commands/license/clear.ts @@ -1,4 +1,4 @@ -import { Container } from 'typedi'; +import { Container } from '@n8n/di'; import { SETTINGS_LICENSE_CERT_KEY } from '@/constants'; import { SettingsRepository } from '@/databases/repositories/settings.repository'; diff --git a/packages/cli/src/commands/license/info.ts b/packages/cli/src/commands/license/info.ts index 5f3e31a573..cc99e925f7 100644 --- a/packages/cli/src/commands/license/info.ts +++ b/packages/cli/src/commands/license/info.ts @@ -1,4 +1,4 @@ -import { Container } from 'typedi'; +import { Container } from '@n8n/di'; import { License } from '@/license'; diff --git a/packages/cli/src/commands/list/workflow.ts b/packages/cli/src/commands/list/workflow.ts index 39f5689fcc..74f5916b5c 100644 --- a/packages/cli/src/commands/list/workflow.ts +++ b/packages/cli/src/commands/list/workflow.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import { Flags } from '@oclif/core'; -import Container from 'typedi'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; diff --git a/packages/cli/src/commands/mfa/disable.ts b/packages/cli/src/commands/mfa/disable.ts index 3458593252..a739fbda88 100644 --- a/packages/cli/src/commands/mfa/disable.ts +++ b/packages/cli/src/commands/mfa/disable.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import { Flags } from '@oclif/core'; -import Container from 'typedi'; import { AuthUserRepository } from '@/databases/repositories/auth-user.repository'; diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 63ec3d9240..179c3a8052 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import { Flags } from '@oclif/core'; import glob from 'fast-glob'; import { createReadStream, createWriteStream, existsSync } from 'fs'; @@ -9,7 +10,6 @@ import { jsonParse, randomString, type IWorkflowExecutionDataProcess } from 'n8n import path from 'path'; import replaceStream from 'replacestream'; import { pipeline } from 'stream/promises'; -import { Container } from 'typedi'; import { ActiveExecutions } from '@/active-executions'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; @@ -225,7 +225,7 @@ export class Start extends BaseCommand { const { taskRunners: taskRunnerConfig } = this.globalConfig; if (taskRunnerConfig.enabled) { - const { TaskRunnerModule } = await import('@/runners/task-runner-module'); + const { TaskRunnerModule } = await import('@/task-runners/task-runner-module'); const taskRunnerModule = Container.get(TaskRunnerModule); await taskRunnerModule.start(); } diff --git a/packages/cli/src/commands/update/workflow.ts b/packages/cli/src/commands/update/workflow.ts index 6c88ed6c43..903d6c9405 100644 --- a/packages/cli/src/commands/update/workflow.ts +++ b/packages/cli/src/commands/update/workflow.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import { Flags } from '@oclif/core'; -import { Container } from 'typedi'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; diff --git a/packages/cli/src/commands/user-management/reset.ts b/packages/cli/src/commands/user-management/reset.ts index 3c6709e802..3e8a1c14f1 100644 --- a/packages/cli/src/commands/user-management/reset.ts +++ b/packages/cli/src/commands/user-management/reset.ts @@ -1,4 +1,4 @@ -import { Container } from 'typedi'; +import { Container } from '@n8n/di'; import type { CredentialsEntity } from '@/databases/entities/credentials-entity'; import { User } from '@/databases/entities/user'; diff --git a/packages/cli/src/commands/webhook.ts b/packages/cli/src/commands/webhook.ts index 77ec770aa0..8b6f318576 100644 --- a/packages/cli/src/commands/webhook.ts +++ b/packages/cli/src/commands/webhook.ts @@ -1,6 +1,6 @@ +import { Container } from '@n8n/di'; import { Flags } from '@oclif/core'; import { ApplicationError } from 'n8n-workflow'; -import { Container } from 'typedi'; import { ActiveExecutions } from '@/active-executions'; import config from '@/config'; diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts index 64c5a34dae..f5138f1ef3 100644 --- a/packages/cli/src/commands/worker.ts +++ b/packages/cli/src/commands/worker.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import { Flags, type Config } from '@oclif/core'; -import { Container } from 'typedi'; import config from '@/config'; import { N8N_VERSION, inTest } from '@/constants'; @@ -7,7 +7,6 @@ import { WorkerMissingEncryptionKey } from '@/errors/worker-missing-encryption-k import { EventMessageGeneric } from '@/eventbus/event-message-classes/event-message-generic'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import { LogStreamingEventRelay } from '@/events/relays/log-streaming.event-relay'; -import { Logger } from '@/logging/logger.service'; import { PubSubHandler } from '@/scaling/pubsub/pubsub-handler'; import { Subscriber } from '@/scaling/pubsub/subscriber.service'; import type { ScalingService } from '@/scaling/scaling.service'; @@ -67,7 +66,7 @@ export class Worker extends BaseCommand { super(argv, cmdConfig); - this.logger = Container.get(Logger).scoped('scaling'); + this.logger = this.logger.scoped('scaling'); } async init() { @@ -114,7 +113,7 @@ export class Worker extends BaseCommand { const { taskRunners: taskRunnerConfig } = this.globalConfig; if (taskRunnerConfig.enabled) { - const { TaskRunnerModule } = await import('@/runners/task-runner-module'); + const { TaskRunnerModule } = await import('@/task-runners/task-runner-module'); const taskRunnerModule = Container.get(TaskRunnerModule); await taskRunnerModule.start(); } diff --git a/packages/cli/src/concurrency/concurrency-control.service.ts b/packages/cli/src/concurrency/concurrency-control.service.ts index 3896daad2e..6088d0f4c3 100644 --- a/packages/cli/src/concurrency/concurrency-control.service.ts +++ b/packages/cli/src/concurrency/concurrency-control.service.ts @@ -1,5 +1,6 @@ +import { Service } from '@n8n/di'; +import { Logger } from 'n8n-core'; import type { WorkflowExecuteMode as ExecutionMode } from 'n8n-workflow'; -import { Service } from 'typedi'; import config from '@/config'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; @@ -7,7 +8,6 @@ import { InvalidConcurrencyLimitError } from '@/errors/invalid-concurrency-limit import { UnknownExecutionModeError } from '@/errors/unknown-execution-mode.error'; import { EventService } from '@/events/event.service'; import type { IExecutingWorkflowData } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { Telemetry } from '@/telemetry'; import { ConcurrencyQueue } from './concurrency-queue'; diff --git a/packages/cli/src/concurrency/concurrency-queue.ts b/packages/cli/src/concurrency/concurrency-queue.ts index c4c482226c..900018889a 100644 --- a/packages/cli/src/concurrency/concurrency-queue.ts +++ b/packages/cli/src/concurrency/concurrency-queue.ts @@ -1,4 +1,4 @@ -import { Service } from 'typedi'; +import { Service } from '@n8n/di'; import { TypedEmitter } from '@/typed-emitter'; diff --git a/packages/cli/src/config/index.ts b/packages/cli/src/config/index.ts index 63497600fe..8839d180ff 100644 --- a/packages/cli/src/config/index.ts +++ b/packages/cli/src/config/index.ts @@ -1,11 +1,13 @@ import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import convict from 'convict'; import { flatten } from 'flat'; import { readFileSync } from 'fs'; import merge from 'lodash/merge'; +import { Logger } from 'n8n-core'; import { ApplicationError, setGlobalState } from 'n8n-workflow'; +import assert from 'node:assert'; import colors from 'picocolors'; -import { Container } from 'typedi'; import { inTest, inE2ETests } from '@/constants'; @@ -31,13 +33,15 @@ const config = convict(schema, { args: [] }); // eslint-disable-next-line @typescript-eslint/unbound-method config.getEnv = config.get; +const logger = Container.get(Logger); +const globalConfig = Container.get(GlobalConfig); + // Load overwrites when not in tests if (!inE2ETests && !inTest) { // Overwrite default configuration with settings which got defined in // optional configuration files const { N8N_CONFIG_FILES } = process.env; if (N8N_CONFIG_FILES !== undefined) { - const globalConfig = Container.get(GlobalConfig); const configFiles = N8N_CONFIG_FILES.split(','); for (const configFile of configFiles) { if (!configFile) continue; @@ -58,9 +62,10 @@ if (!inE2ETests && !inTest) { } } } - console.debug('Loaded config overwrites from', configFile); + logger.debug(`Loaded config overwrites from ${configFile}`); } catch (error) { - console.error('Error loading config file', configFile, error); + assert(error instanceof Error); + logger.error(`Error loading config file ${configFile}`, { error }); } } } @@ -96,7 +101,7 @@ config.validate({ const userManagement = config.get('userManagement'); if (userManagement.jwtRefreshTimeoutHours >= userManagement.jwtSessionDurationHours) { if (!inTest) - console.warn( + logger.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.', ); @@ -105,16 +110,16 @@ if (userManagement.jwtRefreshTimeoutHours >= userManagement.jwtSessionDurationHo const executionProcess = config.getEnv('executions.process'); if (executionProcess) { - console.error( - colors.yellow('Please unset the deprecated env variable'), - colors.bold(colors.yellow('EXECUTIONS_PROCESS')), + logger.error( + colors.yellow('Please unset the deprecated env variable') + + colors.bold(colors.yellow('EXECUTIONS_PROCESS')), ); } if (executionProcess === 'own') { - console.error( + logger.error( colors.bold(colors.red('Application failed to start because "Own" mode has been removed.')), ); - console.error( + logger.error( colors.red( 'If you need the isolation and performance gains, please consider using queue mode instead.\n\n', ), @@ -123,7 +128,7 @@ if (executionProcess === 'own') { } setGlobalState({ - defaultTimezone: Container.get(GlobalConfig).generic.timezone, + defaultTimezone: globalConfig.generic.timezone, }); // eslint-disable-next-line import/no-default-export diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 54fa07e7f5..e5bda7d81b 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -1,8 +1,8 @@ import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import convict from 'convict'; import { InstanceSettings } from 'n8n-core'; import path from 'path'; -import { Container } from 'typedi'; import { ensureStringArray } from './utils'; @@ -341,15 +341,6 @@ export const schema = { }, }, - aiAssistant: { - baseUrl: { - doc: 'Base URL of the AI assistant service', - format: String, - default: '', - env: 'N8N_AI_ASSISTANT_BASE_URL', - }, - }, - expression: { evaluator: { doc: 'Expression evaluator to use', diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 78d7671e1b..4bd1890c4e 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'fs'; import type { n8n } from 'n8n-core'; -import { jsonParse } from 'n8n-workflow'; +import type { ITaskDataConnections } from 'n8n-workflow'; +import { jsonParse, TRIMMED_TASK_DATA_CONNECTIONS_KEY } from 'n8n-workflow'; import { resolve, join, dirname } from 'path'; const { NODE_ENV, E2E_TESTS } = process.env; @@ -93,6 +94,7 @@ export const LICENSE_FEATURES = { AI_ASSISTANT: 'feat:aiAssistant', ASK_AI: 'feat:askAi', COMMUNITY_NODES_CUSTOM_REGISTRY: 'feat:communityNodes:customRegistry', + AI_CREDITS: 'feat:aiCredits', } as const; export const LICENSE_QUOTAS = { @@ -101,6 +103,7 @@ export const LICENSE_QUOTAS = { USERS_LIMIT: 'quota:users', WORKFLOW_HISTORY_PRUNE_LIMIT: 'quota:workflowHistoryPrune', TEAM_PROJECT_LIMIT: 'quota:maxTeamProjects', + AI_CREDITS: 'quota:aiCredits', } as const; export const UNLIMITED_LICENSE_QUOTA = -1; @@ -159,6 +162,22 @@ export const ARTIFICIAL_TASK_DATA = { ], }; +/** + * Connections for an item standing in for a manual execution data item too + * large to be sent live via pubsub. This signals to the client to direct the + * user to the execution history. + */ +export const TRIMMED_TASK_DATA_CONNECTIONS: ITaskDataConnections = { + main: [ + [ + { + json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true }, + pairedItem: undefined, + }, + ], + ], +}; + /** Lowest priority, meaning shut down happens after other groups */ export const LOWEST_SHUTDOWN_PRIORITY = 0; export const DEFAULT_SHUTDOWN_PRIORITY = 100; @@ -174,3 +193,6 @@ export const WsStatusCodes = { CloseAbnormal: 1006, CloseInvalidData: 1007, } as const; + +export const FREE_AI_CREDITS_CREDENTIAL_NAME = 'n8n free OpenAI API credits'; +export const OPEN_AI_API_CREDENTIAL_TYPE = 'openAiApi'; diff --git a/packages/cli/src/controllers/__tests__/ai.controller.test.ts b/packages/cli/src/controllers/__tests__/ai.controller.test.ts new file mode 100644 index 0000000000..2cf0a1cdcd --- /dev/null +++ b/packages/cli/src/controllers/__tests__/ai.controller.test.ts @@ -0,0 +1,112 @@ +import type { + AiAskRequestDto, + AiApplySuggestionRequestDto, + AiChatRequestDto, +} from '@n8n/api-types'; +import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk'; +import { mock } from 'jest-mock-extended'; + +import { InternalServerError } from '@/errors/response-errors/internal-server.error'; +import type { AuthenticatedRequest } from '@/requests'; +import type { AiService } from '@/services/ai.service'; + +import { AiController, type FlushableResponse } from '../ai.controller'; + +describe('AiController', () => { + const aiService = mock(); + + const controller = new AiController(aiService, mock(), mock()); + + const request = mock({ + user: { id: 'user123' }, + }); + const response = mock(); + + beforeEach(() => { + jest.clearAllMocks(); + + response.header.mockReturnThis(); + }); + + describe('chat', () => { + const payload = mock(); + + it('should handle chat request successfully', async () => { + aiService.chat.mockResolvedValue( + mock({ + body: mock({ + pipeTo: jest.fn().mockImplementation(async (writableStream) => { + // Simulate stream writing + const writer = writableStream.getWriter(); + await writer.write(JSON.stringify({ message: 'test response' })); + await writer.close(); + }), + }), + }), + ); + + await controller.chat(request, response, payload); + + expect(aiService.chat).toHaveBeenCalledWith(payload, request.user); + expect(response.header).toHaveBeenCalledWith('Content-type', 'application/json-lines'); + expect(response.flush).toHaveBeenCalled(); + expect(response.end).toHaveBeenCalled(); + }); + + it('should throw InternalServerError if chat fails', async () => { + const mockError = new Error('Chat failed'); + + aiService.chat.mockRejectedValue(mockError); + + await expect(controller.chat(request, response, payload)).rejects.toThrow( + InternalServerError, + ); + }); + }); + + describe('applySuggestion', () => { + const payload = mock(); + + it('should apply suggestion successfully', async () => { + const clientResponse = mock(); + aiService.applySuggestion.mockResolvedValue(clientResponse); + + const result = await controller.applySuggestion(request, response, payload); + + expect(aiService.applySuggestion).toHaveBeenCalledWith(payload, request.user); + expect(result).toEqual(clientResponse); + }); + + it('should throw InternalServerError if applying suggestion fails', async () => { + const mockError = new Error('Apply suggestion failed'); + aiService.applySuggestion.mockRejectedValue(mockError); + + await expect(controller.applySuggestion(request, response, payload)).rejects.toThrow( + InternalServerError, + ); + }); + }); + + describe('askAi method', () => { + const payload = mock(); + + it('should ask AI successfully', async () => { + const clientResponse = mock(); + aiService.askAi.mockResolvedValue(clientResponse); + + const result = await controller.askAi(request, response, payload); + + expect(aiService.askAi).toHaveBeenCalledWith(payload, request.user); + expect(result).toEqual(clientResponse); + }); + + it('should throw InternalServerError if asking AI fails', async () => { + const mockError = new Error('Ask AI failed'); + aiService.askAi.mockRejectedValue(mockError); + + await expect(controller.askAi(request, response, payload)).rejects.toThrow( + InternalServerError, + ); + }); + }); +}); diff --git a/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts b/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts index 3f34fc1d2c..dc40d3357d 100644 --- a/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; -import { Container } from 'typedi'; import type { ApiKey } from '@/databases/entities/api-key'; import type { User } from '@/databases/entities/user'; diff --git a/packages/cli/src/controllers/__tests__/dynamic-node-parameters.controller.test.ts b/packages/cli/src/controllers/__tests__/dynamic-node-parameters.controller.test.ts index ff983cdd4a..87ed378a02 100644 --- a/packages/cli/src/controllers/__tests__/dynamic-node-parameters.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/dynamic-node-parameters.controller.test.ts @@ -1,34 +1,223 @@ +import type { + OptionsRequestDto, + ResourceLocatorRequestDto, + ResourceMapperFieldsRequestDto, + ActionResultRequestDto, +} from '@n8n/api-types'; import { mock } from 'jest-mock-extended'; -import type { ILoadOptions, IWorkflowExecuteAdditionalData } from 'n8n-workflow'; +import type { + ILoadOptions, + IWorkflowExecuteAdditionalData, + INodePropertyOptions, + NodeParameterValueType, +} from 'n8n-workflow'; import { DynamicNodeParametersController } from '@/controllers/dynamic-node-parameters.controller'; -import type { DynamicNodeParametersRequest } from '@/requests'; +import type { AuthenticatedRequest } from '@/requests'; import type { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service'; import * as AdditionalData from '@/workflow-execute-additional-data'; describe('DynamicNodeParametersController', () => { - const service = mock(); - const controller = new DynamicNodeParametersController(service); + let service: jest.Mocked; + let controller: DynamicNodeParametersController; + let mockUser: { id: string }; + let baseAdditionalData: IWorkflowExecuteAdditionalData; beforeEach(() => { - jest.clearAllMocks(); + service = mock(); + controller = new DynamicNodeParametersController(service); + + mockUser = { id: 'user123' }; + baseAdditionalData = mock(); + + jest.spyOn(AdditionalData, 'getBase').mockResolvedValue(baseAdditionalData); }); describe('getOptions', () => { - it('should take `loadOptions` as object', async () => { - jest - .spyOn(AdditionalData, 'getBase') - .mockResolvedValue(mock()); + const basePayload: OptionsRequestDto = { + path: '/test/path', + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + currentNodeParameters: {}, + }; - const req = mock(); - const loadOptions: ILoadOptions = {}; - req.body.loadOptions = loadOptions; + it('should call getOptionsViaMethodName when methodName is provided', async () => { + const payload: OptionsRequestDto = { + ...basePayload, + methodName: 'testMethod', + }; + const req = { user: mockUser } as AuthenticatedRequest; - await controller.getOptions(req); + const expectedResult: INodePropertyOptions[] = [{ name: 'test', value: 'value' }]; + service.getOptionsViaMethodName.mockResolvedValue(expectedResult); - const zerothArg = service.getOptionsViaLoadOptions.mock.calls[0][0]; + const result = await controller.getOptions(req, mock(), payload); - expect(zerothArg).toEqual(loadOptions); + expect(service.getOptionsViaMethodName).toHaveBeenCalledWith( + 'testMethod', + '/test/path', + baseAdditionalData, + { name: 'TestNode', version: 1 }, + {}, + undefined, + ); + expect(result).toEqual(expectedResult); + }); + + it('should call getOptionsViaLoadOptions when loadOptions is provided', async () => { + const loadOptions: ILoadOptions = { + routing: { + operations: {}, + }, + }; + const payload: OptionsRequestDto = { + ...basePayload, + loadOptions, + }; + const req = { user: mockUser } as AuthenticatedRequest; + + const expectedResult: INodePropertyOptions[] = [{ name: 'test', value: 'value' }]; + service.getOptionsViaLoadOptions.mockResolvedValue(expectedResult); + + const result = await controller.getOptions(req, mock(), payload); + + expect(service.getOptionsViaLoadOptions).toHaveBeenCalledWith( + loadOptions, + baseAdditionalData, + { name: 'TestNode', version: 1 }, + {}, + undefined, + ); + expect(result).toEqual(expectedResult); + }); + + it('should return empty array when no method or load options are provided', async () => { + const req = { user: mockUser } as AuthenticatedRequest; + + const result = await controller.getOptions(req, mock(), basePayload); + + expect(result).toEqual([]); + }); + }); + + describe('getResourceLocatorResults', () => { + const basePayload: ResourceLocatorRequestDto = { + path: '/test/path', + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + methodName: 'testMethod', + currentNodeParameters: {}, + }; + + it('should call getResourceLocatorResults with correct parameters', async () => { + const payload: ResourceLocatorRequestDto = { + ...basePayload, + filter: 'testFilter', + paginationToken: 'testToken', + }; + const req = { user: mockUser } as AuthenticatedRequest; + + const expectedResult = { results: [{ name: 'test', value: 'value' }] }; + service.getResourceLocatorResults.mockResolvedValue(expectedResult); + + const result = await controller.getResourceLocatorResults(req, mock(), payload); + + expect(service.getResourceLocatorResults).toHaveBeenCalledWith( + 'testMethod', + '/test/path', + baseAdditionalData, + { name: 'TestNode', version: 1 }, + {}, + undefined, + 'testFilter', + 'testToken', + ); + expect(result).toEqual(expectedResult); + }); + }); + + describe('getResourceMappingFields', () => { + const basePayload: ResourceMapperFieldsRequestDto = { + path: '/test/path', + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + methodName: 'testMethod', + currentNodeParameters: {}, + }; + + it('should call getResourceMappingFields with correct parameters', async () => { + const req = { user: mockUser } as AuthenticatedRequest; + + const expectedResult = { fields: [] }; + service.getResourceMappingFields.mockResolvedValue(expectedResult); + + const result = await controller.getResourceMappingFields(req, mock(), basePayload); + + expect(service.getResourceMappingFields).toHaveBeenCalledWith( + 'testMethod', + '/test/path', + baseAdditionalData, + { name: 'TestNode', version: 1 }, + {}, + undefined, + ); + expect(result).toEqual(expectedResult); + }); + }); + + describe('getLocalResourceMappingFields', () => { + const basePayload: ResourceMapperFieldsRequestDto = { + path: '/test/path', + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + methodName: 'testMethod', + currentNodeParameters: {}, + }; + + it('should call getLocalResourceMappingFields with correct parameters', async () => { + const req = { user: mockUser } as AuthenticatedRequest; + + const expectedResult = { fields: [] }; + service.getLocalResourceMappingFields.mockResolvedValue(expectedResult); + + const result = await controller.getLocalResourceMappingFields(req, mock(), basePayload); + + expect(service.getLocalResourceMappingFields).toHaveBeenCalledWith( + 'testMethod', + '/test/path', + baseAdditionalData, + { name: 'TestNode', version: 1 }, + ); + expect(result).toEqual(expectedResult); + }); + }); + + describe('getActionResult', () => { + const basePayload: ActionResultRequestDto = { + path: '/test/path', + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, + handler: 'testHandler', + currentNodeParameters: {}, + }; + + it('should call getActionResult with correct parameters', async () => { + const payload: ActionResultRequestDto = { + ...basePayload, + payload: { test: 'value' }, + }; + const req = { user: mockUser } as AuthenticatedRequest; + + const expectedResult: NodeParameterValueType = 'test result'; + service.getActionResult.mockResolvedValue(expectedResult); + + const result = await controller.getActionResult(req, mock(), payload); + + expect(service.getActionResult).toHaveBeenCalledWith( + 'testHandler', + '/test/path', + baseAdditionalData, + { name: 'TestNode', version: 1 }, + {}, + { test: 'value' }, + undefined, + ); + expect(result).toEqual(expectedResult); }); }); }); diff --git a/packages/cli/src/controllers/__tests__/me.controller.test.ts b/packages/cli/src/controllers/__tests__/me.controller.test.ts index 37c391a2dc..9ba6f8ada7 100644 --- a/packages/cli/src/controllers/__tests__/me.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/me.controller.test.ts @@ -1,8 +1,8 @@ import { UserUpdateRequestDto } from '@n8n/api-types'; +import { Container } from '@n8n/di'; import type { Response } from 'express'; import { mock, anyObject } from 'jest-mock-extended'; import jwt from 'jsonwebtoken'; -import { Container } from 'typedi'; import { AUTH_COOKIE_NAME } from '@/constants'; import { MeController } from '@/controllers/me.controller'; diff --git a/packages/cli/src/controllers/__tests__/owner.controller.test.ts b/packages/cli/src/controllers/__tests__/owner.controller.test.ts index 0fd42aae43..b5065fa283 100644 --- a/packages/cli/src/controllers/__tests__/owner.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/owner.controller.test.ts @@ -1,7 +1,7 @@ +import type { DismissBannerRequestDto, OwnerSetupRequestDto } from '@n8n/api-types'; import type { Response } from 'express'; -import { anyObject, mock } from 'jest-mock-extended'; -import jwt from 'jsonwebtoken'; -import Container from 'typedi'; +import { mock } from 'jest-mock-extended'; +import type { Logger } from 'n8n-core'; import type { AuthService } from '@/auth/auth.service'; import config from '@/config'; @@ -10,27 +10,31 @@ import type { User } from '@/databases/entities/user'; import type { SettingsRepository } from '@/databases/repositories/settings.repository'; import type { UserRepository } from '@/databases/repositories/user.repository'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { License } from '@/license'; -import type { OwnerRequest } from '@/requests'; -import { PasswordUtility } from '@/services/password.utility'; +import type { EventService } from '@/events/event.service'; +import type { PublicUser } from '@/interfaces'; +import type { AuthenticatedRequest } from '@/requests'; +import type { PasswordUtility } from '@/services/password.utility'; import type { UserService } from '@/services/user.service'; -import { mockInstance } from '@test/mocking'; -import { badPasswords } from '@test/test-data'; describe('OwnerController', () => { const configGetSpy = jest.spyOn(config, 'getEnv'); + const configSetSpy = jest.spyOn(config, 'set'); + + const logger = mock(); + const eventService = mock(); const authService = mock(); const userService = mock(); const userRepository = mock(); const settingsRepository = mock(); - mockInstance(License).isWithinUsersLimit.mockReturnValue(true); + const passwordUtility = mock(); + const controller = new OwnerController( - mock(), - mock(), + logger, + eventService, settingsRepository, authService, userService, - Container.get(PasswordUtility), + passwordUtility, mock(), userRepository, ); @@ -38,38 +42,18 @@ describe('OwnerController', () => { describe('setupOwner', () => { it('should throw a BadRequestError if the instance owner is already setup', async () => { configGetSpy.mockReturnValue(true); - await expect(controller.setupOwner(mock(), mock())).rejects.toThrowError( + await expect(controller.setupOwner(mock(), mock(), mock())).rejects.toThrowError( new BadRequestError('Instance owner already setup'), ); - }); - it('should throw a BadRequestError if the email is invalid', async () => { - configGetSpy.mockReturnValue(false); - const req = mock({ body: { email: 'invalid email' } }); - await expect(controller.setupOwner(req, mock())).rejects.toThrowError( - new BadRequestError('Invalid email address'), - ); - }); - - describe('should throw if the password is invalid', () => { - Object.entries(badPasswords).forEach(([password, errorMessage]) => { - it(password, async () => { - configGetSpy.mockReturnValue(false); - const req = mock({ body: { email: 'valid@email.com', password } }); - await expect(controller.setupOwner(req, mock())).rejects.toThrowError( - new BadRequestError(errorMessage), - ); - }); - }); - }); - - it('should throw a BadRequestError if firstName & lastName are missing ', async () => { - configGetSpy.mockReturnValue(false); - const req = mock({ - body: { email: 'valid@email.com', password: 'NewPassword123', firstName: '', lastName: '' }, - }); - await expect(controller.setupOwner(req, mock())).rejects.toThrowError( - new BadRequestError('First and last names are mandatory'), + expect(userRepository.findOneOrFail).not.toHaveBeenCalled(); + expect(userRepository.save).not.toHaveBeenCalled(); + expect(authService.issueCookie).not.toHaveBeenCalled(); + expect(settingsRepository.update).not.toHaveBeenCalled(); + expect(configSetSpy).not.toHaveBeenCalled(); + expect(eventService.emit).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith( + 'Request to claim instance ownership failed because instance owner already exists', ); }); @@ -80,29 +64,52 @@ describe('OwnerController', () => { authIdentities: [], }); const browserId = 'test-browser-id'; - const req = mock({ - body: { - email: 'valid@email.com', - password: 'NewPassword123', - firstName: 'Jane', - lastName: 'Doe', - }, - user, - browserId, - }); + const req = mock({ user, browserId }); const res = mock(); + const payload = mock({ + email: 'valid@email.com', + password: 'NewPassword123', + firstName: 'Jane', + lastName: 'Doe', + }); configGetSpy.mockReturnValue(false); - userRepository.findOneOrFail.calledWith(anyObject()).mockResolvedValue(user); - userRepository.save.calledWith(anyObject()).mockResolvedValue(user); - jest.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token'); + userRepository.findOneOrFail.mockResolvedValue(user); + userRepository.save.mockResolvedValue(user); + userService.toPublic.mockResolvedValue(mock({ id: 'newUserId' })); - await controller.setupOwner(req, res); + const result = await controller.setupOwner(req, res, payload); expect(userRepository.findOneOrFail).toHaveBeenCalledWith({ where: { role: 'global:owner' }, }); expect(userRepository.save).toHaveBeenCalledWith(user, { transaction: false }); expect(authService.issueCookie).toHaveBeenCalledWith(res, user, browserId); + expect(settingsRepository.update).toHaveBeenCalledWith( + { key: 'userManagement.isInstanceOwnerSetUp' }, + { value: JSON.stringify(true) }, + ); + expect(configSetSpy).toHaveBeenCalledWith('userManagement.isInstanceOwnerSetUp', true); + expect(eventService.emit).toHaveBeenCalledWith('instance-owner-setup', { userId: 'userId' }); + expect(result.id).toEqual('newUserId'); + }); + }); + + describe('dismissBanner', () => { + it('should not call dismissBanner if no banner is provided', async () => { + const payload = mock({ banner: undefined }); + + const result = await controller.dismissBanner(mock(), mock(), payload); + + expect(settingsRepository.dismissBanner).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it('should call dismissBanner with the correct banner name', async () => { + const payload = mock({ banner: 'TRIAL' }); + + await controller.dismissBanner(mock(), mock(), payload); + + expect(settingsRepository.dismissBanner).toHaveBeenCalledWith({ bannerName: 'TRIAL' }); }); }); }); diff --git a/packages/cli/src/controllers/__tests__/users.controller.test.ts b/packages/cli/src/controllers/__tests__/users.controller.test.ts index 91ad6bd28b..5eaaca5840 100644 --- a/packages/cli/src/controllers/__tests__/users.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/users.controller.test.ts @@ -4,7 +4,7 @@ import type { User } from '@/databases/entities/user'; import type { UserRepository } from '@/databases/repositories/user.repository'; import type { EventService } from '@/events/event.service'; import type { AuthenticatedRequest } from '@/requests'; -import type { ProjectService } from '@/services/project.service'; +import type { ProjectService } from '@/services/project.service.ee'; import { UsersController } from '../users.controller'; diff --git a/packages/cli/src/controllers/ai.controller.ts b/packages/cli/src/controllers/ai.controller.ts index be1231911a..791c02bec3 100644 --- a/packages/cli/src/controllers/ai.controller.ts +++ b/packages/cli/src/controllers/ai.controller.ts @@ -1,23 +1,37 @@ +import { + AiChatRequestDto, + AiApplySuggestionRequestDto, + AiAskRequestDto, + AiFreeCreditsRequestDto, +} from '@n8n/api-types'; import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk'; -import type { Response } from 'express'; +import { Response } from 'express'; import { strict as assert } from 'node:assert'; import { WritableStream } from 'node:stream/web'; -import { Post, RestController } from '@/decorators'; +import { FREE_AI_CREDITS_CREDENTIAL_NAME, OPEN_AI_API_CREDENTIAL_TYPE } from '@/constants'; +import { CredentialsService } from '@/credentials/credentials.service'; +import { Body, Post, RestController } from '@/decorators'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; -import { AiAssistantRequest } from '@/requests'; +import type { CredentialRequest } from '@/requests'; +import { AuthenticatedRequest } from '@/requests'; import { AiService } from '@/services/ai.service'; +import { UserService } from '@/services/user.service'; -type FlushableResponse = Response & { flush: () => void }; +export type FlushableResponse = Response & { flush: () => void }; @RestController('/ai') export class AiController { - constructor(private readonly aiService: AiService) {} + constructor( + private readonly aiService: AiService, + private readonly credentialsService: CredentialsService, + private readonly userService: UserService, + ) {} @Post('/chat', { rateLimit: { limit: 100 } }) - async chat(req: AiAssistantRequest.Chat, res: FlushableResponse) { + async chat(req: AuthenticatedRequest, res: FlushableResponse, @Body payload: AiChatRequestDto) { try { - const aiResponse = await this.aiService.chat(req.body, req.user); + const aiResponse = await this.aiService.chat(payload, req.user); if (aiResponse.body) { res.header('Content-type', 'application/json-lines').flush(); await aiResponse.body.pipeTo( @@ -38,10 +52,12 @@ export class AiController { @Post('/chat/apply-suggestion') async applySuggestion( - req: AiAssistantRequest.ApplySuggestionPayload, + req: AuthenticatedRequest, + _: Response, + @Body payload: AiApplySuggestionRequestDto, ): Promise { try { - return await this.aiService.applySuggestion(req.body, req.user); + return await this.aiService.applySuggestion(payload, req.user); } catch (e) { assert(e instanceof Error); throw new InternalServerError(e.message, e); @@ -49,9 +65,45 @@ export class AiController { } @Post('/ask-ai') - async askAi(req: AiAssistantRequest.AskAiPayload): Promise { + async askAi( + req: AuthenticatedRequest, + _: Response, + @Body payload: AiAskRequestDto, + ): Promise { try { - return await this.aiService.askAi(req.body, req.user); + return await this.aiService.askAi(payload, req.user); + } catch (e) { + assert(e instanceof Error); + throw new InternalServerError(e.message, e); + } + } + + @Post('/free-credits') + async aiCredits(req: AuthenticatedRequest, _: Response, @Body payload: AiFreeCreditsRequestDto) { + try { + const aiCredits = await this.aiService.createFreeAiCredits(req.user); + + const credentialProperties: CredentialRequest.CredentialProperties = { + name: FREE_AI_CREDITS_CREDENTIAL_NAME, + type: OPEN_AI_API_CREDENTIAL_TYPE, + data: { + apiKey: aiCredits.apiKey, + url: aiCredits.url, + }, + isManaged: true, + projectId: payload?.projectId, + }; + + const newCredential = await this.credentialsService.createCredential( + credentialProperties, + req.user, + ); + + await this.userService.updateSettings(req.user.id, { + userClaimedAiCredits: true, + }); + + return newCredential; } catch (e) { assert(e instanceof Error); throw new InternalServerError(e.message, e); diff --git a/packages/cli/src/controllers/auth.controller.ts b/packages/cli/src/controllers/auth.controller.ts index 46ee73a562..fb06c1a80b 100644 --- a/packages/cli/src/controllers/auth.controller.ts +++ b/packages/cli/src/controllers/auth.controller.ts @@ -1,29 +1,28 @@ +import { LoginRequestDto, ResolveSignupTokenQueryDto } from '@n8n/api-types'; import { Response } from 'express'; -import { ApplicationError } from 'n8n-workflow'; -import validator from 'validator'; +import { Logger } from 'n8n-core'; import { handleEmailLogin, handleLdapLogin } from '@/auth'; import { AuthService } from '@/auth/auth.service'; import { RESPONSE_ERROR_MESSAGES } from '@/constants'; import type { User } from '@/databases/entities/user'; import { UserRepository } from '@/databases/repositories/user.repository'; -import { Get, Post, RestController } from '@/decorators'; +import { Body, Get, Post, Query, RestController } from '@/decorators'; import { AuthError } from '@/errors/response-errors/auth.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { EventService } from '@/events/event.service'; import type { PublicUser } from '@/interfaces'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import { MfaService } from '@/mfa/mfa.service'; import { PostHogClient } from '@/posthog'; -import { AuthenticatedRequest, LoginRequest, UserRequest } from '@/requests'; +import { AuthenticatedRequest, AuthlessRequest } from '@/requests'; import { UserService } from '@/services/user.service'; import { getCurrentAuthenticationMethod, isLdapCurrentAuthenticationMethod, isSamlCurrentAuthenticationMethod, -} from '@/sso/sso-helpers'; +} from '@/sso.ee/sso-helpers'; @RestController() export class AuthController { @@ -40,10 +39,12 @@ export class AuthController { /** Log in a user */ @Post('/login', { skipAuth: true, rateLimit: true }) - async login(req: LoginRequest, res: Response): Promise { - const { email, password, mfaCode, mfaRecoveryCode } = req.body; - if (!email) throw new ApplicationError('Email is required to log in'); - if (!password) throw new ApplicationError('Password is required to log in'); + async login( + req: AuthlessRequest, + res: Response, + @Body payload: LoginRequestDto, + ): Promise { + const { email, password, mfaCode, mfaRecoveryCode } = payload; let user: User | undefined; @@ -117,8 +118,12 @@ export class AuthController { /** Validate invite token to enable invitee to set up their account */ @Get('/resolve-signup-token', { skipAuth: true }) - async resolveSignupToken(req: UserRequest.ResolveSignUp) { - const { inviterId, inviteeId } = req.query; + async resolveSignupToken( + _req: AuthlessRequest, + _res: Response, + @Query payload: ResolveSignupTokenQueryDto, + ) { + const { inviterId, inviteeId } = payload; const isWithinUsersLimit = this.license.isWithinUsersLimit(); if (!isWithinUsersLimit) { @@ -129,24 +134,6 @@ export class AuthController { throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); } - if (!inviterId || !inviteeId) { - this.logger.debug( - 'Request to resolve signup token failed because of missing user IDs in query string', - { inviterId, inviteeId }, - ); - throw new BadRequestError('Invalid payload'); - } - - // Postgres validates UUID format - for (const userId of [inviterId, inviteeId]) { - if (!validator.isUUID(userId)) { - this.logger.debug('Request to resolve signup token failed because of invalid user ID', { - userId, - }); - throw new BadRequestError('Invalid userId'); - } - } - const users = await this.userRepository.findManyByIds([inviterId, inviteeId]); if (users.length !== 2) { diff --git a/packages/cli/src/controllers/community-packages.controller.ts b/packages/cli/src/controllers/community-packages.controller.ts index 918f1cdf74..ab2134b7e0 100644 --- a/packages/cli/src/controllers/community-packages.controller.ts +++ b/packages/cli/src/controllers/community-packages.controller.ts @@ -115,9 +115,12 @@ export class CommunityPackagesController { // broadcast to connected frontends that node list has been updated installedPackage.installedNodes.forEach((node) => { - this.push.broadcast('reloadNodeType', { - name: node.type, - version: node.latestVersion, + this.push.broadcast({ + type: 'reloadNodeType', + data: { + name: node.type, + version: node.latestVersion, + }, }); }); @@ -206,9 +209,12 @@ export class CommunityPackagesController { // broadcast to connected frontends that node list has been updated installedPackage.installedNodes.forEach((node) => { - this.push.broadcast('removeNodeType', { - name: node.type, - version: node.latestVersion, + this.push.broadcast({ + type: 'removeNodeType', + data: { + name: node.type, + version: node.latestVersion, + }, }); }); @@ -246,16 +252,22 @@ export class CommunityPackagesController { // broadcast to connected frontends that node list has been updated previouslyInstalledPackage.installedNodes.forEach((node) => { - this.push.broadcast('removeNodeType', { - name: node.type, - version: node.latestVersion, + this.push.broadcast({ + type: 'removeNodeType', + data: { + name: node.type, + version: node.latestVersion, + }, }); }); newInstalledPackage.installedNodes.forEach((node) => { - this.push.broadcast('reloadNodeType', { - name: node.name, - version: node.latestVersion, + this.push.broadcast({ + type: 'reloadNodeType', + data: { + name: node.name, + version: node.latestVersion, + }, }); }); @@ -272,9 +284,12 @@ export class CommunityPackagesController { return newInstalledPackage; } catch (error) { previouslyInstalledPackage.installedNodes.forEach((node) => { - this.push.broadcast('removeNodeType', { - name: node.type, - version: node.latestVersion, + this.push.broadcast({ + type: 'removeNodeType', + data: { + name: node.type, + version: node.latestVersion, + }, }); }); diff --git a/packages/cli/src/controllers/dynamic-node-parameters.controller.ts b/packages/cli/src/controllers/dynamic-node-parameters.controller.ts index f3df53d95b..987040d010 100644 --- a/packages/cli/src/controllers/dynamic-node-parameters.controller.ts +++ b/packages/cli/src/controllers/dynamic-node-parameters.controller.ts @@ -1,8 +1,13 @@ +import { + OptionsRequestDto, + ResourceLocatorRequestDto, + ResourceMapperFieldsRequestDto, + ActionResultRequestDto, +} from '@n8n/api-types'; import type { INodePropertyOptions, NodeParameterValueType } from 'n8n-workflow'; -import { Post, RestController } from '@/decorators'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { DynamicNodeParametersRequest } from '@/requests'; +import { Post, RestController, Body } from '@/decorators'; +import { AuthenticatedRequest } from '@/requests'; import { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service'; import { getBase } from '@/workflow-execute-additional-data'; @@ -11,7 +16,11 @@ export class DynamicNodeParametersController { constructor(private readonly service: DynamicNodeParametersService) {} @Post('/options') - async getOptions(req: DynamicNodeParametersRequest.Options): Promise { + async getOptions( + req: AuthenticatedRequest, + _res: Response, + @Body payload: OptionsRequestDto, + ): Promise { const { credentials, currentNodeParameters, @@ -19,7 +28,7 @@ export class DynamicNodeParametersController { path, methodName, loadOptions, - } = req.body; + } = payload; const additionalData = await getBase(req.user.id, currentNodeParameters); @@ -48,7 +57,11 @@ export class DynamicNodeParametersController { } @Post('/resource-locator-results') - async getResourceLocatorResults(req: DynamicNodeParametersRequest.ResourceLocatorResults) { + async getResourceLocatorResults( + req: AuthenticatedRequest, + _res: Response, + @Body payload: ResourceLocatorRequestDto, + ) { const { path, methodName, @@ -57,9 +70,7 @@ export class DynamicNodeParametersController { credentials, currentNodeParameters, nodeTypeAndVersion, - } = req.body; - - if (!methodName) throw new BadRequestError('Missing `methodName` in request body'); + } = payload; const additionalData = await getBase(req.user.id, currentNodeParameters); @@ -76,10 +87,12 @@ export class DynamicNodeParametersController { } @Post('/resource-mapper-fields') - async getResourceMappingFields(req: DynamicNodeParametersRequest.ResourceMapperFields) { - const { path, methodName, credentials, currentNodeParameters, nodeTypeAndVersion } = req.body; - - if (!methodName) throw new BadRequestError('Missing `methodName` in request body'); + async getResourceMappingFields( + req: AuthenticatedRequest, + _res: Response, + @Body payload: ResourceMapperFieldsRequestDto, + ) { + const { path, methodName, credentials, currentNodeParameters, nodeTypeAndVersion } = payload; const additionalData = await getBase(req.user.id, currentNodeParameters); @@ -93,27 +106,49 @@ export class DynamicNodeParametersController { ); } - @Post('/action-result') - async getActionResult( - req: DynamicNodeParametersRequest.ActionResult, - ): Promise { - const { currentNodeParameters, nodeTypeAndVersion, path, credentials, handler, payload } = - req.body; + @Post('/local-resource-mapper-fields') + async getLocalResourceMappingFields( + req: AuthenticatedRequest, + _res: Response, + @Body payload: ResourceMapperFieldsRequestDto, + ) { + const { path, methodName, currentNodeParameters, nodeTypeAndVersion } = payload; const additionalData = await getBase(req.user.id, currentNodeParameters); - if (handler) { - return await this.service.getActionResult( - handler, - path, - additionalData, - nodeTypeAndVersion, - currentNodeParameters, - payload, - credentials, - ); - } + return await this.service.getLocalResourceMappingFields( + methodName, + path, + additionalData, + nodeTypeAndVersion, + ); + } - return; + @Post('/action-result') + async getActionResult( + req: AuthenticatedRequest, + _res: Response, + @Body payload: ActionResultRequestDto, + ): Promise { + const { + currentNodeParameters, + nodeTypeAndVersion, + path, + credentials, + handler, + payload: actionPayload, + } = payload; + + const additionalData = await getBase(req.user.id, currentNodeParameters); + + return await this.service.getActionResult( + handler, + path, + additionalData, + nodeTypeAndVersion, + currentNodeParameters, + actionPayload, + credentials, + ); } } diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index 9c5a1ff36d..c846553d30 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -1,6 +1,7 @@ -import type { PushPayload, PushType } from '@n8n/api-types'; +import type { PushMessage } from '@n8n/api-types'; +import { Container } from '@n8n/di'; import { Request } from 'express'; -import Container from 'typedi'; +import { Logger } from 'n8n-core'; import { v4 as uuid } from 'uuid'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; @@ -14,10 +15,8 @@ import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus' import type { BooleanLicenseFeature, NumericLicenseFeature } from '@/interfaces'; import type { FeatureReturnType } from '@/license'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import { MfaService } from '@/mfa/mfa.service'; import { Push } from '@/push'; -import type { UserSetupPayload } from '@/requests'; import { CacheService } from '@/services/cache/cache.service'; import { PasswordUtility } from '@/services/password.utility'; @@ -48,6 +47,16 @@ const tablesToTruncate = [ 'workflows_tags', ]; +type UserSetupPayload = { + email: string; + password: string; + firstName: string; + lastName: string; + mfaEnabled?: boolean; + mfaSecret?: string; + mfaRecoveryCodes?: string[]; +}; + type ResetRequest = Request< {}, {}, @@ -58,14 +67,12 @@ type ResetRequest = Request< } >; -type PushRequest = Request< +type PushRequest = Request< {}, {}, { - type: T; pushRef: string; - data: PushPayload; - } + } & PushMessage >; @RestController('/e2e') @@ -93,6 +100,7 @@ export class E2EController { [LICENSE_FEATURES.AI_ASSISTANT]: false, [LICENSE_FEATURES.COMMUNITY_NODES_CUSTOM_REGISTRY]: false, [LICENSE_FEATURES.ASK_AI]: false, + [LICENSE_FEATURES.AI_CREDITS]: false, }; private numericFeatures: Record = { @@ -101,6 +109,7 @@ export class E2EController { [LICENSE_QUOTAS.USERS_LIMIT]: -1, [LICENSE_QUOTAS.WORKFLOW_HISTORY_PRUNE_LIMIT]: -1, [LICENSE_QUOTAS.TEAM_PROJECT_LIMIT]: 0, + [LICENSE_QUOTAS.AI_CREDITS]: 0, }; constructor( @@ -144,8 +153,9 @@ export class E2EController { } @Post('/push', { skipAuth: true }) - async pushSend(req: PushRequest) { - this.push.broadcast(req.body.type, req.body.data); + async pushSend(req: PushRequest) { + const { pushRef: _, ...pushMsg } = req.body; + this.push.broadcast(pushMsg); } @Patch('/feature', { skipAuth: true }) diff --git a/packages/cli/src/controllers/invitation.controller.ts b/packages/cli/src/controllers/invitation.controller.ts index cbd2afb9f4..d8a0e503f6 100644 --- a/packages/cli/src/controllers/invitation.controller.ts +++ b/packages/cli/src/controllers/invitation.controller.ts @@ -1,23 +1,23 @@ +import { AcceptInvitationRequestDto, InviteUsersRequestDto } from '@n8n/api-types'; import { Response } from 'express'; -import validator from 'validator'; +import { Logger } from 'n8n-core'; import { AuthService } from '@/auth/auth.service'; import config from '@/config'; import { RESPONSE_ERROR_MESSAGES } from '@/constants'; import type { User } from '@/databases/entities/user'; import { UserRepository } from '@/databases/repositories/user.repository'; -import { Post, GlobalScope, RestController } from '@/decorators'; +import { Post, GlobalScope, RestController, Body, Param } from '@/decorators'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { EventService } from '@/events/event.service'; import { ExternalHooks } from '@/external-hooks'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import { PostHogClient } from '@/posthog'; -import { UserRequest } from '@/requests'; +import { AuthenticatedRequest, AuthlessRequest } from '@/requests'; import { PasswordUtility } from '@/services/password.utility'; import { UserService } from '@/services/user.service'; -import { isSamlLicensedAndEnabled } from '@/sso/saml/saml-helpers'; +import { isSamlLicensedAndEnabled } from '@/sso.ee/saml/saml-helpers'; @RestController('/invitations') export class InvitationController { @@ -39,7 +39,13 @@ export class InvitationController { @Post('/', { rateLimit: { limit: 10 } }) @GlobalScope('user:create') - async inviteUser(req: UserRequest.Invite) { + async inviteUser( + req: AuthenticatedRequest, + _res: Response, + @Body invitations: InviteUsersRequestDto, + ) { + if (invitations.length === 0) return []; + const isWithinUsersLimit = this.license.isWithinUsersLimit(); if (isSamlLicensedAndEnabled()) { @@ -65,50 +71,15 @@ export class InvitationController { throw new BadRequestError('You must set up your own account before inviting others'); } - if (!Array.isArray(req.body)) { - this.logger.debug( - 'Request to send email invite(s) to user(s) failed because the payload is not an array', - { - payload: req.body, - }, - ); - throw new BadRequestError('Invalid payload'); - } - - if (!req.body.length) return []; - - req.body.forEach((invite) => { - if (typeof invite !== 'object' || !invite.email) { - throw new BadRequestError( - 'Request to send email invite(s) to user(s) failed because the payload is not an array shaped Array<{ email: string }>', - ); - } - - if (!validator.isEmail(invite.email)) { - this.logger.debug('Invalid email in payload', { invalidEmail: invite.email }); - throw new BadRequestError( - `Request to send email invite(s) to user(s) failed because of an invalid email address: ${invite.email}`, - ); - } - - 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 'global:member' or 'global:admin'.`, - ); - } - - if (invite.role === 'global:admin' && !this.license.isAdvancedPermissionsLicensed()) { + const attributes = invitations.map(({ email, role }) => { + if (role === 'global:admin' && !this.license.isAdvancedPermissionsLicensed()) { throw new ForbiddenError( 'Cannot invite admin user without advanced permissions. Please upgrade to a license that includes this feature.', ); } + return { email, role }; }); - const attributes = req.body.map(({ email, role }) => ({ - email, - role: role ?? 'global:member', - })); - const { usersInvited, usersCreated } = await this.userService.inviteUsers(req.user, attributes); await this.externalHooks.run('user.invited', [usersCreated]); @@ -120,20 +91,13 @@ export class InvitationController { * Fill out user shell with first name, last name, and password. */ @Post('/:id/accept', { skipAuth: true }) - async acceptInvitation(req: UserRequest.Update, res: Response) { - const { id: inviteeId } = req.params; - - const { inviterId, firstName, lastName, password } = req.body; - - if (!inviterId || !inviteeId || !firstName || !lastName || !password) { - this.logger.debug( - 'Request to fill out a user shell failed because of missing properties in payload', - { payload: req.body }, - ); - throw new BadRequestError('Invalid payload'); - } - - const validPassword = this.passwordUtility.validate(password); + async acceptInvitation( + req: AuthlessRequest, + res: Response, + @Body payload: AcceptInvitationRequestDto, + @Param('id') inviteeId: string, + ) { + const { inviterId, firstName, lastName, password } = payload; const users = await this.userRepository.findManyByIds([inviterId, inviteeId]); @@ -160,7 +124,7 @@ export class InvitationController { invitee.firstName = firstName; invitee.lastName = lastName; - invitee.password = await this.passwordUtility.hash(validPassword); + invitee.password = await this.passwordUtility.hash(password); const updatedUser = await this.userRepository.save(invitee, { transaction: false }); diff --git a/packages/cli/src/controllers/me.controller.ts b/packages/cli/src/controllers/me.controller.ts index a7fb7235fd..bb42d13878 100644 --- a/packages/cli/src/controllers/me.controller.ts +++ b/packages/cli/src/controllers/me.controller.ts @@ -1,10 +1,12 @@ import { + passwordSchema, PasswordUpdateRequestDto, SettingsUpdateRequestDto, UserUpdateRequestDto, } from '@n8n/api-types'; import { plainToInstance } from 'class-transformer'; import { Response } from 'express'; +import { Logger } from 'n8n-core'; import { AuthService } from '@/auth/auth.service'; import type { User } from '@/databases/entities/user'; @@ -16,12 +18,11 @@ import { EventService } from '@/events/event.service'; import { ExternalHooks } from '@/external-hooks'; import { validateEntity } from '@/generic-helpers'; import type { PublicUser } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { MfaService } from '@/mfa/mfa.service'; import { AuthenticatedRequest, MeRequest } from '@/requests'; import { PasswordUtility } from '@/services/password.utility'; import { UserService } from '@/services/user.service'; -import { isSamlLicensedAndEnabled } from '@/sso/saml/saml-helpers'; +import { isSamlLicensedAndEnabled } from '@/sso.ee/saml/saml-helpers'; import { PersonalizationSurveyAnswersV4 } from './survey-answers.dto'; @RestController('/me') @@ -122,10 +123,6 @@ export class MeController { ); } - if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') { - throw new BadRequestError('Invalid payload.'); - } - if (!user.password) { throw new BadRequestError('Requesting user not set up.'); } @@ -135,7 +132,12 @@ export class MeController { throw new BadRequestError('Provided current password is incorrect.'); } - const validPassword = this.passwordUtility.validate(newPassword); + const passwordValidation = passwordSchema.safeParse(newPassword); + if (!passwordValidation.success) { + throw new BadRequestError( + passwordValidation.error.errors.map(({ message }) => message).join(' '), + ); + } if (user.mfaEnabled) { if (typeof mfaCode !== 'string') { @@ -148,7 +150,7 @@ export class MeController { } } - user.password = await this.passwordUtility.hash(validPassword); + user.password = await this.passwordUtility.hash(newPassword); const updatedUser = await this.userRepository.save(user, { transaction: false }); this.logger.info('Password updated successfully', { userId: user.id }); diff --git a/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts b/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts index 2d76642266..570181aa78 100644 --- a/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts +++ b/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts @@ -1,9 +1,10 @@ +import { Container } from '@n8n/di'; import Csrf from 'csrf'; import type { Response } from 'express'; import { mock } from 'jest-mock-extended'; import { Cipher } from 'n8n-core'; +import { Logger } from 'n8n-core'; import nock from 'nock'; -import Container from 'typedi'; import { Time } from '@/constants'; import { OAuth1CredentialController } from '@/controllers/oauth/oauth1-credential.controller'; @@ -12,13 +13,12 @@ import type { CredentialsEntity } from '@/databases/entities/credentials-entity' import type { User } from '@/databases/entities/user'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; -import { VariablesService } from '@/environments/variables/variables.service.ee'; +import { VariablesService } from '@/environments.ee/variables/variables.service.ee'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { ExternalHooks } from '@/external-hooks'; -import { Logger } from '@/logging/logger.service'; import type { OAuthRequest } from '@/requests'; -import { SecretsHelper } from '@/secrets-helpers'; +import { SecretsHelper } from '@/secrets-helpers.ee'; import { mockInstance } from '@test/mocking'; describe('OAuth1CredentialController', () => { diff --git a/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts b/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts index b2bd987fb0..53bba08c58 100644 --- a/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts +++ b/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts @@ -1,9 +1,10 @@ +import { Container } from '@n8n/di'; import Csrf from 'csrf'; import { type Response } from 'express'; import { mock } from 'jest-mock-extended'; import { Cipher } from 'n8n-core'; +import { Logger } from 'n8n-core'; import nock from 'nock'; -import Container from 'typedi'; import { Time } from '@/constants'; import { OAuth2CredentialController } from '@/controllers/oauth/oauth2-credential.controller'; @@ -12,13 +13,12 @@ import type { CredentialsEntity } from '@/databases/entities/credentials-entity' import type { User } from '@/databases/entities/user'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; -import { VariablesService } from '@/environments/variables/variables.service.ee'; +import { VariablesService } from '@/environments.ee/variables/variables.service.ee'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { ExternalHooks } from '@/external-hooks'; -import { Logger } from '@/logging/logger.service'; import type { OAuthRequest } from '@/requests'; -import { SecretsHelper } from '@/secrets-helpers'; +import { SecretsHelper } from '@/secrets-helpers.ee'; import { mockInstance } from '@test/mocking'; describe('OAuth2CredentialController', () => { diff --git a/packages/cli/src/controllers/oauth/abstract-oauth.controller.ts b/packages/cli/src/controllers/oauth/abstract-oauth.controller.ts index 3f5c20dfc3..bac924a023 100644 --- a/packages/cli/src/controllers/oauth/abstract-oauth.controller.ts +++ b/packages/cli/src/controllers/oauth/abstract-oauth.controller.ts @@ -1,10 +1,10 @@ import { GlobalConfig } from '@n8n/config'; +import { Service } from '@n8n/di'; import Csrf from 'csrf'; import type { Response } from 'express'; -import { Credentials } from 'n8n-core'; +import { Credentials, Logger } from 'n8n-core'; import type { ICredentialDataDecryptedObject, IWorkflowExecuteAdditionalData } from 'n8n-workflow'; import { jsonParse, ApplicationError } from 'n8n-workflow'; -import { Service } from 'typedi'; import { RESPONSE_ERROR_MESSAGES, Time } from '@/constants'; import { CredentialsHelper } from '@/credentials-helper'; @@ -16,7 +16,6 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { ExternalHooks } from '@/external-hooks'; import type { ICredentialsDb } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import type { AuthenticatedRequest, OAuthRequest } from '@/requests'; import { UrlService } from '@/services/url.service'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; diff --git a/packages/cli/src/controllers/owner.controller.ts b/packages/cli/src/controllers/owner.controller.ts index 47d50ad3f0..5c7e8d1e2a 100644 --- a/packages/cli/src/controllers/owner.controller.ts +++ b/packages/cli/src/controllers/owner.controller.ts @@ -1,17 +1,17 @@ +import { DismissBannerRequestDto, OwnerSetupRequestDto } from '@n8n/api-types'; import { Response } from 'express'; -import validator from 'validator'; +import { Logger } from 'n8n-core'; import { AuthService } from '@/auth/auth.service'; import config from '@/config'; import { SettingsRepository } from '@/databases/repositories/settings.repository'; import { UserRepository } from '@/databases/repositories/user.repository'; -import { GlobalScope, Post, RestController } from '@/decorators'; +import { Body, GlobalScope, Post, RestController } from '@/decorators'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { EventService } from '@/events/event.service'; import { validateEntity } from '@/generic-helpers'; -import { Logger } from '@/logging/logger.service'; import { PostHogClient } from '@/posthog'; -import { OwnerRequest } from '@/requests'; +import { AuthenticatedRequest } from '@/requests'; import { PasswordUtility } from '@/services/password.utility'; import { UserService } from '@/services/user.service'; @@ -33,8 +33,8 @@ export class OwnerController { * and enable `isInstanceOwnerSetUp` setting. */ @Post('/setup', { skipAuth: true }) - async setupOwner(req: OwnerRequest.Post, res: Response) { - const { email, firstName, lastName, password } = req.body; + async setupOwner(req: AuthenticatedRequest, res: Response, @Body payload: OwnerSetupRequestDto) { + const { email, firstName, lastName, password } = payload; if (config.getEnv('userManagement.isInstanceOwnerSetUp')) { this.logger.debug( @@ -43,31 +43,15 @@ export class OwnerController { throw new BadRequestError('Instance owner already setup'); } - if (!email || !validator.isEmail(email)) { - this.logger.debug('Request to claim instance ownership failed because of invalid email', { - invalidEmail: email, - }); - throw new BadRequestError('Invalid email address'); - } - - const validPassword = this.passwordUtility.validate(password); - - if (!firstName || !lastName) { - this.logger.debug( - 'Request to claim instance ownership failed because of missing first name or last name in payload', - { payload: req.body }, - ); - throw new BadRequestError('First and last names are mandatory'); - } - let owner = await this.userRepository.findOneOrFail({ where: { role: 'global:owner' }, }); owner.email = email; owner.firstName = firstName; owner.lastName = lastName; - owner.password = await this.passwordUtility.hash(validPassword); + owner.password = await this.passwordUtility.hash(password); + // TODO: move XSS validation out into the DTO class await validateEntity(owner); owner = await this.userRepository.save(owner, { transaction: false }); @@ -92,8 +76,13 @@ export class OwnerController { @Post('/dismiss-banner') @GlobalScope('banner:dismiss') - async dismissBanner(req: OwnerRequest.DismissBanner) { - const bannerName = 'banner' in req.body ? (req.body.banner as string) : ''; + async dismissBanner( + _req: AuthenticatedRequest, + _res: Response, + @Body payload: DismissBannerRequestDto, + ) { + const bannerName = payload.banner; + if (!bannerName) return; return await this.settingsRepository.dismissBanner({ bannerName }); } } diff --git a/packages/cli/src/controllers/password-reset.controller.ts b/packages/cli/src/controllers/password-reset.controller.ts index 2179ff3d9e..c2652aa785 100644 --- a/packages/cli/src/controllers/password-reset.controller.ts +++ b/packages/cli/src/controllers/password-reset.controller.ts @@ -1,10 +1,15 @@ +import { + ChangePasswordRequestDto, + ForgotPasswordRequestDto, + ResolvePasswordTokenQueryDto, +} from '@n8n/api-types'; import { Response } from 'express'; -import validator from 'validator'; +import { Logger } from 'n8n-core'; import { AuthService } from '@/auth/auth.service'; import { RESPONSE_ERROR_MESSAGES } from '@/constants'; import { UserRepository } from '@/databases/repositories/user.repository'; -import { Get, Post, RestController } from '@/decorators'; +import { Body, Get, Post, Query, RestController } from '@/decorators'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; @@ -13,12 +18,11 @@ import { UnprocessableRequestError } from '@/errors/response-errors/unprocessabl import { EventService } from '@/events/event.service'; import { ExternalHooks } from '@/external-hooks'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import { MfaService } from '@/mfa/mfa.service'; -import { PasswordResetRequest } from '@/requests'; +import { AuthlessRequest } from '@/requests'; import { PasswordUtility } from '@/services/password.utility'; import { UserService } from '@/services/user.service'; -import { isSamlCurrentAuthenticationMethod } from '@/sso/sso-helpers'; +import { isSamlCurrentAuthenticationMethod } from '@/sso.ee/sso-helpers'; import { UserManagementMailer } from '@/user-management/email'; @RestController() @@ -40,7 +44,11 @@ export class PasswordResetController { * Send a password reset email. */ @Post('/forgot-password', { skipAuth: true, rateLimit: { limit: 3 } }) - async forgotPassword(req: PasswordResetRequest.Email) { + async forgotPassword( + _req: AuthlessRequest, + _res: Response, + @Body payload: ForgotPasswordRequestDto, + ) { if (!this.mailer.isEmailSetUp) { this.logger.debug( 'Request to send password reset email failed because emailing was not set up', @@ -50,22 +58,7 @@ export class PasswordResetController { ); } - const { email } = req.body; - if (!email) { - this.logger.debug( - 'Request to send password reset email failed because of missing email in payload', - { payload: req.body }, - ); - throw new BadRequestError('Email is mandatory'); - } - - if (!validator.isEmail(email)) { - this.logger.debug( - 'Request to send password reset email failed because of invalid email in payload', - { invalidEmail: email }, - ); - throw new BadRequestError('Invalid email address'); - } + const { email } = payload; // User should just be able to reset password if one is already present const user = await this.userRepository.findNonShellUser(email); @@ -138,19 +131,12 @@ export class PasswordResetController { * Verify password reset token and user ID. */ @Get('/resolve-password-token', { skipAuth: true }) - async resolvePasswordToken(req: PasswordResetRequest.Credentials) { - const { token } = req.query; - - if (!token) { - this.logger.debug( - 'Request to resolve password token failed because of missing password reset token', - { - queryString: req.query, - }, - ); - throw new BadRequestError(''); - } - + async resolvePasswordToken( + _req: AuthlessRequest, + _res: Response, + @Query payload: ResolvePasswordTokenQueryDto, + ) { + const { token } = payload; const user = await this.authService.resolvePasswordResetToken(token); if (!user) throw new NotFoundError(''); @@ -170,20 +156,12 @@ export class PasswordResetController { * Verify password reset token and update password. */ @Post('/change-password', { skipAuth: true }) - async changePassword(req: PasswordResetRequest.NewPassword, res: Response) { - const { token, password, mfaCode } = req.body; - - if (!token || !password) { - this.logger.debug( - 'Request to change password failed because of missing user ID or password or reset password token in payload', - { - payload: req.body, - }, - ); - throw new BadRequestError('Missing user ID or password or reset password token'); - } - - const validPassword = this.passwordUtility.validate(password); + async changePassword( + req: AuthlessRequest, + res: Response, + @Body payload: ChangePasswordRequestDto, + ) { + const { token, password, mfaCode } = payload; const user = await this.authService.resolvePasswordResetToken(token); if (!user) throw new NotFoundError(''); @@ -198,7 +176,7 @@ export class PasswordResetController { if (!validToken) throw new BadRequestError('Invalid MFA token.'); } - const passwordHash = await this.passwordUtility.hash(validPassword); + const passwordHash = await this.passwordUtility.hash(password); await this.userService.update(user.id, { password: passwordHash }); diff --git a/packages/cli/src/controllers/project.controller.ts b/packages/cli/src/controllers/project.controller.ts index 415e0e9519..ab48f38c5b 100644 --- a/packages/cli/src/controllers/project.controller.ts +++ b/packages/cli/src/controllers/project.controller.ts @@ -23,7 +23,7 @@ import { ProjectService, TeamProjectOverQuotaError, UnlicensedProjectRoleError, -} from '@/services/project.service'; +} from '@/services/project.service.ee'; import { RoleService } from '@/services/role.service'; @RestController('/projects') @@ -51,7 +51,12 @@ export class ProjectController { @Licensed('feat:projectRole:admin') async createProject(req: ProjectRequest.Create) { try { - const project = await this.projectsService.createTeamProject(req.body.name, req.user); + const project = await this.projectsService.createTeamProject( + req.body.name, + req.user, + undefined, + req.body.icon, + ); this.eventService.emit('team-project-created', { userId: req.user.id, @@ -163,7 +168,7 @@ export class ProjectController { @Get('/:projectId') @ProjectScope('project:read') async getProject(req: ProjectRequest.Get): Promise { - const [{ id, name, type }, relations] = await Promise.all([ + const [{ id, name, icon, type }, relations] = await Promise.all([ this.projectsService.getProject(req.params.projectId), this.projectsService.getProjectRelations(req.params.projectId), ]); @@ -172,6 +177,7 @@ export class ProjectController { return { id, name, + icon, type, relations: relations.map((r) => ({ id: r.user.id, @@ -193,7 +199,7 @@ export class ProjectController { @ProjectScope('project:update') async updateProject(req: ProjectRequest.Update) { if (req.body.name) { - await this.projectsService.updateProject(req.body.name, req.params.projectId); + await this.projectsService.updateProject(req.body.name, req.params.projectId, req.body.icon); } if (req.body.relations) { try { diff --git a/packages/cli/src/controllers/users.controller.ts b/packages/cli/src/controllers/users.controller.ts index 8e19be894d..3177c2c23b 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -1,5 +1,6 @@ import { RoleChangeRequestDto, SettingsUpdateRequestDto } from '@n8n/api-types'; import { Response } from 'express'; +import { Logger } from 'n8n-core'; import { AuthService } from '@/auth/auth.service'; import { CredentialsService } from '@/credentials/credentials.service'; @@ -10,18 +11,25 @@ import { ProjectRepository } from '@/databases/repositories/project.repository'; import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; import { UserRepository } from '@/databases/repositories/user.repository'; -import { GlobalScope, Delete, Get, RestController, Patch, Licensed, Body } from '@/decorators'; -import { Param } from '@/decorators/args'; +import { + GlobalScope, + Delete, + Get, + RestController, + Patch, + Licensed, + Body, + Param, +} from '@/decorators'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { EventService } from '@/events/event.service'; import { ExternalHooks } from '@/external-hooks'; import type { PublicUser } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { listQueryMiddleware } from '@/middlewares'; import { AuthenticatedRequest, ListQuery, UserRequest } from '@/requests'; -import { ProjectService } from '@/services/project.service'; +import { ProjectService } from '@/services/project.service.ee'; import { UserService } from '@/services/user.service'; import { WorkflowService } from '@/workflows/workflow.service'; diff --git a/packages/cli/src/controllers/workflow-statistics.controller.ts b/packages/cli/src/controllers/workflow-statistics.controller.ts index 58c99727db..b14afc9179 100644 --- a/packages/cli/src/controllers/workflow-statistics.controller.ts +++ b/packages/cli/src/controllers/workflow-statistics.controller.ts @@ -1,4 +1,5 @@ import { Response, NextFunction } from 'express'; +import { Logger } from 'n8n-core'; import type { WorkflowStatistics } from '@/databases/entities/workflow-statistics'; import { StatisticsNames } from '@/databases/entities/workflow-statistics'; @@ -7,7 +8,6 @@ import { WorkflowStatisticsRepository } from '@/databases/repositories/workflow- import { Get, Middleware, RestController } from '@/decorators'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import type { IWorkflowStatisticsDataLoaded } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { StatisticsRequest } from './workflow-statistics.types'; diff --git a/packages/cli/src/crash-journal.ts b/packages/cli/src/crash-journal.ts index 577a2f34fe..93f7b2e737 100644 --- a/packages/cli/src/crash-journal.ts +++ b/packages/cli/src/crash-journal.ts @@ -1,12 +1,11 @@ +import { Container } from '@n8n/di'; import { existsSync } from 'fs'; import { mkdir, utimes, open, rm } from 'fs/promises'; -import { InstanceSettings } from 'n8n-core'; +import { InstanceSettings, Logger } from 'n8n-core'; import { sleep } from 'n8n-workflow'; import { join, dirname } from 'path'; -import { Container } from 'typedi'; import { inProduction } from '@/constants'; -import { Logger } from '@/logging/logger.service'; export const touchFile = async (filePath: string): Promise => { await mkdir(dirname(filePath), { recursive: true }); diff --git a/packages/cli/src/credential-types.ts b/packages/cli/src/credential-types.ts index a6d3f29eb0..5720b7cdbe 100644 --- a/packages/cli/src/credential-types.ts +++ b/packages/cli/src/credential-types.ts @@ -1,5 +1,5 @@ +import { Service } from '@n8n/di'; import type { ICredentialType, ICredentialTypes } from 'n8n-workflow'; -import { Service } from 'typedi'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; diff --git a/packages/cli/src/credentials-helper.ts b/packages/cli/src/credentials-helper.ts index 967ee9efe4..4c869222b5 100644 --- a/packages/cli/src/credentials-helper.ts +++ b/packages/cli/src/credentials-helper.ts @@ -2,6 +2,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-return */ +import { Service } from '@n8n/di'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In } from '@n8n/typeorm'; import { Credentials, getAdditionalKeys } from 'n8n-core'; @@ -26,7 +27,6 @@ import type { IDataObject, } from 'n8n-workflow'; import { ICredentialsHelper, NodeHelpers, Workflow, ApplicationError } from 'n8n-workflow'; -import { Service } from 'typedi'; import { CredentialTypes } from '@/credential-types'; import { CredentialsOverwrites } from '@/credentials-overwrites'; diff --git a/packages/cli/src/credentials-overwrites.ts b/packages/cli/src/credentials-overwrites.ts index ed1b492dc6..6689649b0f 100644 --- a/packages/cli/src/credentials-overwrites.ts +++ b/packages/cli/src/credentials-overwrites.ts @@ -1,11 +1,11 @@ import { GlobalConfig } from '@n8n/config'; +import { Service } from '@n8n/di'; +import { Logger } from 'n8n-core'; import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; import { deepCopy, jsonParse } from 'n8n-workflow'; -import { Service } from 'typedi'; import { CredentialTypes } from '@/credential-types'; import type { ICredentialsOverwrite } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; @Service() export class CredentialsOverwrites { diff --git a/packages/cli/src/credentials/__tests__/credentials.controller.test.ts b/packages/cli/src/credentials/__tests__/credentials.controller.test.ts new file mode 100644 index 0000000000..13e72e8003 --- /dev/null +++ b/packages/cli/src/credentials/__tests__/credentials.controller.test.ts @@ -0,0 +1,82 @@ +import { mock } from 'jest-mock-extended'; + +import { createRawProjectData } from '@/__tests__/project.test-data'; +import type { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; +import type { EventService } from '@/events/event.service'; +import type { AuthenticatedRequest } from '@/requests'; + +import { createdCredentialsWithScopes, createNewCredentialsPayload } from './credentials.test-data'; +import { CredentialsController } from '../credentials.controller'; +import type { CredentialsService } from '../credentials.service'; + +describe('CredentialsController', () => { + const eventService = mock(); + const credentialsService = mock(); + const sharedCredentialsRepository = mock(); + + const credentialsController = new CredentialsController( + mock(), + credentialsService, + mock(), + mock(), + mock(), + mock(), + mock(), + sharedCredentialsRepository, + mock(), + eventService, + ); + + let req: AuthenticatedRequest; + beforeAll(() => { + req = { user: { id: '123' } } as AuthenticatedRequest; + }); + + describe('createCredentials', () => { + it('it should create new credentials and emit "credentials-created"', async () => { + // Arrange + + const newCredentialsPayload = createNewCredentialsPayload(); + + req.body = newCredentialsPayload; + + const { data, ...payloadWithoutData } = newCredentialsPayload; + + const createdCredentials = createdCredentialsWithScopes(payloadWithoutData); + + const projectOwningCredentialData = createRawProjectData({ + id: newCredentialsPayload.projectId, + }); + + credentialsService.createCredential.mockResolvedValue(createdCredentials); + + sharedCredentialsRepository.findCredentialOwningProject.mockResolvedValue( + projectOwningCredentialData, + ); + + // Act + + const newApiKey = await credentialsController.createCredentials(req); + + // Assert + + expect(credentialsService.createCredential).toHaveBeenCalledWith( + newCredentialsPayload, + req.user, + ); + expect(sharedCredentialsRepository.findCredentialOwningProject).toHaveBeenCalledWith( + createdCredentials.id, + ); + expect(eventService.emit).toHaveBeenCalledWith('credentials-created', { + user: expect.objectContaining({ id: req.user.id }), + credentialId: createdCredentials.id, + credentialType: createdCredentials.type, + projectId: projectOwningCredentialData.id, + projectType: projectOwningCredentialData.type, + publicApi: false, + }); + + expect(newApiKey).toEqual(createdCredentials); + }); + }); +}); diff --git a/packages/cli/src/credentials/__tests__/credentials.service.test.ts b/packages/cli/src/credentials/__tests__/credentials.service.test.ts index 4d1cbd5256..526acfc17d 100644 --- a/packages/cli/src/credentials/__tests__/credentials.service.test.ts +++ b/packages/cli/src/credentials/__tests__/credentials.service.test.ts @@ -1,10 +1,17 @@ import { mock } from 'jest-mock-extended'; +import { nanoId, date } from 'minifaker'; +import { Credentials } from 'n8n-core'; import { CREDENTIAL_EMPTY_VALUE, type ICredentialType } from 'n8n-workflow'; import { CREDENTIAL_BLANKING_VALUE } from '@/constants'; import type { CredentialTypes } from '@/credential-types'; import { CredentialsService } from '@/credentials/credentials.service'; import type { CredentialsEntity } from '@/databases/entities/credentials-entity'; +import type { AuthenticatedRequest } from '@/requests'; + +import { createNewCredentialsPayload, credentialScopes } from './credentials.test-data'; + +let req = { user: { id: '123' } } as AuthenticatedRequest; describe('CredentialsService', () => { const credType = mock({ @@ -24,6 +31,7 @@ describe('CredentialsService', () => { }, ], }); + const credentialTypes = mock(); const service = new CredentialsService( mock(), @@ -55,7 +63,7 @@ describe('CredentialsService', () => { csrfSecret: 'super-secret', }; - credentialTypes.getByName.calledWith(credential.type).mockReturnValue(credType); + credentialTypes.getByName.calledWith(credential.type).mockReturnValueOnce(credType); const redactedData = service.redact(decryptedData, credential); @@ -68,4 +76,123 @@ describe('CredentialsService', () => { }); }); }); + + describe('createCredential', () => { + it('it should create new credentials and return with scopes', async () => { + // Arrange + + const encryptedData = 'encryptedData'; + + const newCredentialPayloadData = createNewCredentialsPayload(); + + const newCredential = mock({ + name: newCredentialPayloadData.name, + data: JSON.stringify(newCredentialPayloadData.data), + type: newCredentialPayloadData.type, + }); + + const encryptedDataResponse = { + name: newCredentialPayloadData.name, + type: newCredentialPayloadData.type, + updatedAt: date(), + data: encryptedData, + }; + + const saveCredentialsResponse = { + id: nanoId.nanoid(), + name: newCredentialPayloadData.name, + type: newCredentialPayloadData.type, + updatedAt: encryptedDataResponse.updatedAt, + createdAt: date(), + data: encryptedDataResponse.data, + isManaged: false, + shared: undefined, + }; + + service.prepareCreateData = jest.fn().mockReturnValue(newCredential); + service.createEncryptedData = jest.fn().mockImplementation(() => encryptedDataResponse); + service.save = jest.fn().mockResolvedValue(saveCredentialsResponse); + service.getCredentialScopes = jest.fn().mockReturnValue(credentialScopes); + + // Act + + const createdCredential = await service.createCredential(newCredentialPayloadData, req.user); + + // Assert + + expect(service.prepareCreateData).toHaveBeenCalledWith(newCredentialPayloadData); + expect(service.createEncryptedData).toHaveBeenCalledWith(null, newCredential); + expect(service.save).toHaveBeenCalledWith( + newCredential, + encryptedDataResponse, + req.user, + newCredentialPayloadData.projectId, + ); + expect(service.getCredentialScopes).toHaveBeenCalledWith( + req.user, + saveCredentialsResponse.id, + ); + + expect(createdCredential).toEqual({ + ...saveCredentialsResponse, + scopes: credentialScopes, + }); + }); + }); + + describe('decrypt', () => { + it('should redact sensitive values by default', () => { + // ARRANGE + const data = { + clientId: 'abc123', + clientSecret: 'sensitiveSecret', + accessToken: '', + oauthTokenData: 'super-secret', + csrfSecret: 'super-secret', + }; + const credential = mock({ + id: '123', + name: 'Test Credential', + type: 'oauth2', + }); + jest.spyOn(Credentials.prototype, 'getData').mockReturnValueOnce(data); + credentialTypes.getByName.calledWith(credential.type).mockReturnValueOnce(credType); + + // ACT + const redactedData = service.decrypt(credential); + + // ASSERT + expect(redactedData).toEqual({ + clientId: 'abc123', + clientSecret: CREDENTIAL_BLANKING_VALUE, + accessToken: CREDENTIAL_EMPTY_VALUE, + oauthTokenData: CREDENTIAL_BLANKING_VALUE, + csrfSecret: CREDENTIAL_BLANKING_VALUE, + }); + }); + + it('should return sensitive values if `includeRawData` is true', () => { + // ARRANGE + const data = { + clientId: 'abc123', + clientSecret: 'sensitiveSecret', + accessToken: '', + oauthTokenData: 'super-secret', + csrfSecret: 'super-secret', + }; + const credential = mock({ + id: '123', + name: 'Test Credential', + type: 'oauth2', + }); + jest.spyOn(Credentials.prototype, 'getData').mockReturnValueOnce(data); + credentialTypes.getByName.calledWith(credential.type).mockReturnValueOnce(credType); + + // ACT + const redactedData = service.decrypt(credential, true); + + // ASSERT + expect(redactedData).toEqual(data); + }); + }); }); diff --git a/packages/cli/src/credentials/__tests__/credentials.test-data.ts b/packages/cli/src/credentials/__tests__/credentials.test-data.ts new file mode 100644 index 0000000000..8bbbbf3553 --- /dev/null +++ b/packages/cli/src/credentials/__tests__/credentials.test-data.ts @@ -0,0 +1,62 @@ +import type { Scope } from '@n8n/permissions'; +import { nanoId, date } from 'minifaker'; +import { randomString } from 'n8n-workflow'; + +import type { CredentialRequest } from '@/requests'; + +type NewCredentialWithSCopes = { + scopes: Scope[]; + name: string; + data: string; + type: string; + isManaged: boolean; + id: string; + createdAt: Date; + updatedAt: Date; +}; + +const name = 'new Credential'; +const type = 'openAiApi'; +const data = { + apiKey: 'apiKey', + url: 'url', +}; +const projectId = nanoId.nanoid(); + +export const credentialScopes: Scope[] = [ + 'credential:create', + 'credential:delete', + 'credential:list', + 'credential:move', + 'credential:read', + 'credential:share', + 'credential:update', +]; + +export const createNewCredentialsPayload = ( + payload?: Partial, +): CredentialRequest.CredentialProperties => { + return { + name, + type, + data, + projectId, + ...payload, + }; +}; + +export const createdCredentialsWithScopes = ( + payload?: Partial, +): NewCredentialWithSCopes => { + return { + name, + type, + data: randomString(20), + id: nanoId.nanoid(), + createdAt: date(), + updatedAt: date(), + isManaged: false, + scopes: credentialScopes, + ...payload, + }; +}; diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index 76db501cf7..4cc0b500f2 100644 --- a/packages/cli/src/credentials/credentials.controller.ts +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -1,6 +1,8 @@ +import { CredentialsGetManyRequestQuery, CredentialsGetOneRequestQuery } from '@n8n/api-types'; import { GlobalConfig } from '@n8n/config'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In } from '@n8n/typeorm'; +import { Logger } from 'n8n-core'; import { deepCopy } from 'n8n-workflow'; import { z } from 'zod'; @@ -18,12 +20,12 @@ import { RestController, ProjectScope, } from '@/decorators'; +import { Param, Query } from '@/decorators/args'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { EventService } from '@/events/event.service'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import { listQueryMiddleware } from '@/middlewares'; import { CredentialRequest } from '@/requests'; import { NamingService } from '@/services/naming.service'; @@ -49,10 +51,15 @@ export class CredentialsController { ) {} @Get('/', { middlewares: listQueryMiddleware }) - async getMany(req: CredentialRequest.GetMany) { + async getMany( + req: CredentialRequest.GetMany, + _res: unknown, + @Query query: CredentialsGetManyRequestQuery, + ) { const credentials = await this.credentialsService.getMany(req.user, { listQueryOptions: req.listQueryOptions, - includeScopes: req.query.includeScopes, + includeScopes: query.includeScopes, + includeData: query.includeData, }); credentials.forEach((c) => { // @ts-expect-error: This is to emulate the old behavior of removing the shared @@ -82,21 +89,22 @@ export class CredentialsController { @Get('/:credentialId') @ProjectScope('credential:read') - async getOne(req: CredentialRequest.Get) { + async getOne( + req: CredentialRequest.Get, + _res: unknown, + @Param('credentialId') credentialId: string, + @Query query: CredentialsGetOneRequestQuery, + ) { const { shared, ...credential } = this.license.isSharingEnabled() ? await this.enterpriseCredentialsService.getOne( req.user, - req.params.credentialId, + credentialId, // TODO: editor-ui is always sending this, maybe we can just rely on the // the scopes and always decrypt the data if the user has the permissions // to do so. - req.query.includeData === 'true', + query.includeData, ) - : await this.credentialsService.getOne( - req.user, - req.params.credentialId, - req.query.includeData === 'true', - ); + : await this.credentialsService.getOne(req.user, credentialId, query.includeData); const scopes = await this.credentialsService.getCredentialScopes( req.user, @@ -147,32 +155,22 @@ export class CredentialsController { @Post('/') async createCredentials(req: CredentialRequest.Create) { - const newCredential = await this.credentialsService.prepareCreateData(req.body); - - const encryptedData = this.credentialsService.createEncryptedData(null, newCredential); - const { shared, ...credential } = await this.credentialsService.save( - newCredential, - encryptedData, - req.user, - req.body.projectId, - ); + const newCredential = await this.credentialsService.createCredential(req.body, req.user); const project = await this.sharedCredentialsRepository.findCredentialOwningProject( - credential.id, + newCredential.id, ); this.eventService.emit('credentials-created', { user: req.user, - credentialType: credential.type, - credentialId: credential.id, + credentialType: newCredential.type, + credentialId: newCredential.id, publicApi: false, projectId: project?.id, projectType: project?.type, }); - const scopes = await this.credentialsService.getCredentialScopes(req.user, credential.id); - - return { ...credential, scopes }; + return newCredential; } @Patch('/:credentialId') @@ -196,6 +194,10 @@ export class CredentialsController { ); } + if (credential.isManaged) { + throw new BadRequestError('Managed credentials cannot be updated'); + } + const decryptedData = this.credentialsService.decrypt(credential); const preparedCredentialData = await this.credentialsService.prepareUpdateData( req.body, diff --git a/packages/cli/src/credentials/credentials.service.ee.ts b/packages/cli/src/credentials/credentials.service.ee.ts index aad78fe7b7..c53110666b 100644 --- a/packages/cli/src/credentials/credentials.service.ee.ts +++ b/packages/cli/src/credentials/credentials.service.ee.ts @@ -1,7 +1,7 @@ +import { Service } from '@n8n/di'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In, type EntityManager } from '@n8n/typeorm'; import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; -import { Service } from 'typedi'; import type { CredentialsEntity } from '@/databases/entities/credentials-entity'; import { Project } from '@/databases/entities/project'; @@ -11,7 +11,7 @@ import { SharedCredentialsRepository } from '@/databases/repositories/shared-cre import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { TransferCredentialError } from '@/errors/response-errors/transfer-credential.error'; import { OwnershipService } from '@/services/ownership.service'; -import { ProjectService } from '@/services/project.service'; +import { ProjectService } from '@/services/project.service.ee'; import { RoleService } from '@/services/role.service'; import { CredentialsService } from './credentials.service'; @@ -87,10 +87,7 @@ export class EnterpriseCredentialsService { if (credential) { // Decrypt the data if we found the credential with the `credential:update` // scope. - decryptedData = this.credentialsService.redact( - this.credentialsService.decrypt(credential), - credential, - ); + decryptedData = this.credentialsService.decrypt(credential); } else { // Otherwise try to find them with only the `credential:read` scope. In // that case we return them without the decrypted data. diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index f9bbf89e57..0761a4ad48 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -1,3 +1,4 @@ +import { Service } from '@n8n/di'; import type { Scope } from '@n8n/permissions'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { @@ -6,7 +7,7 @@ import { type FindOptionsRelations, type FindOptionsWhere, } from '@n8n/typeorm'; -import { Credentials } from 'n8n-core'; +import { Credentials, Logger } from 'n8n-core'; import type { ICredentialDataDecryptedObject, ICredentialsDecrypted, @@ -14,7 +15,6 @@ import type { INodeProperties, } from 'n8n-workflow'; import { ApplicationError, CREDENTIAL_EMPTY_VALUE, deepCopy, NodeHelpers } from 'n8n-workflow'; -import { Service } from 'typedi'; import { CREDENTIAL_BLANKING_VALUE } from '@/constants'; import { CredentialTypes } from '@/credential-types'; @@ -33,12 +33,12 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { ExternalHooks } from '@/external-hooks'; import { validateEntity } from '@/generic-helpers'; import type { ICredentialsDb } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; -import { userHasScopes } from '@/permissions/check-access'; +import { userHasScopes } from '@/permissions.ee/check-access'; import type { CredentialRequest, ListQuery } from '@/requests'; import { CredentialsTester } from '@/services/credentials-tester.service'; import { OwnershipService } from '@/services/ownership.service'; -import { ProjectService } from '@/services/project.service'; +import { ProjectService } from '@/services/project.service.ee'; +import type { ScopesField } from '@/services/role.service'; import { RoleService } from '@/services/role.service'; export type CredentialsGetSharedOptions = @@ -63,33 +63,47 @@ export class CredentialsService { async getMany( user: User, - options: { - listQueryOptions?: ListQuery.Options; - includeScopes?: string; + { + listQueryOptions = {}, + includeScopes = false, + includeData = false, + }: { + listQueryOptions?: ListQuery.Options & { includeData?: boolean }; + includeScopes?: boolean; + includeData?: boolean; } = {}, ) { const returnAll = user.hasGlobalScope('credential:list'); - const isDefaultSelect = !options.listQueryOptions?.select; + const isDefaultSelect = !listQueryOptions.select; + + if (includeData) { + // We need the scopes to check if we're allowed to include the decrypted + // data. + // Only if the user has the `credential:update` scope the user is allowed + // to get the data. + includeScopes = true; + listQueryOptions.includeData = true; + } let projectRelations: ProjectRelation[] | undefined = undefined; - if (options.includeScopes) { + if (includeScopes) { projectRelations = await this.projectService.getProjectRelationsForUser(user); - if (options.listQueryOptions?.filter?.projectId && user.hasGlobalScope('credential:list')) { + if (listQueryOptions.filter?.projectId && user.hasGlobalScope('credential:list')) { // Only instance owners and admins have the credential:list scope // Those users should be able to use _all_ credentials within their workflows. // TODO: Change this so we filter by `workflowId` in this case. Require a slight FE change const projectRelation = projectRelations.find( - (relation) => relation.projectId === options.listQueryOptions?.filter?.projectId, + (relation) => relation.projectId === listQueryOptions.filter?.projectId, ); if (projectRelation?.role === 'project:personalOwner') { // Will not affect team projects as these have admins, not owners. - delete options.listQueryOptions?.filter?.projectId; + delete listQueryOptions.filter?.projectId; } } } if (returnAll) { - let credentials = await this.credentialsRepository.findMany(options.listQueryOptions); + let credentials = await this.credentialsRepository.findMany(listQueryOptions); if (isDefaultSelect) { // Since we're filtering using project ID as part of the relation, @@ -97,7 +111,7 @@ export class CredentialsService { // it's shared to a project, it won't be able to find the home project. // To solve this, we have to get all the relation now, even though // we're deleting them later. - if ((options.listQueryOptions?.filter?.shared as { projectId?: string })?.projectId) { + if ((listQueryOptions.filter?.shared as { projectId?: string })?.projectId) { const relations = await this.sharedCredentialsRepository.getAllRelationsForCredentials( credentials.map((c) => c.id), ); @@ -108,23 +122,32 @@ export class CredentialsService { credentials = credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c)); } - if (options.includeScopes) { + if (includeScopes) { credentials = credentials.map((c) => this.roleService.addScopes(c, user, projectRelations!), ); } + if (includeData) { + credentials = credentials.map((c: CredentialsEntity & ScopesField) => { + return { + ...c, + data: c.scopes.includes('credential:update') ? this.decrypt(c) : undefined, + } as unknown as CredentialsEntity; + }); + } + return credentials; } - // If the workflow is part of a personal project we want to show the credentials the user making the request has access to, not the credentials the user owning the workflow has access to. - if (typeof options.listQueryOptions?.filter?.projectId === 'string') { - const project = await this.projectService.getProject( - options.listQueryOptions.filter.projectId, - ); + // If the workflow is part of a personal project we want to show the + // credentials the user making the request has access to, not the + // credentials the user owning the workflow has access to. + if (typeof listQueryOptions.filter?.projectId === 'string') { + const project = await this.projectService.getProject(listQueryOptions.filter.projectId); if (project?.type === 'personal') { const currentUsersPersonalProject = await this.projectService.getPersonalProject(user); - options.listQueryOptions.filter.projectId = currentUsersPersonalProject?.id; + listQueryOptions.filter.projectId = currentUsersPersonalProject?.id; } } @@ -133,7 +156,7 @@ export class CredentialsService { }); let credentials = await this.credentialsRepository.findMany( - options.listQueryOptions, + listQueryOptions, ids, // only accessible credentials ); @@ -143,7 +166,7 @@ export class CredentialsService { // it's shared to a project, it won't be able to find the home project. // To solve this, we have to get all the relation now, even though // we're deleting them later. - if ((options.listQueryOptions?.filter?.shared as { projectId?: string })?.projectId) { + if ((listQueryOptions.filter?.shared as { projectId?: string })?.projectId) { const relations = await this.sharedCredentialsRepository.getAllRelationsForCredentials( credentials.map((c) => c.id), ); @@ -155,10 +178,19 @@ export class CredentialsService { credentials = credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c)); } - if (options.includeScopes) { + if (includeScopes) { credentials = credentials.map((c) => this.roleService.addScopes(c, user, projectRelations!)); } + if (includeData) { + credentials = credentials.map((c: CredentialsEntity & ScopesField) => { + return { + ...c, + data: c.scopes.includes('credential:update') ? this.decrypt(c) : undefined, + } as unknown as CredentialsEntity; + }); + } + return credentials; } @@ -197,6 +229,7 @@ export class CredentialsService { name: c.name, type: c.type, scopes: c.scopes, + isManaged: c.isManaged, })); } @@ -308,9 +341,18 @@ export class CredentialsService { return newCredentialData; } - decrypt(credential: CredentialsEntity) { + /** + * Decrypts the credentials data and redacts the content by default. + * + * If `includeRawData` is set to true it will not redact the data. + */ + decrypt(credential: CredentialsEntity, includeRawData = false) { const coreCredential = createCredentialsFromCredentialsEntity(credential); - return coreCredential.getData(); + const data = coreCredential.getData(); + if (includeRawData) { + return data; + } + return this.redact(data, credential); } async update(credentialId: string, newCredentialData: ICredentialsDb) { @@ -500,7 +542,7 @@ export class CredentialsService { if (sharing) { // Decrypt the data if we found the credential with the `credential:update` // scope. - decryptedData = this.redact(this.decrypt(sharing.credentials), sharing.credentials); + decryptedData = this.decrypt(sharing.credentials); } else { // Otherwise try to find them with only the `credential:read` scope. In // that case we return them without the decrypted data. @@ -603,4 +645,25 @@ export class CredentialsService { mergedCredentials.data = decryptedData; } } + + /** + * Create a new credential in user's account and return it along the scopes + * If a projectId is send, then it also binds the credential to that specific project + */ + async createCredential(credentialsData: CredentialRequest.CredentialProperties, user: User) { + const newCredential = await this.prepareCreateData(credentialsData); + + const encryptedData = this.createEncryptedData(null, newCredential); + + const { shared, ...credential } = await this.save( + newCredential, + encryptedData, + user, + credentialsData.projectId, + ); + + const scopes = await this.getCredentialScopes(user, credential.id); + + return { ...credential, scopes }; + } } diff --git a/packages/cli/src/databases/config.ts b/packages/cli/src/databases/config.ts index 8845bd0b13..45348e0079 100644 --- a/packages/cli/src/databases/config.ts +++ b/packages/cli/src/databases/config.ts @@ -1,4 +1,5 @@ import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import type { DataSourceOptions, LoggerOptions } from '@n8n/typeorm'; import type { MysqlConnectionOptions } from '@n8n/typeorm/driver/mysql/MysqlConnectionOptions'; import type { PostgresConnectionOptions } from '@n8n/typeorm/driver/postgres/PostgresConnectionOptions'; @@ -8,7 +9,6 @@ import { InstanceSettings } from 'n8n-core'; import { ApplicationError } from 'n8n-workflow'; import path from 'path'; import type { TlsOptions } from 'tls'; -import { Container } from 'typedi'; import { entities } from './entities'; import { mysqlMigrations } from './migrations/mysqldb'; diff --git a/packages/cli/src/databases/entities/abstract-entity.ts b/packages/cli/src/databases/entities/abstract-entity.ts index 0626a5621f..ac16b55bf5 100644 --- a/packages/cli/src/databases/entities/abstract-entity.ts +++ b/packages/cli/src/databases/entities/abstract-entity.ts @@ -1,4 +1,5 @@ import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import type { ColumnOptions } from '@n8n/typeorm'; import { BeforeInsert, @@ -8,7 +9,6 @@ import { UpdateDateColumn, } from '@n8n/typeorm'; import type { Class } from 'n8n-core'; -import { Container } from 'typedi'; import { generateNanoId } from '../utils/generators'; diff --git a/packages/cli/src/databases/entities/project.ts b/packages/cli/src/databases/entities/project.ts index 88c4ed009a..aa867807fd 100644 --- a/packages/cli/src/databases/entities/project.ts +++ b/packages/cli/src/databases/entities/project.ts @@ -6,6 +6,7 @@ import type { SharedCredentials } from './shared-credentials'; import type { SharedWorkflow } from './shared-workflow'; export type ProjectType = 'personal' | 'team'; +export type ProjectIcon = { type: 'emoji' | 'icon'; value: string } | null; @Entity() export class Project extends WithTimestampsAndStringId { @@ -15,6 +16,9 @@ export class Project extends WithTimestampsAndStringId { @Column({ length: 36 }) type: ProjectType; + @Column({ type: 'json', nullable: true }) + icon: ProjectIcon; + @OneToMany('ProjectRelation', 'project') projectRelations: ProjectRelation[]; diff --git a/packages/cli/src/databases/entities/user.ts b/packages/cli/src/databases/entities/user.ts index b75bec757c..5aae21d003 100644 --- a/packages/cli/src/databases/entities/user.ts +++ b/packages/cli/src/databases/entities/user.ts @@ -18,7 +18,7 @@ import { GLOBAL_OWNER_SCOPES, GLOBAL_MEMBER_SCOPES, GLOBAL_ADMIN_SCOPES, -} from '@/permissions/global-roles'; +} from '@/permissions.ee/global-roles'; import { NoUrl } from '@/validators/no-url.validator'; import { NoXss } from '@/validators/no-xss.validator'; diff --git a/packages/cli/src/databases/migrations/common/1659888469333-AddJsonKeyPinData.ts b/packages/cli/src/databases/migrations/common/1659888469333-AddJsonKeyPinData.ts index 84d3040a10..75ce65ad6b 100644 --- a/packages/cli/src/databases/migrations/common/1659888469333-AddJsonKeyPinData.ts +++ b/packages/cli/src/databases/migrations/common/1659888469333-AddJsonKeyPinData.ts @@ -1,7 +1,7 @@ +import { isObjectLiteral } from 'n8n-core'; import type { IDataObject, INodeExecutionData } from 'n8n-workflow'; import type { MigrationContext, IrreversibleMigration } from '@/databases/types'; -import { isObjectLiteral } from '@/utils'; type OldPinnedData = { [nodeName: string]: IDataObject[] }; type NewPinnedData = { [nodeName: string]: INodeExecutionData[] }; diff --git a/packages/cli/src/databases/migrations/common/1674509946020-CreateLdapEntities.ts b/packages/cli/src/databases/migrations/common/1674509946020-CreateLdapEntities.ts index 5ccd0c40a4..95a2c11a51 100644 --- a/packages/cli/src/databases/migrations/common/1674509946020-CreateLdapEntities.ts +++ b/packages/cli/src/databases/migrations/common/1674509946020-CreateLdapEntities.ts @@ -1,5 +1,5 @@ import type { MigrationContext, ReversibleMigration } from '@/databases/types'; -import { LDAP_DEFAULT_CONFIGURATION, LDAP_FEATURE_NAME } from '@/ldap/constants'; +import { LDAP_DEFAULT_CONFIGURATION, LDAP_FEATURE_NAME } from '@/ldap.ee/constants'; export class CreateLdapEntities1674509946020 implements ReversibleMigration { async up({ escape, dbType, isMysql, runQuery }: MigrationContext) { diff --git a/packages/cli/src/databases/migrations/common/1711390882123-MoveSshKeysToDatabase.ts b/packages/cli/src/databases/migrations/common/1711390882123-MoveSshKeysToDatabase.ts index f8d4eb60a9..2db009da07 100644 --- a/packages/cli/src/databases/migrations/common/1711390882123-MoveSshKeysToDatabase.ts +++ b/packages/cli/src/databases/migrations/common/1711390882123-MoveSshKeysToDatabase.ts @@ -1,8 +1,8 @@ +import { Container } from '@n8n/di'; import { Cipher, InstanceSettings } from 'n8n-core'; import { jsonParse } from 'n8n-workflow'; import { readFile, writeFile, rm } from 'node:fs/promises'; import path from 'node:path'; -import Container from 'typedi'; import type { MigrationContext, ReversibleMigration } from '@/databases/types'; diff --git a/packages/cli/src/databases/migrations/common/1729607673469-AddProjectIcons.ts b/packages/cli/src/databases/migrations/common/1729607673469-AddProjectIcons.ts new file mode 100644 index 0000000000..e2c710428a --- /dev/null +++ b/packages/cli/src/databases/migrations/common/1729607673469-AddProjectIcons.ts @@ -0,0 +1,10 @@ +import type { MigrationContext, ReversibleMigration } from '@/databases/types'; +export class AddProjectIcons1729607673469 implements ReversibleMigration { + async up({ schemaBuilder: { addColumns, column } }: MigrationContext) { + await addColumns('project', [column('icon').json]); + } + + async down({ schemaBuilder: { dropColumns } }: MigrationContext) { + await dropColumns('project', ['icon']); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index 2fc39079d4..89df273472 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -69,6 +69,7 @@ import { CreateProcessedDataTable1726606152711 } from '../common/1726606152711-C import { SeparateExecutionCreationFromStart1727427440136 } from '../common/1727427440136-SeparateExecutionCreationFromStart'; import { AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644 } from '../common/1728659839644-AddMissingPrimaryKeyOnAnnotationTagMapping'; import { UpdateProcessedDataValueColumnToText1729607673464 } from '../common/1729607673464-UpdateProcessedDataValueColumnToText'; +import { AddProjectIcons1729607673469 } from '../common/1729607673469-AddProjectIcons'; import { CreateTestDefinitionTable1730386903556 } from '../common/1730386903556-CreateTestDefinitionTable'; import { AddDescriptionToTestDefinition1731404028106 } from '../common/1731404028106-AddDescriptionToTestDefinition'; import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable'; @@ -152,4 +153,5 @@ export const mysqlMigrations: Migration[] = [ CreateTestRun1732549866705, AddMockedNodesColumnToTestDefinition1733133775640, AddManagedColumnToCredentialsTable1734479635324, + AddProjectIcons1729607673469, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index 605c156003..d5d72282f4 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -69,6 +69,7 @@ import { CreateProcessedDataTable1726606152711 } from '../common/1726606152711-C import { SeparateExecutionCreationFromStart1727427440136 } from '../common/1727427440136-SeparateExecutionCreationFromStart'; import { AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644 } from '../common/1728659839644-AddMissingPrimaryKeyOnAnnotationTagMapping'; import { UpdateProcessedDataValueColumnToText1729607673464 } from '../common/1729607673464-UpdateProcessedDataValueColumnToText'; +import { AddProjectIcons1729607673469 } from '../common/1729607673469-AddProjectIcons'; import { CreateTestDefinitionTable1730386903556 } from '../common/1730386903556-CreateTestDefinitionTable'; import { AddDescriptionToTestDefinition1731404028106 } from '../common/1731404028106-AddDescriptionToTestDefinition'; import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable'; @@ -152,4 +153,5 @@ export const postgresMigrations: Migration[] = [ CreateTestRun1732549866705, AddMockedNodesColumnToTestDefinition1733133775640, AddManagedColumnToCredentialsTable1734479635324, + AddProjectIcons1729607673469, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/1690000000002-MigrateIntegerKeysToString.ts b/packages/cli/src/databases/migrations/sqlite/1690000000002-MigrateIntegerKeysToString.ts index 99cbef5aac..1c72db35ed 100644 --- a/packages/cli/src/databases/migrations/sqlite/1690000000002-MigrateIntegerKeysToString.ts +++ b/packages/cli/src/databases/migrations/sqlite/1690000000002-MigrateIntegerKeysToString.ts @@ -1,8 +1,8 @@ import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import { statSync } from 'fs'; import { InstanceSettings } from 'n8n-core'; import path from 'path'; -import { Container } from 'typedi'; import type { MigrationContext, IrreversibleMigration } from '@/databases/types'; diff --git a/packages/cli/src/databases/migrations/sqlite/1729607673469-AddProjectIcons.ts b/packages/cli/src/databases/migrations/sqlite/1729607673469-AddProjectIcons.ts new file mode 100644 index 0000000000..f5eb94ffc0 --- /dev/null +++ b/packages/cli/src/databases/migrations/sqlite/1729607673469-AddProjectIcons.ts @@ -0,0 +1,5 @@ +import { AddProjectIcons1729607673469 as BaseMigration } from '../common/1729607673469-AddProjectIcons'; + +export class AddProjectIcons1729607673469 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 0981ece99b..7fec59baf2 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -39,6 +39,7 @@ import { DropRoleMapping1705429061930 } from './1705429061930-DropRoleMapping'; import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActivatedAtUserSetting'; import { AddApiKeysTable1724951148974 } from './1724951148974-AddApiKeysTable'; import { AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644 } from './1728659839644-AddMissingPrimaryKeyOnAnnotationTagMapping'; +import { AddProjectIcons1729607673469 } from './1729607673469-AddProjectIcons'; import { AddDescriptionToTestDefinition1731404028106 } from './1731404028106-AddDescriptionToTestDefinition'; import { MigrateTestDefinitionKeyToString1731582748663 } from './1731582748663-MigrateTestDefinitionKeyToString'; import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames'; @@ -146,6 +147,7 @@ const sqliteMigrations: Migration[] = [ CreateTestRun1732549866705, AddMockedNodesColumnToTestDefinition1733133775640, AddManagedColumnToCredentialsTable1734479635324, + AddProjectIcons1729607673469, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/databases/repositories/__tests__/credentials.repository.test.ts b/packages/cli/src/databases/repositories/__tests__/credentials.repository.test.ts new file mode 100644 index 0000000000..7b70587d65 --- /dev/null +++ b/packages/cli/src/databases/repositories/__tests__/credentials.repository.test.ts @@ -0,0 +1,50 @@ +import { Container } from '@n8n/di'; +import { mock } from 'jest-mock-extended'; + +import { CredentialsEntity } from '@/databases/entities/credentials-entity'; +import { mockEntityManager } from '@test/mocking'; + +import { CredentialsRepository } from '../credentials.repository'; + +const entityManager = mockEntityManager(CredentialsEntity); +const repository = Container.get(CredentialsRepository); + +describe('findMany', () => { + const credentialsId = 'cred_123'; + const credential = mock({ id: credentialsId }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('return `data` property if `includeData:true` and select is using the record syntax', async () => { + // ARRANGE + entityManager.find.mockResolvedValueOnce([credential]); + + // ACT + const credentials = await repository.findMany({ includeData: true, select: { id: true } }); + + // ASSERT + expect(credentials).toHaveLength(1); + expect(credentials[0]).toHaveProperty('data'); + }); + + test('return `data` property if `includeData:true` and select is using the record syntax', async () => { + // ARRANGE + entityManager.find.mockResolvedValueOnce([credential]); + + // ACT + const credentials = await repository.findMany({ + includeData: true, + //TODO: fix this + // The function's type does not support this but this is what it + // actually gets from the service because the middlewares are typed + // loosely. + select: ['id'] as never, + }); + + // ASSERT + expect(credentials).toHaveLength(1); + expect(credentials[0]).toHaveProperty('data'); + }); +}); diff --git a/packages/cli/src/databases/repositories/__tests__/execution.repository.test.ts b/packages/cli/src/databases/repositories/__tests__/execution.repository.test.ts index 10d1371f37..8e36f0189b 100644 --- a/packages/cli/src/databases/repositories/__tests__/execution.repository.test.ts +++ b/packages/cli/src/databases/repositories/__tests__/execution.repository.test.ts @@ -1,10 +1,10 @@ import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import type { SelectQueryBuilder } from '@n8n/typeorm'; import { Not, LessThanOrEqual } from '@n8n/typeorm'; import { mock } from 'jest-mock-extended'; import { BinaryDataService } from 'n8n-core'; import { nanoid } from 'nanoid'; -import Container from 'typedi'; import { ExecutionEntity } from '@/databases/entities/execution-entity'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; diff --git a/packages/cli/src/databases/repositories/__tests__/shared-credentials.repository.test.ts b/packages/cli/src/databases/repositories/__tests__/shared-credentials.repository.test.ts index afaca2206f..d7e108389a 100644 --- a/packages/cli/src/databases/repositories/__tests__/shared-credentials.repository.test.ts +++ b/packages/cli/src/databases/repositories/__tests__/shared-credentials.repository.test.ts @@ -1,13 +1,13 @@ +import { Container } from '@n8n/di'; import { hasScope } from '@n8n/permissions'; import { In } from '@n8n/typeorm'; import { mock } from 'jest-mock-extended'; -import { Container } from 'typedi'; import type { CredentialsEntity } from '@/databases/entities/credentials-entity'; import { SharedCredentials } from '@/databases/entities/shared-credentials'; import type { User } from '@/databases/entities/user'; import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; -import { GLOBAL_MEMBER_SCOPES, GLOBAL_OWNER_SCOPES } from '@/permissions/global-roles'; +import { GLOBAL_MEMBER_SCOPES, GLOBAL_OWNER_SCOPES } from '@/permissions.ee/global-roles'; import { mockEntityManager } from '@test/mocking'; describe('SharedCredentialsRepository', () => { diff --git a/packages/cli/src/databases/repositories/__tests__/workflow-statistics.test.ts b/packages/cli/src/databases/repositories/__tests__/workflow-statistics.test.ts index 202938e526..b1d36b0263 100644 --- a/packages/cli/src/databases/repositories/__tests__/workflow-statistics.test.ts +++ b/packages/cli/src/databases/repositories/__tests__/workflow-statistics.test.ts @@ -1,6 +1,6 @@ +import { Container } from '@n8n/di'; import { type InsertResult, QueryFailedError } from '@n8n/typeorm'; import { mock, mockClear } from 'jest-mock-extended'; -import { Container } from 'typedi'; import { StatisticsNames, WorkflowStatistics } from '@/databases/entities/workflow-statistics'; import { WorkflowStatisticsRepository } from '@/databases/repositories/workflow-statistics.repository'; diff --git a/packages/cli/src/databases/repositories/annotation-tag-mapping.repository.ee.ts b/packages/cli/src/databases/repositories/annotation-tag-mapping.repository.ee.ts index 07bb79815b..7f1039c3a1 100644 --- a/packages/cli/src/databases/repositories/annotation-tag-mapping.repository.ee.ts +++ b/packages/cli/src/databases/repositories/annotation-tag-mapping.repository.ee.ts @@ -1,5 +1,5 @@ +import { Service } from '@n8n/di'; import { DataSource, Repository } from '@n8n/typeorm'; -import { Service } from 'typedi'; import { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping.ee'; diff --git a/packages/cli/src/databases/repositories/annotation-tag.repository.ee.ts b/packages/cli/src/databases/repositories/annotation-tag.repository.ee.ts index e3aa993460..3f4d5c161f 100644 --- a/packages/cli/src/databases/repositories/annotation-tag.repository.ee.ts +++ b/packages/cli/src/databases/repositories/annotation-tag.repository.ee.ts @@ -1,5 +1,5 @@ +import { Service } from '@n8n/di'; import { DataSource, Repository } from '@n8n/typeorm'; -import { Service } from 'typedi'; import { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee'; diff --git a/packages/cli/src/databases/repositories/api-key.repository.ts b/packages/cli/src/databases/repositories/api-key.repository.ts index 21ad2c3e40..b24a8984dc 100644 --- a/packages/cli/src/databases/repositories/api-key.repository.ts +++ b/packages/cli/src/databases/repositories/api-key.repository.ts @@ -1,5 +1,5 @@ +import { Service } from '@n8n/di'; import { DataSource, Repository } from '@n8n/typeorm'; -import { Service } from 'typedi'; import { ApiKey } from '../entities/api-key'; diff --git a/packages/cli/src/databases/repositories/auth-identity.repository.ts b/packages/cli/src/databases/repositories/auth-identity.repository.ts index 349fe29cd5..1036994680 100644 --- a/packages/cli/src/databases/repositories/auth-identity.repository.ts +++ b/packages/cli/src/databases/repositories/auth-identity.repository.ts @@ -1,5 +1,5 @@ +import { Service } from '@n8n/di'; import { DataSource, Repository } from '@n8n/typeorm'; -import { Service } from 'typedi'; import { AuthIdentity } from '../entities/auth-identity'; diff --git a/packages/cli/src/databases/repositories/auth-provider-sync-history.repository.ts b/packages/cli/src/databases/repositories/auth-provider-sync-history.repository.ts index d4c5d50b5a..6f217e7454 100644 --- a/packages/cli/src/databases/repositories/auth-provider-sync-history.repository.ts +++ b/packages/cli/src/databases/repositories/auth-provider-sync-history.repository.ts @@ -1,5 +1,5 @@ +import { Service } from '@n8n/di'; import { DataSource, Repository } from '@n8n/typeorm'; -import { Service } from 'typedi'; import { AuthProviderSyncHistory } from '../entities/auth-provider-sync-history'; diff --git a/packages/cli/src/databases/repositories/auth-user.repository.ts b/packages/cli/src/databases/repositories/auth-user.repository.ts index 0d94fc9ba6..c5a06d9b66 100644 --- a/packages/cli/src/databases/repositories/auth-user.repository.ts +++ b/packages/cli/src/databases/repositories/auth-user.repository.ts @@ -1,5 +1,5 @@ +import { Service } from '@n8n/di'; import { DataSource, Repository } from '@n8n/typeorm'; -import { Service } from 'typedi'; import { AuthUser } from '../entities/auth-user'; diff --git a/packages/cli/src/databases/repositories/credentials.repository.ts b/packages/cli/src/databases/repositories/credentials.repository.ts index 4c62e30f63..42fac4f8dc 100644 --- a/packages/cli/src/databases/repositories/credentials.repository.ts +++ b/packages/cli/src/databases/repositories/credentials.repository.ts @@ -1,7 +1,7 @@ +import { Service } from '@n8n/di'; import type { Scope } from '@n8n/permissions'; import { DataSource, In, Repository, Like } from '@n8n/typeorm'; import type { FindManyOptions, FindOptionsWhere } from '@n8n/typeorm'; -import { Service } from 'typedi'; import type { ListQuery } from '@/requests'; import { RoleService } from '@/services/role.service'; @@ -25,7 +25,10 @@ export class CredentialsRepository extends Repository { }); } - async findMany(listQueryOptions?: ListQuery.Options, credentialIds?: string[]) { + async findMany( + listQueryOptions?: ListQuery.Options & { includeData?: boolean }, + credentialIds?: string[], + ) { const findManyOptions = this.toFindManyOptions(listQueryOptions); if (credentialIds) { @@ -35,13 +38,13 @@ export class CredentialsRepository extends Repository { return await this.find(findManyOptions); } - private toFindManyOptions(listQueryOptions?: ListQuery.Options) { + private toFindManyOptions(listQueryOptions?: ListQuery.Options & { includeData?: boolean }) { const findManyOptions: FindManyOptions = {}; type Select = Array; const defaultRelations = ['shared', 'shared.project']; - const defaultSelect: Select = ['id', 'name', 'type', 'createdAt', 'updatedAt']; + const defaultSelect: Select = ['id', 'name', 'type', 'isManaged', 'createdAt', 'updatedAt']; if (!listQueryOptions) return { select: defaultSelect, relations: defaultRelations }; @@ -74,6 +77,14 @@ export class CredentialsRepository extends Repository { findManyOptions.relations = defaultRelations; } + if (listQueryOptions.includeData) { + if (Array.isArray(findManyOptions.select)) { + findManyOptions.select.push('data'); + } else { + findManyOptions.select.data = true; + } + } + return findManyOptions; } diff --git a/packages/cli/src/databases/repositories/event-destinations.repository.ts b/packages/cli/src/databases/repositories/event-destinations.repository.ts index 56a2ab0f90..2af7bca8bb 100644 --- a/packages/cli/src/databases/repositories/event-destinations.repository.ts +++ b/packages/cli/src/databases/repositories/event-destinations.repository.ts @@ -1,5 +1,5 @@ +import { Service } from '@n8n/di'; import { DataSource, Repository } from '@n8n/typeorm'; -import { Service } from 'typedi'; import { EventDestinations } from '../entities/event-destinations'; diff --git a/packages/cli/src/databases/repositories/execution-annotation.repository.ts b/packages/cli/src/databases/repositories/execution-annotation.repository.ts index 97ca972733..a8329669f9 100644 --- a/packages/cli/src/databases/repositories/execution-annotation.repository.ts +++ b/packages/cli/src/databases/repositories/execution-annotation.repository.ts @@ -1,5 +1,5 @@ +import { Service } from '@n8n/di'; import { DataSource, Repository } from '@n8n/typeorm'; -import { Service } from 'typedi'; import { ExecutionAnnotation } from '@/databases/entities/execution-annotation.ee'; diff --git a/packages/cli/src/databases/repositories/execution-data.repository.ts b/packages/cli/src/databases/repositories/execution-data.repository.ts index f7de742941..75463fd0f5 100644 --- a/packages/cli/src/databases/repositories/execution-data.repository.ts +++ b/packages/cli/src/databases/repositories/execution-data.repository.ts @@ -1,7 +1,7 @@ +import { Service } from '@n8n/di'; import { DataSource, In, Repository } from '@n8n/typeorm'; import type { EntityManager } from '@n8n/typeorm'; import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity'; -import { Service } from 'typedi'; import { ExecutionData } from '../entities/execution-data'; diff --git a/packages/cli/src/databases/repositories/execution-metadata.repository.ts b/packages/cli/src/databases/repositories/execution-metadata.repository.ts index efaf239c4a..e0f27e6c09 100644 --- a/packages/cli/src/databases/repositories/execution-metadata.repository.ts +++ b/packages/cli/src/databases/repositories/execution-metadata.repository.ts @@ -1,5 +1,5 @@ +import { Service } from '@n8n/di'; import { DataSource, Repository } from '@n8n/typeorm'; -import { Service } from 'typedi'; import { ExecutionMetadata } from '../entities/execution-metadata'; diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index fbcb7de445..160b7ace87 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -1,4 +1,5 @@ import { GlobalConfig } from '@n8n/config'; +import { Service } from '@n8n/di'; import type { FindManyOptions, FindOneOptions, @@ -21,7 +22,7 @@ import { import { DateUtils } from '@n8n/typeorm/util/DateUtils'; import { parse, stringify } from 'flatted'; import pick from 'lodash/pick'; -import { BinaryDataService, ErrorReporter } from 'n8n-core'; +import { BinaryDataService, ErrorReporter, Logger } from 'n8n-core'; import { ExecutionCancelledError, ApplicationError } from 'n8n-workflow'; import type { AnnotationVote, @@ -29,7 +30,6 @@ import type { ExecutionSummary, IRunExecutionData, } from 'n8n-workflow'; -import { Service } from 'typedi'; import { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee'; import { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping.ee'; @@ -42,7 +42,6 @@ import type { IExecutionFlattedDb, IExecutionResponse, } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { separate } from '@/utils'; import { ExecutionDataRepository } from './execution-data.repository'; diff --git a/packages/cli/src/databases/repositories/installed-nodes.repository.ts b/packages/cli/src/databases/repositories/installed-nodes.repository.ts index 880b4375ca..68fc564ca3 100644 --- a/packages/cli/src/databases/repositories/installed-nodes.repository.ts +++ b/packages/cli/src/databases/repositories/installed-nodes.repository.ts @@ -1,5 +1,5 @@ +import { Service } from '@n8n/di'; import { DataSource, Repository } from '@n8n/typeorm'; -import { Service } from 'typedi'; import { InstalledNodes } from '../entities/installed-nodes'; diff --git a/packages/cli/src/databases/repositories/installed-packages.repository.ts b/packages/cli/src/databases/repositories/installed-packages.repository.ts index 77faf96817..873880dc90 100644 --- a/packages/cli/src/databases/repositories/installed-packages.repository.ts +++ b/packages/cli/src/databases/repositories/installed-packages.repository.ts @@ -1,6 +1,6 @@ +import { Service } from '@n8n/di'; import { DataSource, Repository } from '@n8n/typeorm'; import type { PackageDirectoryLoader } from 'n8n-core'; -import { Service } from 'typedi'; import { InstalledNodesRepository } from './installed-nodes.repository'; import { InstalledPackages } from '../entities/installed-packages'; diff --git a/packages/cli/src/databases/repositories/invalid-auth-token.repository.ts b/packages/cli/src/databases/repositories/invalid-auth-token.repository.ts index 94909b998d..b9c8e0a4d9 100644 --- a/packages/cli/src/databases/repositories/invalid-auth-token.repository.ts +++ b/packages/cli/src/databases/repositories/invalid-auth-token.repository.ts @@ -1,5 +1,5 @@ +import { Service } from '@n8n/di'; import { DataSource, Repository } from '@n8n/typeorm'; -import { Service } from 'typedi'; import { InvalidAuthToken } from '../entities/invalid-auth-token'; diff --git a/packages/cli/src/databases/repositories/license-metrics.repository.ts b/packages/cli/src/databases/repositories/license-metrics.repository.ts index d6cc7c1409..26f4bd7328 100644 --- a/packages/cli/src/databases/repositories/license-metrics.repository.ts +++ b/packages/cli/src/databases/repositories/license-metrics.repository.ts @@ -1,6 +1,6 @@ import { GlobalConfig } from '@n8n/config'; +import { Service } from '@n8n/di'; import { DataSource, Repository, Entity } from '@n8n/typeorm'; -import { Service } from 'typedi'; @Entity() export class LicenseMetrics {} diff --git a/packages/cli/src/databases/repositories/processed-data.repository.ts b/packages/cli/src/databases/repositories/processed-data.repository.ts index f02fbf270a..0063cdf9db 100644 --- a/packages/cli/src/databases/repositories/processed-data.repository.ts +++ b/packages/cli/src/databases/repositories/processed-data.repository.ts @@ -1,5 +1,5 @@ +import { Service } from '@n8n/di'; import { DataSource, Repository } from '@n8n/typeorm'; -import { Service } from 'typedi'; import { ProcessedData } from '../entities/processed-data'; diff --git a/packages/cli/src/databases/repositories/project-relation.repository.ts b/packages/cli/src/databases/repositories/project-relation.repository.ts index 8d8faba1ba..75aaed76df 100644 --- a/packages/cli/src/databases/repositories/project-relation.repository.ts +++ b/packages/cli/src/databases/repositories/project-relation.repository.ts @@ -1,5 +1,5 @@ +import { Service } from '@n8n/di'; import { DataSource, In, Repository } from '@n8n/typeorm'; -import { Service } from 'typedi'; import { ProjectRelation, type ProjectRole } from '../entities/project-relation'; diff --git a/packages/cli/src/databases/repositories/project.repository.ts b/packages/cli/src/databases/repositories/project.repository.ts index c67d4e9456..052668743b 100644 --- a/packages/cli/src/databases/repositories/project.repository.ts +++ b/packages/cli/src/databases/repositories/project.repository.ts @@ -1,6 +1,6 @@ +import { Service } from '@n8n/di'; import type { EntityManager } from '@n8n/typeorm'; import { DataSource, Repository } from '@n8n/typeorm'; -import { Service } from 'typedi'; import { Project } from '../entities/project'; diff --git a/packages/cli/src/databases/repositories/settings.repository.ts b/packages/cli/src/databases/repositories/settings.repository.ts index aa4410d6d4..6e28b71c1b 100644 --- a/packages/cli/src/databases/repositories/settings.repository.ts +++ b/packages/cli/src/databases/repositories/settings.repository.ts @@ -1,9 +1,9 @@ +import { Service } from '@n8n/di'; import { DataSource, Repository } from '@n8n/typeorm'; import { ErrorReporter } from 'n8n-core'; -import { Service } from 'typedi'; import config from '@/config'; -import { EXTERNAL_SECRETS_DB_KEY } from '@/external-secrets/constants'; +import { EXTERNAL_SECRETS_DB_KEY } from '@/external-secrets.ee/constants'; import { Settings } from '../entities/settings'; diff --git a/packages/cli/src/databases/repositories/shared-credentials.repository.ts b/packages/cli/src/databases/repositories/shared-credentials.repository.ts index 516c546929..d7e074595c 100644 --- a/packages/cli/src/databases/repositories/shared-credentials.repository.ts +++ b/packages/cli/src/databases/repositories/shared-credentials.repository.ts @@ -1,7 +1,7 @@ +import { Service } from '@n8n/di'; import type { Scope } from '@n8n/permissions'; import type { EntityManager, FindOptionsRelations, FindOptionsWhere } from '@n8n/typeorm'; import { DataSource, In, Not, Repository } from '@n8n/typeorm'; -import { Service } from 'typedi'; import { RoleService } from '@/services/role.service'; diff --git a/packages/cli/src/databases/repositories/shared-workflow.repository.ts b/packages/cli/src/databases/repositories/shared-workflow.repository.ts index 8f4bedcb15..f0a574fa0c 100644 --- a/packages/cli/src/databases/repositories/shared-workflow.repository.ts +++ b/packages/cli/src/databases/repositories/shared-workflow.repository.ts @@ -1,7 +1,7 @@ +import { Service } from '@n8n/di'; import type { Scope } from '@n8n/permissions'; import { DataSource, Repository, In, Not } from '@n8n/typeorm'; import type { EntityManager, FindManyOptions, FindOptionsWhere } from '@n8n/typeorm'; -import { Service } from 'typedi'; import { RoleService } from '@/services/role.service'; diff --git a/packages/cli/src/databases/repositories/tag.repository.ts b/packages/cli/src/databases/repositories/tag.repository.ts index 800fe225ae..f58b0e5b68 100644 --- a/packages/cli/src/databases/repositories/tag.repository.ts +++ b/packages/cli/src/databases/repositories/tag.repository.ts @@ -1,7 +1,7 @@ +import { Service } from '@n8n/di'; import type { EntityManager } from '@n8n/typeorm'; import { DataSource, In, Repository } from '@n8n/typeorm'; import intersection from 'lodash/intersection'; -import { Service } from 'typedi'; import { TagEntity } from '../entities/tag-entity'; import type { WorkflowEntity } from '../entities/workflow-entity'; diff --git a/packages/cli/src/databases/repositories/test-definition.repository.ee.ts b/packages/cli/src/databases/repositories/test-definition.repository.ee.ts index 6866608b02..0955e6f89d 100644 --- a/packages/cli/src/databases/repositories/test-definition.repository.ee.ts +++ b/packages/cli/src/databases/repositories/test-definition.repository.ee.ts @@ -1,6 +1,6 @@ +import { Service } from '@n8n/di'; import type { FindManyOptions, FindOptionsWhere } from '@n8n/typeorm'; import { DataSource, In, Repository } from '@n8n/typeorm'; -import { Service } from 'typedi'; import { TestDefinition } from '@/databases/entities/test-definition.ee'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; diff --git a/packages/cli/src/databases/repositories/test-metric.repository.ee.ts b/packages/cli/src/databases/repositories/test-metric.repository.ee.ts index 01635ef8f7..a08cf414f8 100644 --- a/packages/cli/src/databases/repositories/test-metric.repository.ee.ts +++ b/packages/cli/src/databases/repositories/test-metric.repository.ee.ts @@ -1,5 +1,5 @@ +import { Service } from '@n8n/di'; import { DataSource, Repository } from '@n8n/typeorm'; -import { Service } from 'typedi'; import { TestMetric } from '@/databases/entities/test-metric.ee'; diff --git a/packages/cli/src/databases/repositories/test-run.repository.ee.ts b/packages/cli/src/databases/repositories/test-run.repository.ee.ts index 43e6f902ba..a5658196ce 100644 --- a/packages/cli/src/databases/repositories/test-run.repository.ee.ts +++ b/packages/cli/src/databases/repositories/test-run.repository.ee.ts @@ -1,6 +1,6 @@ +import { Service } from '@n8n/di'; import type { FindManyOptions } from '@n8n/typeorm'; import { DataSource, Repository } from '@n8n/typeorm'; -import { Service } from 'typedi'; import type { AggregatedTestRunMetrics } from '@/databases/entities/test-run.ee'; import { TestRun } from '@/databases/entities/test-run.ee'; diff --git a/packages/cli/src/databases/repositories/user.repository.ts b/packages/cli/src/databases/repositories/user.repository.ts index 61a4edffa5..d785ce407d 100644 --- a/packages/cli/src/databases/repositories/user.repository.ts +++ b/packages/cli/src/databases/repositories/user.repository.ts @@ -1,6 +1,6 @@ +import { Service } from '@n8n/di'; import type { DeepPartial, EntityManager, FindManyOptions } from '@n8n/typeorm'; import { DataSource, In, IsNull, Not, Repository } from '@n8n/typeorm'; -import { Service } from 'typedi'; import type { ListQuery } from '@/requests'; diff --git a/packages/cli/src/databases/repositories/variables.repository.ts b/packages/cli/src/databases/repositories/variables.repository.ts index 2f2500250b..5ea9711935 100644 --- a/packages/cli/src/databases/repositories/variables.repository.ts +++ b/packages/cli/src/databases/repositories/variables.repository.ts @@ -1,5 +1,5 @@ +import { Service } from '@n8n/di'; import { DataSource, Repository } from '@n8n/typeorm'; -import { Service } from 'typedi'; import { Variables } from '../entities/variables'; diff --git a/packages/cli/src/databases/repositories/webhook.repository.ts b/packages/cli/src/databases/repositories/webhook.repository.ts index b89b9fba0f..5254793a78 100644 --- a/packages/cli/src/databases/repositories/webhook.repository.ts +++ b/packages/cli/src/databases/repositories/webhook.repository.ts @@ -1,5 +1,5 @@ +import { Service } from '@n8n/di'; import { DataSource, Repository } from '@n8n/typeorm'; -import { Service } from 'typedi'; import { WebhookEntity } from '../entities/webhook-entity'; diff --git a/packages/cli/src/databases/repositories/workflow-history.repository.ts b/packages/cli/src/databases/repositories/workflow-history.repository.ts index ca791ebcb7..7e0cdfac1e 100644 --- a/packages/cli/src/databases/repositories/workflow-history.repository.ts +++ b/packages/cli/src/databases/repositories/workflow-history.repository.ts @@ -1,5 +1,5 @@ +import { Service } from '@n8n/di'; import { DataSource, LessThan, Repository } from '@n8n/typeorm'; -import { Service } from 'typedi'; import { WorkflowHistory } from '../entities/workflow-history'; diff --git a/packages/cli/src/databases/repositories/workflow-statistics.repository.ts b/packages/cli/src/databases/repositories/workflow-statistics.repository.ts index c2ea18fe2c..ad94e83ec1 100644 --- a/packages/cli/src/databases/repositories/workflow-statistics.repository.ts +++ b/packages/cli/src/databases/repositories/workflow-statistics.repository.ts @@ -1,6 +1,6 @@ import { GlobalConfig } from '@n8n/config'; +import { Service } from '@n8n/di'; import { DataSource, MoreThanOrEqual, QueryFailedError, Repository } from '@n8n/typeorm'; -import { Service } from 'typedi'; import type { User } from '@/databases/entities/user'; diff --git a/packages/cli/src/databases/repositories/workflow-tag-mapping.repository.ts b/packages/cli/src/databases/repositories/workflow-tag-mapping.repository.ts index 108affaf11..0196437891 100644 --- a/packages/cli/src/databases/repositories/workflow-tag-mapping.repository.ts +++ b/packages/cli/src/databases/repositories/workflow-tag-mapping.repository.ts @@ -1,5 +1,5 @@ +import { Service } from '@n8n/di'; import { DataSource, Repository } from '@n8n/typeorm'; -import { Service } from 'typedi'; import { WorkflowTagMapping } from '../entities/workflow-tag-mapping'; diff --git a/packages/cli/src/databases/repositories/workflow.repository.ts b/packages/cli/src/databases/repositories/workflow.repository.ts index 5dcd369def..7edabdde96 100644 --- a/packages/cli/src/databases/repositories/workflow.repository.ts +++ b/packages/cli/src/databases/repositories/workflow.repository.ts @@ -1,4 +1,5 @@ import { GlobalConfig } from '@n8n/config'; +import { Service } from '@n8n/di'; import { DataSource, Repository, @@ -10,7 +11,6 @@ import { type FindManyOptions, type FindOptionsRelations, } from '@n8n/typeorm'; -import { Service } from 'typedi'; import config from '@/config'; import type { ListQuery } from '@/requests'; diff --git a/packages/cli/src/databases/subscribers/user-subscriber.ts b/packages/cli/src/databases/subscribers/user-subscriber.ts index 1c55572b14..485eeb6fc5 100644 --- a/packages/cli/src/databases/subscribers/user-subscriber.ts +++ b/packages/cli/src/databases/subscribers/user-subscriber.ts @@ -1,10 +1,8 @@ +import { Container } from '@n8n/di'; import type { EntitySubscriberInterface, UpdateEvent } from '@n8n/typeorm'; import { EventSubscriber } from '@n8n/typeorm'; -import { ErrorReporter } from 'n8n-core'; +import { ErrorReporter, Logger } from 'n8n-core'; import { ApplicationError } from 'n8n-workflow'; -import { Container } from 'typedi'; - -import { Logger } from '@/logging/logger.service'; import { Project } from '../entities/project'; import { User } from '../entities/user'; diff --git a/packages/cli/src/databases/types.ts b/packages/cli/src/databases/types.ts index 2bb1802bf2..dce7d9d243 100644 --- a/packages/cli/src/databases/types.ts +++ b/packages/cli/src/databases/types.ts @@ -1,8 +1,7 @@ import type { QueryRunner, ObjectLiteral } from '@n8n/typeorm'; +import type { Logger } from 'n8n-core'; import type { INodeTypes } from 'n8n-workflow'; -import type { Logger } from '@/logging/logger.service'; - import type { createSchemaBuilder } from './dsl'; export type DatabaseType = 'mariadb' | 'postgresdb' | 'mysqldb' | 'sqlite'; diff --git a/packages/cli/src/databases/utils/migration-helpers.ts b/packages/cli/src/databases/utils/migration-helpers.ts index 1093096f43..e248f19c83 100644 --- a/packages/cli/src/databases/utils/migration-helpers.ts +++ b/packages/cli/src/databases/utils/migration-helpers.ts @@ -1,15 +1,14 @@ import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import type { ObjectLiteral } from '@n8n/typeorm'; import type { QueryRunner } from '@n8n/typeorm/query-runner/QueryRunner'; import { readFileSync, rmSync } from 'fs'; -import { InstanceSettings } from 'n8n-core'; +import { InstanceSettings, Logger } from 'n8n-core'; import { ApplicationError, jsonParse } from 'n8n-workflow'; -import { Container } from 'typedi'; import { inTest } from '@/constants'; import { createSchemaBuilder } from '@/databases/dsl'; import type { BaseMigration, Migration, MigrationContext, MigrationFn } from '@/databases/types'; -import { Logger } from '@/logging/logger.service'; import { NodeTypes } from '@/node-types'; const PERSONALIZATION_SURVEY_FILENAME = 'personalizationSurvey.json'; diff --git a/packages/cli/src/databases/utils/transformers.ts b/packages/cli/src/databases/utils/transformers.ts index a18d0cf633..7a454d519c 100644 --- a/packages/cli/src/databases/utils/transformers.ts +++ b/packages/cli/src/databases/utils/transformers.ts @@ -1,7 +1,7 @@ import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import type { ValueTransformer, FindOperator } from '@n8n/typeorm'; import { jsonParse } from 'n8n-workflow'; -import { Container } from 'typedi'; export const idStringifier = { from: (value?: number): string | undefined => value?.toString(), diff --git a/packages/cli/src/db.ts b/packages/cli/src/db.ts index e1c2b0e402..6f336d659d 100644 --- a/packages/cli/src/db.ts +++ b/packages/cli/src/db.ts @@ -1,10 +1,10 @@ +import { Container } from '@n8n/di'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import type { EntityManager } from '@n8n/typeorm'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { DataSource as Connection } from '@n8n/typeorm'; import { ErrorReporter } from 'n8n-core'; import { DbConnectionTimeoutError, ensureError } from 'n8n-workflow'; -import { Container } from 'typedi'; import { inTest } from '@/constants'; import { getConnectionOptions, arePostgresOptions } from '@/databases/config'; diff --git a/packages/cli/src/decorators/__tests__/on-shutdown.test.ts b/packages/cli/src/decorators/__tests__/on-shutdown.test.ts index 774ae2ef48..a5c720c993 100644 --- a/packages/cli/src/decorators/__tests__/on-shutdown.test.ts +++ b/packages/cli/src/decorators/__tests__/on-shutdown.test.ts @@ -1,5 +1,5 @@ +import { Container, Service } from '@n8n/di'; import { mock } from 'jest-mock-extended'; -import Container, { Service } from 'typedi'; import { OnShutdown } from '@/decorators/on-shutdown'; import { ShutdownService } from '@/shutdown/shutdown.service'; diff --git a/packages/cli/src/decorators/controller.registry.ts b/packages/cli/src/decorators/controller.registry.ts index 3a22090db1..645a675011 100644 --- a/packages/cli/src/decorators/controller.registry.ts +++ b/packages/cli/src/decorators/controller.registry.ts @@ -1,9 +1,9 @@ import { GlobalConfig } from '@n8n/config'; +import { Container, Service } from '@n8n/di'; import { Router } from 'express'; import type { Application, Request, Response, RequestHandler } from 'express'; import { rateLimit as expressRateLimit } from 'express-rate-limit'; import { ApplicationError } from 'n8n-workflow'; -import { Container, Service } from 'typedi'; import type { ZodClass } from 'zod-class'; import { AuthService } from '@/auth/auth.service'; @@ -11,7 +11,7 @@ import { inProduction, RESPONSE_ERROR_MESSAGES } from '@/constants'; import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.error'; import type { BooleanLicenseFeature } from '@/interfaces'; import { License } from '@/license'; -import { userHasScopes } from '@/permissions/check-access'; +import { userHasScopes } from '@/permissions.ee/check-access'; import type { AuthenticatedRequest } from '@/requests'; import { send } from '@/response-helper'; // TODO: move `ResponseHelper.send` to this file @@ -73,7 +73,7 @@ export class ControllerRegistry { .replace(/\/$/, ''); app.use(prefix, router); - const controller = Container.get(controllerClass); + const controller = Container.get(controllerClass) as Controller; const controllerMiddlewares = metadata.middlewares.map( (handlerName) => controller[handlerName].bind(controller) as RequestHandler, ); @@ -93,7 +93,7 @@ export class ControllerRegistry { if (arg.type === 'param') args.push(req.params[arg.key]); else if (['body', 'query'].includes(arg.type)) { const paramType = argTypes[index] as ZodClass; - if (paramType && 'parse' in paramType) { + if (paramType && 'safeParse' in paramType) { const output = paramType.safeParse(req[arg.type]); if (output.success) args.push(output.data); else { diff --git a/packages/cli/src/decorators/index.ts b/packages/cli/src/decorators/index.ts index bd32add475..8002bbe094 100644 --- a/packages/cli/src/decorators/index.ts +++ b/packages/cli/src/decorators/index.ts @@ -1,4 +1,4 @@ -export { Body } from './args'; +export { Body, Query, Param } from './args'; export { RestController } from './rest-controller'; export { Get, Post, Put, Patch, Delete } from './route'; export { Middleware } from './middleware'; diff --git a/packages/cli/src/decorators/on-shutdown.ts b/packages/cli/src/decorators/on-shutdown.ts index dbcb547d42..3d17002105 100644 --- a/packages/cli/src/decorators/on-shutdown.ts +++ b/packages/cli/src/decorators/on-shutdown.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import { ApplicationError } from 'n8n-workflow'; -import { Container } from 'typedi'; import { DEFAULT_SHUTDOWN_PRIORITY } from '@/constants'; import { type ServiceClass, ShutdownService } from '@/shutdown/shutdown.service'; diff --git a/packages/cli/src/decorators/rest-controller.ts b/packages/cli/src/decorators/rest-controller.ts index d540f6a8ac..cdd32b6e21 100644 --- a/packages/cli/src/decorators/rest-controller.ts +++ b/packages/cli/src/decorators/rest-controller.ts @@ -1,4 +1,4 @@ -import { Service } from 'typedi'; +import { Service } from '@n8n/di'; import { getControllerMetadata } from './controller.registry'; import type { Controller } from './types'; diff --git a/packages/cli/src/decorators/types.ts b/packages/cli/src/decorators/types.ts index f410df7831..17a7425882 100644 --- a/packages/cli/src/decorators/types.ts +++ b/packages/cli/src/decorators/types.ts @@ -1,6 +1,6 @@ +import type { Constructable } from '@n8n/di'; import type { Scope } from '@n8n/permissions'; import type { RequestHandler } from 'express'; -import type { Class } from 'n8n-core'; import type { BooleanLicenseFeature } from '@/interfaces'; @@ -46,5 +46,5 @@ export interface ControllerMetadata { routes: Map; } -export type Controller = Class & +export type Controller = Constructable & Record Promise>; diff --git a/packages/cli/src/deduplication/deduplication-helper.ts b/packages/cli/src/deduplication/deduplication-helper.ts index a913a21a8c..dc6b59ac29 100644 --- a/packages/cli/src/deduplication/deduplication-helper.ts +++ b/packages/cli/src/deduplication/deduplication-helper.ts @@ -1,3 +1,4 @@ +import { Container } from '@n8n/di'; import { createHash } from 'crypto'; import { type ICheckProcessedContextData, @@ -10,7 +11,6 @@ import { tryToParseDateTime, } from 'n8n-workflow'; import * as assert from 'node:assert/strict'; -import { Container } from 'typedi'; import type { ProcessedData } from '@/databases/entities/processed-data'; import { ProcessedDataRepository } from '@/databases/repositories/processed-data.repository'; diff --git a/packages/cli/src/deprecation/deprecation.service.ts b/packages/cli/src/deprecation/deprecation.service.ts index fef25ff0dd..1b2e6c6174 100644 --- a/packages/cli/src/deprecation/deprecation.service.ts +++ b/packages/cli/src/deprecation/deprecation.service.ts @@ -1,7 +1,6 @@ +import { Service } from '@n8n/di'; +import { Logger } from 'n8n-core'; import { ApplicationError } from 'n8n-workflow'; -import { Service } from 'typedi'; - -import { Logger } from '@/logging/logger.service'; type EnvVarName = string; diff --git a/packages/cli/src/environments/source-control/__tests__/source-control-export.service.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control-export.service.test.ts similarity index 98% rename from packages/cli/src/environments/source-control/__tests__/source-control-export.service.test.ts rename to packages/cli/src/environments.ee/source-control/__tests__/source-control-export.service.test.ts index b04d8a2239..2e5a5e63e3 100644 --- a/packages/cli/src/environments/source-control/__tests__/source-control-export.service.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control-export.service.test.ts @@ -1,7 +1,7 @@ +import { Container } from '@n8n/di'; import mock from 'jest-mock-extended/lib/Mock'; import { Cipher, type InstanceSettings } from 'n8n-core'; import { ApplicationError, deepCopy } from 'n8n-workflow'; -import Container from 'typedi'; import type { CredentialsEntity } from '@/databases/entities/credentials-entity'; import type { SharedCredentials } from '@/databases/entities/shared-credentials'; diff --git a/packages/cli/src/environments/source-control/__tests__/source-control-git.service.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control-git.service.test.ts similarity index 100% rename from packages/cli/src/environments/source-control/__tests__/source-control-git.service.test.ts rename to packages/cli/src/environments.ee/source-control/__tests__/source-control-git.service.test.ts diff --git a/packages/cli/src/environments/source-control/__tests__/source-control-helper.ee.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control-helper.ee.test.ts similarity index 93% rename from packages/cli/src/environments/source-control/__tests__/source-control-helper.ee.test.ts rename to packages/cli/src/environments.ee/source-control/__tests__/source-control-helper.ee.test.ts index 508d1eb49a..c6d1b3857b 100644 --- a/packages/cli/src/environments/source-control/__tests__/source-control-helper.ee.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control-helper.ee.test.ts @@ -1,12 +1,12 @@ +import { Container } from '@n8n/di'; import { constants as fsConstants, accessSync } from 'fs'; import { InstanceSettings } from 'n8n-core'; import path from 'path'; -import Container from 'typedi'; import { SOURCE_CONTROL_SSH_FOLDER, SOURCE_CONTROL_GIT_FOLDER, -} from '@/environments/source-control/constants'; +} from '@/environments.ee/source-control/constants'; import { generateSshKeyPair, getRepoType, @@ -14,10 +14,10 @@ import { getTrackingInformationFromPrePushResult, getTrackingInformationFromPullResult, sourceControlFoldersExistCheck, -} from '@/environments/source-control/source-control-helper.ee'; -import { SourceControlPreferencesService } from '@/environments/source-control/source-control-preferences.service.ee'; -import type { SourceControlPreferences } from '@/environments/source-control/types/source-control-preferences'; -import type { SourceControlledFile } from '@/environments/source-control/types/source-controlled-file'; +} from '@/environments.ee/source-control/source-control-helper.ee'; +import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee'; +import type { SourceControlPreferences } from '@/environments.ee/source-control/types/source-control-preferences'; +import type { SourceControlledFile } from '@/environments.ee/source-control/types/source-controlled-file'; import { License } from '@/license'; import { mockInstance } from '@test/mocking'; diff --git a/packages/cli/src/environments/source-control/__tests__/source-control.service.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts similarity index 77% rename from packages/cli/src/environments/source-control/__tests__/source-control.service.test.ts rename to packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts index e1ebf0e56a..9024a0a32c 100644 --- a/packages/cli/src/environments/source-control/__tests__/source-control.service.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts @@ -1,12 +1,13 @@ +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import { InstanceSettings } from 'n8n-core'; -import { SourceControlPreferencesService } from '@/environments/source-control/source-control-preferences.service.ee'; -import { SourceControlService } from '@/environments/source-control/source-control.service.ee'; +import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee'; +import { SourceControlService } from '@/environments.ee/source-control/source-control.service.ee'; describe('SourceControlService', () => { const preferencesService = new SourceControlPreferencesService( - new InstanceSettings(mock()), + Container.get(InstanceSettings), mock(), mock(), ); diff --git a/packages/cli/src/environments/source-control/constants.ts b/packages/cli/src/environments.ee/source-control/constants.ts similarity index 100% rename from packages/cli/src/environments/source-control/constants.ts rename to packages/cli/src/environments.ee/source-control/constants.ts diff --git a/packages/cli/src/environments/source-control/middleware/source-control-enabled-middleware.ee.ts b/packages/cli/src/environments.ee/source-control/middleware/source-control-enabled-middleware.ee.ts similarity index 96% rename from packages/cli/src/environments/source-control/middleware/source-control-enabled-middleware.ee.ts rename to packages/cli/src/environments.ee/source-control/middleware/source-control-enabled-middleware.ee.ts index 2a8480561d..4157374d56 100644 --- a/packages/cli/src/environments/source-control/middleware/source-control-enabled-middleware.ee.ts +++ b/packages/cli/src/environments.ee/source-control/middleware/source-control-enabled-middleware.ee.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import type { RequestHandler } from 'express'; -import { Container } from 'typedi'; import { isSourceControlLicensed } from '../source-control-helper.ee'; import { SourceControlPreferencesService } from '../source-control-preferences.service.ee'; diff --git a/packages/cli/src/environments/source-control/source-control-export.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts similarity index 98% rename from packages/cli/src/environments/source-control/source-control-export.service.ee.ts rename to packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts index 03352410f4..4980460318 100644 --- a/packages/cli/src/environments/source-control/source-control-export.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts @@ -1,9 +1,9 @@ +import { Container, Service } from '@n8n/di'; import { rmSync } from 'fs'; -import { Credentials, InstanceSettings } from 'n8n-core'; +import { Credentials, InstanceSettings, Logger } from 'n8n-core'; import { ApplicationError, type ICredentialDataDecryptedObject } from 'n8n-workflow'; import { writeFile as fsWriteFile, rm as fsRm } from 'node:fs/promises'; import path from 'path'; -import Container, { Service } from 'typedi'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; @@ -11,7 +11,6 @@ import { SharedWorkflowRepository } from '@/databases/repositories/shared-workfl import { TagRepository } from '@/databases/repositories/tag.repository'; import { WorkflowTagMappingRepository } from '@/databases/repositories/workflow-tag-mapping.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; -import { Logger } from '@/logging/logger.service'; import { SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER, diff --git a/packages/cli/src/environments/source-control/source-control-git.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-git.service.ee.ts similarity index 99% rename from packages/cli/src/environments/source-control/source-control-git.service.ee.ts rename to packages/cli/src/environments.ee/source-control/source-control-git.service.ee.ts index 99571cdd52..01aaf78c68 100644 --- a/packages/cli/src/environments/source-control/source-control-git.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-git.service.ee.ts @@ -1,4 +1,6 @@ +import { Service } from '@n8n/di'; import { execSync } from 'child_process'; +import { Logger } from 'n8n-core'; import { ApplicationError } from 'n8n-workflow'; import path from 'path'; import type { @@ -11,10 +13,8 @@ import type { SimpleGitOptions, StatusResult, } from 'simple-git'; -import { Service } from 'typedi'; import type { User } from '@/databases/entities/user'; -import { Logger } from '@/logging/logger.service'; import { OwnershipService } from '@/services/ownership.service'; import { diff --git a/packages/cli/src/environments/source-control/source-control-helper.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-helper.ee.ts similarity index 98% rename from packages/cli/src/environments/source-control/source-control-helper.ee.ts rename to packages/cli/src/environments.ee/source-control/source-control-helper.ee.ts index 00a9875741..031155a5b5 100644 --- a/packages/cli/src/environments/source-control/source-control-helper.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-helper.ee.ts @@ -1,12 +1,12 @@ +import { Container } from '@n8n/di'; import { generateKeyPairSync } from 'crypto'; import { constants as fsConstants, mkdirSync, accessSync } from 'fs'; +import { Logger } from 'n8n-core'; import { ApplicationError } from 'n8n-workflow'; import { ok } from 'node:assert/strict'; import path from 'path'; -import { Container } from 'typedi'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import { isContainedWithin } from '@/utils/path-util'; import { diff --git a/packages/cli/src/environments/source-control/source-control-import.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts similarity index 99% rename from packages/cli/src/environments/source-control/source-control-import.service.ee.ts rename to packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts index 2e7da80c13..8da041297b 100644 --- a/packages/cli/src/environments/source-control/source-control-import.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts @@ -1,11 +1,11 @@ +import { Container, Service } from '@n8n/di'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In } from '@n8n/typeorm'; import glob from 'fast-glob'; -import { Credentials, ErrorReporter, InstanceSettings } from 'n8n-core'; +import { Credentials, ErrorReporter, InstanceSettings, Logger } from 'n8n-core'; import { ApplicationError, jsonParse, ensureError } from 'n8n-workflow'; import { readFile as fsReadFile } from 'node:fs/promises'; import path from 'path'; -import { Container, Service } from 'typedi'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; import type { Project } from '@/databases/entities/project'; @@ -23,7 +23,6 @@ import { VariablesRepository } from '@/databases/repositories/variables.reposito import { WorkflowTagMappingRepository } from '@/databases/repositories/workflow-tag-mapping.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import type { IWorkflowToImport } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { isUniqueConstraintError } from '@/response-helper'; import { assertNever } from '@/utils'; diff --git a/packages/cli/src/environments/source-control/source-control-preferences.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-preferences.service.ee.ts similarity index 98% rename from packages/cli/src/environments/source-control/source-control-preferences.service.ee.ts rename to packages/cli/src/environments.ee/source-control/source-control-preferences.service.ee.ts index 7c061b6c3c..e530d9d530 100644 --- a/packages/cli/src/environments/source-control/source-control-preferences.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-preferences.service.ee.ts @@ -1,15 +1,14 @@ +import { Container, Service } from '@n8n/di'; import type { ValidationError } from 'class-validator'; import { validate } from 'class-validator'; import { rm as fsRm } from 'fs/promises'; -import { Cipher, InstanceSettings } from 'n8n-core'; +import { Cipher, InstanceSettings, Logger } from 'n8n-core'; import { ApplicationError, jsonParse } from 'n8n-workflow'; import { writeFile, chmod, readFile } from 'node:fs/promises'; import path from 'path'; -import Container, { Service } from 'typedi'; import config from '@/config'; import { SettingsRepository } from '@/databases/repositories/settings.repository'; -import { Logger } from '@/logging/logger.service'; import { SOURCE_CONTROL_SSH_FOLDER, diff --git a/packages/cli/src/environments/source-control/source-control.controller.ee.ts b/packages/cli/src/environments.ee/source-control/source-control.controller.ee.ts similarity index 100% rename from packages/cli/src/environments/source-control/source-control.controller.ee.ts rename to packages/cli/src/environments.ee/source-control/source-control.controller.ee.ts diff --git a/packages/cli/src/environments/source-control/source-control.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts similarity index 99% rename from packages/cli/src/environments/source-control/source-control.service.ee.ts rename to packages/cli/src/environments.ee/source-control/source-control.service.ee.ts index e010210262..3952b6d6b5 100644 --- a/packages/cli/src/environments/source-control/source-control.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts @@ -1,8 +1,9 @@ +import { Service } from '@n8n/di'; import { writeFileSync } from 'fs'; +import { Logger } from 'n8n-core'; import { ApplicationError } from 'n8n-workflow'; import path from 'path'; import type { PushResult } from 'simple-git'; -import { Service } from 'typedi'; import type { TagEntity } from '@/databases/entities/tag-entity'; import type { User } from '@/databases/entities/user'; @@ -10,7 +11,6 @@ import type { Variables } from '@/databases/entities/variables'; import { TagRepository } from '@/databases/repositories/tag.repository'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { EventService } from '@/events/event.service'; -import { Logger } from '@/logging/logger.service'; import { SOURCE_CONTROL_DEFAULT_EMAIL, diff --git a/packages/cli/src/environments/source-control/types/export-result.ts b/packages/cli/src/environments.ee/source-control/types/export-result.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/export-result.ts rename to packages/cli/src/environments.ee/source-control/types/export-result.ts diff --git a/packages/cli/src/environments/source-control/types/exportable-credential.ts b/packages/cli/src/environments.ee/source-control/types/exportable-credential.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/exportable-credential.ts rename to packages/cli/src/environments.ee/source-control/types/exportable-credential.ts diff --git a/packages/cli/src/environments/source-control/types/exportable-workflow.ts b/packages/cli/src/environments.ee/source-control/types/exportable-workflow.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/exportable-workflow.ts rename to packages/cli/src/environments.ee/source-control/types/exportable-workflow.ts diff --git a/packages/cli/src/environments/source-control/types/import-result.ts b/packages/cli/src/environments.ee/source-control/types/import-result.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/import-result.ts rename to packages/cli/src/environments.ee/source-control/types/import-result.ts diff --git a/packages/cli/src/environments/source-control/types/key-pair-type.ts b/packages/cli/src/environments.ee/source-control/types/key-pair-type.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/key-pair-type.ts rename to packages/cli/src/environments.ee/source-control/types/key-pair-type.ts diff --git a/packages/cli/src/environments/source-control/types/key-pair.ts b/packages/cli/src/environments.ee/source-control/types/key-pair.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/key-pair.ts rename to packages/cli/src/environments.ee/source-control/types/key-pair.ts diff --git a/packages/cli/src/environments/source-control/types/requests.ts b/packages/cli/src/environments.ee/source-control/types/requests.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/requests.ts rename to packages/cli/src/environments.ee/source-control/types/requests.ts diff --git a/packages/cli/src/environments/source-control/types/resource-owner.ts b/packages/cli/src/environments.ee/source-control/types/resource-owner.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/resource-owner.ts rename to packages/cli/src/environments.ee/source-control/types/resource-owner.ts diff --git a/packages/cli/src/environments/source-control/types/source-control-commit.ts b/packages/cli/src/environments.ee/source-control/types/source-control-commit.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/source-control-commit.ts rename to packages/cli/src/environments.ee/source-control/types/source-control-commit.ts diff --git a/packages/cli/src/environments/source-control/types/source-control-disconnect.ts b/packages/cli/src/environments.ee/source-control/types/source-control-disconnect.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/source-control-disconnect.ts rename to packages/cli/src/environments.ee/source-control/types/source-control-disconnect.ts diff --git a/packages/cli/src/environments/source-control/types/source-control-generate-key-pair.ts b/packages/cli/src/environments.ee/source-control/types/source-control-generate-key-pair.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/source-control-generate-key-pair.ts rename to packages/cli/src/environments.ee/source-control/types/source-control-generate-key-pair.ts diff --git a/packages/cli/src/environments/source-control/types/source-control-get-status.ts b/packages/cli/src/environments.ee/source-control/types/source-control-get-status.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/source-control-get-status.ts rename to packages/cli/src/environments.ee/source-control/types/source-control-get-status.ts diff --git a/packages/cli/src/environments/source-control/types/source-control-preferences.ts b/packages/cli/src/environments.ee/source-control/types/source-control-preferences.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/source-control-preferences.ts rename to packages/cli/src/environments.ee/source-control/types/source-control-preferences.ts diff --git a/packages/cli/src/environments/source-control/types/source-control-pull-work-folder.ts b/packages/cli/src/environments.ee/source-control/types/source-control-pull-work-folder.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/source-control-pull-work-folder.ts rename to packages/cli/src/environments.ee/source-control/types/source-control-pull-work-folder.ts diff --git a/packages/cli/src/environments/source-control/types/source-control-push-work-folder.ts b/packages/cli/src/environments.ee/source-control/types/source-control-push-work-folder.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/source-control-push-work-folder.ts rename to packages/cli/src/environments.ee/source-control/types/source-control-push-work-folder.ts diff --git a/packages/cli/src/environments/source-control/types/source-control-push.ts b/packages/cli/src/environments.ee/source-control/types/source-control-push.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/source-control-push.ts rename to packages/cli/src/environments.ee/source-control/types/source-control-push.ts diff --git a/packages/cli/src/environments/source-control/types/source-control-set-branch.ts b/packages/cli/src/environments.ee/source-control/types/source-control-set-branch.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/source-control-set-branch.ts rename to packages/cli/src/environments.ee/source-control/types/source-control-set-branch.ts diff --git a/packages/cli/src/environments/source-control/types/source-control-set-read-only.ts b/packages/cli/src/environments.ee/source-control/types/source-control-set-read-only.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/source-control-set-read-only.ts rename to packages/cli/src/environments.ee/source-control/types/source-control-set-read-only.ts diff --git a/packages/cli/src/environments/source-control/types/source-control-stage.ts b/packages/cli/src/environments.ee/source-control/types/source-control-stage.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/source-control-stage.ts rename to packages/cli/src/environments.ee/source-control/types/source-control-stage.ts diff --git a/packages/cli/src/environments/source-control/types/source-control-workflow-version-id.ts b/packages/cli/src/environments.ee/source-control/types/source-control-workflow-version-id.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/source-control-workflow-version-id.ts rename to packages/cli/src/environments.ee/source-control/types/source-control-workflow-version-id.ts diff --git a/packages/cli/src/environments/source-control/types/source-controlled-file.ts b/packages/cli/src/environments.ee/source-control/types/source-controlled-file.ts similarity index 100% rename from packages/cli/src/environments/source-control/types/source-controlled-file.ts rename to packages/cli/src/environments.ee/source-control/types/source-controlled-file.ts diff --git a/packages/cli/src/environments/variables/environment-helpers.ts b/packages/cli/src/environments.ee/variables/environment-helpers.ts similarity index 94% rename from packages/cli/src/environments/variables/environment-helpers.ts rename to packages/cli/src/environments.ee/variables/environment-helpers.ts index 720d2f3a23..dd9a17c95b 100644 --- a/packages/cli/src/environments/variables/environment-helpers.ts +++ b/packages/cli/src/environments.ee/variables/environment-helpers.ts @@ -1,4 +1,4 @@ -import { Container } from 'typedi'; +import { Container } from '@n8n/di'; import { License } from '@/license'; diff --git a/packages/cli/src/environments/variables/variables.controller.ee.ts b/packages/cli/src/environments.ee/variables/variables.controller.ee.ts similarity index 94% rename from packages/cli/src/environments/variables/variables.controller.ee.ts rename to packages/cli/src/environments.ee/variables/variables.controller.ee.ts index a38906b800..460d5fa009 100644 --- a/packages/cli/src/environments/variables/variables.controller.ee.ts +++ b/packages/cli/src/environments.ee/variables/variables.controller.ee.ts @@ -1,7 +1,15 @@ import { VariableListRequestDto } from '@n8n/api-types'; -import { Delete, Get, GlobalScope, Licensed, Patch, Post, RestController } from '@/decorators'; -import { Query } from '@/decorators/args'; +import { + Delete, + Get, + GlobalScope, + Licensed, + Patch, + Post, + Query, + RestController, +} from '@/decorators'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { VariableCountLimitReachedError } from '@/errors/variable-count-limit-reached.error'; diff --git a/packages/cli/src/environments/variables/variables.service.ee.ts b/packages/cli/src/environments.ee/variables/variables.service.ee.ts similarity index 98% rename from packages/cli/src/environments/variables/variables.service.ee.ts rename to packages/cli/src/environments.ee/variables/variables.service.ee.ts index 38ad5703ea..ebb134efd3 100644 --- a/packages/cli/src/environments/variables/variables.service.ee.ts +++ b/packages/cli/src/environments.ee/variables/variables.service.ee.ts @@ -1,4 +1,4 @@ -import { Container, Service } from 'typedi'; +import { Container, Service } from '@n8n/di'; import type { Variables } from '@/databases/entities/variables'; import { VariablesRepository } from '@/databases/repositories/variables.repository'; diff --git a/packages/cli/src/errors/feature-not-licensed.error.ts b/packages/cli/src/errors/feature-not-licensed.error.ts index a61015f2e4..aa53655154 100644 --- a/packages/cli/src/errors/feature-not-licensed.error.ts +++ b/packages/cli/src/errors/feature-not-licensed.error.ts @@ -6,6 +6,7 @@ export class FeatureNotLicensedError extends ApplicationError { constructor(feature: (typeof LICENSE_FEATURES)[keyof typeof LICENSE_FEATURES]) { super( `Your license does not allow for ${feature}. To enable ${feature}, please upgrade to a license that supports this feature.`, + { level: 'warning' }, ); } } diff --git a/packages/cli/src/evaluation/metric.schema.ts b/packages/cli/src/evaluation.ee/metric.schema.ts similarity index 100% rename from packages/cli/src/evaluation/metric.schema.ts rename to packages/cli/src/evaluation.ee/metric.schema.ts diff --git a/packages/cli/src/evaluation/metrics.controller.ts b/packages/cli/src/evaluation.ee/metrics.controller.ts similarity index 99% rename from packages/cli/src/evaluation/metrics.controller.ts rename to packages/cli/src/evaluation.ee/metrics.controller.ts index 816228bf13..2072b978b1 100644 --- a/packages/cli/src/evaluation/metrics.controller.ts +++ b/packages/cli/src/evaluation.ee/metrics.controller.ts @@ -6,7 +6,7 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { testMetricCreateRequestBodySchema, testMetricPatchRequestBodySchema, -} from '@/evaluation/metric.schema'; +} from '@/evaluation.ee/metric.schema'; import { getSharedWorkflowIds } from '@/public-api/v1/handlers/workflows/workflows.service'; import { TestDefinitionService } from './test-definition.service.ee'; diff --git a/packages/cli/src/evaluation/test-definition.schema.ts b/packages/cli/src/evaluation.ee/test-definition.schema.ts similarity index 100% rename from packages/cli/src/evaluation/test-definition.schema.ts rename to packages/cli/src/evaluation.ee/test-definition.schema.ts diff --git a/packages/cli/src/evaluation/test-definition.service.ee.ts b/packages/cli/src/evaluation.ee/test-definition.service.ee.ts similarity index 99% rename from packages/cli/src/evaluation/test-definition.service.ee.ts rename to packages/cli/src/evaluation.ee/test-definition.service.ee.ts index e9a31e7eee..75fbeedfe9 100644 --- a/packages/cli/src/evaluation/test-definition.service.ee.ts +++ b/packages/cli/src/evaluation.ee/test-definition.service.ee.ts @@ -1,4 +1,4 @@ -import { Service } from 'typedi'; +import { Service } from '@n8n/di'; import type { MockedNodeItem, TestDefinition } from '@/databases/entities/test-definition.ee'; import { AnnotationTagRepository } from '@/databases/repositories/annotation-tag.repository.ee'; diff --git a/packages/cli/src/evaluation/test-definitions.controller.ee.ts b/packages/cli/src/evaluation.ee/test-definitions.controller.ee.ts similarity index 97% rename from packages/cli/src/evaluation/test-definitions.controller.ee.ts rename to packages/cli/src/evaluation.ee/test-definitions.controller.ee.ts index ef4a3ed461..bd4a841948 100644 --- a/packages/cli/src/evaluation/test-definitions.controller.ee.ts +++ b/packages/cli/src/evaluation.ee/test-definitions.controller.ee.ts @@ -7,8 +7,8 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { testDefinitionCreateRequestBodySchema, testDefinitionPatchRequestBodySchema, -} from '@/evaluation/test-definition.schema'; -import { TestRunnerService } from '@/evaluation/test-runner/test-runner.service.ee'; +} from '@/evaluation.ee/test-definition.schema'; +import { TestRunnerService } from '@/evaluation.ee/test-runner/test-runner.service.ee'; import { listQueryMiddleware } from '@/middlewares'; import { getSharedWorkflowIds } from '@/public-api/v1/handlers/workflows/workflows.service'; diff --git a/packages/cli/src/evaluation/test-definitions.types.ee.ts b/packages/cli/src/evaluation.ee/test-definitions.types.ee.ts similarity index 100% rename from packages/cli/src/evaluation/test-definitions.types.ee.ts rename to packages/cli/src/evaluation.ee/test-definitions.types.ee.ts diff --git a/packages/cli/src/evaluation/test-runner/__tests__/create-pin-data.ee.test.ts b/packages/cli/src/evaluation.ee/test-runner/__tests__/create-pin-data.ee.test.ts similarity index 100% rename from packages/cli/src/evaluation/test-runner/__tests__/create-pin-data.ee.test.ts rename to packages/cli/src/evaluation.ee/test-runner/__tests__/create-pin-data.ee.test.ts diff --git a/packages/cli/src/evaluation/test-runner/__tests__/evaluation-metrics.ee.test.ts b/packages/cli/src/evaluation.ee/test-runner/__tests__/evaluation-metrics.ee.test.ts similarity index 100% rename from packages/cli/src/evaluation/test-runner/__tests__/evaluation-metrics.ee.test.ts rename to packages/cli/src/evaluation.ee/test-runner/__tests__/evaluation-metrics.ee.test.ts diff --git a/packages/cli/src/evaluation/test-runner/__tests__/get-start-node.ee.test.ts b/packages/cli/src/evaluation.ee/test-runner/__tests__/get-start-node.ee.test.ts similarity index 100% rename from packages/cli/src/evaluation/test-runner/__tests__/get-start-node.ee.test.ts rename to packages/cli/src/evaluation.ee/test-runner/__tests__/get-start-node.ee.test.ts diff --git a/packages/cli/src/evaluation/test-runner/__tests__/mock-data/execution-data.json b/packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/execution-data.json similarity index 100% rename from packages/cli/src/evaluation/test-runner/__tests__/mock-data/execution-data.json rename to packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/execution-data.json diff --git a/packages/cli/src/evaluation/test-runner/__tests__/mock-data/execution-data.multiple-triggers-2.json b/packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/execution-data.multiple-triggers-2.json similarity index 100% rename from packages/cli/src/evaluation/test-runner/__tests__/mock-data/execution-data.multiple-triggers-2.json rename to packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/execution-data.multiple-triggers-2.json diff --git a/packages/cli/src/evaluation/test-runner/__tests__/mock-data/execution-data.multiple-triggers.json b/packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/execution-data.multiple-triggers.json similarity index 100% rename from packages/cli/src/evaluation/test-runner/__tests__/mock-data/execution-data.multiple-triggers.json rename to packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/execution-data.multiple-triggers.json diff --git a/packages/cli/src/evaluation/test-runner/__tests__/mock-data/workflow.evaluation.json b/packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/workflow.evaluation.json similarity index 100% rename from packages/cli/src/evaluation/test-runner/__tests__/mock-data/workflow.evaluation.json rename to packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/workflow.evaluation.json diff --git a/packages/cli/src/evaluation/test-runner/__tests__/mock-data/workflow.multiple-triggers.json b/packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/workflow.multiple-triggers.json similarity index 100% rename from packages/cli/src/evaluation/test-runner/__tests__/mock-data/workflow.multiple-triggers.json rename to packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/workflow.multiple-triggers.json diff --git a/packages/cli/src/evaluation/test-runner/__tests__/mock-data/workflow.under-test.json b/packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/workflow.under-test.json similarity index 100% rename from packages/cli/src/evaluation/test-runner/__tests__/mock-data/workflow.under-test.json rename to packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/workflow.under-test.json diff --git a/packages/cli/src/evaluation/test-runner/__tests__/test-runner.service.ee.test.ts b/packages/cli/src/evaluation.ee/test-runner/__tests__/test-runner.service.ee.test.ts similarity index 100% rename from packages/cli/src/evaluation/test-runner/__tests__/test-runner.service.ee.test.ts rename to packages/cli/src/evaluation.ee/test-runner/__tests__/test-runner.service.ee.test.ts diff --git a/packages/cli/src/evaluation/test-runner/evaluation-metrics.ee.ts b/packages/cli/src/evaluation.ee/test-runner/evaluation-metrics.ee.ts similarity index 100% rename from packages/cli/src/evaluation/test-runner/evaluation-metrics.ee.ts rename to packages/cli/src/evaluation.ee/test-runner/evaluation-metrics.ee.ts diff --git a/packages/cli/src/evaluation/test-runner/test-runner.service.ee.ts b/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts similarity index 99% rename from packages/cli/src/evaluation/test-runner/test-runner.service.ee.ts rename to packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts index f355565955..3ffa90d326 100644 --- a/packages/cli/src/evaluation/test-runner/test-runner.service.ee.ts +++ b/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts @@ -1,3 +1,4 @@ +import { Service } from '@n8n/di'; import { parse } from 'flatted'; import type { IDataObject, @@ -8,7 +9,6 @@ import type { } from 'n8n-workflow'; import { ExecutionCancelledError, NodeConnectionType, Workflow } from 'n8n-workflow'; import assert from 'node:assert'; -import { Service } from 'typedi'; import { ActiveExecutions } from '@/active-executions'; import type { ExecutionEntity } from '@/databases/entities/execution-entity'; diff --git a/packages/cli/src/evaluation/test-runner/utils.ee.ts b/packages/cli/src/evaluation.ee/test-runner/utils.ee.ts similarity index 100% rename from packages/cli/src/evaluation/test-runner/utils.ee.ts rename to packages/cli/src/evaluation.ee/test-runner/utils.ee.ts diff --git a/packages/cli/src/evaluation/test-runs.controller.ee.ts b/packages/cli/src/evaluation.ee/test-runs.controller.ee.ts similarity index 97% rename from packages/cli/src/evaluation/test-runs.controller.ee.ts rename to packages/cli/src/evaluation.ee/test-runs.controller.ee.ts index 37e195ad31..82fbe2c386 100644 --- a/packages/cli/src/evaluation/test-runs.controller.ee.ts +++ b/packages/cli/src/evaluation.ee/test-runs.controller.ee.ts @@ -6,7 +6,7 @@ import { Delete, Get, Post, RestController } from '@/decorators'; import { ConflictError } from '@/errors/response-errors/conflict.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { NotImplementedError } from '@/errors/response-errors/not-implemented.error'; -import { TestRunsRequest } from '@/evaluation/test-definitions.types.ee'; +import { TestRunsRequest } from '@/evaluation.ee/test-definitions.types.ee'; import { TestRunnerService } from '@/evaluation/test-runner/test-runner.service.ee'; import { listQueryMiddleware } from '@/middlewares'; import { getSharedWorkflowIds } from '@/public-api/v1/handlers/workflows/workflows.service'; diff --git a/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-from-db.ts b/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-from-db.ts index 4046855f30..f091d529e4 100644 --- a/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-from-db.ts +++ b/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-from-db.ts @@ -1,8 +1,8 @@ +import { Container } from '@n8n/di'; +import { Logger } from 'n8n-core'; import { MessageEventBusDestinationTypeNames } from 'n8n-workflow'; -import { Container } from 'typedi'; import type { EventDestinations } from '@/databases/entities/event-destinations'; -import { Logger } from '@/logging/logger.service'; import { MessageEventBusDestinationSentry } from './message-event-bus-destination-sentry.ee'; import { MessageEventBusDestinationSyslog } from './message-event-bus-destination-syslog.ee'; diff --git a/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-syslog.ee.ts b/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-syslog.ee.ts index 83db469d79..b1f0675dab 100644 --- a/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-syslog.ee.ts +++ b/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-syslog.ee.ts @@ -1,13 +1,12 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { Container } from '@n8n/di'; +import { Logger } from 'n8n-core'; import type { MessageEventBusDestinationOptions, MessageEventBusDestinationSyslogOptions, } from 'n8n-workflow'; import { MessageEventBusDestinationTypeNames } from 'n8n-workflow'; import syslog from 'syslog-client'; -import Container from 'typedi'; - -import { Logger } from '@/logging/logger.service'; import { MessageEventBusDestination } from './message-event-bus-destination.ee'; import { eventMessageGenericDestinationTestEvent } from '../event-message-classes/event-message-generic'; diff --git a/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-webhook.ee.ts b/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-webhook.ee.ts index a5373d0cc5..34d4eec569 100644 --- a/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-webhook.ee.ts +++ b/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-webhook.ee.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { Container } from '@n8n/di'; import axios from 'axios'; import type { AxiosRequestConfig, Method } from 'axios'; import { Agent as HTTPSAgent } from 'https'; @@ -11,10 +12,9 @@ import type { IWorkflowExecuteAdditionalData, MessageEventBusDestinationWebhookOptions, } from 'n8n-workflow'; -import Container from 'typedi'; import { CredentialsHelper } from '@/credentials-helper'; -import * as SecretsHelpers from '@/external-secrets/external-secrets-helper.ee'; +import * as SecretsHelpers from '@/external-secrets.ee/external-secrets-helper.ee'; import { MessageEventBusDestination } from './message-event-bus-destination.ee'; import { eventMessageGenericDestinationTestEvent } from '../event-message-classes/event-message-generic'; diff --git a/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination.ee.ts b/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination.ee.ts index 7b65767b04..6f990a5cfb 100644 --- a/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination.ee.ts +++ b/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination.ee.ts @@ -1,11 +1,11 @@ +import { Container } from '@n8n/di'; +import { Logger } from 'n8n-core'; import type { INodeCredentials, MessageEventBusDestinationOptions } from 'n8n-workflow'; import { MessageEventBusDestinationTypeNames } from 'n8n-workflow'; -import { Container } from 'typedi'; import { v4 as uuid } from 'uuid'; import { EventDestinationsRepository } from '@/databases/repositories/event-destinations.repository'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import type { EventMessageTypes } from '../event-message-classes'; import type { AbstractEventMessage } from '../event-message-classes/abstract-event-message'; diff --git a/packages/cli/src/eventbus/message-event-bus-writer/message-event-bus-log-writer.ts b/packages/cli/src/eventbus/message-event-bus-writer/message-event-bus-log-writer.ts index 3f3cb50b18..0cc5d5e3a6 100644 --- a/packages/cli/src/eventbus/message-event-bus-writer/message-event-bus-log-writer.ts +++ b/packages/cli/src/eventbus/message-event-bus-writer/message-event-bus-log-writer.ts @@ -1,18 +1,17 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import { once as eventOnce } from 'events'; import { createReadStream, existsSync, rmSync } from 'fs'; import remove from 'lodash/remove'; -import { InstanceSettings } from 'n8n-core'; +import { InstanceSettings, Logger } from 'n8n-core'; import { EventMessageTypeNames, jsonParse } from 'n8n-workflow'; import path, { parse } from 'path'; import readline from 'readline'; -import Container from 'typedi'; import { Worker } from 'worker_threads'; import { inTest } from '@/constants'; -import { Logger } from '@/logging/logger.service'; import type { EventMessageTypes } from '../event-message-classes'; import { isEventMessageOptions } from '../event-message-classes/abstract-event-message'; diff --git a/packages/cli/src/eventbus/message-event-bus/message-event-bus.ts b/packages/cli/src/eventbus/message-event-bus/message-event-bus.ts index 3cf5a5a5d0..22a974f5ee 100644 --- a/packages/cli/src/eventbus/message-event-bus/message-event-bus.ts +++ b/packages/cli/src/eventbus/message-event-bus/message-event-bus.ts @@ -1,19 +1,19 @@ import { GlobalConfig } from '@n8n/config'; +import { Service } from '@n8n/di'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import type { DeleteResult } from '@n8n/typeorm'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In } from '@n8n/typeorm'; import EventEmitter from 'events'; import uniqby from 'lodash/uniqBy'; +import { Logger } from 'n8n-core'; import type { MessageEventBusDestinationOptions } from 'n8n-workflow'; -import { Service } from 'typedi'; import config from '@/config'; import { EventDestinationsRepository } from '@/databases/repositories/event-destinations.repository'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import { Publisher } from '@/scaling/pubsub/publisher.service'; import { ExecutionRecoveryService } from '../../executions/execution-recovery.service'; diff --git a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts index 4448dbc41e..9b4d8aecd2 100644 --- a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts @@ -1,7 +1,8 @@ import type { GlobalConfig } from '@n8n/config'; import { mock } from 'jest-mock-extended'; import { InstanceSettings } from 'n8n-core'; -import type { IWorkflowBase } from 'n8n-workflow'; +import type { INode, INodesGraphResult } from 'n8n-workflow'; +import { NodeApiError, TelemetryHelpers, type IRun, type IWorkflowBase } from 'n8n-workflow'; import { N8N_VERSION } from '@/constants'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; @@ -28,6 +29,9 @@ describe('TelemetryEventRelay', () => { mode: 'smtp', }, }, + diagnostics: { + enabled: true, + }, endpoints: { metrics: { enable: true, @@ -1106,4 +1110,393 @@ describe('TelemetryEventRelay', () => { }); }); }); + + describe('workflow post execute events', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mockWorkflowBase = mock({ + id: 'workflow123', + name: 'Test Workflow', + active: true, + nodes: [ + { + id: 'node1', + name: 'Start', + type: 'n8n-nodes-base.start', + parameters: {}, + typeVersion: 1, + position: [100, 200], + }, + ], + connections: {}, + createdAt: new Date(), + updatedAt: new Date(), + staticData: {}, + settings: {}, + }); + + it('should not track when workflow has no id', async () => { + const event: RelayEventMap['workflow-post-execute'] = { + workflow: { ...mockWorkflowBase, id: '' }, + executionId: 'execution123', + userId: 'user123', + }; + + eventService.emit('workflow-post-execute', event); + + expect(telemetry.trackWorkflowExecution).not.toHaveBeenCalled(); + }); + + it('should not track when execution status is "waiting"', async () => { + const event: RelayEventMap['workflow-post-execute'] = { + workflow: mockWorkflowBase, + executionId: 'execution123', + userId: 'user123', + runData: { + status: 'waiting', + data: { resultData: {} }, + } as IRun, + }; + + eventService.emit('workflow-post-execute', event); + + expect(telemetry.trackWorkflowExecution).not.toHaveBeenCalled(); + }); + + it('should track successful workflow execution', async () => { + const runData = mock({ + finished: true, + status: 'success', + mode: 'manual', + data: { resultData: {} }, + }); + + const event: RelayEventMap['workflow-post-execute'] = { + workflow: mockWorkflowBase, + executionId: 'execution123', + userId: 'user123', + runData: runData as unknown as IRun, + }; + + eventService.emit('workflow-post-execute', event); + + await flushPromises(); + + expect(telemetry.trackWorkflowExecution).toHaveBeenCalledWith( + expect.objectContaining({ + workflow_id: 'workflow123', + user_id: 'user123', + success: true, + is_manual: true, + execution_mode: 'manual', + }), + ); + }); + + it('should call telemetry.track when manual node execution finished', async () => { + sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:editor'); + + const runData = { + status: 'error', + mode: 'manual', + data: { + startData: { + destinationNode: 'OpenAI', + runNodeFilter: ['OpenAI'], + }, + resultData: { + runData: {}, + lastNodeExecuted: 'OpenAI', + error: new NodeApiError( + { + id: '1', + typeVersion: 1, + name: 'Jira', + type: 'n8n-nodes-base.jira', + parameters: {}, + position: [100, 200], + }, + { + message: 'Error message', + description: 'Incorrect API key provided', + httpCode: '401', + stack: '', + }, + { + message: 'Error message', + description: 'Error description', + level: 'warning', + functionality: 'regular', + }, + ), + }, + }, + } as IRun; + + const nodeGraph: INodesGraphResult = { + nodeGraph: { node_types: [], node_connections: [], webhookNodeNames: [] }, + nameIndices: { + Jira: '1', + OpenAI: '1', + }, + } as unknown as INodesGraphResult; + + jest.spyOn(TelemetryHelpers, 'generateNodesGraph').mockImplementation(() => nodeGraph); + + jest + .spyOn(TelemetryHelpers, 'getNodeTypeForName') + .mockImplementation( + () => ({ type: 'n8n-nodes-base.jira', version: 1, name: 'Jira' }) as unknown as INode, + ); + + const event: RelayEventMap['workflow-post-execute'] = { + workflow: mockWorkflowBase, + executionId: 'execution123', + userId: 'user123', + runData, + }; + + eventService.emit('workflow-post-execute', event); + + await flushPromises(); + + expect(telemetry.track).toHaveBeenCalledWith( + 'Manual node exec finished', + expect.objectContaining({ + webhook_domain: null, + user_id: 'user123', + workflow_id: 'workflow123', + status: 'error', + executionStatus: 'error', + sharing_role: 'sharee', + error_message: 'Error message', + error_node_type: 'n8n-nodes-base.jira', + error_node_id: '1', + node_id: '1', + node_type: 'n8n-nodes-base.jira', + node_graph_string: JSON.stringify(nodeGraph.nodeGraph), + }), + ); + + expect(telemetry.trackWorkflowExecution).toHaveBeenCalledWith( + expect.objectContaining({ + workflow_id: 'workflow123', + success: false, + is_manual: true, + execution_mode: 'manual', + version_cli: N8N_VERSION, + error_message: 'Error message', + error_node_type: 'n8n-nodes-base.jira', + node_graph_string: JSON.stringify(nodeGraph.nodeGraph), + error_node_id: '1', + }), + ); + }); + + it('should call telemetry.track when manual node execution finished with canceled error message', async () => { + sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:owner'); + + const runData = { + status: 'error', + mode: 'manual', + data: { + startData: { + destinationNode: 'OpenAI', + runNodeFilter: ['OpenAI'], + }, + resultData: { + runData: {}, + lastNodeExecuted: 'OpenAI', + error: new NodeApiError( + { + id: '1', + typeVersion: 1, + name: 'Jira', + type: 'n8n-nodes-base.jira', + parameters: {}, + position: [100, 200], + }, + { + message: 'Error message', + description: 'Incorrect API key provided', + httpCode: '401', + stack: '', + }, + { + message: 'Error message canceled', + description: 'Error description', + level: 'warning', + functionality: 'regular', + }, + ), + }, + }, + } as IRun; + + const nodeGraph: INodesGraphResult = { + nodeGraph: { node_types: [], node_connections: [] }, + nameIndices: { + Jira: '1', + OpenAI: '1', + }, + } as unknown as INodesGraphResult; + + jest.spyOn(TelemetryHelpers, 'generateNodesGraph').mockImplementation(() => nodeGraph); + + jest + .spyOn(TelemetryHelpers, 'getNodeTypeForName') + .mockImplementation( + () => ({ type: 'n8n-nodes-base.jira', version: 1, name: 'Jira' }) as unknown as INode, + ); + + const event: RelayEventMap['workflow-post-execute'] = { + workflow: mockWorkflowBase, + executionId: 'execution123', + userId: 'user123', + runData, + }; + + eventService.emit('workflow-post-execute', event); + + await flushPromises(); + + expect(telemetry.track).toHaveBeenCalledWith( + 'Manual node exec finished', + expect.objectContaining({ + webhook_domain: null, + user_id: 'user123', + workflow_id: 'workflow123', + status: 'canceled', + executionStatus: 'canceled', + sharing_role: 'owner', + error_message: 'Error message canceled', + error_node_type: 'n8n-nodes-base.jira', + error_node_id: '1', + node_id: '1', + node_type: 'n8n-nodes-base.jira', + node_graph_string: JSON.stringify(nodeGraph.nodeGraph), + }), + ); + + expect(telemetry.trackWorkflowExecution).toHaveBeenCalledWith( + expect.objectContaining({ + workflow_id: 'workflow123', + success: false, + is_manual: true, + execution_mode: 'manual', + version_cli: N8N_VERSION, + error_message: 'Error message canceled', + error_node_type: 'n8n-nodes-base.jira', + node_graph_string: JSON.stringify(nodeGraph.nodeGraph), + error_node_id: '1', + }), + ); + }); + + it('should call telemetry.track when manual workflow execution finished', async () => { + sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:owner'); + + const runData = { + status: 'error', + mode: 'manual', + data: { + startData: { + runNodeFilter: ['OpenAI'], + }, + resultData: { + runData: { + Jira: [ + { + data: { main: [[{ json: { headers: { origin: 'https://www.test.com' } } }]] }, + }, + ], + }, + lastNodeExecuted: 'OpenAI', + error: new NodeApiError( + { + id: '1', + typeVersion: 1, + name: 'Jira', + type: 'n8n-nodes-base.jira', + parameters: {}, + position: [100, 200], + }, + { + message: 'Error message', + description: 'Incorrect API key provided', + httpCode: '401', + stack: '', + }, + { + message: 'Error message', + description: 'Error description', + level: 'warning', + functionality: 'regular', + }, + ), + }, + }, + } as unknown as IRun; + + const nodeGraph: INodesGraphResult = { + webhookNodeNames: ['Jira'], + nodeGraph: { node_types: [], node_connections: [] }, + nameIndices: { + Jira: '1', + OpenAI: '1', + }, + } as unknown as INodesGraphResult; + + jest.spyOn(TelemetryHelpers, 'generateNodesGraph').mockImplementation(() => nodeGraph); + + jest + .spyOn(TelemetryHelpers, 'getNodeTypeForName') + .mockImplementation( + () => ({ type: 'n8n-nodes-base.jira', version: 1, name: 'Jira' }) as unknown as INode, + ); + + const event: RelayEventMap['workflow-post-execute'] = { + workflow: mockWorkflowBase, + executionId: 'execution123', + userId: 'user123', + runData, + }; + + eventService.emit('workflow-post-execute', event); + + await flushPromises(); + + expect(telemetry.track).toHaveBeenCalledWith( + 'Manual workflow exec finished', + expect.objectContaining({ + webhook_domain: 'test.com', + user_id: 'user123', + workflow_id: 'workflow123', + status: 'error', + executionStatus: 'error', + sharing_role: 'owner', + error_message: 'Error message', + error_node_type: 'n8n-nodes-base.jira', + error_node_id: '1', + node_graph_string: JSON.stringify(nodeGraph.nodeGraph), + }), + ); + + expect(telemetry.trackWorkflowExecution).toHaveBeenCalledWith( + expect.objectContaining({ + workflow_id: 'workflow123', + success: false, + is_manual: true, + execution_mode: 'manual', + version_cli: N8N_VERSION, + error_message: 'Error message', + error_node_type: 'n8n-nodes-base.jira', + node_graph_string: JSON.stringify(nodeGraph.nodeGraph), + error_node_id: '1', + }), + ); + }); + }); }); diff --git a/packages/cli/src/events/event.service.ts b/packages/cli/src/events/event.service.ts index b8e00ecea7..dbcb5f2955 100644 --- a/packages/cli/src/events/event.service.ts +++ b/packages/cli/src/events/event.service.ts @@ -1,4 +1,4 @@ -import { Service } from 'typedi'; +import { Service } from '@n8n/di'; import { TypedEmitter } from '@/typed-emitter'; diff --git a/packages/cli/src/events/maps/pub-sub.event-map.ts b/packages/cli/src/events/maps/pub-sub.event-map.ts index ff27741b9b..0d71fcff91 100644 --- a/packages/cli/src/events/maps/pub-sub.event-map.ts +++ b/packages/cli/src/events/maps/pub-sub.event-map.ts @@ -1,4 +1,4 @@ -import type { PushType, WorkerStatus } from '@n8n/api-types'; +import type { PushMessage, WorkerStatus } from '@n8n/api-types'; import type { IWorkflowDb } from '@/interfaces'; @@ -64,9 +64,7 @@ export type PubSubCommandMap = { errorMessage: string; }; - 'relay-execution-lifecycle-event': { - type: PushType; - args: Record; + 'relay-execution-lifecycle-event': PushMessage & { pushRef: string; }; diff --git a/packages/cli/src/events/relays/event-relay.ts b/packages/cli/src/events/relays/event-relay.ts index 13e7dc01be..742bfcba27 100644 --- a/packages/cli/src/events/relays/event-relay.ts +++ b/packages/cli/src/events/relays/event-relay.ts @@ -1,4 +1,4 @@ -import { Service } from 'typedi'; +import { Service } from '@n8n/di'; import { EventService } from '@/events/event.service'; import type { RelayEventMap } from '@/events/maps/relay.event-map'; diff --git a/packages/cli/src/events/relays/log-streaming.event-relay.ts b/packages/cli/src/events/relays/log-streaming.event-relay.ts index c65af2874c..b048b09a83 100644 --- a/packages/cli/src/events/relays/log-streaming.event-relay.ts +++ b/packages/cli/src/events/relays/log-streaming.event-relay.ts @@ -1,5 +1,5 @@ +import { Service } from '@n8n/di'; import type { IWorkflowBase } from 'n8n-workflow'; -import { Service } from 'typedi'; import { Redactable } from '@/decorators/redactable'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; diff --git a/packages/cli/src/events/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts index a34646f100..221449bbab 100644 --- a/packages/cli/src/events/relays/telemetry.event-relay.ts +++ b/packages/cli/src/events/relays/telemetry.event-relay.ts @@ -1,11 +1,11 @@ import { GlobalConfig } from '@n8n/config'; +import { Service } from '@n8n/di'; import { snakeCase } from 'change-case'; import { InstanceSettings } from 'n8n-core'; import type { ExecutionStatus, INodesGraphResult, ITelemetryTrackProperties } from 'n8n-workflow'; import { TelemetryHelpers } from 'n8n-workflow'; import os from 'node:os'; import { get as pslGet } from 'psl'; -import { Service } from 'typedi'; import config from '@/config'; import { N8N_VERSION } from '@/constants'; diff --git a/packages/cli/src/execution-lifecycle-hooks/__tests__/save-execution-progress.test.ts b/packages/cli/src/execution-lifecycle-hooks/__tests__/save-execution-progress.test.ts index eedbf27c9e..ac52cf3920 100644 --- a/packages/cli/src/execution-lifecycle-hooks/__tests__/save-execution-progress.test.ts +++ b/packages/cli/src/execution-lifecycle-hooks/__tests__/save-execution-progress.test.ts @@ -1,11 +1,11 @@ import { ErrorReporter } from 'n8n-core'; +import { Logger } from 'n8n-core'; import type { IRunExecutionData, ITaskData, IWorkflowBase } from 'n8n-workflow'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { saveExecutionProgress } from '@/execution-lifecycle-hooks/save-execution-progress'; import * as fnModule from '@/execution-lifecycle-hooks/to-save-settings'; import type { IExecutionResponse } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { mockInstance } from '@test/mocking'; mockInstance(Logger); diff --git a/packages/cli/src/execution-lifecycle-hooks/restore-binary-data-id.ts b/packages/cli/src/execution-lifecycle-hooks/restore-binary-data-id.ts index d9a1a9a0e9..47c1031859 100644 --- a/packages/cli/src/execution-lifecycle-hooks/restore-binary-data-id.ts +++ b/packages/cli/src/execution-lifecycle-hooks/restore-binary-data-id.ts @@ -1,10 +1,9 @@ -import { BinaryDataService } from 'n8n-core'; +import { Container } from '@n8n/di'; import type { BinaryData } from 'n8n-core'; +import { BinaryDataService, Logger } from 'n8n-core'; import type { IRun, WorkflowExecuteMode } from 'n8n-workflow'; -import Container from 'typedi'; import config from '@/config'; -import { Logger } from '@/logging/logger.service'; /** * Whenever the execution ID is not available to the binary data service at the diff --git a/packages/cli/src/execution-lifecycle-hooks/save-execution-progress.ts b/packages/cli/src/execution-lifecycle-hooks/save-execution-progress.ts index c1de2646c0..9e751c90f6 100644 --- a/packages/cli/src/execution-lifecycle-hooks/save-execution-progress.ts +++ b/packages/cli/src/execution-lifecycle-hooks/save-execution-progress.ts @@ -1,10 +1,9 @@ -import { ErrorReporter } from 'n8n-core'; +import { Container } from '@n8n/di'; +import { ErrorReporter, Logger } from 'n8n-core'; import type { IRunExecutionData, ITaskData, IWorkflowBase } from 'n8n-workflow'; -import { Container } from 'typedi'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { toSaveSettings } from '@/execution-lifecycle-hooks/to-save-settings'; -import { Logger } from '@/logging/logger.service'; export async function saveExecutionProgress( workflowData: IWorkflowBase, diff --git a/packages/cli/src/execution-lifecycle-hooks/shared/shared-hook-functions.ts b/packages/cli/src/execution-lifecycle-hooks/shared/shared-hook-functions.ts index 68fd528f14..657c430e17 100644 --- a/packages/cli/src/execution-lifecycle-hooks/shared/shared-hook-functions.ts +++ b/packages/cli/src/execution-lifecycle-hooks/shared/shared-hook-functions.ts @@ -1,10 +1,10 @@ +import { Container } from '@n8n/di'; import pick from 'lodash/pick'; +import { Logger } from 'n8n-core'; import { ensureError, type ExecutionStatus, type IRun, type IWorkflowBase } from 'n8n-workflow'; -import { Container } from 'typedi'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import type { IExecutionDb, UpdateExecutionPayload } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { ExecutionMetadataService } from '@/services/execution-metadata.service'; import { isWorkflowIdValid } from '@/utils'; diff --git a/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts b/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts index 115a1a52f6..9cb681a7ef 100644 --- a/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts +++ b/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts @@ -1,8 +1,8 @@ +import { Container } from '@n8n/di'; import { stringify } from 'flatted'; import { mock } from 'jest-mock-extended'; import { InstanceSettings } from 'n8n-core'; import { randomInt } from 'n8n-workflow'; -import Container from 'typedi'; import { ARTIFICIAL_TASK_DATA } from '@/constants'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; @@ -22,7 +22,7 @@ import { setupMessages } from './utils'; describe('ExecutionRecoveryService', () => { const push = mockInstance(Push); - const instanceSettings = new InstanceSettings(mock()); + const instanceSettings = Container.get(InstanceSettings); let executionRecoveryService: ExecutionRecoveryService; let executionRepository: ExecutionRepository; diff --git a/packages/cli/src/executions/__tests__/execution.service.test.ts b/packages/cli/src/executions/__tests__/execution.service.test.ts index b91836578c..e425c1e588 100644 --- a/packages/cli/src/executions/__tests__/execution.service.test.ts +++ b/packages/cli/src/executions/__tests__/execution.service.test.ts @@ -183,7 +183,7 @@ describe('ExecutionService', () => { describe('scaling mode', () => { describe('manual execution', () => { - it('should delegate to regular mode in scaling mode', async () => { + it('should stop a `running` execution in scaling mode', async () => { /** * Arrange */ @@ -197,6 +197,8 @@ describe('ExecutionService', () => { concurrencyControl.has.mockReturnValue(false); activeExecutions.has.mockReturnValue(true); waitTracker.has.mockReturnValue(false); + const job = mock({ data: { executionId: '123' } }); + scalingService.findJobsByStatus.mockResolvedValue([job]); executionRepository.stopDuringRun.mockResolvedValue(mock()); // @ts-expect-error Private method const stopInRegularModeSpy = jest.spyOn(executionService, 'stopInRegularMode'); @@ -209,7 +211,7 @@ describe('ExecutionService', () => { /** * Assert */ - expect(stopInRegularModeSpy).toHaveBeenCalledWith(execution); + expect(stopInRegularModeSpy).not.toHaveBeenCalled(); expect(activeExecutions.stopExecution).toHaveBeenCalledWith(execution.id); expect(executionRepository.stopDuringRun).toHaveBeenCalledWith(execution); @@ -242,8 +244,8 @@ describe('ExecutionService', () => { */ expect(waitTracker.stopExecution).not.toHaveBeenCalled(); expect(activeExecutions.stopExecution).toHaveBeenCalled(); - expect(scalingService.findJobsByStatus).toHaveBeenCalled(); - expect(scalingService.stopJob).toHaveBeenCalled(); + expect(scalingService.findJobsByStatus).not.toHaveBeenCalled(); + expect(scalingService.stopJob).not.toHaveBeenCalled(); expect(executionRepository.stopDuringRun).toHaveBeenCalled(); }); @@ -268,8 +270,8 @@ describe('ExecutionService', () => { * Assert */ expect(waitTracker.stopExecution).toHaveBeenCalledWith(execution.id); - expect(scalingService.findJobsByStatus).toHaveBeenCalled(); - expect(scalingService.stopJob).toHaveBeenCalled(); + expect(scalingService.findJobsByStatus).not.toHaveBeenCalled(); + expect(scalingService.stopJob).not.toHaveBeenCalled(); expect(executionRepository.stopDuringRun).toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/executions/execution-recovery.service.ts b/packages/cli/src/executions/execution-recovery.service.ts index 33576d1368..503e53d023 100644 --- a/packages/cli/src/executions/execution-recovery.service.ts +++ b/packages/cli/src/executions/execution-recovery.service.ts @@ -1,8 +1,8 @@ +import { Service } from '@n8n/di'; import type { DateTime } from 'luxon'; -import { InstanceSettings } from 'n8n-core'; +import { InstanceSettings, Logger } from 'n8n-core'; import { sleep } from 'n8n-workflow'; import type { IRun, ITaskData } from 'n8n-workflow'; -import { Service } from 'typedi'; import { ARTIFICIAL_TASK_DATA } from '@/constants'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; @@ -10,7 +10,6 @@ import { NodeCrashedError } from '@/errors/node-crashed.error'; import { WorkflowCrashedError } from '@/errors/workflow-crashed.error'; import { EventService } from '@/events/event.service'; import type { IExecutionResponse } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { Push } from '@/push'; import { getWorkflowHooksMain } from '@/workflow-execute-additional-data'; // @TODO: Dependency cycle @@ -49,7 +48,7 @@ export class ExecutionRecoveryService { this.push.once('editorUiConnected', async () => { await sleep(1000); - this.push.broadcast('executionRecovered', { executionId }); + this.push.broadcast({ type: 'executionRecovered', data: { executionId } }); }); return amendedExecution; diff --git a/packages/cli/src/executions/execution.service.ee.ts b/packages/cli/src/executions/execution.service.ee.ts index cb4ae332ed..b345ef6c99 100644 --- a/packages/cli/src/executions/execution.service.ee.ts +++ b/packages/cli/src/executions/execution.service.ee.ts @@ -1,4 +1,4 @@ -import { Service } from 'typedi'; +import { Service } from '@n8n/di'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import type { IExecutionResponse, IExecutionFlattedResponse } from '@/interfaces'; diff --git a/packages/cli/src/executions/execution.service.ts b/packages/cli/src/executions/execution.service.ts index 433955254f..7daff214bb 100644 --- a/packages/cli/src/executions/execution.service.ts +++ b/packages/cli/src/executions/execution.service.ts @@ -1,5 +1,7 @@ import { GlobalConfig } from '@n8n/config'; +import { Service } from '@n8n/di'; import { validate as jsonSchemaValidate } from 'jsonschema'; +import { Logger } from 'n8n-core'; import type { ExecutionError, ExecutionStatus, @@ -15,7 +17,6 @@ import { Workflow, WorkflowOperationError, } from 'n8n-workflow'; -import { Container, Service } from 'typedi'; import { ActiveExecutions } from '@/active-executions'; import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service'; @@ -38,7 +39,6 @@ import type { IWorkflowDb, } from '@/interfaces'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import { NodeTypes } from '@/node-types'; import { WaitTracker } from '@/wait-tracker'; import { WorkflowRunner } from '@/workflow-runner'; @@ -464,11 +464,6 @@ export class ExecutionService { } private async stopInScalingMode(execution: IExecutionResponse) { - if (execution.mode === 'manual') { - // manual executions in scaling mode are processed by main - return await this.stopInRegularMode(execution); - } - if (this.activeExecutions.has(execution.id)) { this.activeExecutions.stopExecution(execution.id); } @@ -477,18 +472,6 @@ export class ExecutionService { this.waitTracker.stopExecution(execution.id); } - const { ScalingService } = await import('@/scaling/scaling.service'); - const scalingService = Container.get(ScalingService); - const jobs = await scalingService.findJobsByStatus(['active', 'waiting']); - - const job = jobs.find(({ data }) => data.executionId === execution.id); - - if (job) { - await scalingService.stopJob(job); - } else { - this.logger.debug('Job to stop not in queue', { executionId: execution.id }); - } - return await this.executionRepository.stopDuringRun(execution); } diff --git a/packages/cli/src/expression-evaluator.ts b/packages/cli/src/expression-evaluator.ts index 434c78e114..d77838dbc1 100644 --- a/packages/cli/src/expression-evaluator.ts +++ b/packages/cli/src/expression-evaluator.ts @@ -1,6 +1,6 @@ +import { Container } from '@n8n/di'; import { ErrorReporter } from 'n8n-core'; import { ExpressionEvaluatorProxy } from 'n8n-workflow'; -import Container from 'typedi'; import config from '@/config'; diff --git a/packages/cli/src/external-hooks.ts b/packages/cli/src/external-hooks.ts index 764625fa23..8a0ba82c98 100644 --- a/packages/cli/src/external-hooks.ts +++ b/packages/cli/src/external-hooks.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-var-requires */ +import { Service } from '@n8n/di'; import { ApplicationError } from 'n8n-workflow'; -import { Service } from 'typedi'; import config from '@/config'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; diff --git a/packages/cli/src/external-secrets/__tests__/external-secrets-manager.ee.test.ts b/packages/cli/src/external-secrets.ee/__tests__/external-secrets-manager.ee.test.ts similarity index 94% rename from packages/cli/src/external-secrets/__tests__/external-secrets-manager.ee.test.ts rename to packages/cli/src/external-secrets.ee/__tests__/external-secrets-manager.ee.test.ts index 05eabd104f..f3ff03af85 100644 --- a/packages/cli/src/external-secrets/__tests__/external-secrets-manager.ee.test.ts +++ b/packages/cli/src/external-secrets.ee/__tests__/external-secrets-manager.ee.test.ts @@ -1,10 +1,10 @@ +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import { Cipher } from 'n8n-core'; -import { Container } from 'typedi'; import { SettingsRepository } from '@/databases/repositories/settings.repository'; -import { ExternalSecretsManager } from '@/external-secrets/external-secrets-manager.ee'; -import { ExternalSecretsProviders } from '@/external-secrets/external-secrets-providers.ee'; +import { ExternalSecretsManager } from '@/external-secrets.ee/external-secrets-manager.ee'; +import { ExternalSecretsProviders } from '@/external-secrets.ee/external-secrets-providers.ee'; import type { ExternalSecretsSettings } from '@/interfaces'; import { License } from '@/license'; import { diff --git a/packages/cli/src/external-secrets/constants.ts b/packages/cli/src/external-secrets.ee/constants.ts similarity index 100% rename from packages/cli/src/external-secrets/constants.ts rename to packages/cli/src/external-secrets.ee/constants.ts diff --git a/packages/cli/src/external-secrets/external-secrets-helper.ee.ts b/packages/cli/src/external-secrets.ee/external-secrets-helper.ee.ts similarity index 91% rename from packages/cli/src/external-secrets/external-secrets-helper.ee.ts rename to packages/cli/src/external-secrets.ee/external-secrets-helper.ee.ts index 946badde42..d2a8ba34bb 100644 --- a/packages/cli/src/external-secrets/external-secrets-helper.ee.ts +++ b/packages/cli/src/external-secrets.ee/external-secrets-helper.ee.ts @@ -1,5 +1,5 @@ import { GlobalConfig } from '@n8n/config'; -import Container from 'typedi'; +import { Container } from '@n8n/di'; import { License } from '@/license'; diff --git a/packages/cli/src/external-secrets/external-secrets-manager.ee.ts b/packages/cli/src/external-secrets.ee/external-secrets-manager.ee.ts similarity index 98% rename from packages/cli/src/external-secrets/external-secrets-manager.ee.ts rename to packages/cli/src/external-secrets.ee/external-secrets-manager.ee.ts index 2de681a7d6..83579c3e8e 100644 --- a/packages/cli/src/external-secrets/external-secrets-manager.ee.ts +++ b/packages/cli/src/external-secrets.ee/external-secrets-manager.ee.ts @@ -1,6 +1,6 @@ -import { Cipher } from 'n8n-core'; +import { Service } from '@n8n/di'; +import { Cipher, Logger } from 'n8n-core'; import { jsonParse, type IDataObject, ApplicationError, ensureError } from 'n8n-workflow'; -import { Service } from 'typedi'; import { SettingsRepository } from '@/databases/repositories/settings.repository'; import { EventService } from '@/events/event.service'; @@ -10,7 +10,6 @@ import type { SecretsProviderSettings, } from '@/interfaces'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import { Publisher } from '@/scaling/pubsub/publisher.service'; import { EXTERNAL_SECRETS_INITIAL_BACKOFF, EXTERNAL_SECRETS_MAX_BACKOFF } from './constants'; diff --git a/packages/cli/src/external-secrets/external-secrets-providers.ee.ts b/packages/cli/src/external-secrets.ee/external-secrets-providers.ee.ts similarity index 96% rename from packages/cli/src/external-secrets/external-secrets-providers.ee.ts rename to packages/cli/src/external-secrets.ee/external-secrets-providers.ee.ts index 642c107603..8be0f4af06 100644 --- a/packages/cli/src/external-secrets/external-secrets-providers.ee.ts +++ b/packages/cli/src/external-secrets.ee/external-secrets-providers.ee.ts @@ -1,4 +1,4 @@ -import { Service } from 'typedi'; +import { Service } from '@n8n/di'; import type { SecretsProvider } from '@/interfaces'; diff --git a/packages/cli/src/external-secrets/external-secrets.controller.ee.ts b/packages/cli/src/external-secrets.ee/external-secrets.controller.ee.ts similarity index 100% rename from packages/cli/src/external-secrets/external-secrets.controller.ee.ts rename to packages/cli/src/external-secrets.ee/external-secrets.controller.ee.ts diff --git a/packages/cli/src/external-secrets/external-secrets.service.ee.ts b/packages/cli/src/external-secrets.ee/external-secrets.service.ee.ts similarity index 99% rename from packages/cli/src/external-secrets/external-secrets.service.ee.ts rename to packages/cli/src/external-secrets.ee/external-secrets.service.ee.ts index aab3b2069a..f9f0613348 100644 --- a/packages/cli/src/external-secrets/external-secrets.service.ee.ts +++ b/packages/cli/src/external-secrets.ee/external-secrets.service.ee.ts @@ -1,6 +1,6 @@ +import { Container, Service } from '@n8n/di'; import type { IDataObject } from 'n8n-workflow'; import { deepCopy } from 'n8n-workflow'; -import Container, { Service } from 'typedi'; import { CREDENTIAL_BLANKING_VALUE } from '@/constants'; import { ExternalSecretsProviderNotFoundError } from '@/errors/external-secrets-provider-not-found.error'; diff --git a/packages/cli/src/external-secrets/providers/__tests__/azure-key-vault.test.ts b/packages/cli/src/external-secrets.ee/providers/__tests__/azure-key-vault.test.ts similarity index 100% rename from packages/cli/src/external-secrets/providers/__tests__/azure-key-vault.test.ts rename to packages/cli/src/external-secrets.ee/providers/__tests__/azure-key-vault.test.ts diff --git a/packages/cli/src/external-secrets/providers/__tests__/gcp-secrets-manager.test.ts b/packages/cli/src/external-secrets.ee/providers/__tests__/gcp-secrets-manager.test.ts similarity index 100% rename from packages/cli/src/external-secrets/providers/__tests__/gcp-secrets-manager.test.ts rename to packages/cli/src/external-secrets.ee/providers/__tests__/gcp-secrets-manager.test.ts diff --git a/packages/cli/src/external-secrets/providers/aws-secrets/aws-secrets-client.ts b/packages/cli/src/external-secrets.ee/providers/aws-secrets/aws-secrets-client.ts similarity index 100% rename from packages/cli/src/external-secrets/providers/aws-secrets/aws-secrets-client.ts rename to packages/cli/src/external-secrets.ee/providers/aws-secrets/aws-secrets-client.ts diff --git a/packages/cli/src/external-secrets/providers/aws-secrets/aws-secrets-manager.ts b/packages/cli/src/external-secrets.ee/providers/aws-secrets/aws-secrets-manager.ts similarity index 96% rename from packages/cli/src/external-secrets/providers/aws-secrets/aws-secrets-manager.ts rename to packages/cli/src/external-secrets.ee/providers/aws-secrets/aws-secrets-manager.ts index 6c2c0669fb..3e43041735 100644 --- a/packages/cli/src/external-secrets/providers/aws-secrets/aws-secrets-manager.ts +++ b/packages/cli/src/external-secrets.ee/providers/aws-secrets/aws-secrets-manager.ts @@ -1,10 +1,10 @@ +import { Container } from '@n8n/di'; +import { Logger } from 'n8n-core'; import type { INodeProperties } from 'n8n-workflow'; -import Container from 'typedi'; import { UnknownAuthTypeError } from '@/errors/unknown-auth-type.error'; -import { DOCS_HELP_NOTICE, EXTERNAL_SECRETS_NAME_REGEX } from '@/external-secrets/constants'; +import { DOCS_HELP_NOTICE, EXTERNAL_SECRETS_NAME_REGEX } from '@/external-secrets.ee/constants'; import type { SecretsProvider, SecretsProviderState } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { AwsSecretsClient } from './aws-secrets-client'; import type { AwsSecretsManagerContext } from './types'; diff --git a/packages/cli/src/external-secrets/providers/aws-secrets/types.ts b/packages/cli/src/external-secrets.ee/providers/aws-secrets/types.ts similarity index 100% rename from packages/cli/src/external-secrets/providers/aws-secrets/types.ts rename to packages/cli/src/external-secrets.ee/providers/aws-secrets/types.ts diff --git a/packages/cli/src/external-secrets/providers/azure-key-vault/azure-key-vault.ts b/packages/cli/src/external-secrets.ee/providers/azure-key-vault/azure-key-vault.ts similarity index 97% rename from packages/cli/src/external-secrets/providers/azure-key-vault/azure-key-vault.ts rename to packages/cli/src/external-secrets.ee/providers/azure-key-vault/azure-key-vault.ts index 7961f21bad..ce83b09146 100644 --- a/packages/cli/src/external-secrets/providers/azure-key-vault/azure-key-vault.ts +++ b/packages/cli/src/external-secrets.ee/providers/azure-key-vault/azure-key-vault.ts @@ -1,11 +1,11 @@ import type { SecretClient } from '@azure/keyvault-secrets'; +import { Container } from '@n8n/di'; +import { Logger } from 'n8n-core'; import { ensureError } from 'n8n-workflow'; import type { INodeProperties } from 'n8n-workflow'; -import Container from 'typedi'; -import { DOCS_HELP_NOTICE, EXTERNAL_SECRETS_NAME_REGEX } from '@/external-secrets/constants'; +import { DOCS_HELP_NOTICE, EXTERNAL_SECRETS_NAME_REGEX } from '@/external-secrets.ee/constants'; import type { SecretsProvider, SecretsProviderState } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import type { AzureKeyVaultContext } from './types'; diff --git a/packages/cli/src/external-secrets/providers/azure-key-vault/types.ts b/packages/cli/src/external-secrets.ee/providers/azure-key-vault/types.ts similarity index 100% rename from packages/cli/src/external-secrets/providers/azure-key-vault/types.ts rename to packages/cli/src/external-secrets.ee/providers/azure-key-vault/types.ts diff --git a/packages/cli/src/external-secrets/providers/gcp-secrets-manager/gcp-secrets-manager.ts b/packages/cli/src/external-secrets.ee/providers/gcp-secrets-manager/gcp-secrets-manager.ts similarity index 97% rename from packages/cli/src/external-secrets/providers/gcp-secrets-manager/gcp-secrets-manager.ts rename to packages/cli/src/external-secrets.ee/providers/gcp-secrets-manager/gcp-secrets-manager.ts index c4bf71cb72..72f9380ff4 100644 --- a/packages/cli/src/external-secrets/providers/gcp-secrets-manager/gcp-secrets-manager.ts +++ b/packages/cli/src/external-secrets.ee/providers/gcp-secrets-manager/gcp-secrets-manager.ts @@ -1,10 +1,10 @@ import type { SecretManagerServiceClient as GcpClient } from '@google-cloud/secret-manager'; +import { Container } from '@n8n/di'; +import { Logger } from 'n8n-core'; import { ensureError, jsonParse, type INodeProperties } from 'n8n-workflow'; -import Container from 'typedi'; -import { DOCS_HELP_NOTICE, EXTERNAL_SECRETS_NAME_REGEX } from '@/external-secrets/constants'; +import { DOCS_HELP_NOTICE, EXTERNAL_SECRETS_NAME_REGEX } from '@/external-secrets.ee/constants'; import type { SecretsProvider, SecretsProviderState } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import type { GcpSecretsManagerContext, diff --git a/packages/cli/src/external-secrets/providers/gcp-secrets-manager/types.ts b/packages/cli/src/external-secrets.ee/providers/gcp-secrets-manager/types.ts similarity index 100% rename from packages/cli/src/external-secrets/providers/gcp-secrets-manager/types.ts rename to packages/cli/src/external-secrets.ee/providers/gcp-secrets-manager/types.ts diff --git a/packages/cli/src/external-secrets/providers/infisical.ts b/packages/cli/src/external-secrets.ee/providers/infisical.ts similarity index 100% rename from packages/cli/src/external-secrets/providers/infisical.ts rename to packages/cli/src/external-secrets.ee/providers/infisical.ts diff --git a/packages/cli/src/external-secrets/providers/vault.ts b/packages/cli/src/external-secrets.ee/providers/vault.ts similarity index 99% rename from packages/cli/src/external-secrets/providers/vault.ts rename to packages/cli/src/external-secrets.ee/providers/vault.ts index 0f1e93a5da..e27df4cc6d 100644 --- a/packages/cli/src/external-secrets/providers/vault.ts +++ b/packages/cli/src/external-secrets.ee/providers/vault.ts @@ -1,11 +1,11 @@ +import { Container } from '@n8n/di'; import type { AxiosInstance, AxiosResponse } from 'axios'; import axios from 'axios'; +import { Logger } from 'n8n-core'; import type { IDataObject, INodeProperties } from 'n8n-workflow'; -import { Container } from 'typedi'; import type { SecretsProviderSettings, SecretsProviderState } from '@/interfaces'; import { SecretsProvider } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { DOCS_HELP_NOTICE, EXTERNAL_SECRETS_NAME_REGEX } from '../constants'; import { preferGet } from '../external-secrets-helper.ee'; diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index 8581483577..9213048fdd 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -1,6 +1,6 @@ +import { Container } from '@n8n/di'; import { Help } from '@oclif/core'; -import Container from 'typedi'; -import { Logger } from 'winston'; +import { Logger } from 'n8n-core'; // oclif expects a default export // eslint-disable-next-line import/no-default-export diff --git a/packages/cli/src/ldap/__tests__/helpers.test.ts b/packages/cli/src/ldap.ee/__tests__/helpers.test.ts similarity index 96% rename from packages/cli/src/ldap/__tests__/helpers.test.ts rename to packages/cli/src/ldap.ee/__tests__/helpers.test.ts index 5d38c58e1a..3e7a9c4b4b 100644 --- a/packages/cli/src/ldap/__tests__/helpers.test.ts +++ b/packages/cli/src/ldap.ee/__tests__/helpers.test.ts @@ -2,7 +2,7 @@ import { AuthIdentity } from '@/databases/entities/auth-identity'; import { User } from '@/databases/entities/user'; import { UserRepository } from '@/databases/repositories/user.repository'; import { generateNanoId } from '@/databases/utils/generators'; -import * as helpers from '@/ldap/helpers.ee'; +import * as helpers from '@/ldap.ee/helpers.ee'; import { mockInstance } from '@test/mocking'; const userRepository = mockInstance(UserRepository); diff --git a/packages/cli/src/ldap/constants.ts b/packages/cli/src/ldap.ee/constants.ts similarity index 100% rename from packages/cli/src/ldap/constants.ts rename to packages/cli/src/ldap.ee/constants.ts diff --git a/packages/cli/src/ldap/helpers.ee.ts b/packages/cli/src/ldap.ee/helpers.ee.ts similarity index 99% rename from packages/cli/src/ldap/helpers.ee.ts rename to packages/cli/src/ldap.ee/helpers.ee.ts index 7af0917054..dcc643d808 100644 --- a/packages/cli/src/ldap/helpers.ee.ts +++ b/packages/cli/src/ldap.ee/helpers.ee.ts @@ -1,8 +1,8 @@ +import { Container } from '@n8n/di'; import { validate } from 'jsonschema'; import type { Entry as LdapUser } from 'ldapts'; import { Filter } from 'ldapts/filters/Filter'; import { randomString } from 'n8n-workflow'; -import { Container } from 'typedi'; import config from '@/config'; import { AuthIdentity } from '@/databases/entities/auth-identity'; diff --git a/packages/cli/src/ldap/ldap.controller.ee.ts b/packages/cli/src/ldap.ee/ldap.controller.ee.ts similarity index 100% rename from packages/cli/src/ldap/ldap.controller.ee.ts rename to packages/cli/src/ldap.ee/ldap.controller.ee.ts diff --git a/packages/cli/src/ldap/ldap.service.ee.ts b/packages/cli/src/ldap.ee/ldap.service.ee.ts similarity index 98% rename from packages/cli/src/ldap/ldap.service.ee.ts rename to packages/cli/src/ldap.ee/ldap.service.ee.ts index b552db6974..a4d9cbc97d 100644 --- a/packages/cli/src/ldap/ldap.service.ee.ts +++ b/packages/cli/src/ldap.ee/ldap.service.ee.ts @@ -1,11 +1,11 @@ +import { Service } from '@n8n/di'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { QueryFailedError } from '@n8n/typeorm'; import type { Entry as LdapUser, ClientOptions } from 'ldapts'; import { Client } from 'ldapts'; -import { Cipher } from 'n8n-core'; +import { Cipher, Logger } from 'n8n-core'; import { ApplicationError, jsonParse } from 'n8n-workflow'; import type { ConnectionOptions } from 'tls'; -import { Service } from 'typedi'; import config from '@/config'; import type { RunningMode, SyncStatus } from '@/databases/entities/auth-provider-sync-history'; @@ -14,13 +14,12 @@ import { SettingsRepository } from '@/databases/repositories/settings.repository import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { EventService } from '@/events/event.service'; -import { Logger } from '@/logging/logger.service'; import { getCurrentAuthenticationMethod, isEmailCurrentAuthenticationMethod, isLdapCurrentAuthenticationMethod, setCurrentAuthenticationMethod, -} from '@/sso/sso-helpers'; +} from '@/sso.ee/sso-helpers'; import { BINARY_AD_ATTRIBUTES, diff --git a/packages/cli/src/ldap/types.ts b/packages/cli/src/ldap.ee/types.ts similarity index 100% rename from packages/cli/src/ldap/types.ts rename to packages/cli/src/ldap.ee/types.ts diff --git a/packages/cli/src/license.ts b/packages/cli/src/license.ts index 2a3ae6fd6d..7a747530c2 100644 --- a/packages/cli/src/license.ts +++ b/packages/cli/src/license.ts @@ -1,13 +1,12 @@ import { GlobalConfig } from '@n8n/config'; +import { Container, Service } from '@n8n/di'; import type { TEntitlement, TFeatures, TLicenseBlock } from '@n8n_io/license-sdk'; import { LicenseManager } from '@n8n_io/license-sdk'; -import { InstanceSettings, ObjectStoreService } from 'n8n-core'; -import Container, { Service } from 'typedi'; +import { InstanceSettings, ObjectStoreService, Logger } from 'n8n-core'; import config from '@/config'; import { SettingsRepository } from '@/databases/repositories/settings.repository'; import { OnShutdown } from '@/decorators/on-shutdown'; -import { Logger } from '@/logging/logger.service'; import { LicenseMetricsService } from '@/metrics/license-metrics.service'; import { @@ -256,6 +255,10 @@ export class License { return this.isFeatureEnabled(LICENSE_FEATURES.ASK_AI); } + isAiCreditsEnabled() { + return this.isFeatureEnabled(LICENSE_FEATURES.AI_CREDITS); + } + isAdvancedExecutionFiltersEnabled() { return this.isFeatureEnabled(LICENSE_FEATURES.ADVANCED_EXECUTION_FILTERS); } @@ -366,6 +369,10 @@ export class License { return this.getFeatureValue(LICENSE_QUOTAS.VARIABLES_LIMIT) ?? UNLIMITED_LICENSE_QUOTA; } + getAiCredits() { + return this.getFeatureValue(LICENSE_QUOTAS.AI_CREDITS) ?? 0; + } + getWorkflowHistoryPruneLimit() { return ( this.getFeatureValue(LICENSE_QUOTAS.WORKFLOW_HISTORY_PRUNE_LIMIT) ?? UNLIMITED_LICENSE_QUOTA diff --git a/packages/cli/src/license/license.service.ts b/packages/cli/src/license/license.service.ts index 1419d58b83..bd2eca414c 100644 --- a/packages/cli/src/license/license.service.ts +++ b/packages/cli/src/license/license.service.ts @@ -1,13 +1,13 @@ +import { Service } from '@n8n/di'; import axios, { AxiosError } from 'axios'; +import { Logger } from 'n8n-core'; import { ensureError } from 'n8n-workflow'; -import { Service } from 'typedi'; import type { User } from '@/databases/entities/user'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { EventService } from '@/events/event.service'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import { UrlService } from '@/services/url.service'; type LicenseError = Error & { errorId?: keyof typeof LicenseErrors }; diff --git a/packages/cli/src/load-nodes-and-credentials.ts b/packages/cli/src/load-nodes-and-credentials.ts index f6f66d024b..b027192388 100644 --- a/packages/cli/src/load-nodes-and-credentials.ts +++ b/packages/cli/src/load-nodes-and-credentials.ts @@ -1,4 +1,5 @@ import { GlobalConfig } from '@n8n/config'; +import { Container, Service } from '@n8n/di'; import glob from 'fast-glob'; import fsPromises from 'fs/promises'; import type { Class, DirectoryLoader, Types } from 'n8n-core'; @@ -11,6 +12,7 @@ import { LazyPackageDirectoryLoader, UnrecognizedCredentialTypeError, UnrecognizedNodeTypeError, + Logger, } from 'n8n-core'; import type { KnownNodesAndCredentials, @@ -27,7 +29,6 @@ import type { import { ApplicationError, NodeConnectionType } from 'n8n-workflow'; import path from 'path'; import picocolors from 'picocolors'; -import { Container, Service } from 'typedi'; import { CUSTOM_API_CALL_KEY, @@ -36,7 +37,6 @@ import { CLI_DIR, inE2ETests, } from '@/constants'; -import { Logger } from '@/logging/logger.service'; import { isContainedWithin } from '@/utils/path-util'; interface LoadedNodesAndCredentials { @@ -520,7 +520,7 @@ export class LoadNodesAndCredentials { loader.reset(); await loader.loadAll(); await this.postProcessLoaders(); - push.broadcast('nodeDescriptionUpdated', {}); + push.broadcast({ type: 'nodeDescriptionUpdated', data: {} }); }, 100); const toWatch = loader.isLazyLoaded diff --git a/packages/cli/src/logging/constants.ts b/packages/cli/src/logging/constants.ts deleted file mode 100644 index 107327694b..0000000000 --- a/packages/cli/src/logging/constants.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const noOp = () => {}; - -export const LOG_LEVELS = ['error', 'warn', 'info', 'debug', 'silent'] as const; diff --git a/packages/cli/src/logging/types.ts b/packages/cli/src/logging/types.ts deleted file mode 100644 index bb01834326..0000000000 --- a/packages/cli/src/logging/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { LogScope } from '@n8n/config'; - -import type { LOG_LEVELS } from './constants'; - -export type LogLevel = (typeof LOG_LEVELS)[number]; - -export type LogMetadata = { - [key: string]: unknown; - scopes?: LogScope[]; - file?: string; - function?: string; -}; - -export type LogLocationMetadata = Pick; diff --git a/packages/cli/src/manual-execution.service.ts b/packages/cli/src/manual-execution.service.ts index 65174d20b5..fd6c27215f 100644 --- a/packages/cli/src/manual-execution.service.ts +++ b/packages/cli/src/manual-execution.service.ts @@ -1,9 +1,11 @@ +import { Service } from '@n8n/di'; import * as a from 'assert/strict'; import { DirectedGraph, filterDisabledNodes, recreateNodeExecutionStack, WorkflowExecute, + Logger, } from 'n8n-core'; import type { IPinData, @@ -14,9 +16,6 @@ import type { Workflow, } from 'n8n-workflow'; import type PCancelable from 'p-cancelable'; -import { Service } from 'typedi'; - -import { Logger } from '@/logging/logger.service'; @Service() export class ManualExecutionService { diff --git a/packages/cli/src/metrics/license-metrics.service.ts b/packages/cli/src/metrics/license-metrics.service.ts index 338fa70a3e..bd4ca6055b 100644 --- a/packages/cli/src/metrics/license-metrics.service.ts +++ b/packages/cli/src/metrics/license-metrics.service.ts @@ -1,4 +1,4 @@ -import { Service } from 'typedi'; +import { Service } from '@n8n/di'; import { LicenseMetricsRepository } from '@/databases/repositories/license-metrics.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; diff --git a/packages/cli/src/metrics/prometheus-metrics.service.ts b/packages/cli/src/metrics/prometheus-metrics.service.ts index 41714d25ad..b17b8f8578 100644 --- a/packages/cli/src/metrics/prometheus-metrics.service.ts +++ b/packages/cli/src/metrics/prometheus-metrics.service.ts @@ -1,11 +1,11 @@ import { GlobalConfig } from '@n8n/config'; +import { Service } from '@n8n/di'; import type express from 'express'; import promBundle from 'express-prom-bundle'; import { InstanceSettings } from 'n8n-core'; import { EventMessageTypeNames } from 'n8n-workflow'; import promClient, { type Counter, type Gauge } from 'prom-client'; import semverParse from 'semver/functions/parse'; -import { Service } from 'typedi'; import config from '@/config'; import { N8N_VERSION } from '@/constants'; diff --git a/packages/cli/src/mfa/helpers.ts b/packages/cli/src/mfa/helpers.ts index d08ed98c16..9e785d293c 100644 --- a/packages/cli/src/mfa/helpers.ts +++ b/packages/cli/src/mfa/helpers.ts @@ -1,4 +1,4 @@ -import Container from 'typedi'; +import { Container } from '@n8n/di'; import config from '@/config'; import { UserRepository } from '@/databases/repositories/user.repository'; diff --git a/packages/cli/src/mfa/mfa.service.ts b/packages/cli/src/mfa/mfa.service.ts index 84433f5f18..19bc9aead0 100644 --- a/packages/cli/src/mfa/mfa.service.ts +++ b/packages/cli/src/mfa/mfa.service.ts @@ -1,5 +1,5 @@ +import { Service } from '@n8n/di'; import { Cipher } from 'n8n-core'; -import { Service } from 'typedi'; import { v4 as uuid } from 'uuid'; import { AuthUserRepository } from '@/databases/repositories/auth-user.repository'; diff --git a/packages/cli/src/mfa/totp.service.ts b/packages/cli/src/mfa/totp.service.ts index ec9f651635..5d543fd2b1 100644 --- a/packages/cli/src/mfa/totp.service.ts +++ b/packages/cli/src/mfa/totp.service.ts @@ -1,5 +1,5 @@ +import { Service } from '@n8n/di'; import OTPAuth from 'otpauth'; -import { Service } from 'typedi'; @Service() export class TOTPService { diff --git a/packages/cli/src/middlewares/body-parser.ts b/packages/cli/src/middlewares/body-parser.ts index 59327ea080..699ff72c6b 100644 --- a/packages/cli/src/middlewares/body-parser.ts +++ b/packages/cli/src/middlewares/body-parser.ts @@ -1,11 +1,11 @@ import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import type { Request, RequestHandler } from 'express'; import { parseIncomingMessage } from 'n8n-core'; import { jsonParse } from 'n8n-workflow'; import { parse as parseQueryString } from 'querystring'; import getRawBody from 'raw-body'; import { type Readable } from 'stream'; -import Container from 'typedi'; import { Parser as XmlParser } from 'xml2js'; import { createGunzip, createInflate } from 'zlib'; diff --git a/packages/cli/src/middlewares/list-query/dtos/base.filter.dto.ts b/packages/cli/src/middlewares/list-query/dtos/base.filter.dto.ts index 678cb86981..9fa5c21677 100644 --- a/packages/cli/src/middlewares/list-query/dtos/base.filter.dto.ts +++ b/packages/cli/src/middlewares/list-query/dtos/base.filter.dto.ts @@ -1,9 +1,8 @@ import { plainToInstance, instanceToPlain } from 'class-transformer'; import { validate } from 'class-validator'; +import { isObjectLiteral } from 'n8n-core'; import { ApplicationError, jsonParse } from 'n8n-workflow'; -import { isObjectLiteral } from '@/utils'; - export class BaseFilter { protected static async toFilter(rawFilter: string, Filter: typeof BaseFilter) { const dto = jsonParse(rawFilter, { errorMessage: 'Failed to parse filter JSON' }); diff --git a/packages/cli/src/node-types.ts b/packages/cli/src/node-types.ts index 1837cd7bcc..77d872dc26 100644 --- a/packages/cli/src/node-types.ts +++ b/packages/cli/src/node-types.ts @@ -1,10 +1,10 @@ +import { Service } from '@n8n/di'; import type { NeededNodeType } from '@n8n/task-runner'; import type { Dirent } from 'fs'; import { readdir } from 'fs/promises'; import type { INodeType, INodeTypeDescription, INodeTypes, IVersionedNodeType } from 'n8n-workflow'; import { ApplicationError, NodeHelpers } from 'n8n-workflow'; import { join, dirname } from 'path'; -import { Service } from 'typedi'; import { LoadNodesAndCredentials } from './load-nodes-and-credentials'; diff --git a/packages/cli/src/permissions/check-access.ts b/packages/cli/src/permissions.ee/check-access.ts similarity index 98% rename from packages/cli/src/permissions/check-access.ts rename to packages/cli/src/permissions.ee/check-access.ts index f4abfcc00f..062e3c1865 100644 --- a/packages/cli/src/permissions/check-access.ts +++ b/packages/cli/src/permissions.ee/check-access.ts @@ -1,8 +1,8 @@ +import { Container } from '@n8n/di'; import type { Scope } from '@n8n/permissions'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In } from '@n8n/typeorm'; import { ApplicationError } from 'n8n-workflow'; -import { Container } from 'typedi'; import type { User } from '@/databases/entities/user'; import { ProjectRepository } from '@/databases/repositories/project.repository'; diff --git a/packages/cli/src/permissions/global-roles.ts b/packages/cli/src/permissions.ee/global-roles.ts similarity index 100% rename from packages/cli/src/permissions/global-roles.ts rename to packages/cli/src/permissions.ee/global-roles.ts diff --git a/packages/cli/src/permissions/project-roles.ts b/packages/cli/src/permissions.ee/project-roles.ts similarity index 100% rename from packages/cli/src/permissions/project-roles.ts rename to packages/cli/src/permissions.ee/project-roles.ts diff --git a/packages/cli/src/permissions/resource-roles.ts b/packages/cli/src/permissions.ee/resource-roles.ts similarity index 100% rename from packages/cli/src/permissions/resource-roles.ts rename to packages/cli/src/permissions.ee/resource-roles.ts diff --git a/packages/cli/src/posthog/index.ts b/packages/cli/src/posthog/index.ts index be025c8a85..6dcc6dd650 100644 --- a/packages/cli/src/posthog/index.ts +++ b/packages/cli/src/posthog/index.ts @@ -1,8 +1,8 @@ import { GlobalConfig } from '@n8n/config'; +import { Service } from '@n8n/di'; import { InstanceSettings } from 'n8n-core'; import type { FeatureFlags, ITelemetryTrackProperties } from 'n8n-workflow'; import type { PostHog } from 'posthog-node'; -import { Service } from 'typedi'; import type { PublicUser } from '@/interfaces'; diff --git a/packages/cli/src/public-api/index.ts b/packages/cli/src/public-api/index.ts index 92b3602828..1696135990 100644 --- a/packages/cli/src/public-api/index.ts +++ b/packages/cli/src/public-api/index.ts @@ -1,11 +1,11 @@ import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import type { Router } from 'express'; import express from 'express'; import type { HttpError } from 'express-openapi-validator/dist/framework/types'; import fs from 'fs/promises'; import path from 'path'; import type { JsonObject } from 'swagger-ui-express'; -import { Container } from 'typedi'; import validator from 'validator'; import YAML from 'yamljs'; diff --git a/packages/cli/src/public-api/v1/handlers/audit/audit.handler.ts b/packages/cli/src/public-api/v1/handlers/audit/audit.handler.ts index eb611d9ba5..595dd0cf42 100644 --- a/packages/cli/src/public-api/v1/handlers/audit/audit.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/audit/audit.handler.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import type { Response } from 'express'; -import Container from 'typedi'; import type { AuditRequest } from '@/public-api/types'; import { globalScope } from '@/public-api/v1/shared/middlewares/global.middleware'; diff --git a/packages/cli/src/public-api/v1/handlers/credentials/credentials.handler.ts b/packages/cli/src/public-api/v1/handlers/credentials/credentials.handler.ts index d987aa827e..ef029cdaf6 100644 --- a/packages/cli/src/public-api/v1/handlers/credentials/credentials.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/credentials/credentials.handler.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { Container } from '@n8n/di'; import type express from 'express'; -import { Container } from 'typedi'; import { z } from 'zod'; import { CredentialTypes } from '@/credential-types'; diff --git a/packages/cli/src/public-api/v1/handlers/credentials/credentials.middleware.ts b/packages/cli/src/public-api/v1/handlers/credentials/credentials.middleware.ts index 59ebb7b6d0..181092a9b9 100644 --- a/packages/cli/src/public-api/v1/handlers/credentials/credentials.middleware.ts +++ b/packages/cli/src/public-api/v1/handlers/credentials/credentials.middleware.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-invalid-void-type */ +import { Container } from '@n8n/di'; import type express from 'express'; import { validate } from 'jsonschema'; -import { Container } from 'typedi'; import { CredentialTypes } from '@/credential-types'; import { CredentialsHelper } from '@/credentials-helper'; diff --git a/packages/cli/src/public-api/v1/handlers/credentials/credentials.service.ts b/packages/cli/src/public-api/v1/handlers/credentials/credentials.service.ts index 2477ef928f..4e2754a12e 100644 --- a/packages/cli/src/public-api/v1/handlers/credentials/credentials.service.ts +++ b/packages/cli/src/public-api/v1/handlers/credentials/credentials.service.ts @@ -1,3 +1,4 @@ +import { Container } from '@n8n/di'; import { Credentials } from 'n8n-core'; import type { DisplayCondition, @@ -5,7 +6,6 @@ import type { INodeProperties, INodePropertyOptions, } from 'n8n-workflow'; -import { Container } from 'typedi'; import { CredentialsEntity } from '@/databases/entities/credentials-entity'; import { SharedCredentials } from '@/databases/entities/shared-credentials'; diff --git a/packages/cli/src/public-api/v1/handlers/executions/executions.handler.ts b/packages/cli/src/public-api/v1/handlers/executions/executions.handler.ts index 896a4f2d0d..1eedc8de8b 100644 --- a/packages/cli/src/public-api/v1/handlers/executions/executions.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/executions/executions.handler.ts @@ -1,6 +1,6 @@ +import { Container } from '@n8n/di'; import type express from 'express'; import { replaceCircularReferences } from 'n8n-workflow'; -import { Container } from 'typedi'; import { ActiveExecutions } from '@/active-executions'; import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service'; diff --git a/packages/cli/src/public-api/v1/handlers/projects/projects.handler.ts b/packages/cli/src/public-api/v1/handlers/projects/projects.handler.ts index ea7220f788..a693058f93 100644 --- a/packages/cli/src/public-api/v1/handlers/projects/projects.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/projects/projects.handler.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import type { Response } from 'express'; -import Container from 'typedi'; import { ProjectController } from '@/controllers/project.controller'; import { ProjectRepository } from '@/databases/repositories/project.repository'; diff --git a/packages/cli/src/public-api/v1/handlers/source-control/source-control.handler.ts b/packages/cli/src/public-api/v1/handlers/source-control/source-control.handler.ts index fdcb2f16ba..646a3f075e 100644 --- a/packages/cli/src/public-api/v1/handlers/source-control/source-control.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/source-control/source-control.handler.ts @@ -1,14 +1,14 @@ +import { Container } from '@n8n/di'; import type express from 'express'; import type { StatusResult } from 'simple-git'; -import { Container } from 'typedi'; import { getTrackingInformationFromPullResult, isSourceControlLicensed, -} from '@/environments/source-control/source-control-helper.ee'; -import { SourceControlPreferencesService } from '@/environments/source-control/source-control-preferences.service.ee'; -import { SourceControlService } from '@/environments/source-control/source-control.service.ee'; -import type { ImportResult } from '@/environments/source-control/types/import-result'; +} from '@/environments.ee/source-control/source-control-helper.ee'; +import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee'; +import { SourceControlService } from '@/environments.ee/source-control/source-control.service.ee'; +import type { ImportResult } from '@/environments.ee/source-control/types/import-result'; import { EventService } from '@/events/event.service'; import type { PublicSourceControlRequest } from '../../../types'; diff --git a/packages/cli/src/public-api/v1/handlers/tags/tags.handler.ts b/packages/cli/src/public-api/v1/handlers/tags/tags.handler.ts index daf5d78414..821be0c5fd 100644 --- a/packages/cli/src/public-api/v1/handlers/tags/tags.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/tags/tags.handler.ts @@ -1,7 +1,7 @@ +import { Container } from '@n8n/di'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import type { FindManyOptions } from '@n8n/typeorm'; import type express from 'express'; -import { Container } from 'typedi'; import type { TagEntity } from '@/databases/entities/tag-entity'; import { TagRepository } from '@/databases/repositories/tag.repository'; diff --git a/packages/cli/src/public-api/v1/handlers/users/users.handler.ee.ts b/packages/cli/src/public-api/v1/handlers/users/users.handler.ee.ts index 3b84b89da3..1a544aca29 100644 --- a/packages/cli/src/public-api/v1/handlers/users/users.handler.ee.ts +++ b/packages/cli/src/public-api/v1/handlers/users/users.handler.ee.ts @@ -1,7 +1,7 @@ -import { RoleChangeRequestDto } from '@n8n/api-types'; +import { InviteUsersRequestDto, RoleChangeRequestDto } from '@n8n/api-types'; +import { Container } from '@n8n/di'; import type express from 'express'; import type { Response } from 'express'; -import { Container } from 'typedi'; import { InvitationController } from '@/controllers/invitation.controller'; import { UsersController } from '@/controllers/users.controller'; @@ -18,7 +18,7 @@ import { } from '../../shared/middlewares/global.middleware'; import { encodeNextCursor } from '../../shared/services/pagination.service'; -type Create = UserRequest.Invite; +type Create = AuthenticatedRequest<{}, {}, InviteUsersRequestDto>; type Delete = UserRequest.Delete; type ChangeRole = AuthenticatedRequest<{ id: string }, {}, RoleChangeRequestDto, {}>; @@ -82,8 +82,16 @@ export = { createUser: [ globalScope('user:create'), async (req: Create, res: Response) => { - const usersInvited = await Container.get(InvitationController).inviteUser(req); + const { data, error } = InviteUsersRequestDto.safeParse(req.body); + if (error) { + return res.status(400).json(error.errors[0]); + } + const usersInvited = await Container.get(InvitationController).inviteUser( + req, + res, + data as InviteUsersRequestDto, + ); return res.status(201).json(usersInvited); }, ], diff --git a/packages/cli/src/public-api/v1/handlers/users/users.service.ee.ts b/packages/cli/src/public-api/v1/handlers/users/users.service.ee.ts index f4e1b86878..fa50ce3cf0 100644 --- a/packages/cli/src/public-api/v1/handlers/users/users.service.ee.ts +++ b/packages/cli/src/public-api/v1/handlers/users/users.service.ee.ts @@ -1,7 +1,7 @@ +import { Container } from '@n8n/di'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In } from '@n8n/typeorm'; import pick from 'lodash/pick'; -import { Container } from 'typedi'; import { validate as uuidValidate } from 'uuid'; import type { User } from '@/databases/entities/user'; diff --git a/packages/cli/src/public-api/v1/handlers/variables/variables.handler.ts b/packages/cli/src/public-api/v1/handlers/variables/variables.handler.ts index 65fb1daab5..d3eec9b2d5 100644 --- a/packages/cli/src/public-api/v1/handlers/variables/variables.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/variables/variables.handler.ts @@ -1,8 +1,8 @@ +import { Container } from '@n8n/di'; import type { Response } from 'express'; -import Container from 'typedi'; import { VariablesRepository } from '@/databases/repositories/variables.repository'; -import { VariablesController } from '@/environments/variables/variables.controller.ee'; +import { VariablesController } from '@/environments.ee/variables/variables.controller.ee'; import type { PaginatedRequest } from '@/public-api/types'; import type { VariablesRequest } from '@/requests'; diff --git a/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts index 7a9003dc28..b79ea4547b 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts @@ -1,9 +1,9 @@ +import { Container } from '@n8n/di'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import type { FindOptionsWhere } from '@n8n/typeorm'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In, Like, QueryFailedError } from '@n8n/typeorm'; import type express from 'express'; -import { Container } from 'typedi'; import { v4 as uuid } from 'uuid'; import { z } from 'zod'; @@ -17,7 +17,7 @@ import { WorkflowRepository } from '@/databases/repositories/workflow.repository import { EventService } from '@/events/event.service'; import { ExternalHooks } from '@/external-hooks'; import { addNodeIds, replaceInvalidCredentials } from '@/workflow-helpers'; -import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service.ee'; +import { WorkflowHistoryService } from '@/workflows/workflow-history.ee/workflow-history.service.ee'; import { WorkflowService } from '@/workflows/workflow.service'; import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee'; diff --git a/packages/cli/src/public-api/v1/handlers/workflows/workflows.service.ts b/packages/cli/src/public-api/v1/handlers/workflows/workflows.service.ts index f4a2d38156..53e67d8b0d 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/workflows.service.ts +++ b/packages/cli/src/public-api/v1/handlers/workflows/workflows.service.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import type { Scope } from '@n8n/permissions'; -import { Container } from 'typedi'; import config from '@/config'; import type { Project } from '@/databases/entities/project'; diff --git a/packages/cli/src/public-api/v1/shared/middlewares/global.middleware.ts b/packages/cli/src/public-api/v1/shared/middlewares/global.middleware.ts index ed68d4761c..3343d8daac 100644 --- a/packages/cli/src/public-api/v1/shared/middlewares/global.middleware.ts +++ b/packages/cli/src/public-api/v1/shared/middlewares/global.middleware.ts @@ -1,12 +1,12 @@ /* eslint-disable @typescript-eslint/no-invalid-void-type */ +import { Container } from '@n8n/di'; import type { Scope } from '@n8n/permissions'; import type express from 'express'; -import { Container } from 'typedi'; import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; import type { BooleanLicenseFeature } from '@/interfaces'; import { License } from '@/license'; -import { userHasScopes } from '@/permissions/check-access'; +import { userHasScopes } from '@/permissions.ee/check-access'; import type { AuthenticatedRequest } from '@/requests'; import type { PaginatedRequest } from '../../../types'; diff --git a/packages/cli/src/push/__tests__/index.test.ts b/packages/cli/src/push/__tests__/index.test.ts index 03457926b1..ae46487a38 100644 --- a/packages/cli/src/push/__tests__/index.test.ts +++ b/packages/cli/src/push/__tests__/index.test.ts @@ -20,7 +20,7 @@ describe('Push', () => { test('should validate pushRef on requests for websocket backend', () => { config.set('push.backend', 'websocket'); - const push = new Push(mock(), mock()); + const push = new Push(mock(), mock(), mock()); const ws = mock(); const request = mock({ user, ws }); request.query = { pushRef: '' }; @@ -33,7 +33,7 @@ describe('Push', () => { test('should validate pushRef on requests for SSE backend', () => { config.set('push.backend', 'sse'); - const push = new Push(mock(), mock()); + const push = new Push(mock(), mock(), mock()); const request = mock({ user, ws: undefined }); request.query = { pushRef: '' }; expect(() => push.handleRequest(request, mock())).toThrow(BadRequestError); diff --git a/packages/cli/src/push/__tests__/websocket.push.test.ts b/packages/cli/src/push/__tests__/websocket.push.test.ts index 2362e5a0c6..64a5526300 100644 --- a/packages/cli/src/push/__tests__/websocket.push.test.ts +++ b/packages/cli/src/push/__tests__/websocket.push.test.ts @@ -1,10 +1,10 @@ import type { PushMessage } from '@n8n/api-types'; +import { Container } from '@n8n/di'; import { EventEmitter } from 'events'; -import { Container } from 'typedi'; +import { Logger } from 'n8n-core'; import type WebSocket from 'ws'; import type { User } from '@/databases/entities/user'; -import { Logger } from '@/logging/logger.service'; import { WebSocketPush } from '@/push/websocket.push'; import { mockInstance } from '@test/mocking'; @@ -73,7 +73,7 @@ describe('WebSocketPush', () => { it('sends data to one connection', () => { webSocketPush.add(pushRef1, userId, mockWebSocket1); webSocketPush.add(pushRef2, userId, mockWebSocket2); - webSocketPush.sendToOne(pushMessage.type, pushMessage.data, pushRef1); + webSocketPush.sendToOne(pushMessage, pushRef1); expect(mockWebSocket1.send).toHaveBeenCalledWith(expectedMsg); expect(mockWebSocket2.send).not.toHaveBeenCalled(); @@ -82,7 +82,7 @@ describe('WebSocketPush', () => { it('sends data to all connections', () => { webSocketPush.add(pushRef1, userId, mockWebSocket1); webSocketPush.add(pushRef2, userId, mockWebSocket2); - webSocketPush.sendToAll(pushMessage.type, pushMessage.data); + webSocketPush.sendToAll(pushMessage); expect(mockWebSocket1.send).toHaveBeenCalledWith(expectedMsg); expect(mockWebSocket2.send).toHaveBeenCalledWith(expectedMsg); @@ -101,7 +101,7 @@ describe('WebSocketPush', () => { it('sends data to all users connections', () => { webSocketPush.add(pushRef1, userId, mockWebSocket1); webSocketPush.add(pushRef2, userId, mockWebSocket2); - webSocketPush.sendToUsers(pushMessage.type, pushMessage.data, [userId]); + webSocketPush.sendToUsers(pushMessage, [userId]); expect(mockWebSocket1.send).toHaveBeenCalledWith(expectedMsg); expect(mockWebSocket2.send).toHaveBeenCalledWith(expectedMsg); diff --git a/packages/cli/src/push/abstract.push.ts b/packages/cli/src/push/abstract.push.ts index 83a859fc75..d42fddb556 100644 --- a/packages/cli/src/push/abstract.push.ts +++ b/packages/cli/src/push/abstract.push.ts @@ -1,10 +1,9 @@ -import type { PushPayload, PushType } from '@n8n/api-types'; -import { ErrorReporter } from 'n8n-core'; +import type { PushMessage } from '@n8n/api-types'; +import { Service } from '@n8n/di'; +import { ErrorReporter, Logger } from 'n8n-core'; import { assert, jsonStringify } from 'n8n-workflow'; -import { Service } from 'typedi'; import type { User } from '@/databases/entities/user'; -import { Logger } from '@/logging/logger.service'; import type { OnPushMessage } from '@/push/types'; import { TypedEmitter } from '@/typed-emitter'; @@ -69,7 +68,7 @@ export abstract class AbstractPush extends TypedEmitter(type: Type, data: PushPayload, pushRefs: string[]) { + private sendTo({ type, data }: PushMessage, pushRefs: string[]) { this.logger.debug(`Pushed to frontend: ${type}`, { dataType: type, pushRefs: pushRefs.join(', '), @@ -90,30 +89,26 @@ export abstract class AbstractPush extends TypedEmitter(type: Type, data: PushPayload) { - this.sendTo(type, data, Object.keys(this.connections)); + sendToAll(pushMsg: PushMessage) { + this.sendTo(pushMsg, Object.keys(this.connections)); } - sendToOne(type: Type, data: PushPayload, pushRef: string) { + sendToOne(pushMsg: PushMessage, pushRef: string) { if (this.connections[pushRef] === undefined) { this.logger.error(`The session "${pushRef}" is not registered.`, { pushRef }); return; } - this.sendTo(type, data, [pushRef]); + this.sendTo(pushMsg, [pushRef]); } - sendToUsers( - type: Type, - data: PushPayload, - userIds: Array, - ) { + sendToUsers(pushMsg: PushMessage, userIds: Array) { const { connections } = this; const userPushRefs = Object.keys(connections).filter((pushRef) => userIds.includes(this.userIdByPushRef[pushRef]), ); - this.sendTo(type, data, userPushRefs); + this.sendTo(pushMsg, userPushRefs); } closeAllConnections() { diff --git a/packages/cli/src/push/index.ts b/packages/cli/src/push/index.ts index 0007001e33..e795df5722 100644 --- a/packages/cli/src/push/index.ts +++ b/packages/cli/src/push/index.ts @@ -1,15 +1,17 @@ -import type { PushPayload, PushType } from '@n8n/api-types'; +import type { PushMessage } from '@n8n/api-types'; +import { Container, Service } from '@n8n/di'; import type { Application } from 'express'; import { ServerResponse } from 'http'; import type { Server } from 'http'; -import { InstanceSettings } from 'n8n-core'; +import { InstanceSettings, Logger } from 'n8n-core'; +import { deepCopy } from 'n8n-workflow'; import type { Socket } from 'net'; -import { Container, Service } from 'typedi'; import { parse as parseUrl } from 'url'; import { Server as WSServer } from 'ws'; import { AuthService } from '@/auth/auth.service'; import config from '@/config'; +import { TRIMMED_TASK_DATA_CONNECTIONS } from '@/constants'; import type { User } from '@/databases/entities/user'; import { OnShutdown } from '@/decorators/on-shutdown'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; @@ -27,6 +29,12 @@ type PushEvents = { const useWebSockets = config.getEnv('push.backend') === 'websocket'; +/** + * Max allowed size of a push message in bytes. Events going through the pubsub + * channel are trimmed if exceeding this size. + */ +const MAX_PAYLOAD_SIZE_BYTES = 5 * 1024 * 1024; // 5 MiB + /** * Push service for uni- or bi-directional communication with frontend clients. * Uses either server-sent events (SSE, unidirectional from backend --> frontend) @@ -43,8 +51,10 @@ export class Push extends TypedEmitter { constructor( private readonly instanceSettings: InstanceSettings, private readonly publisher: Publisher, + private readonly logger: Logger, ) { super(); + this.logger = this.logger.scoped('push'); if (useWebSockets) this.backend.on('message', (msg) => this.emit('message', msg)); } @@ -81,40 +91,92 @@ export class Push extends TypedEmitter { this.emit('editorUiConnected', pushRef); } - broadcast(type: Type, data: PushPayload) { - this.backend.sendToAll(type, data); + broadcast(pushMsg: PushMessage) { + this.backend.sendToAll(pushMsg); } - send(type: Type, data: PushPayload, pushRef: string) { - /** - * Multi-main setup: In a manual webhook execution, the main process that - * handles a webhook might not be the same as the main process that created - * the webhook. If so, the handler process commands the creator process to - * relay the former's execution lifecycle events to the creator's frontend. - */ - if (this.instanceSettings.isMultiMain && !this.backend.hasPushRef(pushRef)) { - void this.publisher.publishCommand({ - command: 'relay-execution-lifecycle-event', - payload: { type, args: data, pushRef }, - }); + /** Returns whether a given push ref is registered. */ + hasPushRef(pushRef: string) { + return this.backend.hasPushRef(pushRef); + } + + send(pushMsg: PushMessage, pushRef: string) { + if (this.shouldRelayViaPubSub(pushRef)) { + this.relayViaPubSub(pushMsg, pushRef); return; } - this.backend.sendToOne(type, data, pushRef); + this.backend.sendToOne(pushMsg, pushRef); } - sendToUsers( - type: Type, - data: PushPayload, - userIds: Array, - ) { - this.backend.sendToUsers(type, data, userIds); + sendToUsers(pushMsg: PushMessage, userIds: Array) { + this.backend.sendToUsers(pushMsg, userIds); } @OnShutdown() onShutdown() { this.backend.closeAllConnections(); } + + /** + * Whether to relay a push message via pubsub channel to other instances, + * instead of pushing the message directly to the frontend. + * + * This is needed in two scenarios: + * + * In scaling mode, in single- or multi-main setup, in a manual execution, a + * worker has no connection to a frontend and so relays to all mains lifecycle + * events for manual executions. Only the main who holds the session for the + * execution will push to the frontend who commissioned the execution. + * + * In scaling mode, in multi-main setup, in a manual webhook execution, if + * the main who handles a webhook is not the main who created the webhook, + * the handler main relays execution lifecycle events to all mains. Only + * the main who holds the session for the execution will push events to + * the frontend who commissioned the execution. + */ + private shouldRelayViaPubSub(pushRef: string) { + const { isWorker, isMultiMain } = this.instanceSettings; + + return isWorker || (isMultiMain && !this.hasPushRef(pushRef)); + } + + /** + * Relay a push message via the `n8n.commands` pubsub channel, + * reducing the payload size if too large. + * + * See {@link shouldRelayViaPubSub} for more details. + */ + private relayViaPubSub(pushMsg: PushMessage, pushRef: string) { + const eventSizeBytes = new TextEncoder().encode(JSON.stringify(pushMsg.data)).length; + + if (eventSizeBytes <= MAX_PAYLOAD_SIZE_BYTES) { + void this.publisher.publishCommand({ + command: 'relay-execution-lifecycle-event', + payload: { ...pushMsg, pushRef }, + }); + return; + } + + // too large for pubsub channel, trim it + + const pushMsgCopy = deepCopy(pushMsg); + + const toMb = (bytes: number) => (bytes / (1024 * 1024)).toFixed(0); + const eventMb = toMb(eventSizeBytes); + const maxMb = toMb(MAX_PAYLOAD_SIZE_BYTES); + const { type } = pushMsgCopy; + + this.logger.warn(`Size of "${type}" (${eventMb} MB) exceeds max size ${maxMb} MB. Trimming...`); + + if (type === 'nodeExecuteAfter') pushMsgCopy.data.data.data = TRIMMED_TASK_DATA_CONNECTIONS; + else if (type === 'executionFinished') pushMsgCopy.data.rawData = ''; // prompt client to fetch from DB + + void this.publisher.publishCommand({ + command: 'relay-execution-lifecycle-event', + payload: { ...pushMsgCopy, pushRef }, + }); + } } export const setupPushServer = (restEndpoint: string, server: Server, app: Application) => { diff --git a/packages/cli/src/push/sse.push.ts b/packages/cli/src/push/sse.push.ts index 04e39d6d79..9c92cc8cb3 100644 --- a/packages/cli/src/push/sse.push.ts +++ b/packages/cli/src/push/sse.push.ts @@ -1,4 +1,4 @@ -import { Service } from 'typedi'; +import { Service } from '@n8n/di'; import type { User } from '@/databases/entities/user'; diff --git a/packages/cli/src/push/websocket.push.ts b/packages/cli/src/push/websocket.push.ts index 97e45028b2..297490f6cf 100644 --- a/packages/cli/src/push/websocket.push.ts +++ b/packages/cli/src/push/websocket.push.ts @@ -1,5 +1,5 @@ +import { Service } from '@n8n/di'; import { ApplicationError } from 'n8n-workflow'; -import { Service } from 'typedi'; import type WebSocket from 'ws'; import type { User } from '@/databases/entities/user'; diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 7afb1e1bd3..5776549566 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -1,26 +1,20 @@ import type { Scope } from '@n8n/permissions'; -import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk'; import type express from 'express'; import type { - BannerName, ICredentialDataDecryptedObject, IDataObject, - ILoadOptions, INodeCredentialTestRequest, - INodeCredentials, - INodeParameters, - INodeTypeNameVersion, IPersonalizationSurveyAnswersV4, IUser, } from 'n8n-workflow'; import type { CredentialsEntity } from '@/databases/entities/credentials-entity'; -import type { Project, ProjectType } from '@/databases/entities/project'; +import type { Project, ProjectIcon, ProjectType } from '@/databases/entities/project'; import type { AssignableRole, GlobalRole, User } from '@/databases/entities/user'; import type { Variables } from '@/databases/entities/variables'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import type { WorkflowHistory } from '@/databases/entities/workflow-history'; -import type { PublicUser, SecretsProvider, SecretsProviderState } from '@/interfaces'; +import type { SecretsProvider, SecretsProviderState } from '@/interfaces'; import type { ProjectRole } from './databases/entities/project-relation'; import type { ScopesField } from './services/role.service'; @@ -125,7 +119,7 @@ export namespace ListQuery { } type SlimUser = Pick; -export type SlimProject = Pick; +export type SlimProject = Pick; export function hasSharing( workflows: ListQuery.Workflow.Plain[] | ListQuery.Workflow.WithSharing[], @@ -144,6 +138,7 @@ export declare namespace CredentialRequest { type: string; data: ICredentialDataDecryptedObject; projectId?: string; + isManaged?: boolean; }>; type Create = AuthenticatedRequest<{}, {}, CredentialProperties>; @@ -196,53 +191,11 @@ export declare namespace MeRequest { export type SurveyAnswers = AuthenticatedRequest<{}, {}, IPersonalizationSurveyAnswersV4>; } -export interface UserSetupPayload { - email: string; - password: string; - firstName: string; - lastName: string; - mfaEnabled?: boolean; - mfaSecret?: string; - mfaRecoveryCodes?: string[]; -} - -// ---------------------------------- -// /owner -// ---------------------------------- - -export declare namespace OwnerRequest { - type Post = AuthenticatedRequest<{}, {}, UserSetupPayload, {}>; - - type DismissBanner = AuthenticatedRequest<{}, {}, Partial<{ bannerName: BannerName }>, {}>; -} - -// ---------------------------------- -// password reset endpoints -// ---------------------------------- - -export declare namespace PasswordResetRequest { - export type Email = AuthlessRequest<{}, {}, Pick>; - - export type Credentials = AuthlessRequest<{}, {}, {}, { userId?: string; token?: string }>; - - export type NewPassword = AuthlessRequest< - {}, - {}, - Pick & { token?: string; userId?: string; mfaCode?: string } - >; -} - // ---------------------------------- // /users // ---------------------------------- export declare namespace UserRequest { - export type Invite = AuthenticatedRequest< - {}, - {}, - Array<{ email: string; role?: AssignableRole }> - >; - export type InviteResponse = { user: { id: string; @@ -254,18 +207,6 @@ export declare namespace UserRequest { error?: string; }; - export type ResolveSignUp = AuthlessRequest< - {}, - {}, - {}, - { inviterId?: string; inviteeId?: string } - >; - - export type SignUp = AuthenticatedRequest< - { id: string }, - { inviterId?: string; inviteeId?: string } - >; - export type Delete = AuthenticatedRequest< { id: string; email: string; identifier: string }, {}, @@ -281,36 +222,8 @@ export declare namespace UserRequest { >; export type PasswordResetLink = AuthenticatedRequest<{ id: string }, {}, {}, {}>; - - export type Reinvite = AuthenticatedRequest<{ id: string }>; - - export type Update = AuthlessRequest< - { id: string }, - {}, - { - inviterId: string; - firstName: string; - lastName: string; - password: string; - } - >; } -// ---------------------------------- -// /login -// ---------------------------------- - -export type LoginRequest = AuthlessRequest< - {}, - {}, - { - email: string; - password: string; - mfaCode?: string; - mfaRecoveryCode?: string; - } ->; - // ---------------------------------- // MFA endpoints // ---------------------------------- @@ -351,47 +264,6 @@ export declare namespace OAuthRequest { } } -// ---------------------------------- -// /dynamic-node-parameters -// ---------------------------------- -export declare namespace DynamicNodeParametersRequest { - type BaseRequest = AuthenticatedRequest< - {}, - {}, - { - path: string; - nodeTypeAndVersion: INodeTypeNameVersion; - currentNodeParameters: INodeParameters; - methodName?: string; - credentials?: INodeCredentials; - } & RequestBody, - {} - >; - - /** POST /dynamic-node-parameters/options */ - type Options = BaseRequest<{ - loadOptions?: ILoadOptions; - }>; - - /** POST /dynamic-node-parameters/resource-locator-results */ - type ResourceLocatorResults = BaseRequest<{ - methodName: string; - filter?: string; - paginationToken?: string; - }>; - - /** POST dynamic-node-parameters/resource-mapper-fields */ - type ResourceMapperFields = BaseRequest<{ - methodName: string; - }>; - - /** POST /dynamic-node-parameters/action-result */ - type ActionResult = BaseRequest<{ - handler: string; - payload: IDataObject | string | undefined; - }>; -} - // ---------------------------------- // /tags // ---------------------------------- @@ -523,6 +395,7 @@ export declare namespace ProjectRequest { Project, { name: string; + icon?: ProjectIcon; } >; @@ -551,6 +424,7 @@ export declare namespace ProjectRequest { type ProjectWithRelations = { id: string; name: string | undefined; + icon: ProjectIcon; type: ProjectType; relations: ProjectRelationResponse[]; scopes: Scope[]; @@ -560,7 +434,11 @@ export declare namespace ProjectRequest { type Update = AuthenticatedRequest< { projectId: string }, {}, - { name?: string; relations?: ProjectRelationPayload[] } + { + name?: string; + relations?: ProjectRelationPayload[]; + icon?: { type: 'icon' | 'emoji'; value: string }; + } >; type Delete = AuthenticatedRequest<{ projectId: string }, {}, {}, { transferId?: string }>; } @@ -574,15 +452,3 @@ export declare namespace NpsSurveyRequest { // once some schema validation is added type NpsSurveyUpdate = AuthenticatedRequest<{}, {}, unknown>; } - -// ---------------------------------- -// /ai-assistant -// ---------------------------------- - -export declare namespace AiAssistantRequest { - type Chat = AuthenticatedRequest<{}, {}, AiAssistantSDK.ChatRequestPayload>; - - type SuggestionPayload = { sessionId: string; suggestionId: string }; - type ApplySuggestionPayload = AuthenticatedRequest<{}, {}, SuggestionPayload>; - type AskAiPayload = AuthenticatedRequest<{}, {}, AiAssistantSDK.AskAiRequestPayload>; -} diff --git a/packages/cli/src/response-helper.ts b/packages/cli/src/response-helper.ts index 0e70aa312f..74f274560e 100644 --- a/packages/cli/src/response-helper.ts +++ b/packages/cli/src/response-helper.ts @@ -1,13 +1,12 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { Container } from '@n8n/di'; import type { Request, Response } from 'express'; -import { ErrorReporter } from 'n8n-core'; +import { ErrorReporter, Logger } from 'n8n-core'; import { FORM_TRIGGER_PATH_IDENTIFIER, NodeApiError } from 'n8n-workflow'; import { Readable } from 'node:stream'; import picocolors from 'picocolors'; -import Container from 'typedi'; import { inDevelopment } from '@/constants'; -import { Logger } from '@/logging/logger.service'; import { ResponseError } from './errors/response-errors/abstract/response.error'; diff --git a/packages/cli/src/runners/runner-lifecycle-events.ts b/packages/cli/src/runners/runner-lifecycle-events.ts deleted file mode 100644 index 8ea2da38b1..0000000000 --- a/packages/cli/src/runners/runner-lifecycle-events.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Service } from 'typedi'; - -import { TypedEmitter } from '@/typed-emitter'; - -type RunnerLifecycleEventMap = { - 'runner:failed-heartbeat-check': never; - 'runner:timed-out-during-task': never; -}; - -@Service() -export class RunnerLifecycleEvents extends TypedEmitter {} diff --git a/packages/cli/src/scaling/__tests__/job-processor.service.test.ts b/packages/cli/src/scaling/__tests__/job-processor.service.test.ts index 73264e6382..897986c915 100644 --- a/packages/cli/src/scaling/__tests__/job-processor.service.test.ts +++ b/packages/cli/src/scaling/__tests__/job-processor.service.test.ts @@ -19,6 +19,7 @@ describe('JobProcessor', () => { mock(), mock(), mock(), + mock(), ); const result = await jobProcessor.processJob(mock()); diff --git a/packages/cli/src/scaling/__tests__/pubsub-handler.test.ts b/packages/cli/src/scaling/__tests__/pubsub-handler.test.ts index 314ded0b8b..bc15911913 100644 --- a/packages/cli/src/scaling/__tests__/pubsub-handler.test.ts +++ b/packages/cli/src/scaling/__tests__/pubsub-handler.test.ts @@ -7,17 +7,16 @@ import type { ActiveWorkflowManager } from '@/active-workflow-manager'; import type { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import type { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import { EventService } from '@/events/event.service'; -import type { ExternalSecretsManager } from '@/external-secrets/external-secrets-manager.ee'; +import type { ExternalSecretsManager } from '@/external-secrets.ee/external-secrets-manager.ee'; import type { IWorkflowDb } from '@/interfaces'; import type { License } from '@/license'; import type { Push } from '@/push'; -import type { WebSocketPush } from '@/push/websocket.push'; import type { CommunityPackagesService } from '@/services/community-packages.service'; import type { TestWebhooks } from '@/webhooks/test-webhooks'; import type { Publisher } from '../pubsub/publisher.service'; import { PubSubHandler } from '../pubsub/pubsub-handler'; -import type { WorkerStatusService } from '../worker-status.service'; +import type { WorkerStatusService } from '../worker-status.service.ee'; const flushPromises = async () => await new Promise((resolve) => setImmediate(resolve)); @@ -620,7 +619,10 @@ describe('PubSubHandler', () => { expect(activeWorkflowManager.add).toHaveBeenCalledWith(workflowId, 'activate', undefined, { shouldPublish: false, }); - expect(push.broadcast).toHaveBeenCalledWith('workflowActivated', { workflowId }); + expect(push.broadcast).toHaveBeenCalledWith({ + type: 'workflowActivated', + data: { workflowId }, + }); expect(publisher.publishCommand).toHaveBeenCalledWith({ command: 'display-workflow-activation', payload: { workflowId }, @@ -680,7 +682,10 @@ describe('PubSubHandler', () => { expect(activeWorkflowManager.removeWorkflowTriggersAndPollers).toHaveBeenCalledWith( workflowId, ); - expect(push.broadcast).toHaveBeenCalledWith('workflowDeactivated', { workflowId }); + expect(push.broadcast).toHaveBeenCalledWith({ + type: 'workflowDeactivated', + data: { workflowId }, + }); expect(publisher.publishCommand).toHaveBeenCalledWith({ command: 'display-workflow-deactivation', payload: { workflowId }, @@ -735,7 +740,10 @@ describe('PubSubHandler', () => { eventService.emit('display-workflow-activation', { workflowId }); - expect(push.broadcast).toHaveBeenCalledWith('workflowActivated', { workflowId }); + expect(push.broadcast).toHaveBeenCalledWith({ + type: 'workflowActivated', + data: { workflowId }, + }); }); it('should handle `display-workflow-deactivation` event', () => { @@ -758,7 +766,10 @@ describe('PubSubHandler', () => { eventService.emit('display-workflow-deactivation', { workflowId }); - expect(push.broadcast).toHaveBeenCalledWith('workflowDeactivated', { workflowId }); + expect(push.broadcast).toHaveBeenCalledWith({ + type: 'workflowDeactivated', + data: { workflowId }, + }); }); it('should handle `display-workflow-activation-error` event', () => { @@ -782,9 +793,12 @@ describe('PubSubHandler', () => { eventService.emit('display-workflow-activation-error', { workflowId, errorMessage }); - expect(push.broadcast).toHaveBeenCalledWith('workflowFailedToActivate', { - workflowId, - errorMessage, + expect(push.broadcast).toHaveBeenCalledWith({ + type: 'workflowFailedToActivate', + data: { + workflowId, + errorMessage, + }, }); }); @@ -806,15 +820,19 @@ describe('PubSubHandler', () => { const pushRef = 'test-push-ref'; const type = 'executionStarted'; - const args = { testArg: 'value' }; + const data = { + executionId: '123', + mode: 'webhook' as const, + startedAt: new Date(), + workflowId: '456', + flattedRunData: '[]', + }; - push.getBackend.mockReturnValue( - mock({ hasPushRef: jest.fn().mockReturnValue(true) }), - ); + push.hasPushRef.mockReturnValue(true); - eventService.emit('relay-execution-lifecycle-event', { type, args, pushRef }); + eventService.emit('relay-execution-lifecycle-event', { type, data, pushRef }); - expect(push.send).toHaveBeenCalledWith(type, args, pushRef); + expect(push.send).toHaveBeenCalledWith({ type, data }, pushRef); }); it('should handle `clear-test-webhooks` event', () => { @@ -837,9 +855,7 @@ describe('PubSubHandler', () => { const workflowEntity = mock({ id: 'test-workflow-id' }); const pushRef = 'test-push-ref'; - push.getBackend.mockReturnValue( - mock({ hasPushRef: jest.fn().mockReturnValue(true) }), - ); + push.hasPushRef.mockReturnValue(true); testWebhooks.toWorkflow.mockReturnValue(mock({ id: 'test-workflow-id' })); eventService.emit('clear-test-webhooks', { webhookKey, workflowEntity, pushRef }); @@ -868,9 +884,12 @@ describe('PubSubHandler', () => { eventService.emit('response-to-get-worker-status', workerStatus); - expect(push.broadcast).toHaveBeenCalledWith('sendWorkerStatusMessage', { - workerId: workerStatus.senderId, - status: workerStatus, + expect(push.broadcast).toHaveBeenCalledWith({ + type: 'sendWorkerStatusMessage', + data: { + workerId: workerStatus.senderId, + status: workerStatus, + }, }); }); }); diff --git a/packages/cli/src/scaling/__tests__/scaling.service.test.ts b/packages/cli/src/scaling/__tests__/scaling.service.test.ts index b400bf6dfb..2d03dac507 100644 --- a/packages/cli/src/scaling/__tests__/scaling.service.test.ts +++ b/packages/cli/src/scaling/__tests__/scaling.service.test.ts @@ -1,9 +1,9 @@ import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import * as BullModule from 'bull'; import { mock } from 'jest-mock-extended'; import { InstanceSettings } from 'n8n-core'; -import { ApplicationError } from 'n8n-workflow'; -import Container from 'typedi'; +import { ApplicationError, ExecutionCancelledError } from 'n8n-workflow'; import { mockInstance, mockLogger } from '@test/mocking'; @@ -287,6 +287,8 @@ describe('ScalingService', () => { const result = await scalingService.stopJob(job); expect(job.progress).toHaveBeenCalledWith({ kind: 'abort-job' }); + expect(job.discard).toHaveBeenCalled(); + expect(job.moveToFailed).toHaveBeenCalledWith(new ExecutionCancelledError('123'), true); expect(result).toBe(true); }); diff --git a/packages/cli/src/scaling/job-processor.ts b/packages/cli/src/scaling/job-processor.ts index 51b86c3922..2aff0787c4 100644 --- a/packages/cli/src/scaling/job-processor.ts +++ b/packages/cli/src/scaling/job-processor.ts @@ -1,14 +1,19 @@ import type { RunningJobSummary } from '@n8n/api-types'; -import { ErrorReporter, InstanceSettings, WorkflowExecute } from 'n8n-core'; -import type { ExecutionStatus, IExecuteResponsePromiseData, IRun } from 'n8n-workflow'; +import { Service } from '@n8n/di'; +import { InstanceSettings, WorkflowExecute, ErrorReporter, Logger } from 'n8n-core'; +import type { + ExecutionStatus, + IExecuteResponsePromiseData, + IRun, + IWorkflowExecutionDataProcess, +} from 'n8n-workflow'; import { BINARY_ENCODING, ApplicationError, Workflow } from 'n8n-workflow'; import type PCancelable from 'p-cancelable'; -import { Service } from 'typedi'; import config from '@/config'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; -import { Logger } from '@/logging/logger.service'; +import { ManualExecutionService } from '@/manual-execution.service'; import { NodeTypes } from '@/node-types'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; @@ -35,6 +40,7 @@ export class JobProcessor { private readonly workflowRepository: WorkflowRepository, private readonly nodeTypes: NodeTypes, private readonly instanceSettings: InstanceSettings, + private readonly manualExecutionService: ManualExecutionService, ) { this.logger = this.logger.scoped('scaling'); } @@ -116,13 +122,20 @@ export class JobProcessor { executionTimeoutTimestamp, ); + const { pushRef } = job.data; + additionalData.hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerExecuter( execution.mode, job.data.executionId, execution.workflowData, - { retryOf: execution.retryOf as string }, + { retryOf: execution.retryOf as string, pushRef }, ); + if (pushRef) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + additionalData.sendDataToUI = WorkflowExecuteAdditionalData.sendDataToUI.bind({ pushRef }); + } + additionalData.hooks.hookFunctions.sendResponse = [ async (response: IExecuteResponsePromiseData): Promise => { const msg: RespondToWebhookMessage = { @@ -147,7 +160,31 @@ export class JobProcessor { let workflowExecute: WorkflowExecute; let workflowRun: PCancelable; - if (execution.data !== undefined) { + + const { startData, resultData, manualData, isTestWebhook } = execution.data; + + if (execution.mode === 'manual' && !isTestWebhook) { + const data: IWorkflowExecutionDataProcess = { + executionMode: execution.mode, + workflowData: execution.workflowData, + destinationNode: startData?.destinationNode, + startNodes: startData?.startNodes, + runData: resultData.runData, + pinData: resultData.pinData, + partialExecutionVersion: manualData?.partialExecutionVersion, + dirtyNodeNames: manualData?.dirtyNodeNames, + triggerToStartFrom: manualData?.triggerToStartFrom, + userId: manualData?.userId, + }; + + workflowRun = this.manualExecutionService.runManually( + data, + workflow, + additionalData, + executionId, + resultData.pinData, + ); + } else if (execution.data !== undefined) { workflowExecute = new WorkflowExecute(additionalData, execution.mode, execution.data); workflowRun = workflowExecute.processRunExecutionData(workflow); } else { diff --git a/packages/cli/src/scaling/multi-main-setup.ee.ts b/packages/cli/src/scaling/multi-main-setup.ee.ts index dab9f17cc6..52f186bdcc 100644 --- a/packages/cli/src/scaling/multi-main-setup.ee.ts +++ b/packages/cli/src/scaling/multi-main-setup.ee.ts @@ -1,10 +1,9 @@ import { GlobalConfig } from '@n8n/config'; -import { InstanceSettings } from 'n8n-core'; -import { Service } from 'typedi'; +import { Service } from '@n8n/di'; +import { InstanceSettings, Logger } from 'n8n-core'; import config from '@/config'; import { Time } from '@/constants'; -import { Logger } from '@/logging/logger.service'; import { Publisher } from '@/scaling/pubsub/publisher.service'; import { RedisClientService } from '@/services/redis-client.service'; import { TypedEmitter } from '@/typed-emitter'; diff --git a/packages/cli/src/scaling/pubsub/publisher.service.ts b/packages/cli/src/scaling/pubsub/publisher.service.ts index fc007f76c0..a0d2079704 100644 --- a/packages/cli/src/scaling/pubsub/publisher.service.ts +++ b/packages/cli/src/scaling/pubsub/publisher.service.ts @@ -1,10 +1,9 @@ +import { Service } from '@n8n/di'; import type { Redis as SingleNodeClient, Cluster as MultiNodeClient } from 'ioredis'; -import { InstanceSettings } from 'n8n-core'; -import { Service } from 'typedi'; +import { InstanceSettings, Logger } from 'n8n-core'; +import type { LogMetadata } from 'n8n-workflow'; import config from '@/config'; -import { Logger } from '@/logging/logger.service'; -import type { LogMetadata } from '@/logging/types'; import { RedisClientService } from '@/services/redis-client.service'; import type { PubSub } from './pubsub.types'; @@ -65,10 +64,10 @@ export class Publisher { const metadata: LogMetadata = { msg: msg.command, channel: 'n8n.commands' }; if (msg.command === 'relay-execution-lifecycle-event') { - const { args, type } = msg.payload; + const { data, type } = msg.payload; msgName += ` (${type})`; metadata.type = type; - metadata.executionId = args.executionId; + if ('executionId' in data) metadata.executionId = data.executionId; } this.logger.debug(`Published pubsub msg: ${msgName}`, metadata); diff --git a/packages/cli/src/scaling/pubsub/pubsub-handler.ts b/packages/cli/src/scaling/pubsub/pubsub-handler.ts index deeed5b584..25b189c141 100644 --- a/packages/cli/src/scaling/pubsub/pubsub-handler.ts +++ b/packages/cli/src/scaling/pubsub/pubsub-handler.ts @@ -1,13 +1,13 @@ +import { Service } from '@n8n/di'; import { InstanceSettings } from 'n8n-core'; import { ensureError } from 'n8n-workflow'; -import { Service } from 'typedi'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import { EventService } from '@/events/event.service'; import type { PubSubEventMap } from '@/events/maps/pub-sub.event-map'; -import { ExternalSecretsManager } from '@/external-secrets/external-secrets-manager.ee'; +import { ExternalSecretsManager } from '@/external-secrets.ee/external-secrets-manager.ee'; import { License } from '@/license'; import { Push } from '@/push'; import { Publisher } from '@/scaling/pubsub/publisher.service'; @@ -16,7 +16,7 @@ import { assertNever } from '@/utils'; import { TestWebhooks } from '@/webhooks/test-webhooks'; import type { PubSub } from './pubsub.types'; -import { WorkerStatusService } from '../worker-status.service'; +import { WorkerStatusService } from '../worker-status.service.ee'; /** * Responsible for handling events emitted from messages received via a pubsub channel. @@ -59,9 +59,12 @@ export class PubSubHandler { ...this.commonHandlers, ...this.multiMainHandlers, 'response-to-get-worker-status': async (payload) => - this.push.broadcast('sendWorkerStatusMessage', { - workerId: payload.senderId, - status: payload, + this.push.broadcast({ + type: 'sendWorkerStatusMessage', + data: { + workerId: payload.senderId, + status: payload, + }, }), }); @@ -113,7 +116,7 @@ export class PubSubHandler { shouldPublish: false, // prevent leader from re-publishing message }); - this.push.broadcast('workflowActivated', { workflowId }); + this.push.broadcast({ type: 'workflowActivated', data: { workflowId } }); await this.publisher.publishCommand({ command: 'display-workflow-activation', @@ -125,7 +128,10 @@ export class PubSubHandler { await this.workflowRepository.update(workflowId, { active: false }); - this.push.broadcast('workflowFailedToActivate', { workflowId, errorMessage: message }); + this.push.broadcast({ + type: 'workflowFailedToActivate', + data: { workflowId, errorMessage: message }, + }); await this.publisher.publishCommand({ command: 'display-workflow-activation-error', @@ -139,7 +145,7 @@ export class PubSubHandler { await this.activeWorkflowManager.removeActivationError(workflowId); await this.activeWorkflowManager.removeWorkflowTriggersAndPollers(workflowId); - this.push.broadcast('workflowDeactivated', { workflowId }); + this.push.broadcast({ type: 'workflowDeactivated', data: { workflowId } }); // instruct followers to show workflow deactivation in UI await this.publisher.publishCommand({ @@ -148,18 +154,18 @@ export class PubSubHandler { }); }, 'display-workflow-activation': async ({ workflowId }) => - this.push.broadcast('workflowActivated', { workflowId }), + this.push.broadcast({ type: 'workflowActivated', data: { workflowId } }), 'display-workflow-deactivation': async ({ workflowId }) => - this.push.broadcast('workflowDeactivated', { workflowId }), + this.push.broadcast({ type: 'workflowDeactivated', data: { workflowId } }), 'display-workflow-activation-error': async ({ workflowId, errorMessage }) => - this.push.broadcast('workflowFailedToActivate', { workflowId, errorMessage }), - 'relay-execution-lifecycle-event': async ({ type, args, pushRef }) => { - if (!this.push.getBackend().hasPushRef(pushRef)) return; + this.push.broadcast({ type: 'workflowFailedToActivate', data: { workflowId, errorMessage } }), + 'relay-execution-lifecycle-event': async ({ pushRef, ...pushMsg }) => { + if (!this.push.hasPushRef(pushRef)) return; - this.push.send(type, args, pushRef); + this.push.send(pushMsg, pushRef); }, 'clear-test-webhooks': async ({ webhookKey, workflowEntity, pushRef }) => { - if (!this.push.getBackend().hasPushRef(pushRef)) return; + if (!this.push.hasPushRef(pushRef)) return; this.testWebhooks.clearTimeout(webhookKey); diff --git a/packages/cli/src/scaling/pubsub/subscriber.service.ts b/packages/cli/src/scaling/pubsub/subscriber.service.ts index 248c1198d2..4d2b5e3314 100644 --- a/packages/cli/src/scaling/pubsub/subscriber.service.ts +++ b/packages/cli/src/scaling/pubsub/subscriber.service.ts @@ -1,13 +1,12 @@ +import { Service } from '@n8n/di'; import type { Redis as SingleNodeClient, Cluster as MultiNodeClient } from 'ioredis'; import debounce from 'lodash/debounce'; -import { InstanceSettings } from 'n8n-core'; +import { InstanceSettings, Logger } from 'n8n-core'; import { jsonParse } from 'n8n-workflow'; -import { Service } from 'typedi'; +import type { LogMetadata } from 'n8n-workflow'; import config from '@/config'; import { EventService } from '@/events/event.service'; -import { Logger } from '@/logging/logger.service'; -import type { LogMetadata } from '@/logging/types'; import { RedisClientService } from '@/services/redis-client.service'; import type { PubSub } from './pubsub.types'; @@ -95,10 +94,10 @@ export class Subscriber { const metadata: LogMetadata = { msg: msgName, channel }; if ('command' in msg && msg.command === 'relay-execution-lifecycle-event') { - const { args, type } = msg.payload; + const { data, type } = msg.payload; msgName += ` (${type})`; metadata.type = type; - metadata.executionId = args.executionId; + if ('executionId' in data) metadata.executionId = data.executionId; } this.logger.debug(`Received pubsub msg: ${msgName}`, metadata); diff --git a/packages/cli/src/scaling/scaling.service.ts b/packages/cli/src/scaling/scaling.service.ts index ebc8e4499c..536e835c72 100644 --- a/packages/cli/src/scaling/scaling.service.ts +++ b/packages/cli/src/scaling/scaling.service.ts @@ -1,9 +1,16 @@ import { GlobalConfig } from '@n8n/config'; -import { ErrorReporter, InstanceSettings } from 'n8n-core'; -import { ApplicationError, BINARY_ENCODING, sleep, jsonStringify, ensureError } from 'n8n-workflow'; +import { Container, Service } from '@n8n/di'; +import { ErrorReporter, InstanceSettings, Logger } from 'n8n-core'; +import { + ApplicationError, + BINARY_ENCODING, + sleep, + jsonStringify, + ensureError, + ExecutionCancelledError, +} from 'n8n-workflow'; import type { IExecuteResponsePromiseData } from 'n8n-workflow'; -import { strict } from 'node:assert'; -import Container, { Service } from 'typedi'; +import assert, { strict } from 'node:assert'; import { ActiveExecutions } from '@/active-executions'; import config from '@/config'; @@ -12,7 +19,6 @@ import { ExecutionRepository } from '@/databases/repositories/execution.reposito import { OnShutdown } from '@/decorators/on-shutdown'; import { MaxStalledCountError } from '@/errors/max-stalled-count.error'; import { EventService } from '@/events/event.service'; -import { Logger } from '@/logging/logger.service'; import { OrchestrationService } from '@/services/orchestration.service'; import { assertNever } from '@/utils'; @@ -207,7 +213,8 @@ export class ScalingService { try { if (await job.isActive()) { await job.progress({ kind: 'abort-job' }); // being processed by worker - this.logger.debug('Stopped active job', props); + await job.discard(); // prevent retries + await job.moveToFailed(new ExecutionCancelledError(job.data.executionId), true); // remove from queue return true; } @@ -215,8 +222,15 @@ export class ScalingService { this.logger.debug('Stopped inactive job', props); return true; } catch (error: unknown) { - await job.progress({ kind: 'abort-job' }); - this.logger.error('Failed to stop job', { ...props, error }); + assert(error instanceof Error); + this.logger.error('Failed to stop job', { + ...props, + error: { + message: error.message, + name: error.name, + stack: error.stack, + }, + }); return false; } } diff --git a/packages/cli/src/scaling/scaling.types.ts b/packages/cli/src/scaling/scaling.types.ts index ae7e790a16..3c69294172 100644 --- a/packages/cli/src/scaling/scaling.types.ts +++ b/packages/cli/src/scaling/scaling.types.ts @@ -12,6 +12,7 @@ export type JobId = Job['id']; export type JobData = { executionId: string; loadStaticData: boolean; + pushRef?: string; }; export type JobResult = { diff --git a/packages/cli/src/scaling/worker-server.ts b/packages/cli/src/scaling/worker-server.ts index ee622d789c..07b9533626 100644 --- a/packages/cli/src/scaling/worker-server.ts +++ b/packages/cli/src/scaling/worker-server.ts @@ -1,11 +1,11 @@ import { GlobalConfig } from '@n8n/config'; +import { Service } from '@n8n/di'; import type { Application } from 'express'; import express from 'express'; -import { InstanceSettings } from 'n8n-core'; +import { InstanceSettings, Logger } from 'n8n-core'; import { strict as assert } from 'node:assert'; import http from 'node:http'; import type { Server } from 'node:http'; -import { Service } from 'typedi'; import { CredentialsOverwrites } from '@/credentials-overwrites'; import * as Db from '@/db'; @@ -13,7 +13,6 @@ import { CredentialsOverwritesAlreadySetError } from '@/errors/credentials-overw import { NonJsonBodyError } from '@/errors/non-json-body.error'; import { ExternalHooks } from '@/external-hooks'; import type { ICredentialsOverwrite } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { PrometheusMetricsService } from '@/metrics/prometheus-metrics.service'; import { rawBodyReader, bodyParser } from '@/middlewares'; import * as ResponseHelper from '@/response-helper'; diff --git a/packages/cli/src/scaling/worker-status.service.ts b/packages/cli/src/scaling/worker-status.service.ee.ts similarity index 97% rename from packages/cli/src/scaling/worker-status.service.ts rename to packages/cli/src/scaling/worker-status.service.ee.ts index a50a1b8d2e..226ee3f70f 100644 --- a/packages/cli/src/scaling/worker-status.service.ts +++ b/packages/cli/src/scaling/worker-status.service.ee.ts @@ -1,7 +1,7 @@ import type { WorkerStatus } from '@n8n/api-types'; +import { Service } from '@n8n/di'; import { InstanceSettings } from 'n8n-core'; import os from 'node:os'; -import { Service } from 'typedi'; import { N8N_VERSION } from '@/constants'; diff --git a/packages/cli/src/secrets-helpers.ts b/packages/cli/src/secrets-helpers.ee.ts similarity index 87% rename from packages/cli/src/secrets-helpers.ts rename to packages/cli/src/secrets-helpers.ee.ts index 88a75ae3da..7390bf8795 100644 --- a/packages/cli/src/secrets-helpers.ts +++ b/packages/cli/src/secrets-helpers.ee.ts @@ -1,7 +1,7 @@ +import { Service } from '@n8n/di'; import type { SecretsHelpersBase } from 'n8n-workflow'; -import { Service } from 'typedi'; -import { ExternalSecretsManager } from './external-secrets/external-secrets-manager.ee'; +import { ExternalSecretsManager } from './external-secrets.ee/external-secrets-manager.ee'; @Service() export class SecretsHelper implements SecretsHelpersBase { diff --git a/packages/cli/src/security-audit/risk-reporters/credentials-risk-reporter.ts b/packages/cli/src/security-audit/risk-reporters/credentials-risk-reporter.ts index 0c8d84211e..ca8bf11415 100644 --- a/packages/cli/src/security-audit/risk-reporters/credentials-risk-reporter.ts +++ b/packages/cli/src/security-audit/risk-reporters/credentials-risk-reporter.ts @@ -1,6 +1,6 @@ import { SecurityConfig } from '@n8n/config'; +import { Service } from '@n8n/di'; import type { IWorkflowBase } from 'n8n-workflow'; -import { Service } from 'typedi'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; diff --git a/packages/cli/src/security-audit/risk-reporters/database-risk-reporter.ts b/packages/cli/src/security-audit/risk-reporters/database-risk-reporter.ts index 9dad0829ae..f66ae02282 100644 --- a/packages/cli/src/security-audit/risk-reporters/database-risk-reporter.ts +++ b/packages/cli/src/security-audit/risk-reporters/database-risk-reporter.ts @@ -1,4 +1,4 @@ -import { Service } from 'typedi'; +import { Service } from '@n8n/di'; import type { WorkflowEntity as Workflow } from '@/databases/entities/workflow-entity'; import { diff --git a/packages/cli/src/security-audit/risk-reporters/filesystem-risk-reporter.ts b/packages/cli/src/security-audit/risk-reporters/filesystem-risk-reporter.ts index 645e6f8684..a73b152d2e 100644 --- a/packages/cli/src/security-audit/risk-reporters/filesystem-risk-reporter.ts +++ b/packages/cli/src/security-audit/risk-reporters/filesystem-risk-reporter.ts @@ -1,4 +1,4 @@ -import { Service } from 'typedi'; +import { Service } from '@n8n/di'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import { FILESYSTEM_INTERACTION_NODE_TYPES, FILESYSTEM_REPORT } from '@/security-audit/constants'; diff --git a/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts b/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts index 37113e4c40..8d870b5d76 100644 --- a/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts +++ b/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts @@ -1,12 +1,11 @@ import { GlobalConfig } from '@n8n/config'; +import { Service } from '@n8n/di'; import axios from 'axios'; -import { InstanceSettings } from 'n8n-core'; -import { Service } from 'typedi'; +import { InstanceSettings, Logger } from 'n8n-core'; import config from '@/config'; import { getN8nPackageJson, inDevelopment } from '@/constants'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; -import { Logger } from '@/logging/logger.service'; import { isApiEnabled } from '@/public-api'; import { ENV_VARS_DOCS_URL, diff --git a/packages/cli/src/security-audit/risk-reporters/nodes-risk-reporter.ts b/packages/cli/src/security-audit/risk-reporters/nodes-risk-reporter.ts index 4908ff9329..58f6da5e18 100644 --- a/packages/cli/src/security-audit/risk-reporters/nodes-risk-reporter.ts +++ b/packages/cli/src/security-audit/risk-reporters/nodes-risk-reporter.ts @@ -1,7 +1,7 @@ import { GlobalConfig } from '@n8n/config'; +import { Service } from '@n8n/di'; import glob from 'fast-glob'; import * as path from 'path'; -import { Service } from 'typedi'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; diff --git a/packages/cli/src/security-audit/security-audit.service.ts b/packages/cli/src/security-audit/security-audit.service.ts index 97b5424a19..b0946b5409 100644 --- a/packages/cli/src/security-audit/security-audit.service.ts +++ b/packages/cli/src/security-audit/security-audit.service.ts @@ -1,5 +1,5 @@ import { SecurityConfig } from '@n8n/config'; -import Container, { Service } from 'typedi'; +import { Container, Service } from '@n8n/di'; import config from '@/config'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 74a1311444..09e3774aae 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -1,10 +1,10 @@ +import { Container, Service } from '@n8n/di'; import cookieParser from 'cookie-parser'; import express from 'express'; import { access as fsAccess } from 'fs/promises'; import helmet from 'helmet'; import { InstanceSettings } from 'n8n-core'; import { resolve } from 'path'; -import { Container, Service } from 'typedi'; import { AbstractServer } from '@/abstract-server'; import config from '@/config'; @@ -23,7 +23,7 @@ import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus' import { EventService } from '@/events/event.service'; import { LogStreamingEventRelay } from '@/events/relays/log-streaming.event-relay'; import type { ICredentialsOverwrite } from '@/interfaces'; -import { isLdapEnabled } from '@/ldap/helpers.ee'; +import { isLdapEnabled } from '@/ldap.ee/helpers.ee'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { handleMfaDisable, isMfaFeatureEnabled } from '@/mfa/helpers'; import { PostHogClient } from '@/posthog'; @@ -60,12 +60,12 @@ import '@/credentials/credentials.controller'; import '@/eventbus/event-bus.controller'; import '@/events/events.controller'; import '@/executions/executions.controller'; -import '@/external-secrets/external-secrets.controller.ee'; +import '@/external-secrets.ee/external-secrets.controller.ee'; import '@/license/license.controller'; -import '@/evaluation/test-definitions.controller.ee'; -import '@/evaluation/metrics.controller'; -import '@/evaluation/test-runs.controller.ee'; -import '@/workflows/workflow-history/workflow-history.controller.ee'; +import '@/evaluation.ee/test-definitions.controller.ee'; +import '@/evaluation.ee/metrics.controller'; +import '@/evaluation.ee/test-runs.controller.ee'; +import '@/workflows/workflow-history.ee/workflow-history.controller.ee'; import '@/workflows/workflows.controller'; @Service() @@ -114,8 +114,8 @@ export class Server extends AbstractServer { } if (isLdapEnabled()) { - const { LdapService } = await import('@/ldap/ldap.service.ee'); - await import('@/ldap/ldap.controller.ee'); + const { LdapService } = await import('@/ldap.ee/ldap.service.ee'); + await import('@/ldap.ee/ldap.controller.ee'); await Container.get(LdapService).init(); } @@ -142,9 +142,9 @@ export class Server extends AbstractServer { // initialize SamlService if it is licensed, even if not enabled, to // set up the initial environment try { - const { SamlService } = await import('@/sso/saml/saml.service.ee'); + const { SamlService } = await import('@/sso.ee/saml/saml.service.ee'); await Container.get(SamlService).init(); - await import('@/sso/saml/routes/saml.controller.ee'); + await import('@/sso.ee/saml/routes/saml.controller.ee'); } catch (error) { this.logger.warn(`SAML initialization failed: ${(error as Error).message}`); } @@ -154,11 +154,11 @@ export class Server extends AbstractServer { // ---------------------------------------- try { const { SourceControlService } = await import( - '@/environments/source-control/source-control.service.ee' + '@/environments.ee/source-control/source-control.service.ee' ); await Container.get(SourceControlService).init(); - await import('@/environments/source-control/source-control.controller.ee'); - await import('@/environments/variables/variables.controller.ee'); + await import('@/environments.ee/source-control/source-control.controller.ee'); + await import('@/environments.ee/variables/variables.controller.ee'); } catch (error) { this.logger.warn(`Source Control initialization failed: ${(error as Error).message}`); } diff --git a/packages/cli/src/services/__tests__/ai.service.test.ts b/packages/cli/src/services/__tests__/ai.service.test.ts new file mode 100644 index 0000000000..dbdcaa3e71 --- /dev/null +++ b/packages/cli/src/services/__tests__/ai.service.test.ts @@ -0,0 +1,132 @@ +import type { + AiAskRequestDto, + AiApplySuggestionRequestDto, + AiChatRequestDto, +} from '@n8n/api-types'; +import type { GlobalConfig } from '@n8n/config'; +import { AiAssistantClient, type AiAssistantSDK } from '@n8n_io/ai-assistant-sdk'; +import { mock } from 'jest-mock-extended'; +import type { IUser } from 'n8n-workflow'; + +import { N8N_VERSION } from '@/constants'; +import type { License } from '@/license'; + +import { AiService } from '../ai.service'; + +jest.mock('@n8n_io/ai-assistant-sdk', () => ({ + AiAssistantClient: jest.fn(), +})); + +describe('AiService', () => { + let aiService: AiService; + + const baseUrl = 'https://ai-assistant-url.com'; + const user = mock({ id: 'user123' }); + const client = mock(); + const license = mock(); + const globalConfig = mock({ + logging: { level: 'info' }, + aiAssistant: { baseUrl }, + }); + + beforeEach(() => { + jest.clearAllMocks(); + (AiAssistantClient as jest.Mock).mockImplementation(() => client); + aiService = new AiService(license, globalConfig); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('init', () => { + it('should not initialize client if AI assistant is not enabled', async () => { + license.isAiAssistantEnabled.mockReturnValue(false); + + await aiService.init(); + + expect(AiAssistantClient).not.toHaveBeenCalled(); + }); + + it('should initialize client when AI assistant is enabled', async () => { + license.isAiAssistantEnabled.mockReturnValue(true); + license.loadCertStr.mockResolvedValue('mock-license-cert'); + license.getConsumerId.mockReturnValue('mock-consumer-id'); + + await aiService.init(); + + expect(AiAssistantClient).toHaveBeenCalledWith({ + licenseCert: 'mock-license-cert', + consumerId: 'mock-consumer-id', + n8nVersion: N8N_VERSION, + baseUrl, + logLevel: 'info', + }); + }); + }); + + describe('chat', () => { + const payload = mock(); + + it('should call client chat method after initialization', async () => { + license.isAiAssistantEnabled.mockReturnValue(true); + const clientResponse = mock(); + client.chat.mockResolvedValue(clientResponse); + + const result = await aiService.chat(payload, user); + + expect(client.chat).toHaveBeenCalledWith(payload, { id: user.id }); + expect(result).toEqual(clientResponse); + }); + + it('should throw error if client is not initialized', async () => { + license.isAiAssistantEnabled.mockReturnValue(false); + + await expect(aiService.chat(payload, user)).rejects.toThrow('Assistant client not setup'); + }); + }); + + describe('applySuggestion', () => { + const payload = mock(); + + it('should call client applySuggestion', async () => { + license.isAiAssistantEnabled.mockReturnValue(true); + const clientResponse = mock(); + client.applySuggestion.mockResolvedValue(clientResponse); + + const result = await aiService.applySuggestion(payload, user); + + expect(client.applySuggestion).toHaveBeenCalledWith(payload, { id: user.id }); + expect(result).toEqual(clientResponse); + }); + + it('should throw error if client is not initialized', async () => { + license.isAiAssistantEnabled.mockReturnValue(false); + + await expect(aiService.applySuggestion(payload, user)).rejects.toThrow( + 'Assistant client not setup', + ); + }); + }); + + describe('askAi', () => { + const payload = mock(); + + it('should call client askAi method after initialization', async () => { + license.isAiAssistantEnabled.mockReturnValue(true); + const clientResponse = mock(); + client.askAi.mockResolvedValue(clientResponse); + + const result = await aiService.askAi(payload, user); + + expect(client.askAi).toHaveBeenCalledWith(payload, { id: user.id }); + expect(result).toEqual(clientResponse); + }); + + it('should throw error if client is not initialized', async () => { + license.isAiAssistantEnabled.mockReturnValue(false); + + await expect(aiService.askAi(payload, user)).rejects.toThrow('Assistant client not setup'); + }); + }); +}); diff --git a/packages/cli/src/services/__tests__/execution-metadata.service.test.ts b/packages/cli/src/services/__tests__/execution-metadata.service.test.ts index 4b713d6872..309da5a354 100644 --- a/packages/cli/src/services/__tests__/execution-metadata.service.test.ts +++ b/packages/cli/src/services/__tests__/execution-metadata.service.test.ts @@ -1,4 +1,4 @@ -import { Container } from 'typedi'; +import { Container } from '@n8n/di'; import { ExecutionMetadataRepository } from '@/databases/repositories/execution-metadata.repository'; import { ExecutionMetadataService } from '@/services/execution-metadata.service'; diff --git a/packages/cli/src/services/__tests__/orchestration.service.test.ts b/packages/cli/src/services/__tests__/orchestration.service.test.ts index a8e72c49bf..89c043a589 100644 --- a/packages/cli/src/services/__tests__/orchestration.service.test.ts +++ b/packages/cli/src/services/__tests__/orchestration.service.test.ts @@ -1,11 +1,11 @@ +import { Container } from '@n8n/di'; import type Redis from 'ioredis'; import { mock } from 'jest-mock-extended'; import { InstanceSettings } from 'n8n-core'; -import Container from 'typedi'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; import config from '@/config'; -import { ExternalSecretsManager } from '@/external-secrets/external-secrets-manager.ee'; +import { ExternalSecretsManager } from '@/external-secrets.ee/external-secrets-manager.ee'; import { Push } from '@/push'; import { OrchestrationService } from '@/services/orchestration.service'; import { RedisClientService } from '@/services/redis-client.service'; diff --git a/packages/cli/src/services/__tests__/password.utility.test.ts b/packages/cli/src/services/__tests__/password.utility.test.ts index 48be7588c2..4662f7c32a 100644 --- a/packages/cli/src/services/__tests__/password.utility.test.ts +++ b/packages/cli/src/services/__tests__/password.utility.test.ts @@ -1,4 +1,4 @@ -import Container from 'typedi'; +import { Container } from '@n8n/di'; import { PasswordUtility } from '@/services/password.utility'; @@ -49,57 +49,4 @@ describe('PasswordUtility', () => { expect(isMatch).toBe(false); }); }); - - describe('validate()', () => { - test('should throw on empty password', () => { - const check = () => passwordUtility.validate(); - - expect(check).toThrowError('Password is mandatory'); - }); - - test('should return same password if valid', () => { - const validPassword = 'abcd1234X'; - - const validated = passwordUtility.validate(validPassword); - - expect(validated).toBe(validPassword); - }); - - test('should require at least one uppercase letter', () => { - const invalidPassword = 'abcd1234'; - - const failingCheck = () => passwordUtility.validate(invalidPassword); - - expect(failingCheck).toThrowError('Password must contain at least 1 uppercase letter.'); - }); - - test('should require at least one number', () => { - const validPassword = 'abcd1234X'; - const invalidPassword = 'abcdEFGH'; - - const validated = passwordUtility.validate(validPassword); - - expect(validated).toBe(validPassword); - - const check = () => passwordUtility.validate(invalidPassword); - - expect(check).toThrowError('Password must contain at least 1 number.'); - }); - - test('should require a minimum length of 8 characters', () => { - const invalidPassword = 'a'.repeat(7); - - const check = () => passwordUtility.validate(invalidPassword); - - expect(check).toThrowError('Password must be 8 to 64 characters long.'); - }); - - test('should require a maximum length of 64 characters', () => { - const invalidPassword = 'a'.repeat(65); - - const check = () => passwordUtility.validate(invalidPassword); - - expect(check).toThrowError('Password must be 8 to 64 characters long.'); - }); - }); }); diff --git a/packages/cli/src/services/__tests__/workflow-statistics.service.test.ts b/packages/cli/src/services/__tests__/workflow-statistics.service.test.ts index 6d28dbe563..6336eb546a 100644 --- a/packages/cli/src/services/__tests__/workflow-statistics.service.test.ts +++ b/packages/cli/src/services/__tests__/workflow-statistics.service.test.ts @@ -1,4 +1,5 @@ import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import { QueryFailedError, type DataSource, @@ -8,7 +9,6 @@ import { import { mocked } from 'jest-mock'; import { mock } from 'jest-mock-extended'; import type { INode, IRun, WorkflowExecuteMode } from 'n8n-workflow'; -import { Container } from 'typedi'; import config from '@/config'; import type { Project } from '@/databases/entities/project'; diff --git a/packages/cli/src/services/access.service.ts b/packages/cli/src/services/access.service.ts index 49ac7bfe23..7a7030b93e 100644 --- a/packages/cli/src/services/access.service.ts +++ b/packages/cli/src/services/access.service.ts @@ -1,5 +1,5 @@ +import { Service } from '@n8n/di'; import type { Workflow } from 'n8n-workflow'; -import { Service } from 'typedi'; import type { User } from '@/databases/entities/user'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; diff --git a/packages/cli/src/services/active-workflows.service.ts b/packages/cli/src/services/active-workflows.service.ts index 61aa875d1a..98b96af33a 100644 --- a/packages/cli/src/services/active-workflows.service.ts +++ b/packages/cli/src/services/active-workflows.service.ts @@ -1,11 +1,11 @@ -import { Service } from 'typedi'; +import { Service } from '@n8n/di'; +import { Logger } from 'n8n-core'; import { ActivationErrorsService } from '@/activation-errors.service'; import type { User } from '@/databases/entities/user'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { Logger } from '@/logging/logger.service'; @Service() export class ActiveWorkflowsService { diff --git a/packages/cli/src/services/ai.service.ts b/packages/cli/src/services/ai.service.ts index a7b07219b5..32c6c8b0f9 100644 --- a/packages/cli/src/services/ai.service.ts +++ b/packages/cli/src/services/ai.service.ts @@ -1,11 +1,12 @@ +import type { + AiApplySuggestionRequestDto, + AiAskRequestDto, + AiChatRequestDto, +} from '@n8n/api-types'; import { GlobalConfig } from '@n8n/config'; -import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk'; +import { Service } from '@n8n/di'; import { AiAssistantClient } from '@n8n_io/ai-assistant-sdk'; import { assert, type IUser } from 'n8n-workflow'; -import { Service } from 'typedi'; - -import config from '@/config'; -import type { AiAssistantRequest } from '@/requests'; import { N8N_VERSION } from '../constants'; import { License } from '../license'; @@ -21,13 +22,14 @@ export class AiService { async init() { const aiAssistantEnabled = this.licenseService.isAiAssistantEnabled(); + if (!aiAssistantEnabled) { return; } const licenseCert = await this.licenseService.loadCertStr(); const consumerId = this.licenseService.getConsumerId(); - const baseUrl = config.get('aiAssistant.baseUrl'); + const baseUrl = this.globalConfig.aiAssistant.baseUrl; const logLevel = this.globalConfig.logging.level; this.client = new AiAssistantClient({ @@ -39,7 +41,7 @@ export class AiService { }); } - async chat(payload: AiAssistantSDK.ChatRequestPayload, user: IUser) { + async chat(payload: AiChatRequestDto, user: IUser) { if (!this.client) { await this.init(); } @@ -48,7 +50,7 @@ export class AiService { return await this.client.chat(payload, { id: user.id }); } - async applySuggestion(payload: AiAssistantRequest.SuggestionPayload, user: IUser) { + async applySuggestion(payload: AiApplySuggestionRequestDto, user: IUser) { if (!this.client) { await this.init(); } @@ -57,7 +59,7 @@ export class AiService { return await this.client.applySuggestion(payload, { id: user.id }); } - async askAi(payload: AiAssistantSDK.AskAiRequestPayload, user: IUser) { + async askAi(payload: AiAskRequestDto, user: IUser) { if (!this.client) { await this.init(); } @@ -65,4 +67,13 @@ export class AiService { return await this.client.askAi(payload, { id: user.id }); } + + async createFreeAiCredits(user: IUser) { + if (!this.client) { + await this.init(); + } + assert(this.client, 'Assistant client not setup'); + + return await this.client.generateAiCreditsCredentials(user); + } } diff --git a/packages/cli/src/services/annotation-tag.service.ee.ts b/packages/cli/src/services/annotation-tag.service.ee.ts index 671395168c..9c56a7bee4 100644 --- a/packages/cli/src/services/annotation-tag.service.ee.ts +++ b/packages/cli/src/services/annotation-tag.service.ee.ts @@ -1,4 +1,4 @@ -import { Service } from 'typedi'; +import { Service } from '@n8n/di'; import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee'; import { AnnotationTagRepository } from '@/databases/repositories/annotation-tag.repository.ee'; diff --git a/packages/cli/src/services/cache/__tests__/cache-mock.service.test.ts b/packages/cli/src/services/cache/__tests__/cache-mock.service.test.ts index d515aae5be..a6863997a5 100644 --- a/packages/cli/src/services/cache/__tests__/cache-mock.service.test.ts +++ b/packages/cli/src/services/cache/__tests__/cache-mock.service.test.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; -import Container from 'typedi'; import { CacheService } from '@/services/cache/cache.service'; diff --git a/packages/cli/src/services/cache/__tests__/cache.service.test.ts b/packages/cli/src/services/cache/__tests__/cache.service.test.ts index e87e46984b..5aaaf4b860 100644 --- a/packages/cli/src/services/cache/__tests__/cache.service.test.ts +++ b/packages/cli/src/services/cache/__tests__/cache.service.test.ts @@ -1,6 +1,6 @@ import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import { sleep } from 'n8n-workflow'; -import Container from 'typedi'; import config from '@/config'; import { CacheService } from '@/services/cache/cache.service'; diff --git a/packages/cli/src/services/cache/cache.service.ts b/packages/cli/src/services/cache/cache.service.ts index f82bac3d02..ab681d3e5b 100644 --- a/packages/cli/src/services/cache/cache.service.ts +++ b/packages/cli/src/services/cache/cache.service.ts @@ -1,7 +1,7 @@ import { GlobalConfig } from '@n8n/config'; +import { Container, Service } from '@n8n/di'; import { caching } from 'cache-manager'; import { ApplicationError, jsonStringify } from 'n8n-workflow'; -import Container, { Service } from 'typedi'; import config from '@/config'; import { Time } from '@/constants'; diff --git a/packages/cli/src/services/community-packages.service.ts b/packages/cli/src/services/community-packages.service.ts index 9f09d0c310..c85872c5e2 100644 --- a/packages/cli/src/services/community-packages.service.ts +++ b/packages/cli/src/services/community-packages.service.ts @@ -1,11 +1,11 @@ import { GlobalConfig } from '@n8n/config'; +import { Service } from '@n8n/di'; import axios from 'axios'; import { exec } from 'child_process'; import { access as fsAccess, mkdir as fsMkdir } from 'fs/promises'; -import { InstanceSettings } from 'n8n-core'; import type { PackageDirectoryLoader } from 'n8n-core'; +import { InstanceSettings, Logger } from 'n8n-core'; import { ApplicationError, type PublicInstalledPackage } from 'n8n-workflow'; -import { Service } from 'typedi'; import { promisify } from 'util'; import { @@ -22,7 +22,6 @@ import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; import type { CommunityPackages } from '@/interfaces'; import { License } from '@/license'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; -import { Logger } from '@/logging/logger.service'; import { Publisher } from '@/scaling/pubsub/publisher.service'; import { toError } from '@/utils'; diff --git a/packages/cli/src/services/credentials-tester.service.ts b/packages/cli/src/services/credentials-tester.service.ts index 4a999d6541..1954923481 100644 --- a/packages/cli/src/services/credentials-tester.service.ts +++ b/packages/cli/src/services/credentials-tester.service.ts @@ -3,8 +3,15 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-return */ /* eslint-disable @typescript-eslint/no-unsafe-call */ +import { Service } from '@n8n/di'; import get from 'lodash/get'; -import { ErrorReporter, NodeExecuteFunctions, RoutingNode } from 'n8n-core'; +import { + ErrorReporter, + Logger, + NodeExecuteFunctions, + RoutingNode, + isObjectLiteral, +} from 'n8n-core'; import type { ICredentialsDecrypted, ICredentialTestFunction, @@ -24,17 +31,14 @@ import type { IDataObject, } from 'n8n-workflow'; import { VersionedNodeType, NodeHelpers, Workflow, ApplicationError } from 'n8n-workflow'; -import { Service } from 'typedi'; import { CredentialTypes } from '@/credential-types'; import type { User } from '@/databases/entities/user'; -import { Logger } from '@/logging/logger.service'; import { NodeTypes } from '@/node-types'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; import { RESPONSE_ERROR_MESSAGES } from '../constants'; import { CredentialsHelper } from '../credentials-helper'; -import { isObjectLiteral } from '../utils'; const { OAUTH2_CREDENTIAL_TEST_SUCCEEDED, OAUTH2_CREDENTIAL_TEST_FAILED } = RESPONSE_ERROR_MESSAGES; diff --git a/packages/cli/src/services/cta.service.ts b/packages/cli/src/services/cta.service.ts index 221674cfa7..ec0763dcda 100644 --- a/packages/cli/src/services/cta.service.ts +++ b/packages/cli/src/services/cta.service.ts @@ -1,4 +1,4 @@ -import { Service } from 'typedi'; +import { Service } from '@n8n/di'; import type { User } from '@/databases/entities/user'; import { WorkflowStatisticsRepository } from '@/databases/repositories/workflow-statistics.repository'; diff --git a/packages/cli/src/services/curl.service.ts b/packages/cli/src/services/curl.service.ts index 8875fd9828..fae967154e 100644 --- a/packages/cli/src/services/curl.service.ts +++ b/packages/cli/src/services/curl.service.ts @@ -1,7 +1,7 @@ +import { Service } from '@n8n/di'; import get from 'lodash/get'; import type { IDataObject } from 'n8n-workflow'; import { jsonParse } from 'n8n-workflow'; -import { Service } from 'typedi'; import curlconverter from 'curlconverter'; diff --git a/packages/cli/src/services/dynamic-node-parameters.service.ts b/packages/cli/src/services/dynamic-node-parameters.service.ts index a20d63b5fa..0cde1c8489 100644 --- a/packages/cli/src/services/dynamic-node-parameters.service.ts +++ b/packages/cli/src/services/dynamic-node-parameters.service.ts @@ -1,4 +1,5 @@ -import { LoadOptionsContext, RoutingNode } from 'n8n-core'; +import { Service } from '@n8n/di'; +import { LoadOptionsContext, RoutingNode, LocalLoadOptionsContext } from 'n8n-core'; import type { ILoadOptions, ILoadOptionsFunctions, @@ -17,15 +18,42 @@ import type { INodeTypeNameVersion, NodeParameterValueType, IDataObject, + ILocalLoadOptionsFunctions, } from 'n8n-workflow'; import { Workflow, ApplicationError } from 'n8n-workflow'; -import { Service } from 'typedi'; import { NodeTypes } from '@/node-types'; +import { WorkflowLoaderService } from './workflow-loader.service'; + +type LocalResourceMappingMethod = ( + this: ILocalLoadOptionsFunctions, +) => Promise; +type ListSearchMethod = ( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +) => Promise; +type LoadOptionsMethod = (this: ILoadOptionsFunctions) => Promise; +type ActionHandlerMethod = ( + this: ILoadOptionsFunctions, + payload?: string, +) => Promise; +type ResourceMappingMethod = (this: ILoadOptionsFunctions) => Promise; + +type NodeMethod = + | LocalResourceMappingMethod + | ListSearchMethod + | LoadOptionsMethod + | ActionHandlerMethod + | ResourceMappingMethod; + @Service() export class DynamicNodeParametersService { - constructor(private nodeTypes: NodeTypes) {} + constructor( + private nodeTypes: NodeTypes, + private workflowLoaderService: WorkflowLoaderService, + ) {} /** Returns the available options via a predefined method */ async getOptionsViaMethodName( @@ -40,6 +68,8 @@ export class DynamicNodeParametersService { const method = this.getMethod('loadOptions', methodName, nodeType); const workflow = this.getWorkflow(nodeTypeAndVersion, currentNodeParameters, credentials); const thisArgs = this.getThisArg(path, additionalData, workflow); + // Need to use untyped call since `this` usage is widespread and we don't have `strictBindCallApply` + // enabled in `tsconfig.json` // eslint-disable-next-line @typescript-eslint/no-unsafe-return return method.call(thisArgs); } @@ -157,6 +187,20 @@ export class DynamicNodeParametersService { return method.call(thisArgs); } + /** Returns the available workflow input mapping fields for the ResourceMapper component */ + async getLocalResourceMappingFields( + methodName: string, + path: string, + additionalData: IWorkflowExecuteAdditionalData, + nodeTypeAndVersion: INodeTypeNameVersion, + ): Promise { + const nodeType = this.getNodeType(nodeTypeAndVersion); + const method = this.getMethod('localResourceMapping', methodName, nodeType); + const thisArgs = this.getLocalLoadOptionsContext(path, additionalData); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return method.call(thisArgs); + } + /** Returns the result of the action handler */ async getActionResult( handler: string, @@ -179,33 +223,34 @@ export class DynamicNodeParametersService { type: 'resourceMapping', methodName: string, nodeType: INodeType, - ): (this: ILoadOptionsFunctions) => Promise; + ): ResourceMappingMethod; private getMethod( - type: 'listSearch', + type: 'localResourceMapping', methodName: string, nodeType: INodeType, - ): ( - this: ILoadOptionsFunctions, - filter?: string | undefined, - paginationToken?: string | undefined, - ) => Promise; + ): LocalResourceMappingMethod; + private getMethod(type: 'listSearch', methodName: string, nodeType: INodeType): ListSearchMethod; private getMethod( type: 'loadOptions', methodName: string, nodeType: INodeType, - ): (this: ILoadOptionsFunctions) => Promise; + ): LoadOptionsMethod; private getMethod( type: 'actionHandler', methodName: string, nodeType: INodeType, - ): (this: ILoadOptionsFunctions, payload?: string) => Promise; - + ): ActionHandlerMethod; private getMethod( - type: 'resourceMapping' | 'listSearch' | 'loadOptions' | 'actionHandler', + type: + | 'resourceMapping' + | 'localResourceMapping' + | 'listSearch' + | 'loadOptions' + | 'actionHandler', methodName: string, nodeType: INodeType, - ) { - const method = nodeType.methods?.[type]?.[methodName]; + ): NodeMethod { + const method = nodeType.methods?.[type]?.[methodName] as NodeMethod; if (typeof method !== 'function') { throw new ApplicationError('Node type does not have method defined', { tags: { nodeType: nodeType.description.name }, @@ -253,4 +298,16 @@ export class DynamicNodeParametersService { const node = workflow.nodes['Temp-Node']; return new LoadOptionsContext(workflow, node, additionalData, path); } + + private getLocalLoadOptionsContext( + path: string, + additionalData: IWorkflowExecuteAdditionalData, + ): ILocalLoadOptionsFunctions { + return new LocalLoadOptionsContext( + this.nodeTypes, + additionalData, + path, + this.workflowLoaderService, + ); + } } diff --git a/packages/cli/src/services/execution-metadata.service.ts b/packages/cli/src/services/execution-metadata.service.ts index b88b68f12f..0c66b1daa2 100644 --- a/packages/cli/src/services/execution-metadata.service.ts +++ b/packages/cli/src/services/execution-metadata.service.ts @@ -1,4 +1,4 @@ -import { Service } from 'typedi'; +import { Service } from '@n8n/di'; import type { ExecutionMetadata } from '@/databases/entities/execution-metadata'; import { ExecutionMetadataRepository } from '@/databases/repositories/execution-metadata.repository'; diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 1645e98304..099ae2c935 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -1,31 +1,30 @@ import type { FrontendSettings, ITelemetrySettings } from '@n8n/api-types'; import { GlobalConfig, FrontendConfig, SecurityConfig } from '@n8n/config'; +import { Container, Service } from '@n8n/di'; import { createWriteStream } from 'fs'; import { mkdir } from 'fs/promises'; import uniq from 'lodash/uniq'; -import { InstanceSettings } from 'n8n-core'; +import { InstanceSettings, Logger } from 'n8n-core'; import type { ICredentialType, INodeTypeBaseDescription } from 'n8n-workflow'; import path from 'path'; -import { Container, Service } from 'typedi'; import config from '@/config'; import { inE2ETests, LICENSE_FEATURES, N8N_VERSION } from '@/constants'; import { CredentialTypes } from '@/credential-types'; import { CredentialsOverwrites } from '@/credentials-overwrites'; -import { getVariablesLimit } from '@/environments/variables/environment-helpers'; -import { getLdapLoginLabel } from '@/ldap/helpers.ee'; +import { getVariablesLimit } from '@/environments.ee/variables/environment-helpers'; +import { getLdapLoginLabel } from '@/ldap.ee/helpers.ee'; import { License } from '@/license'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; -import { Logger } from '@/logging/logger.service'; import { isApiEnabled } from '@/public-api'; import type { CommunityPackagesService } from '@/services/community-packages.service'; -import { getSamlLoginLabel } from '@/sso/saml/saml-helpers'; -import { getCurrentAuthenticationMethod } from '@/sso/sso-helpers'; +import { getSamlLoginLabel } from '@/sso.ee/saml/saml-helpers'; +import { getCurrentAuthenticationMethod } from '@/sso.ee/sso-helpers'; import { UserManagementMailer } from '@/user-management/email'; import { getWorkflowHistoryLicensePruneTime, getWorkflowHistoryPruneTime, -} from '@/workflows/workflow-history/workflow-history-helper.ee'; +} from '@/workflows/workflow-history.ee/workflow-history-helper.ee'; import { UrlService } from './url.service'; @@ -217,6 +216,10 @@ export class FrontendService { askAi: { enabled: false, }, + aiCredits: { + enabled: false, + credits: 0, + }, workflowHistory: { pruneTime: -1, licensePruneTime: -1, @@ -284,6 +287,7 @@ export class FrontendService { const isS3Licensed = this.license.isBinaryDataS3Licensed(); const isAiAssistantEnabled = this.license.isAiAssistantEnabled(); const isAskAiEnabled = this.license.isAskAiEnabled(); + const isAiCreditsEnabled = this.license.isAiCreditsEnabled(); this.settings.license.planName = this.license.getPlanName(); this.settings.license.consumerId = this.license.getConsumerId(); @@ -344,6 +348,11 @@ export class FrontendService { this.settings.askAi.enabled = isAskAiEnabled; } + if (isAiCreditsEnabled) { + this.settings.aiCredits.enabled = isAiCreditsEnabled; + this.settings.aiCredits.credits = this.license.getAiCredits(); + } + this.settings.mfa.enabled = config.get('mfa.enabled'); this.settings.executionMode = config.getEnv('executions.mode'); diff --git a/packages/cli/src/services/hooks.service.ts b/packages/cli/src/services/hooks.service.ts index 19b2f8deb8..2d2b137279 100644 --- a/packages/cli/src/services/hooks.service.ts +++ b/packages/cli/src/services/hooks.service.ts @@ -1,9 +1,9 @@ +import { Service } from '@n8n/di'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import type { FindManyOptions, FindOneOptions, FindOptionsWhere } from '@n8n/typeorm'; import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity'; import RudderStack, { type constructorOptions } from '@rudderstack/rudder-sdk-node'; import type { NextFunction, Response } from 'express'; -import { Service } from 'typedi'; import { AuthService } from '@/auth/auth.service'; import type { AuthUser } from '@/databases/entities/auth-user'; diff --git a/packages/cli/src/services/import.service.ts b/packages/cli/src/services/import.service.ts index 2402863bab..dcb7bff0ad 100644 --- a/packages/cli/src/services/import.service.ts +++ b/packages/cli/src/services/import.service.ts @@ -1,5 +1,6 @@ +import { Service } from '@n8n/di'; +import { Logger } from 'n8n-core'; import { type INode, type INodeCredentialsDetails } from 'n8n-workflow'; -import { Service } from 'typedi'; import { v4 as uuid } from 'uuid'; import { Project } from '@/databases/entities/project'; @@ -11,7 +12,6 @@ import { CredentialsRepository } from '@/databases/repositories/credentials.repo import { TagRepository } from '@/databases/repositories/tag.repository'; import * as Db from '@/db'; import type { ICredentialsDb } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { replaceInvalidCredentials } from '@/workflow-helpers'; @Service() diff --git a/packages/cli/src/services/jwt.service.ts b/packages/cli/src/services/jwt.service.ts index 8058de10dd..0601309013 100644 --- a/packages/cli/src/services/jwt.service.ts +++ b/packages/cli/src/services/jwt.service.ts @@ -1,7 +1,7 @@ +import { Service } from '@n8n/di'; import { createHash } from 'crypto'; import jwt from 'jsonwebtoken'; import { InstanceSettings } from 'n8n-core'; -import { Service } from 'typedi'; import config from '@/config'; diff --git a/packages/cli/src/services/naming.service.ts b/packages/cli/src/services/naming.service.ts index a715b2b360..70384ac15a 100644 --- a/packages/cli/src/services/naming.service.ts +++ b/packages/cli/src/services/naming.service.ts @@ -1,4 +1,4 @@ -import { Service } from 'typedi'; +import { Service } from '@n8n/di'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; diff --git a/packages/cli/src/services/orchestration.service.ts b/packages/cli/src/services/orchestration.service.ts index adf03d97bf..2745108c69 100644 --- a/packages/cli/src/services/orchestration.service.ts +++ b/packages/cli/src/services/orchestration.service.ts @@ -1,6 +1,6 @@ import { GlobalConfig } from '@n8n/config'; +import { Container, Service } from '@n8n/di'; import { InstanceSettings } from 'n8n-core'; -import Container, { Service } from 'typedi'; import config from '@/config'; import type { Publisher } from '@/scaling/pubsub/publisher.service'; diff --git a/packages/cli/src/services/ownership.service.ts b/packages/cli/src/services/ownership.service.ts index d0ad442bc1..21ce4b573a 100644 --- a/packages/cli/src/services/ownership.service.ts +++ b/packages/cli/src/services/ownership.service.ts @@ -1,4 +1,4 @@ -import { Service } from 'typedi'; +import { Service } from '@n8n/di'; import type { Project } from '@/databases/entities/project'; import type { User } from '@/databases/entities/user'; @@ -87,12 +87,14 @@ export class OwnershipService { id: project.id, type: project.type, name: project.name, + icon: project.icon, }; } else { entity.sharedWithProjects.push({ id: project.id, type: project.type, name: project.name, + icon: project.icon, }); } } diff --git a/packages/cli/src/services/password.utility.ts b/packages/cli/src/services/password.utility.ts index 9719db44bb..f4119c47a0 100644 --- a/packages/cli/src/services/password.utility.ts +++ b/packages/cli/src/services/password.utility.ts @@ -1,11 +1,5 @@ +import { Service as Utility } from '@n8n/di'; import { compare, hash } from 'bcryptjs'; -import { Service as Utility } from 'typedi'; - -import { - MAX_PASSWORD_CHAR_LENGTH as maxLength, - MIN_PASSWORD_CHAR_LENGTH as minLength, -} from '@/constants'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; const SALT_ROUNDS = 10; @@ -18,28 +12,4 @@ export class PasswordUtility { async compare(plaintext: string, hashed: string) { return await compare(plaintext, hashed); } - - validate(plaintext?: string) { - if (!plaintext) throw new BadRequestError('Password is mandatory'); - - const errorMessages: string[] = []; - - if (plaintext.length < minLength || plaintext.length > maxLength) { - errorMessages.push(`Password must be ${minLength} to ${maxLength} characters long.`); - } - - if (!/\d/.test(plaintext)) { - errorMessages.push('Password must contain at least 1 number.'); - } - - if (!/[A-Z]/.test(plaintext)) { - errorMessages.push('Password must contain at least 1 uppercase letter.'); - } - - if (errorMessages.length > 0) { - throw new BadRequestError(errorMessages.join(' ')); - } - - return plaintext; - } } diff --git a/packages/cli/src/services/project.service.ts b/packages/cli/src/services/project.service.ee.ts similarity index 95% rename from packages/cli/src/services/project.service.ts rename to packages/cli/src/services/project.service.ee.ts index d78e3a07e1..aa93360287 100644 --- a/packages/cli/src/services/project.service.ts +++ b/packages/cli/src/services/project.service.ee.ts @@ -1,13 +1,14 @@ +import { Container, Service } from '@n8n/di'; import { type Scope } from '@n8n/permissions'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import type { FindOptionsWhere, EntityManager } from '@n8n/typeorm'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In, Not } from '@n8n/typeorm'; import { ApplicationError } from 'n8n-workflow'; -import Container, { Service } from 'typedi'; import { UNLIMITED_LICENSE_QUOTA } from '@/constants'; -import { Project, type ProjectType } from '@/databases/entities/project'; +import type { ProjectIcon, ProjectType } from '@/databases/entities/project'; +import { Project } from '@/databases/entities/project'; import { ProjectRelation } from '@/databases/entities/project-relation'; import type { ProjectRole } from '@/databases/entities/project-relation'; import type { User } from '@/databases/entities/user'; @@ -167,7 +168,12 @@ export class ProjectService { return await this.projectRelationRepository.getPersonalProjectOwners(projectIds); } - async createTeamProject(name: string, adminUser: User, id?: string): Promise { + async createTeamProject( + name: string, + adminUser: User, + id?: string, + icon?: ProjectIcon, + ): Promise { const limit = this.license.getTeamProjectLimit(); if ( limit !== UNLIMITED_LICENSE_QUOTA && @@ -180,6 +186,7 @@ export class ProjectService { this.projectRepository.create({ id, name, + icon, type: 'team', }), ); @@ -190,7 +197,11 @@ export class ProjectService { return project; } - async updateProject(name: string, projectId: string): Promise { + async updateProject( + name: string, + projectId: string, + icon?: { type: 'icon' | 'emoji'; value: string }, + ): Promise { const result = await this.projectRepository.update( { id: projectId, @@ -198,6 +209,7 @@ export class ProjectService { }, { name, + icon, }, ); diff --git a/packages/cli/src/services/pruning/pruning.service.ts b/packages/cli/src/services/pruning/pruning.service.ts index aad8c5490f..749c2d9d45 100644 --- a/packages/cli/src/services/pruning/pruning.service.ts +++ b/packages/cli/src/services/pruning/pruning.service.ts @@ -1,14 +1,13 @@ import { ExecutionsConfig } from '@n8n/config'; -import { BinaryDataService, InstanceSettings } from 'n8n-core'; +import { Service } from '@n8n/di'; +import { BinaryDataService, InstanceSettings, Logger } from 'n8n-core'; import { ensureError } from 'n8n-workflow'; import { strict } from 'node:assert'; -import { Service } from 'typedi'; import { Time } from '@/constants'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { connectionState as dbConnectionState } from '@/db'; import { OnShutdown } from '@/decorators/on-shutdown'; -import { Logger } from '@/logging/logger.service'; import { OrchestrationService } from '../orchestration.service'; diff --git a/packages/cli/src/services/public-api-key.service.ts b/packages/cli/src/services/public-api-key.service.ts index bca3cd0d62..a6b1133bc2 100644 --- a/packages/cli/src/services/public-api-key.service.ts +++ b/packages/cli/src/services/public-api-key.service.ts @@ -1,5 +1,5 @@ +import { Service } from '@n8n/di'; import type { OpenAPIV3 } from 'openapi-types'; -import { Service } from 'typedi'; import { ApiKey } from '@/databases/entities/api-key'; import type { User } from '@/databases/entities/user'; diff --git a/packages/cli/src/services/redis-client.service.ts b/packages/cli/src/services/redis-client.service.ts index c584530165..6dea89a801 100644 --- a/packages/cli/src/services/redis-client.service.ts +++ b/packages/cli/src/services/redis-client.service.ts @@ -1,10 +1,10 @@ import { GlobalConfig } from '@n8n/config'; +import { Service } from '@n8n/di'; import ioRedis from 'ioredis'; import type { Cluster, RedisOptions } from 'ioredis'; -import { Service } from 'typedi'; +import { Logger } from 'n8n-core'; import { Debounce } from '@/decorators/debounce'; -import { Logger } from '@/logging/logger.service'; import { TypedEmitter } from '@/typed-emitter'; import type { RedisClientType } from '../scaling/redis/redis.types'; diff --git a/packages/cli/src/services/role.service.ts b/packages/cli/src/services/role.service.ts index 97adbbfb7d..7590dca2d5 100644 --- a/packages/cli/src/services/role.service.ts +++ b/packages/cli/src/services/role.service.ts @@ -1,6 +1,6 @@ +import { Service } from '@n8n/di'; import { combineScopes, type Resource, type Scope } from '@n8n/permissions'; import { ApplicationError } from 'n8n-workflow'; -import { Service } from 'typedi'; import type { CredentialsEntity } from '@/databases/entities/credentials-entity'; import type { ProjectRelation, ProjectRole } from '@/databases/entities/project-relation'; @@ -15,19 +15,19 @@ import { GLOBAL_ADMIN_SCOPES, GLOBAL_MEMBER_SCOPES, GLOBAL_OWNER_SCOPES, -} from '@/permissions/global-roles'; +} from '@/permissions.ee/global-roles'; import { PERSONAL_PROJECT_OWNER_SCOPES, PROJECT_EDITOR_SCOPES, PROJECT_VIEWER_SCOPES, REGULAR_PROJECT_ADMIN_SCOPES, -} from '@/permissions/project-roles'; +} from '@/permissions.ee/project-roles'; import { CREDENTIALS_SHARING_OWNER_SCOPES, CREDENTIALS_SHARING_USER_SCOPES, WORKFLOW_SHARING_EDITOR_SCOPES, WORKFLOW_SHARING_OWNER_SCOPES, -} from '@/permissions/resource-roles'; +} from '@/permissions.ee/resource-roles'; import type { ListQuery } from '@/requests'; export type RoleNamespace = 'global' | 'project' | 'credential' | 'workflow'; diff --git a/packages/cli/src/services/tag.service.ts b/packages/cli/src/services/tag.service.ts index 7ed3feebff..09695f44ff 100644 --- a/packages/cli/src/services/tag.service.ts +++ b/packages/cli/src/services/tag.service.ts @@ -1,4 +1,4 @@ -import { Service } from 'typedi'; +import { Service } from '@n8n/di'; import type { TagEntity } from '@/databases/entities/tag-entity'; import { TagRepository } from '@/databases/repositories/tag.repository'; diff --git a/packages/cli/src/services/url.service.ts b/packages/cli/src/services/url.service.ts index f9d3fcdbbd..3851c011ca 100644 --- a/packages/cli/src/services/url.service.ts +++ b/packages/cli/src/services/url.service.ts @@ -1,5 +1,5 @@ import { GlobalConfig } from '@n8n/config'; -import { Service } from 'typedi'; +import { Service } from '@n8n/di'; import config from '@/config'; diff --git a/packages/cli/src/services/user.service.ts b/packages/cli/src/services/user.service.ts index e47dd026b0..ab188fc972 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -1,13 +1,13 @@ +import { Service } from '@n8n/di'; +import { Logger } from 'n8n-core'; import type { IUserSettings } from 'n8n-workflow'; import { ApplicationError } from 'n8n-workflow'; -import { Service } from 'typedi'; import type { User, AssignableRole } from '@/databases/entities/user'; import { UserRepository } from '@/databases/repositories/user.repository'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { EventService } from '@/events/event.service'; import type { Invitation, PublicUser } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import type { PostHogClient } from '@/posthog'; import type { UserRequest } from '@/requests'; import { UrlService } from '@/services/url.service'; diff --git a/packages/cli/src/services/workflow-loader.service.ts b/packages/cli/src/services/workflow-loader.service.ts new file mode 100644 index 0000000000..5d5e08c1fe --- /dev/null +++ b/packages/cli/src/services/workflow-loader.service.ts @@ -0,0 +1,19 @@ +import { Service } from '@n8n/di'; +import { ApplicationError, type IWorkflowBase, type IWorkflowLoader } from 'n8n-workflow'; + +import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; + +@Service() +export class WorkflowLoaderService implements IWorkflowLoader { + constructor(private readonly workflowRepository: WorkflowRepository) {} + + async get(workflowId: string): Promise { + const workflow = await this.workflowRepository.findById(workflowId); + + if (!workflow) { + throw new ApplicationError(`Failed to find workflow with ID "${workflowId}"`); + } + + return workflow; + } +} diff --git a/packages/cli/src/services/workflow-statistics.service.ts b/packages/cli/src/services/workflow-statistics.service.ts index 53cbac5094..efcdc8320f 100644 --- a/packages/cli/src/services/workflow-statistics.service.ts +++ b/packages/cli/src/services/workflow-statistics.service.ts @@ -1,10 +1,10 @@ +import { Service } from '@n8n/di'; +import { Logger } from 'n8n-core'; import type { INode, IRun, IWorkflowBase } from 'n8n-workflow'; -import { Service } from 'typedi'; import { StatisticsNames } from '@/databases/entities/workflow-statistics'; import { WorkflowStatisticsRepository } from '@/databases/repositories/workflow-statistics.repository'; import { EventService } from '@/events/event.service'; -import { Logger } from '@/logging/logger.service'; import { UserService } from '@/services/user.service'; import { TypedEmitter } from '@/typed-emitter'; diff --git a/packages/cli/src/shutdown/__tests__/shutdown.service.test.ts b/packages/cli/src/shutdown/__tests__/shutdown.service.test.ts index 26d6471584..41afed9398 100644 --- a/packages/cli/src/shutdown/__tests__/shutdown.service.test.ts +++ b/packages/cli/src/shutdown/__tests__/shutdown.service.test.ts @@ -1,7 +1,7 @@ +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import type { ErrorReporter } from 'n8n-core'; import { ApplicationError } from 'n8n-workflow'; -import Container from 'typedi'; import type { ServiceClass } from '@/shutdown/shutdown.service'; import { ShutdownService } from '@/shutdown/shutdown.service'; diff --git a/packages/cli/src/shutdown/shutdown.service.ts b/packages/cli/src/shutdown/shutdown.service.ts index 8ff8570757..d31ca9b154 100644 --- a/packages/cli/src/shutdown/shutdown.service.ts +++ b/packages/cli/src/shutdown/shutdown.service.ts @@ -1,9 +1,9 @@ +import { Container, Service } from '@n8n/di'; import { type Class, ErrorReporter } from 'n8n-core'; +import { Logger } from 'n8n-core'; import { ApplicationError, assert } from 'n8n-workflow'; -import { Container, Service } from 'typedi'; import { LOWEST_SHUTDOWN_PRIORITY, HIGHEST_SHUTDOWN_PRIORITY } from '@/constants'; -import { Logger } from '@/logging/logger.service'; type HandlerFn = () => Promise | void; export type ServiceClass = Class>; diff --git a/packages/cli/src/sso/saml/__tests__/saml-helpers.test.ts b/packages/cli/src/sso.ee/saml/__tests__/saml-helpers.test.ts similarity index 92% rename from packages/cli/src/sso/saml/__tests__/saml-helpers.test.ts rename to packages/cli/src/sso.ee/saml/__tests__/saml-helpers.test.ts index 76ae2e4d50..d75fdc8a7f 100644 --- a/packages/cli/src/sso/saml/__tests__/saml-helpers.test.ts +++ b/packages/cli/src/sso.ee/saml/__tests__/saml-helpers.test.ts @@ -3,8 +3,8 @@ import { User } from '@/databases/entities/user'; import { AuthIdentityRepository } from '@/databases/repositories/auth-identity.repository'; import { UserRepository } from '@/databases/repositories/user.repository'; import { generateNanoId } from '@/databases/utils/generators'; -import * as helpers from '@/sso/saml/saml-helpers'; -import type { SamlUserAttributes } from '@/sso/saml/types/saml-user-attributes'; +import * as helpers from '@/sso.ee/saml/saml-helpers'; +import type { SamlUserAttributes } from '@/sso.ee/saml/types'; import { mockInstance } from '@test/mocking'; const userRepository = mockInstance(UserRepository); diff --git a/packages/cli/src/sso/saml/__tests__/saml-validator.test.ts b/packages/cli/src/sso.ee/saml/__tests__/saml-validator.test.ts similarity index 72% rename from packages/cli/src/sso/saml/__tests__/saml-validator.test.ts rename to packages/cli/src/sso.ee/saml/__tests__/saml-validator.test.ts index 8594676ab2..9f93550bf2 100644 --- a/packages/cli/src/sso/saml/__tests__/saml-validator.test.ts +++ b/packages/cli/src/sso.ee/saml/__tests__/saml-validator.test.ts @@ -1,10 +1,15 @@ -import { Logger } from '@/logging/logger.service'; -import { mockInstance } from '@test/mocking'; +import { mock } from 'jest-mock-extended'; -import { validateMetadata, validateResponse } from '../saml-validator'; +import { SamlValidator } from '../saml-validator'; describe('saml-validator', () => { - mockInstance(Logger); + const validator = new SamlValidator(mock()); + const VALID_CERTIFICATE = + 'MIIC8DCCAdigAwIBAgIQf+iroClVKohAtsyk0Ne13TANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yNDExMTMxMDEwNTNaFw0yNzExMTMxMDEwNTNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE8Ad1OMQKfaHi6YrsEcmMNwIAQ86h7JmnuABf5xLNd27jaMF4FVxHbEtC/BYxtcmwld5zbkCVXQ6PT6VoeYIjHMVnptFXg15EGgjnqpxWsjLDQNoSdSQu8VhG+8Yb5M7KPt+UEZfsRZVrgqMjdSEMVrOzPMD8KMB7wnghYX6npcZhn7D5w/F9gVDpI1Um8M/FIUKYVSYFjky1i24WvKmcBf71mAacZp48Zuj5by/ELIb6gAjpW5xpd02smpLthy/Yo4XDIQQurFOfjqyZd8xAZu/SfPsbjtymWw59tgd9RdYISl6O/241kY9h6Ojtx6WShOVDi6q+bJrfj9Z8WKcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCiVxiQ9KpjihliQzIW45YO0EvRJtoPtyVAh9RiSGozbTl4otfrUJf8nbRtj7iZBRuuW4rrtRAH5kDb+i1wNUUQED2Pl/l4x5cN0oBytP3GSymq6NJx1gUOBO1BrNY+c3r5yHOUyj5qpbw9UkqpG1AqQkLLeZqB/yVCyOBQT7SKTbXVYhGefFM/+6z0/rGsWZN5OF6/2NC06ws1v4In28Atgpg4XxFh5TL7rPMJ11ca5MN9lHJoIUsvls053eQBcd7vJneqzd904B6WtPld6KOJK4dzIt9edHzPhaz158awWwx3iHsMn1Y/T0WVy5/4ZTzxY/i4U3t1Yt8ktxewVJYT'; + + beforeAll(async () => { + await validator.init(); + }); describe('validateMetadata', () => { test('successfully validates metadata containing ws federation tags', async () => { @@ -30,8 +35,7 @@ describe('saml-validator', () => { DQnnT/5se4dqYN86R35MCdbyKVl64lGPLSIVrxFxrOQ9YRK1br7Z1Bt1/LQD4f92z+GwAl+9tZTWhuoy6OGHCV6LlqBEztW43KnlCKw6eaNg4/6NluzJ/XeknXYLURDnfFVyGbLQAYWGND4Qm8CUXO/GjGfWTZuArvrDDC36/2FA41jKXtf1InxGFx1Bbaskx3n3KCFFth/V9knbnc1zftEe022aQluPRoGccROOI4ZeLUFL6+1gYlxjx0gFIOTRiuvrzR765lHNrF7iZ4aD+XukqtkGEtxTkiLoB+Bnr8Fd7IF5rV5FKTZWSxo+ZFcLimrDGtFPItVrC/oKRc+MGA== - - MIIC8DCCAdigAwIBAgIQf+iroClVKohAtsyk0Ne13TANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yNDExMTMxMDEwNTNaFw0yNzExMTMxMDEwNTNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE8Ad1OMQKfaHi6YrsEcmMNwIAQ86h7JmnuABf5xLNd27jaMF4FVxHbEtC/BYxtcmwld5zbkCVXQ6PT6VoeYIjHMVnptFXg15EGgjnqpxWsjLDQNoSdSQu8VhG+8Yb5M7KPt+UEZfsRZVrgqMjdSEMVrOzPMD8KMB7wnghYX6npcZhn7D5w/F9gVDpI1Um8M/FIUKYVSYFjky1i24WvKmcBf71mAacZp48Zuj5by/ELIb6gAjpW5xpd02smpLthy/Yo4XDIQQurFOfjqyZd8xAZu/SfPsbjtymWw59tgd9RdYISl6O/241kY9h6Ojtx6WShOVDi6q+bJrfj9Z8WKcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCiVxiQ9KpjihliQzIW45YO0EvRJtoPtyVAh9RiSGozbTl4otfrUJf8nbRtj7iZBRuuW4rrtRAH5kDb+i1wNUUQED2Pl/l4x5cN0oBytP3GSymq6NJx1gUOBO1BrNY+c3r5yHOUyj5qpbw9UkqpG1AqQkLLeZqB/yVCyOBQT7SKTbXVYhGefFM/+6z0/rGsWZN5OF6/2NC06ws1v4In28Atgpg4XxFh5TL7rPMJ11ca5MN9lHJoIUsvls053eQBcd7vJneqzd904B6WtPld6KOJK4dzIt9edHzPhaz158awWwx3iHsMn1Y/T0WVy5/4ZTzxY/i4U3t1Yt8ktxewVJYT + ${VALID_CERTIFICATE} @@ -42,8 +46,7 @@ describe('saml-validator', () => { - - MIIC8DCCAdigAwIBAgIQf+iroClVKohAtsyk0Ne13TANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yNDExMTMxMDEwNTNaFw0yNzExMTMxMDEwNTNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE8Ad1OMQKfaHi6YrsEcmMNwIAQ86h7JmnuABf5xLNd27jaMF4FVxHbEtC/BYxtcmwld5zbkCVXQ6PT6VoeYIjHMVnptFXg15EGgjnqpxWsjLDQNoSdSQu8VhG+8Yb5M7KPt+UEZfsRZVrgqMjdSEMVrOzPMD8KMB7wnghYX6npcZhn7D5w/F9gVDpI1Um8M/FIUKYVSYFjky1i24WvKmcBf71mAacZp48Zuj5by/ELIb6gAjpW5xpd02smpLthy/Yo4XDIQQurFOfjqyZd8xAZu/SfPsbjtymWw59tgd9RdYISl6O/241kY9h6Ojtx6WShOVDi6q+bJrfj9Z8WKcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCiVxiQ9KpjihliQzIW45YO0EvRJtoPtyVAh9RiSGozbTl4otfrUJf8nbRtj7iZBRuuW4rrtRAH5kDb+i1wNUUQED2Pl/l4x5cN0oBytP3GSymq6NJx1gUOBO1BrNY+c3r5yHOUyj5qpbw9UkqpG1AqQkLLeZqB/yVCyOBQT7SKTbXVYhGefFM/+6z0/rGsWZN5OF6/2NC06ws1v4In28Atgpg4XxFh5TL7rPMJ11ca5MN9lHJoIUsvls053eQBcd7vJneqzd904B6WtPld6KOJK4dzIt9edHzPhaz158awWwx3iHsMn1Y/T0WVy5/4ZTzxY/i4U3t1Yt8ktxewVJYT + ${VALID_CERTIFICATE} @@ -168,8 +171,7 @@ describe('saml-validator', () => { - - MIIC8DCCAdigAwIBAgIQf+iroClVKohAtsyk0Ne13TANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yNDExMTMxMDEwNTNaFw0yNzExMTMxMDEwNTNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE8Ad1OMQKfaHi6YrsEcmMNwIAQ86h7JmnuABf5xLNd27jaMF4FVxHbEtC/BYxtcmwld5zbkCVXQ6PT6VoeYIjHMVnptFXg15EGgjnqpxWsjLDQNoSdSQu8VhG+8Yb5M7KPt+UEZfsRZVrgqMjdSEMVrOzPMD8KMB7wnghYX6npcZhn7D5w/F9gVDpI1Um8M/FIUKYVSYFjky1i24WvKmcBf71mAacZp48Zuj5by/ELIb6gAjpW5xpd02smpLthy/Yo4XDIQQurFOfjqyZd8xAZu/SfPsbjtymWw59tgd9RdYISl6O/241kY9h6Ojtx6WShOVDi6q+bJrfj9Z8WKcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCiVxiQ9KpjihliQzIW45YO0EvRJtoPtyVAh9RiSGozbTl4otfrUJf8nbRtj7iZBRuuW4rrtRAH5kDb+i1wNUUQED2Pl/l4x5cN0oBytP3GSymq6NJx1gUOBO1BrNY+c3r5yHOUyj5qpbw9UkqpG1AqQkLLeZqB/yVCyOBQT7SKTbXVYhGefFM/+6z0/rGsWZN5OF6/2NC06ws1v4In28Atgpg4XxFh5TL7rPMJ11ca5MN9lHJoIUsvls053eQBcd7vJneqzd904B6WtPld6KOJK4dzIt9edHzPhaz158awWwx3iHsMn1Y/T0WVy5/4ZTzxY/i4U3t1Yt8ktxewVJYT + ${VALID_CERTIFICATE} @@ -193,8 +195,7 @@ describe('saml-validator', () => { - - MIIC8DCCAdigAwIBAgIQf+iroClVKohAtsyk0Ne13TANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yNDExMTMxMDEwNTNaFw0yNzExMTMxMDEwNTNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE8Ad1OMQKfaHi6YrsEcmMNwIAQ86h7JmnuABf5xLNd27jaMF4FVxHbEtC/BYxtcmwld5zbkCVXQ6PT6VoeYIjHMVnptFXg15EGgjnqpxWsjLDQNoSdSQu8VhG+8Yb5M7KPt+UEZfsRZVrgqMjdSEMVrOzPMD8KMB7wnghYX6npcZhn7D5w/F9gVDpI1Um8M/FIUKYVSYFjky1i24WvKmcBf71mAacZp48Zuj5by/ELIb6gAjpW5xpd02smpLthy/Yo4XDIQQurFOfjqyZd8xAZu/SfPsbjtymWw59tgd9RdYISl6O/241kY9h6Ojtx6WShOVDi6q+bJrfj9Z8WKcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCiVxiQ9KpjihliQzIW45YO0EvRJtoPtyVAh9RiSGozbTl4otfrUJf8nbRtj7iZBRuuW4rrtRAH5kDb+i1wNUUQED2Pl/l4x5cN0oBytP3GSymq6NJx1gUOBO1BrNY+c3r5yHOUyj5qpbw9UkqpG1AqQkLLeZqB/yVCyOBQT7SKTbXVYhGefFM/+6z0/rGsWZN5OF6/2NC06ws1v4In28Atgpg4XxFh5TL7rPMJ11ca5MN9lHJoIUsvls053eQBcd7vJneqzd904B6WtPld6KOJK4dzIt9edHzPhaz158awWwx3iHsMn1Y/T0WVy5/4ZTzxY/i4U3t1Yt8ktxewVJYT + ${VALID_CERTIFICATE} @@ -208,7 +209,7 @@ describe('saml-validator', () => { `; // ACT - const result = await validateMetadata(metadata); + const result = await validator.validateMetadata(metadata); // ASSERT expect(result).toBe(true); @@ -224,7 +225,85 @@ describe('saml-validator', () => { `; // ACT - const result = await validateMetadata(metadata); + const result = await validator.validateMetadata(metadata); + + // ASSERT + expect(result).toBe(false); + }); + + test('rejects malformed XML metadata', async () => { + // ARRANGE + const metadata = ` + + + + + + ${VALID_CERTIFICATE} + + + + + + `; // Missing closing tags + + // ACT + const result = await validator.validateMetadata(metadata); + + // ASSERT + expect(result).toBe(false); + }); + + test('rejects metadata missing SingleSignOnService', async () => { + // ARRANGE + const metadata = ` + + + + + + ${VALID_CERTIFICATE} + + + + + + `; + + // ACT + const result = await validator.validateMetadata(metadata); + + // ASSERT + expect(result).toBe(false); + }); + + test('rejects metadata with invalid X.509 certificate', async () => { + // ARRANGE + const metadata = ` + + + + + + + INVALID_CERTIFICATE + + + + + + + `; + + // ACT + const result = await validator.validateMetadata(metadata); // ASSERT expect(result).toBe(false); @@ -327,13 +406,13 @@ describe('saml-validator', () => { `; // ACT - const result = await validateResponse(response); + const result = await validator.validateResponse(response); // ASSERT expect(result).toBe(true); }); - test('rejects invalidate response', async () => { + test('rejects invalid response', async () => { // ARRANGE // Invalid because required children are missing const response = ` { `; // ACT - const result = await validateResponse(response); + const result = await validator.validateResponse(response); + + // ASSERT + expect(result).toBe(false); + }); + + test('rejects expired SAML response', async () => { + // ARRANGE + const response = ` + + https://sts.windows.net/random-issuer/ + + + + + https://sts.windows.net/random-issuer/ + + + random_name_id + + + + + // Expired + + http://localhost:5678/rest/sso/saml/metadata + + + + `; + + // ACT + const result = await validator.validateResponse(response); // ASSERT expect(result).toBe(false); diff --git a/packages/cli/src/sso/saml/__tests__/saml.service.ee.test.ts b/packages/cli/src/sso.ee/saml/__tests__/saml.service.ee.test.ts similarity index 91% rename from packages/cli/src/sso/saml/__tests__/saml.service.ee.test.ts rename to packages/cli/src/sso.ee/saml/__tests__/saml.service.ee.test.ts index 8bd5e32da2..ebf34e3075 100644 --- a/packages/cli/src/sso/saml/__tests__/saml.service.ee.test.ts +++ b/packages/cli/src/sso.ee/saml/__tests__/saml.service.ee.test.ts @@ -3,20 +3,16 @@ import { mock } from 'jest-mock-extended'; import type { IdentityProviderInstance, ServiceProviderInstance } from 'samlify'; import { SettingsRepository } from '@/databases/repositories/settings.repository'; -import { Logger } from '@/logging/logger.service'; -import { UrlService } from '@/services/url.service'; -import * as samlHelpers from '@/sso/saml/saml-helpers'; -import { SamlService } from '@/sso/saml/saml.service.ee'; +import * as samlHelpers from '@/sso.ee/saml/saml-helpers'; +import { SamlService } from '@/sso.ee/saml/saml.service.ee'; import { mockInstance } from '@test/mocking'; import { SAML_PREFERENCES_DB_KEY } from '../constants'; import { InvalidSamlMetadataError } from '../errors/invalid-saml-metadata.error'; describe('SamlService', () => { - const logger = mockInstance(Logger); - const urlService = mockInstance(UrlService); - const samlService = new SamlService(logger, urlService); const settingsRepository = mockInstance(SettingsRepository); + const samlService = new SamlService(mock(), mock(), mock(), mock(), settingsRepository); beforeEach(() => { jest.restoreAllMocks(); diff --git a/packages/cli/src/sso/saml/constants.ts b/packages/cli/src/sso.ee/saml/constants.ts similarity index 100% rename from packages/cli/src/sso/saml/constants.ts rename to packages/cli/src/sso.ee/saml/constants.ts diff --git a/packages/cli/src/sso/saml/errors/invalid-saml-metadata.error.ts b/packages/cli/src/sso.ee/saml/errors/invalid-saml-metadata.error.ts similarity index 100% rename from packages/cli/src/sso/saml/errors/invalid-saml-metadata.error.ts rename to packages/cli/src/sso.ee/saml/errors/invalid-saml-metadata.error.ts diff --git a/packages/cli/src/sso/saml/middleware/saml-enabled-middleware.ts b/packages/cli/src/sso.ee/saml/middleware/saml-enabled-middleware.ts similarity index 100% rename from packages/cli/src/sso/saml/middleware/saml-enabled-middleware.ts rename to packages/cli/src/sso.ee/saml/middleware/saml-enabled-middleware.ts diff --git a/packages/cli/src/sso/saml/routes/__tests__/saml.controller.ee.test.ts b/packages/cli/src/sso.ee/saml/routes/__tests__/saml.controller.ee.test.ts similarity index 63% rename from packages/cli/src/sso/saml/routes/__tests__/saml.controller.ee.test.ts rename to packages/cli/src/sso.ee/saml/routes/__tests__/saml.controller.ee.test.ts index c4a33ed441..928f6d6df0 100644 --- a/packages/cli/src/sso/saml/routes/__tests__/saml.controller.ee.test.ts +++ b/packages/cli/src/sso.ee/saml/routes/__tests__/saml.controller.ee.test.ts @@ -2,18 +2,14 @@ import { type Response } from 'express'; import { mock } from 'jest-mock-extended'; import type { User } from '@/databases/entities/user'; -import { UrlService } from '@/services/url.service'; -import { mockInstance } from '@test/mocking'; +import type { AuthlessRequest } from '@/requests'; -import { SamlService } from '../../saml.service.ee'; +import type { SamlService } from '../../saml.service.ee'; import { getServiceProviderConfigTestReturnUrl } from '../../service-provider.ee'; -import type { SamlConfiguration } from '../../types/requests'; -import type { SamlUserAttributes } from '../../types/saml-user-attributes'; +import type { SamlUserAttributes } from '../../types'; import { SamlController } from '../saml.controller.ee'; -const urlService = mockInstance(UrlService); -urlService.getInstanceBaseUrl.mockReturnValue(''); -const samlService = mockInstance(SamlService); +const samlService = mock(); const controller = new SamlController(mock(), samlService, mock(), mock()); const user = mock({ @@ -31,46 +27,45 @@ const attributes: SamlUserAttributes = { }; describe('Test views', () => { + const RelayState = getServiceProviderConfigTestReturnUrl(); + test('Should render success with template', async () => { - const req = mock(); + const req = mock(); const res = mock(); - req.body.RelayState = getServiceProviderConfigTestReturnUrl(); samlService.handleSamlLogin.mockResolvedValueOnce({ authenticatedUser: user, attributes, onboardingRequired: false, }); - await controller.acsPost(req, res); + await controller.acsPost(req, res, { RelayState }); expect(res.render).toBeCalledWith('saml-connection-test-success', attributes); }); test('Should render failure with template', async () => { - const req = mock(); + const req = mock(); const res = mock(); - req.body.RelayState = getServiceProviderConfigTestReturnUrl(); samlService.handleSamlLogin.mockResolvedValueOnce({ authenticatedUser: undefined, attributes, onboardingRequired: false, }); - await controller.acsPost(req, res); + await controller.acsPost(req, res, { RelayState }); expect(res.render).toBeCalledWith('saml-connection-test-failed', { message: '', attributes }); }); test('Should render error with template', async () => { - const req = mock(); + const req = mock(); const res = mock(); - req.body.RelayState = getServiceProviderConfigTestReturnUrl(); samlService.handleSamlLogin.mockRejectedValueOnce(new Error('Test Error')); - await controller.acsPost(req, res); + await controller.acsPost(req, res, { RelayState }); expect(res.render).toBeCalledWith('saml-connection-test-failed', { message: 'Test Error' }); }); diff --git a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts b/packages/cli/src/sso.ee/saml/routes/saml.controller.ee.ts similarity index 77% rename from packages/cli/src/sso/saml/routes/saml.controller.ee.ts rename to packages/cli/src/sso.ee/saml/routes/saml.controller.ee.ts index c7b954914b..c8f636eec4 100644 --- a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts +++ b/packages/cli/src/sso.ee/saml/routes/saml.controller.ee.ts @@ -1,15 +1,14 @@ -import { validate } from 'class-validator'; -import express from 'express'; +import { SamlAcsDto, SamlPreferences, SamlToggleDto } from '@n8n/api-types'; +import { Response } from 'express'; import querystring from 'querystring'; import type { PostBindingContext } from 'samlify/types/src/entity'; import url from 'url'; import { AuthService } from '@/auth/auth.service'; -import { Get, Post, RestController, GlobalScope } from '@/decorators'; +import { Get, Post, RestController, GlobalScope, Body } from '@/decorators'; import { AuthError } from '@/errors/response-errors/auth.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { EventService } from '@/events/event.service'; -import { AuthenticatedRequest } from '@/requests'; +import { AuthenticatedRequest, AuthlessRequest } from '@/requests'; import { sendErrorResponse } from '@/response-helper'; import { UrlService } from '@/services/url.service'; @@ -25,7 +24,6 @@ import { getServiceProviderReturnUrl, } from '../service-provider.ee'; import type { SamlLoginBinding } from '../types'; -import { SamlConfiguration } from '../types/requests'; import { getInitSSOFormView } from '../views/init-sso-post'; @RestController('/sso/saml') @@ -38,7 +36,7 @@ export class SamlController { ) {} @Get('/metadata', { skipAuth: true }) - async getServiceProviderMetadata(_: express.Request, res: express.Response) { + async getServiceProviderMetadata(_: AuthlessRequest, res: Response) { return res .header('Content-Type', 'text/xml') .send(this.samlService.getServiceProviderInstance().getMetadata()); @@ -62,17 +60,8 @@ export class SamlController { */ @Post('/config', { middlewares: [samlLicensedMiddleware] }) @GlobalScope('saml:manage') - async configPost(req: SamlConfiguration.Update) { - const validationResult = await validate(req.body); - if (validationResult.length === 0) { - const result = await this.samlService.setSamlPreferences(req.body); - return result; - } else { - throw new BadRequestError( - 'Body is not a valid SamlPreferences object: ' + - validationResult.map((e) => e.toString()).join(','), - ); - } + async configPost(_req: AuthenticatedRequest, _res: Response, @Body payload: SamlPreferences) { + return await this.samlService.setSamlPreferences(payload); } /** @@ -80,11 +69,12 @@ export class SamlController { */ @Post('/config/toggle', { middlewares: [samlLicensedMiddleware] }) @GlobalScope('saml:manage') - async toggleEnabledPost(req: SamlConfiguration.Toggle, res: express.Response) { - if (req.body.loginEnabled === undefined) { - throw new BadRequestError('Body should contain a boolean "loginEnabled" property'); - } - await this.samlService.setSamlPreferences({ loginEnabled: req.body.loginEnabled }); + async toggleEnabledPost( + _req: AuthenticatedRequest, + res: Response, + @Body { loginEnabled }: SamlToggleDto, + ) { + await this.samlService.setSamlPreferences({ loginEnabled }); return res.sendStatus(200); } @@ -92,7 +82,7 @@ export class SamlController { * Assertion Consumer Service endpoint */ @Get('/acs', { middlewares: [samlLicensedMiddleware], skipAuth: true, usesTemplates: true }) - async acsGet(req: SamlConfiguration.AcsRequest, res: express.Response) { + async acsGet(req: AuthlessRequest, res: Response) { return await this.acsHandler(req, res, 'redirect'); } @@ -100,8 +90,8 @@ export class SamlController { * Assertion Consumer Service endpoint */ @Post('/acs', { middlewares: [samlLicensedMiddleware], skipAuth: true, usesTemplates: true }) - async acsPost(req: SamlConfiguration.AcsRequest, res: express.Response) { - return await this.acsHandler(req, res, 'post'); + async acsPost(req: AuthlessRequest, res: Response, @Body payload: SamlAcsDto) { + return await this.acsHandler(req, res, 'post', payload); } /** @@ -110,14 +100,15 @@ export class SamlController { * For test connections, returns status 202 if SAML is not enabled */ private async acsHandler( - req: SamlConfiguration.AcsRequest, - res: express.Response, + req: AuthlessRequest, + res: Response, binding: SamlLoginBinding, + payload: SamlAcsDto = {}, ) { try { const loginResult = await this.samlService.handleSamlLogin(req, binding); // if RelayState is set to the test connection Url, this is a test connection - if (isConnectionTestRequest(req)) { + if (isConnectionTestRequest(payload)) { if (loginResult.authenticatedUser) { return res.render('saml-connection-test-success', loginResult.attributes); } else { @@ -139,7 +130,7 @@ export class SamlController { if (loginResult.onboardingRequired) { return res.redirect(this.urlService.getInstanceBaseUrl() + '/saml/onboarding'); } else { - const redirectUrl = req.body?.RelayState ?? '/'; + const redirectUrl = payload.RelayState ?? '/'; return res.redirect(this.urlService.getInstanceBaseUrl() + redirectUrl); } } else { @@ -153,7 +144,7 @@ export class SamlController { // Need to manually send the error response since we're using templates return sendErrorResponse(res, new AuthError('SAML Authentication failed')); } catch (error) { - if (isConnectionTestRequest(req)) { + if (isConnectionTestRequest(payload)) { return res.render('saml-connection-test-failed', { message: (error as Error).message }); } this.eventService.emit('user-login-failed', { @@ -173,7 +164,7 @@ export class SamlController { * This endpoint is available if SAML is licensed and enabled */ @Get('/initsso', { middlewares: [samlLicensedAndEnabledMiddleware], skipAuth: true }) - async initSsoGet(req: express.Request, res: express.Response) { + async initSsoGet(req: AuthlessRequest, res: Response) { let redirectUrl = ''; try { const refererUrl = req.headers.referer; @@ -198,11 +189,11 @@ export class SamlController { */ @Get('/config/test', { middlewares: [samlLicensedMiddleware] }) @GlobalScope('saml:manage') - async configTestGet(_: AuthenticatedRequest, res: express.Response) { + async configTestGet(_: AuthenticatedRequest, res: Response) { return await this.handleInitSSO(res, getServiceProviderConfigTestReturnUrl()); } - private async handleInitSSO(res: express.Response, relayState?: string) { + private async handleInitSSO(res: Response, relayState?: string) { const result = await this.samlService.getLoginRequestUrl(relayState); if (result?.binding === 'redirect') { return result.context.context; diff --git a/packages/cli/src/sso/saml/saml-helpers.ts b/packages/cli/src/sso.ee/saml/saml-helpers.ts similarity index 92% rename from packages/cli/src/sso/saml/saml-helpers.ts rename to packages/cli/src/sso.ee/saml/saml-helpers.ts index 996e17b359..b0620acdd3 100644 --- a/packages/cli/src/sso/saml/saml-helpers.ts +++ b/packages/cli/src/sso.ee/saml/saml-helpers.ts @@ -1,6 +1,7 @@ +import type { SamlAcsDto, SamlPreferences } from '@n8n/api-types'; +import { Container } from '@n8n/di'; import { randomString } from 'n8n-workflow'; import type { FlowResult } from 'samlify/types/src/flow'; -import { Container } from 'typedi'; import config from '@/config'; import { AuthIdentity } from '@/databases/entities/auth-identity'; @@ -14,10 +15,7 @@ import { PasswordUtility } from '@/services/password.utility'; import { SAML_LOGIN_ENABLED, SAML_LOGIN_LABEL } from './constants'; import { getServiceProviderConfigTestReturnUrl } from './service-provider.ee'; -import type { SamlConfiguration } from './types/requests'; -import type { SamlAttributeMapping } from './types/saml-attribute-mapping'; -import type { SamlPreferences } from './types/saml-preferences'; -import type { SamlUserAttributes } from './types/saml-user-attributes'; +import type { SamlAttributeMapping, SamlUserAttributes } from './types'; import { getCurrentAuthenticationMethod, isEmailCurrentAuthenticationMethod, @@ -165,6 +163,6 @@ export function getMappedSamlAttributesFromFlowResult( return result; } -export function isConnectionTestRequest(req: SamlConfiguration.AcsRequest): boolean { - return req.body.RelayState === getServiceProviderConfigTestReturnUrl(); +export function isConnectionTestRequest(payload: SamlAcsDto): boolean { + return payload.RelayState === getServiceProviderConfigTestReturnUrl(); } diff --git a/packages/cli/src/sso.ee/saml/saml-validator.ts b/packages/cli/src/sso.ee/saml/saml-validator.ts new file mode 100644 index 0000000000..e2deaa6b6d --- /dev/null +++ b/packages/cli/src/sso.ee/saml/saml-validator.ts @@ -0,0 +1,87 @@ +import { Service } from '@n8n/di'; +import { Logger } from 'n8n-core'; +import type { XMLFileInfo, XMLLintOptions, XMLValidationResult } from 'xmllint-wasm'; + +@Service() +export class SamlValidator { + private xmlMetadata: XMLFileInfo; + + private xmlProtocol: XMLFileInfo; + + private preload: XMLFileInfo[] = []; + + constructor(private readonly logger: Logger) {} + + private xmllint: { + validateXML: (options: XMLLintOptions) => Promise; + }; + + async init() { + await this.loadSchemas(); + this.xmllint = await import('xmllint-wasm'); + } + + async validateMetadata(metadata: string): Promise { + return await this.validateXml('metadata', metadata); + } + + async validateResponse(response: string): Promise { + return await this.validateXml('response', response); + } + + // dynamically load schema files + private async loadSchemas(): Promise { + this.xmlProtocol = (await import('./schema/saml-schema-protocol-2.0.xsd')).xmlFileInfo; + this.xmlMetadata = (await import('./schema/saml-schema-metadata-2.0.xsd')).xmlFileInfo; + this.preload = ( + await Promise.all([ + // SAML + import('./schema/saml-schema-assertion-2.0.xsd'), + import('./schema/xmldsig-core-schema.xsd'), + import('./schema/xenc-schema.xsd'), + import('./schema/xml.xsd'), + + // WS-Federation + import('./schema/ws-federation.xsd'), + import('./schema/oasis-200401-wss-wssecurity-secext-1.0.xsd'), + import('./schema/oasis-200401-wss-wssecurity-utility-1.0.xsd'), + import('./schema/ws-addr.xsd'), + import('./schema/metadata-exchange.xsd'), + import('./schema/ws-securitypolicy-1.2.xsd'), + import('./schema/ws-authorization.xsd'), + ]) + ).map((m) => m.xmlFileInfo); + } + + private async validateXml(type: 'metadata' | 'response', contents: string): Promise { + const fileName = `${type}.xml`; + const schema = type === 'metadata' ? [this.xmlMetadata] : [this.xmlProtocol]; + const preload = [type === 'metadata' ? this.xmlProtocol : this.xmlMetadata, ...this.preload]; + + try { + const validationResult = await this.xmllint.validateXML({ + xml: [{ fileName, contents }], + extension: 'schema', + schema, + preload, + }); + if (validationResult?.valid) { + this.logger.debug(`SAML ${type} is valid`); + return true; + } else { + this.logger.debug(`SAML ${type} is invalid`); + this.logger.warn( + validationResult + ? validationResult.errors + .map((error) => `${error.message} - ${error.rawMessage}`) + .join('\n') + : '', + ); + } + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + this.logger.warn(error); + } + return false; + } +} diff --git a/packages/cli/src/sso/saml/saml.service.ee.ts b/packages/cli/src/sso.ee/saml/saml.service.ee.ts similarity index 91% rename from packages/cli/src/sso/saml/saml.service.ee.ts rename to packages/cli/src/sso.ee/saml/saml.service.ee.ts index 3672c8fe6f..a0d20e4102 100644 --- a/packages/cli/src/sso/saml/saml.service.ee.ts +++ b/packages/cli/src/sso.ee/saml/saml.service.ee.ts @@ -1,10 +1,12 @@ +import type { SamlPreferences } from '@n8n/api-types'; +import { Service } from '@n8n/di'; import axios from 'axios'; import type express from 'express'; import https from 'https'; +import { Logger } from 'n8n-core'; import { ApplicationError, jsonParse } from 'n8n-workflow'; import type { IdentityProviderInstance, ServiceProviderInstance } from 'samlify'; import type { BindingContext, PostBindingContext } from 'samlify/types/src/entity'; -import Container, { Service } from 'typedi'; import type { Settings } from '@/databases/entities/settings'; import type { User } from '@/databases/entities/user'; @@ -12,7 +14,6 @@ import { SettingsRepository } from '@/databases/repositories/settings.repository import { UserRepository } from '@/databases/repositories/user.repository'; import { AuthError } from '@/errors/response-errors/auth.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { Logger } from '@/logging/logger.service'; import { UrlService } from '@/services/url.service'; import { SAML_PREFERENCES_DB_KEY } from './constants'; @@ -27,11 +28,9 @@ import { setSamlLoginLabel, updateUserFromSamlAttributes, } from './saml-helpers'; -import { validateMetadata, validateResponse } from './saml-validator'; +import { SamlValidator } from './saml-validator'; import { getServiceProviderInstance } from './service-provider.ee'; -import type { SamlLoginBinding } from './types'; -import type { SamlPreferences } from './types/saml-preferences'; -import type { SamlUserAttributes } from './types/saml-user-attributes'; +import type { SamlLoginBinding, SamlUserAttributes } from './types'; import { isSsoJustInTimeProvisioningEnabled } from '../sso-helpers'; @Service() @@ -79,12 +78,16 @@ export class SamlService { constructor( private readonly logger: Logger, private readonly urlService: UrlService, + private readonly validator: SamlValidator, + private readonly userRepository: UserRepository, + private readonly settingsRepository: SettingsRepository, ) {} async init(): Promise { try { // load preferences first but do not apply so as to not load samlify unnecessarily await this.loadFromDbAndApplySamlPreferences(false); + await this.validator.init(); if (isSamlLicensedAndEnabled()) { await this.loadSamlify(); await this.loadFromDbAndApplySamlPreferences(true); @@ -108,9 +111,10 @@ export class SamlService { this.logger.debug('Loading samlify library into memory'); this.samlify = await import('samlify'); } + this.samlify.setSchemaValidator({ validate: async (response: string) => { - const valid = await validateResponse(response); + const valid = await this.validator.validateResponse(response); if (!valid) { throw new InvalidSamlMetadataError(); } @@ -188,7 +192,7 @@ export class SamlService { const attributes = await this.getAttributesFromLoginResponse(req, binding); if (attributes.email) { const lowerCasedEmail = attributes.email.toLowerCase(); - const user = await Container.get(UserRepository).findOne({ + const user = await this.userRepository.findOne({ where: { email: lowerCasedEmail }, relations: ['authIdentities'], }); @@ -233,7 +237,7 @@ export class SamlService { }; } - async setSamlPreferences(prefs: SamlPreferences): Promise { + async setSamlPreferences(prefs: Partial): Promise { await this.loadSamlify(); await this.loadPreferencesWithoutValidation(prefs); if (prefs.metadataUrl) { @@ -242,7 +246,7 @@ export class SamlService { this._samlPreferences.metadata = fetchedMetadata; } } else if (prefs.metadata) { - const validationResult = await validateMetadata(prefs.metadata); + const validationResult = await this.validator.validateMetadata(prefs.metadata); if (!validationResult) { throw new InvalidSamlMetadataError(); } @@ -252,7 +256,7 @@ export class SamlService { return result; } - async loadPreferencesWithoutValidation(prefs: SamlPreferences) { + async loadPreferencesWithoutValidation(prefs: Partial) { this._samlPreferences.loginBinding = prefs.loginBinding ?? this._samlPreferences.loginBinding; this._samlPreferences.metadata = prefs.metadata ?? this._samlPreferences.metadata; this._samlPreferences.mapping = prefs.mapping ?? this._samlPreferences.mapping; @@ -278,7 +282,7 @@ export class SamlService { } async loadFromDbAndApplySamlPreferences(apply = true): Promise { - const samlPreferences = await Container.get(SettingsRepository).findOne({ + const samlPreferences = await this.settingsRepository.findOne({ where: { key: SAML_PREFERENCES_DB_KEY }, }); if (samlPreferences) { @@ -296,18 +300,18 @@ export class SamlService { } async saveSamlPreferencesToDb(): Promise { - const samlPreferences = await Container.get(SettingsRepository).findOne({ + const samlPreferences = await this.settingsRepository.findOne({ where: { key: SAML_PREFERENCES_DB_KEY }, }); const settingsValue = JSON.stringify(this.samlPreferences); let result: Settings; if (samlPreferences) { samlPreferences.value = settingsValue; - result = await Container.get(SettingsRepository).save(samlPreferences, { + result = await this.settingsRepository.save(samlPreferences, { transaction: false, }); } else { - result = await Container.get(SettingsRepository).save( + result = await this.settingsRepository.save( { key: SAML_PREFERENCES_DB_KEY, value: settingsValue, @@ -332,7 +336,7 @@ export class SamlService { const response = await axios.get(this._samlPreferences.metadataUrl, { httpsAgent: agent }); if (response.status === 200 && response.data) { const xml = (await response.data) as string; - const validationResult = await validateMetadata(xml); + const validationResult = await this.validator.validateMetadata(xml); if (!validationResult) { throw new BadRequestError( `Data received from ${this._samlPreferences.metadataUrl} is not valid SAML metadata.`, @@ -392,6 +396,6 @@ export class SamlService { */ async reset() { await setSamlLoginEnabled(false); - await Container.get(SettingsRepository).delete({ key: SAML_PREFERENCES_DB_KEY }); + await this.settingsRepository.delete({ key: SAML_PREFERENCES_DB_KEY }); } } diff --git a/packages/cli/src/sso/saml/schema/metadata-exchange.xsd.ts b/packages/cli/src/sso.ee/saml/schema/metadata-exchange.xsd.ts similarity index 100% rename from packages/cli/src/sso/saml/schema/metadata-exchange.xsd.ts rename to packages/cli/src/sso.ee/saml/schema/metadata-exchange.xsd.ts diff --git a/packages/cli/src/sso/saml/schema/oasis-200401-wss-wssecurity-secext-1.0.xsd.ts b/packages/cli/src/sso.ee/saml/schema/oasis-200401-wss-wssecurity-secext-1.0.xsd.ts similarity index 100% rename from packages/cli/src/sso/saml/schema/oasis-200401-wss-wssecurity-secext-1.0.xsd.ts rename to packages/cli/src/sso.ee/saml/schema/oasis-200401-wss-wssecurity-secext-1.0.xsd.ts diff --git a/packages/cli/src/sso/saml/schema/oasis-200401-wss-wssecurity-utility-1.0.xsd.ts b/packages/cli/src/sso.ee/saml/schema/oasis-200401-wss-wssecurity-utility-1.0.xsd.ts similarity index 100% rename from packages/cli/src/sso/saml/schema/oasis-200401-wss-wssecurity-utility-1.0.xsd.ts rename to packages/cli/src/sso.ee/saml/schema/oasis-200401-wss-wssecurity-utility-1.0.xsd.ts diff --git a/packages/cli/src/sso/saml/schema/saml-schema-assertion-2.0.xsd.ts b/packages/cli/src/sso.ee/saml/schema/saml-schema-assertion-2.0.xsd.ts similarity index 100% rename from packages/cli/src/sso/saml/schema/saml-schema-assertion-2.0.xsd.ts rename to packages/cli/src/sso.ee/saml/schema/saml-schema-assertion-2.0.xsd.ts diff --git a/packages/cli/src/sso/saml/schema/saml-schema-metadata-2.0.xsd.ts b/packages/cli/src/sso.ee/saml/schema/saml-schema-metadata-2.0.xsd.ts similarity index 100% rename from packages/cli/src/sso/saml/schema/saml-schema-metadata-2.0.xsd.ts rename to packages/cli/src/sso.ee/saml/schema/saml-schema-metadata-2.0.xsd.ts diff --git a/packages/cli/src/sso/saml/schema/saml-schema-protocol-2.0.xsd.ts b/packages/cli/src/sso.ee/saml/schema/saml-schema-protocol-2.0.xsd.ts similarity index 100% rename from packages/cli/src/sso/saml/schema/saml-schema-protocol-2.0.xsd.ts rename to packages/cli/src/sso.ee/saml/schema/saml-schema-protocol-2.0.xsd.ts diff --git a/packages/cli/src/sso/saml/schema/ws-addr.xsd.ts b/packages/cli/src/sso.ee/saml/schema/ws-addr.xsd.ts similarity index 100% rename from packages/cli/src/sso/saml/schema/ws-addr.xsd.ts rename to packages/cli/src/sso.ee/saml/schema/ws-addr.xsd.ts diff --git a/packages/cli/src/sso/saml/schema/ws-authorization.xsd.ts b/packages/cli/src/sso.ee/saml/schema/ws-authorization.xsd.ts similarity index 100% rename from packages/cli/src/sso/saml/schema/ws-authorization.xsd.ts rename to packages/cli/src/sso.ee/saml/schema/ws-authorization.xsd.ts diff --git a/packages/cli/src/sso/saml/schema/ws-federation.xsd.ts b/packages/cli/src/sso.ee/saml/schema/ws-federation.xsd.ts similarity index 100% rename from packages/cli/src/sso/saml/schema/ws-federation.xsd.ts rename to packages/cli/src/sso.ee/saml/schema/ws-federation.xsd.ts diff --git a/packages/cli/src/sso/saml/schema/ws-securitypolicy-1.2.xsd.ts b/packages/cli/src/sso.ee/saml/schema/ws-securitypolicy-1.2.xsd.ts similarity index 100% rename from packages/cli/src/sso/saml/schema/ws-securitypolicy-1.2.xsd.ts rename to packages/cli/src/sso.ee/saml/schema/ws-securitypolicy-1.2.xsd.ts diff --git a/packages/cli/src/sso/saml/schema/xenc-schema.xsd.ts b/packages/cli/src/sso.ee/saml/schema/xenc-schema.xsd.ts similarity index 100% rename from packages/cli/src/sso/saml/schema/xenc-schema.xsd.ts rename to packages/cli/src/sso.ee/saml/schema/xenc-schema.xsd.ts diff --git a/packages/cli/src/sso/saml/schema/xml.xsd.ts b/packages/cli/src/sso.ee/saml/schema/xml.xsd.ts similarity index 100% rename from packages/cli/src/sso/saml/schema/xml.xsd.ts rename to packages/cli/src/sso.ee/saml/schema/xml.xsd.ts diff --git a/packages/cli/src/sso/saml/schema/xmldsig-core-schema.xsd.ts b/packages/cli/src/sso.ee/saml/schema/xmldsig-core-schema.xsd.ts similarity index 100% rename from packages/cli/src/sso/saml/schema/xmldsig-core-schema.xsd.ts rename to packages/cli/src/sso.ee/saml/schema/xmldsig-core-schema.xsd.ts diff --git a/packages/cli/src/sso/saml/service-provider.ee.ts b/packages/cli/src/sso.ee/saml/service-provider.ee.ts similarity index 94% rename from packages/cli/src/sso/saml/service-provider.ee.ts rename to packages/cli/src/sso.ee/saml/service-provider.ee.ts index 2e6511df09..2fa82eda0a 100644 --- a/packages/cli/src/sso/saml/service-provider.ee.ts +++ b/packages/cli/src/sso.ee/saml/service-provider.ee.ts @@ -1,10 +1,9 @@ +import type { SamlPreferences } from '@n8n/api-types'; +import { Container } from '@n8n/di'; import type { ServiceProviderInstance } from 'samlify'; -import { Container } from 'typedi'; import { UrlService } from '@/services/url.service'; -import type { SamlPreferences } from './types/saml-preferences'; - let serviceProviderInstance: ServiceProviderInstance | undefined; export function getServiceProviderEntityId(): string { diff --git a/packages/cli/src/sso.ee/saml/types.ts b/packages/cli/src/sso.ee/saml/types.ts new file mode 100644 index 0000000000..35687777b1 --- /dev/null +++ b/packages/cli/src/sso.ee/saml/types.ts @@ -0,0 +1,5 @@ +import type { SamlPreferences } from '@n8n/api-types'; + +export type SamlLoginBinding = SamlPreferences['loginBinding']; +export type SamlAttributeMapping = NonNullable; +export type SamlUserAttributes = SamlAttributeMapping; diff --git a/packages/cli/src/sso/saml/views/init-sso-post.ts b/packages/cli/src/sso.ee/saml/views/init-sso-post.ts similarity index 100% rename from packages/cli/src/sso/saml/views/init-sso-post.ts rename to packages/cli/src/sso.ee/saml/views/init-sso-post.ts diff --git a/packages/cli/src/sso/sso-helpers.ts b/packages/cli/src/sso.ee/sso-helpers.ts similarity index 97% rename from packages/cli/src/sso/sso-helpers.ts rename to packages/cli/src/sso.ee/sso-helpers.ts index f600e72cfd..d69d9bc3a8 100644 --- a/packages/cli/src/sso/sso-helpers.ts +++ b/packages/cli/src/sso.ee/sso-helpers.ts @@ -1,4 +1,4 @@ -import Container from 'typedi'; +import { Container } from '@n8n/di'; import config from '@/config'; import type { AuthProviderType } from '@/databases/entities/auth-identity'; diff --git a/packages/cli/src/sso/saml/saml-validator.ts b/packages/cli/src/sso/saml/saml-validator.ts deleted file mode 100644 index 07e9853f90..0000000000 --- a/packages/cli/src/sso/saml/saml-validator.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { Container } from 'typedi'; -import type { XMLFileInfo } from 'xmllint-wasm'; - -import { Logger } from '@/logging/logger.service'; - -let xmlMetadata: XMLFileInfo; -let xmlProtocol: XMLFileInfo; - -let preload: XMLFileInfo[] = []; - -// eslint-disable-next-line @typescript-eslint/consistent-type-imports -let xmllintWasm: typeof import('xmllint-wasm') | undefined; - -// dynamically load schema files -async function loadSchemas(): Promise { - xmlProtocol = (await import('./schema/saml-schema-protocol-2.0.xsd')).xmlFileInfo; - xmlMetadata = (await import('./schema/saml-schema-metadata-2.0.xsd')).xmlFileInfo; - preload = ( - await Promise.all([ - // SAML - import('./schema/saml-schema-assertion-2.0.xsd'), - import('./schema/xmldsig-core-schema.xsd'), - import('./schema/xenc-schema.xsd'), - import('./schema/xml.xsd'), - - // WS-Federation - import('./schema/ws-federation.xsd'), - import('./schema/oasis-200401-wss-wssecurity-secext-1.0.xsd'), - import('./schema/oasis-200401-wss-wssecurity-utility-1.0.xsd'), - import('./schema/ws-addr.xsd'), - import('./schema/metadata-exchange.xsd'), - import('./schema/ws-securitypolicy-1.2.xsd'), - import('./schema/ws-authorization.xsd'), - ]) - ).map((m) => m.xmlFileInfo); -} - -// dynamically load xmllint-wasm -async function loadXmllintWasm(): Promise { - if (xmllintWasm === undefined) { - Container.get(Logger).debug('Loading xmllint-wasm library into memory'); - xmllintWasm = await import('xmllint-wasm'); - } -} - -export async function validateMetadata(metadata: string): Promise { - const logger = Container.get(Logger); - try { - await loadXmllintWasm(); - await loadSchemas(); - const validationResult = await xmllintWasm?.validateXML({ - xml: [ - { - fileName: 'metadata.xml', - contents: metadata, - }, - ], - extension: 'schema', - schema: [xmlMetadata], - preload: [xmlProtocol, ...preload], - }); - if (validationResult?.valid) { - logger.debug('SAML Metadata is valid'); - return true; - } else { - logger.warn('SAML Validate Metadata: Invalid metadata'); - logger.warn( - validationResult - ? validationResult.errors - .map((error) => `${error.message} - ${error.rawMessage}`) - .join('\n') - : '', - ); - } - } catch (error) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - logger.warn(error); - } - return false; -} - -export async function validateResponse(response: string): Promise { - const logger = Container.get(Logger); - try { - await loadXmllintWasm(); - await loadSchemas(); - const validationResult = await xmllintWasm?.validateXML({ - xml: [ - { - fileName: 'response.xml', - contents: response, - }, - ], - extension: 'schema', - schema: [xmlProtocol], - preload: [xmlMetadata, ...preload], - }); - if (validationResult?.valid) { - logger.debug('SAML Response is valid'); - return true; - } else { - logger.warn('SAML Validate Response: Failed'); - logger.warn( - validationResult - ? validationResult.errors - .map((error) => `${error.message} - ${error.rawMessage}`) - .join('\n') - : '', - ); - } - } catch (error) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - logger.warn(error); - } - return false; -} diff --git a/packages/cli/src/sso/saml/types/index.ts b/packages/cli/src/sso/saml/types/index.ts deleted file mode 100644 index 560f7003f8..0000000000 --- a/packages/cli/src/sso/saml/types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export type SamlLoginBinding = 'post' | 'redirect'; diff --git a/packages/cli/src/sso/saml/types/requests.ts b/packages/cli/src/sso/saml/types/requests.ts deleted file mode 100644 index 69fb89a1eb..0000000000 --- a/packages/cli/src/sso/saml/types/requests.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { AuthenticatedRequest, AuthlessRequest } from '@/requests'; - -import type { SamlPreferences } from './saml-preferences'; - -export declare namespace SamlConfiguration { - type Update = AuthenticatedRequest<{}, {}, SamlPreferences, {}>; - type Toggle = AuthenticatedRequest<{}, {}, { loginEnabled: boolean }, {}>; - - type AcsRequest = AuthlessRequest< - {}, - {}, - { - RelayState?: string; - }, - {} - >; -} diff --git a/packages/cli/src/sso/saml/types/saml-attribute-mapping.ts b/packages/cli/src/sso/saml/types/saml-attribute-mapping.ts deleted file mode 100644 index af7dd76e23..0000000000 --- a/packages/cli/src/sso/saml/types/saml-attribute-mapping.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface SamlAttributeMapping { - email: string; - firstName: string; - lastName: string; - userPrincipalName: string; -} diff --git a/packages/cli/src/sso/saml/types/saml-preferences.ts b/packages/cli/src/sso/saml/types/saml-preferences.ts deleted file mode 100644 index 1231684360..0000000000 --- a/packages/cli/src/sso/saml/types/saml-preferences.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator'; -import { SignatureConfig } from 'samlify/types/src/types'; - -import { SamlLoginBinding } from '.'; -import { SamlAttributeMapping } from './saml-attribute-mapping'; - -export class SamlPreferences { - @IsObject() - @IsOptional() - mapping?: SamlAttributeMapping; - - @IsString() - @IsOptional() - metadata?: string; - - @IsString() - @IsOptional() - metadataUrl?: string; - - @IsBoolean() - @IsOptional() - ignoreSSL?: boolean = false; - - @IsString() - @IsOptional() - loginBinding?: SamlLoginBinding = 'redirect'; - - @IsBoolean() - @IsOptional() - loginEnabled?: boolean; - - @IsString() - @IsOptional() - loginLabel?: string; - - @IsBoolean() - @IsOptional() - authnRequestsSigned?: boolean = false; - - @IsBoolean() - @IsOptional() - wantAssertionsSigned?: boolean = true; - - @IsBoolean() - @IsOptional() - wantMessageSigned?: boolean = true; - - @IsString() - @IsOptional() - acsBinding?: SamlLoginBinding = 'post'; - - @IsObject() - @IsOptional() - signatureConfig?: SignatureConfig = { - prefix: 'ds', - location: { - reference: '/samlp:Response/saml:Issuer', - action: 'after', - }, - }; - - @IsString() - @IsOptional() - relayState?: string = ''; -} diff --git a/packages/cli/src/sso/saml/types/saml-user-attributes.ts b/packages/cli/src/sso/saml/types/saml-user-attributes.ts deleted file mode 100644 index fa3c849f65..0000000000 --- a/packages/cli/src/sso/saml/types/saml-user-attributes.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface SamlUserAttributes { - email: string; - firstName: string; - lastName: string; - userPrincipalName: string; -} diff --git a/packages/cli/src/subworkflows/subworkflow-policy-checker.service.ts b/packages/cli/src/subworkflows/subworkflow-policy-checker.service.ts index 6c64fc0b3a..218c083c54 100644 --- a/packages/cli/src/subworkflows/subworkflow-policy-checker.service.ts +++ b/packages/cli/src/subworkflows/subworkflow-policy-checker.service.ts @@ -1,10 +1,10 @@ import { GlobalConfig } from '@n8n/config'; +import { Service } from '@n8n/di'; +import { Logger } from 'n8n-core'; import { type Workflow, type INode, type WorkflowSettings } from 'n8n-workflow'; -import { Service } from 'typedi'; import type { Project } from '@/databases/entities/project'; import { SubworkflowPolicyDenialError } from '@/errors/subworkflow-policy-denial.error'; -import { Logger } from '@/logging/logger.service'; import { AccessService } from '@/services/access.service'; import { OwnershipService } from '@/services/ownership.service'; import { UrlService } from '@/services/url.service'; diff --git a/packages/cli/src/runners/__tests__/forward-to-logger.test.ts b/packages/cli/src/task-runners/__tests__/forward-to-logger.test.ts similarity index 100% rename from packages/cli/src/runners/__tests__/forward-to-logger.test.ts rename to packages/cli/src/task-runners/__tests__/forward-to-logger.test.ts diff --git a/packages/cli/src/runners/__tests__/node-process-oom-detector.test.ts b/packages/cli/src/task-runners/__tests__/node-process-oom-detector.test.ts similarity index 100% rename from packages/cli/src/runners/__tests__/node-process-oom-detector.test.ts rename to packages/cli/src/task-runners/__tests__/node-process-oom-detector.test.ts diff --git a/packages/cli/src/runners/__tests__/sliding-window-signal.test.ts b/packages/cli/src/task-runners/__tests__/sliding-window-signal.test.ts similarity index 100% rename from packages/cli/src/runners/__tests__/sliding-window-signal.test.ts rename to packages/cli/src/task-runners/__tests__/sliding-window-signal.test.ts diff --git a/packages/cli/src/runners/__tests__/task-broker.test.ts b/packages/cli/src/task-runners/__tests__/task-broker.test.ts similarity index 99% rename from packages/cli/src/runners/__tests__/task-broker.test.ts rename to packages/cli/src/task-runners/__tests__/task-broker.test.ts index 1f5030ada8..ced7e1c07e 100644 --- a/packages/cli/src/runners/__tests__/task-broker.test.ts +++ b/packages/cli/src/task-runners/__tests__/task-broker.test.ts @@ -7,9 +7,9 @@ import { Time } from '@/constants'; import { TaskRejectError } from '../errors'; import { TaskRunnerTimeoutError } from '../errors/task-runner-timeout.error'; -import type { RunnerLifecycleEvents } from '../runner-lifecycle-events'; import { TaskBroker } from '../task-broker.service'; import type { TaskOffer, TaskRequest, TaskRunner } from '../task-broker.service'; +import type { TaskRunnerLifecycleEvents } from '../task-runner-lifecycle-events'; const createValidUntil = (ms: number) => process.hrtime.bigint() + BigInt(ms * 1_000_000); @@ -718,7 +718,7 @@ describe('TaskBroker', () => { describe('task timeouts', () => { let taskBroker: TaskBroker; let config: TaskRunnersConfig; - let runnerLifecycleEvents = mock(); + let runnerLifecycleEvents = mock(); beforeAll(() => { jest.useFakeTimers(); diff --git a/packages/cli/src/runners/__tests__/task-runner-process-restart-loop-detector.test.ts b/packages/cli/src/task-runners/__tests__/task-runner-process-restart-loop-detector.test.ts similarity index 74% rename from packages/cli/src/runners/__tests__/task-runner-process-restart-loop-detector.test.ts rename to packages/cli/src/task-runners/__tests__/task-runner-process-restart-loop-detector.test.ts index 61cfb8b8e8..bf3bab4c27 100644 --- a/packages/cli/src/runners/__tests__/task-runner-process-restart-loop-detector.test.ts +++ b/packages/cli/src/task-runners/__tests__/task-runner-process-restart-loop-detector.test.ts @@ -1,12 +1,12 @@ import { TaskRunnersConfig } from '@n8n/config'; import { mock } from 'jest-mock-extended'; +import type { Logger } from 'n8n-core'; -import type { Logger } from '@/logging/logger.service'; -import type { TaskRunnerAuthService } from '@/runners/auth/task-runner-auth.service'; -import { TaskRunnerRestartLoopError } from '@/runners/errors/task-runner-restart-loop-error'; -import { RunnerLifecycleEvents } from '@/runners/runner-lifecycle-events'; -import { TaskRunnerProcess } from '@/runners/task-runner-process'; -import { TaskRunnerProcessRestartLoopDetector } from '@/runners/task-runner-process-restart-loop-detector'; +import type { TaskRunnerAuthService } from '@/task-runners/auth/task-runner-auth.service'; +import { TaskRunnerRestartLoopError } from '@/task-runners/errors/task-runner-restart-loop-error'; +import { TaskRunnerLifecycleEvents } from '@/task-runners/task-runner-lifecycle-events'; +import { TaskRunnerProcess } from '@/task-runners/task-runner-process'; +import { TaskRunnerProcessRestartLoopDetector } from '@/task-runners/task-runner-process-restart-loop-detector'; describe('TaskRunnerProcessRestartLoopDetector', () => { const mockLogger = mock(); @@ -16,7 +16,7 @@ describe('TaskRunnerProcessRestartLoopDetector', () => { mockLogger, runnerConfig, mockAuthService, - new RunnerLifecycleEvents(), + new TaskRunnerLifecycleEvents(), ); it('should detect a restart loop if process exits 5 times within 5s', () => { diff --git a/packages/cli/src/runners/__tests__/task-runner-process.test.ts b/packages/cli/src/task-runners/__tests__/task-runner-process.test.ts similarity index 88% rename from packages/cli/src/runners/__tests__/task-runner-process.test.ts rename to packages/cli/src/task-runners/__tests__/task-runner-process.test.ts index 85dbaa6930..d00ce7b88f 100644 --- a/packages/cli/src/runners/__tests__/task-runner-process.test.ts +++ b/packages/cli/src/task-runners/__tests__/task-runner-process.test.ts @@ -1,13 +1,13 @@ import { TaskRunnersConfig } from '@n8n/config'; import { mock } from 'jest-mock-extended'; +import { Logger } from 'n8n-core'; import type { ChildProcess, SpawnOptions } from 'node:child_process'; -import { Logger } from '@/logging/logger.service'; -import type { TaskRunnerAuthService } from '@/runners/auth/task-runner-auth.service'; -import { TaskRunnerProcess } from '@/runners/task-runner-process'; +import type { TaskRunnerAuthService } from '@/task-runners/auth/task-runner-auth.service'; +import { TaskRunnerProcess } from '@/task-runners/task-runner-process'; import { mockInstance } from '@test/mocking'; -import type { RunnerLifecycleEvents } from '../runner-lifecycle-events'; +import type { TaskRunnerLifecycleEvents } from '../task-runner-lifecycle-events'; const spawnMock = jest.fn(() => mock({ @@ -43,7 +43,7 @@ describe('TaskRunnerProcess', () => { }); it('should register listener for `runner:failed-heartbeat-check` event', () => { - const runnerLifecycleEvents = mock(); + const runnerLifecycleEvents = mock(); new TaskRunnerProcess(logger, runnerConfig, authService, runnerLifecycleEvents); expect(runnerLifecycleEvents.on).toHaveBeenCalledWith( @@ -53,7 +53,7 @@ describe('TaskRunnerProcess', () => { }); it('should register listener for `runner:timed-out-during-task` event', () => { - const runnerLifecycleEvents = mock(); + const runnerLifecycleEvents = mock(); new TaskRunnerProcess(logger, runnerConfig, authService, runnerLifecycleEvents); expect(runnerLifecycleEvents.on).toHaveBeenCalledWith( diff --git a/packages/cli/src/runners/__tests__/task-runner-server.test.ts b/packages/cli/src/task-runners/__tests__/task-runner-server.test.ts similarity index 87% rename from packages/cli/src/runners/__tests__/task-runner-server.test.ts rename to packages/cli/src/task-runners/__tests__/task-runner-server.test.ts index ae25cd1231..33de18c605 100644 --- a/packages/cli/src/runners/__tests__/task-runner-server.test.ts +++ b/packages/cli/src/task-runners/__tests__/task-runner-server.test.ts @@ -3,10 +3,10 @@ import { mock } from 'jest-mock-extended'; import { ServerResponse } from 'node:http'; import type WebSocket from 'ws'; -import type { TaskRunnerAuthController } from '@/runners/auth/task-runner-auth.controller'; -import { TaskRunnerServer } from '@/runners/task-runner-server'; +import type { TaskRunnerAuthController } from '@/task-runners/auth/task-runner-auth.controller'; +import { TaskRunnerServer } from '@/task-runners/task-runner-server'; -import type { TaskRunnerServerInitRequest } from '../runner-types'; +import type { TaskRunnerServerInitRequest } from '../task-runner-types'; describe('TaskRunnerServer', () => { describe('handleUpgradeRequest', () => { diff --git a/packages/cli/src/runners/__tests__/task-runner-ws-server.test.ts b/packages/cli/src/task-runners/__tests__/task-runner-ws-server.test.ts similarity index 96% rename from packages/cli/src/runners/__tests__/task-runner-ws-server.test.ts rename to packages/cli/src/task-runners/__tests__/task-runner-ws-server.test.ts index 24b12fa190..cabedc530b 100644 --- a/packages/cli/src/runners/__tests__/task-runner-ws-server.test.ts +++ b/packages/cli/src/task-runners/__tests__/task-runner-ws-server.test.ts @@ -3,7 +3,7 @@ import { mock } from 'jest-mock-extended'; import type WebSocket from 'ws'; import { Time, WsStatusCodes } from '@/constants'; -import { TaskRunnerWsServer } from '@/runners/runner-ws-server'; +import { TaskRunnerWsServer } from '@/task-runners/task-runner-ws-server'; describe('TaskRunnerWsServer', () => { describe('removeConnection', () => { diff --git a/packages/cli/src/runners/auth/__tests__/task-runner-auth.controller.test.ts b/packages/cli/src/task-runners/auth/__tests__/task-runner-auth.controller.test.ts similarity index 97% rename from packages/cli/src/runners/auth/__tests__/task-runner-auth.controller.test.ts rename to packages/cli/src/task-runners/auth/__tests__/task-runner-auth.controller.test.ts index 7d43f91458..3c650d1644 100644 --- a/packages/cli/src/runners/auth/__tests__/task-runner-auth.controller.test.ts +++ b/packages/cli/src/task-runners/auth/__tests__/task-runner-auth.controller.test.ts @@ -8,7 +8,7 @@ import { mockInstance } from '@test/mocking'; import { BadRequestError } from '../../../errors/response-errors/bad-request.error'; import { ForbiddenError } from '../../../errors/response-errors/forbidden.error'; import type { AuthlessRequest } from '../../../requests'; -import type { TaskRunnerServerInitRequest } from '../../runner-types'; +import type { TaskRunnerServerInitRequest } from '../../task-runner-types'; import { TaskRunnerAuthController } from '../task-runner-auth.controller'; import { TaskRunnerAuthService } from '../task-runner-auth.service'; diff --git a/packages/cli/src/runners/auth/__tests__/task-runner-auth.service.test.ts b/packages/cli/src/task-runners/auth/__tests__/task-runner-auth.service.test.ts similarity index 100% rename from packages/cli/src/runners/auth/__tests__/task-runner-auth.service.test.ts rename to packages/cli/src/task-runners/auth/__tests__/task-runner-auth.service.test.ts diff --git a/packages/cli/src/runners/auth/task-runner-auth.controller.ts b/packages/cli/src/task-runners/auth/task-runner-auth.controller.ts similarity index 94% rename from packages/cli/src/runners/auth/task-runner-auth.controller.ts rename to packages/cli/src/task-runners/auth/task-runner-auth.controller.ts index a117dfca0d..4ea5f3b6f4 100644 --- a/packages/cli/src/runners/auth/task-runner-auth.controller.ts +++ b/packages/cli/src/task-runners/auth/task-runner-auth.controller.ts @@ -1,5 +1,5 @@ +import { Service } from '@n8n/di'; import type { NextFunction, Response } from 'express'; -import { Service } from 'typedi'; import type { AuthlessRequest } from '@/requests'; @@ -7,7 +7,7 @@ import { taskRunnerAuthRequestBodySchema } from './task-runner-auth.schema'; import { TaskRunnerAuthService } from './task-runner-auth.service'; import { BadRequestError } from '../../errors/response-errors/bad-request.error'; import { ForbiddenError } from '../../errors/response-errors/forbidden.error'; -import type { TaskRunnerServerInitRequest } from '../runner-types'; +import type { TaskRunnerServerInitRequest } from '../task-runner-types'; /** * Controller responsible for authenticating Task Runner connections diff --git a/packages/cli/src/runners/auth/task-runner-auth.schema.ts b/packages/cli/src/task-runners/auth/task-runner-auth.schema.ts similarity index 100% rename from packages/cli/src/runners/auth/task-runner-auth.schema.ts rename to packages/cli/src/task-runners/auth/task-runner-auth.schema.ts diff --git a/packages/cli/src/runners/auth/task-runner-auth.service.ts b/packages/cli/src/task-runners/auth/task-runner-auth.service.ts similarity index 97% rename from packages/cli/src/runners/auth/task-runner-auth.service.ts rename to packages/cli/src/task-runners/auth/task-runner-auth.service.ts index 5907cf6678..c16afc5c63 100644 --- a/packages/cli/src/runners/auth/task-runner-auth.service.ts +++ b/packages/cli/src/task-runners/auth/task-runner-auth.service.ts @@ -1,6 +1,6 @@ import { GlobalConfig } from '@n8n/config'; +import { Service } from '@n8n/di'; import { randomBytes } from 'crypto'; -import { Service } from 'typedi'; import { Time } from '@/constants'; import { CacheService } from '@/services/cache/cache.service'; diff --git a/packages/cli/src/runners/default-task-runner-disconnect-analyzer.ts b/packages/cli/src/task-runners/default-task-runner-disconnect-analyzer.ts similarity index 94% rename from packages/cli/src/runners/default-task-runner-disconnect-analyzer.ts rename to packages/cli/src/task-runners/default-task-runner-disconnect-analyzer.ts index 9db537ee95..a0193c40e0 100644 --- a/packages/cli/src/runners/default-task-runner-disconnect-analyzer.ts +++ b/packages/cli/src/task-runners/default-task-runner-disconnect-analyzer.ts @@ -1,10 +1,10 @@ -import { Service } from 'typedi'; +import { Service } from '@n8n/di'; import config from '@/config'; import { TaskRunnerDisconnectedError } from './errors/task-runner-disconnected-error'; import { TaskRunnerFailedHeartbeatError } from './errors/task-runner-failed-heartbeat.error'; -import type { DisconnectAnalyzer, DisconnectErrorOptions } from './runner-types'; +import type { DisconnectAnalyzer, DisconnectErrorOptions } from './task-runner-types'; /** * Analyzes the disconnect reason of a task runner to provide a more diff --git a/packages/cli/src/runners/errors.ts b/packages/cli/src/task-runners/errors.ts similarity index 100% rename from packages/cli/src/runners/errors.ts rename to packages/cli/src/task-runners/errors.ts diff --git a/packages/cli/src/runners/errors/__tests__/task-runner-disconnected-error.test.ts b/packages/cli/src/task-runners/errors/__tests__/task-runner-disconnected-error.test.ts similarity index 100% rename from packages/cli/src/runners/errors/__tests__/task-runner-disconnected-error.test.ts rename to packages/cli/src/task-runners/errors/__tests__/task-runner-disconnected-error.test.ts diff --git a/packages/cli/src/runners/errors/missing-auth-token.error.ts b/packages/cli/src/task-runners/errors/missing-auth-token.error.ts similarity index 100% rename from packages/cli/src/runners/errors/missing-auth-token.error.ts rename to packages/cli/src/task-runners/errors/missing-auth-token.error.ts diff --git a/packages/cli/src/runners/errors/task-runner-disconnected-error.ts b/packages/cli/src/task-runners/errors/task-runner-disconnected-error.ts similarity index 100% rename from packages/cli/src/runners/errors/task-runner-disconnected-error.ts rename to packages/cli/src/task-runners/errors/task-runner-disconnected-error.ts diff --git a/packages/cli/src/runners/errors/task-runner-failed-heartbeat.error.ts b/packages/cli/src/task-runners/errors/task-runner-failed-heartbeat.error.ts similarity index 100% rename from packages/cli/src/runners/errors/task-runner-failed-heartbeat.error.ts rename to packages/cli/src/task-runners/errors/task-runner-failed-heartbeat.error.ts diff --git a/packages/cli/src/runners/errors/task-runner-oom-error.ts b/packages/cli/src/task-runners/errors/task-runner-oom-error.ts similarity index 100% rename from packages/cli/src/runners/errors/task-runner-oom-error.ts rename to packages/cli/src/task-runners/errors/task-runner-oom-error.ts diff --git a/packages/cli/src/runners/errors/task-runner-restart-loop-error.ts b/packages/cli/src/task-runners/errors/task-runner-restart-loop-error.ts similarity index 100% rename from packages/cli/src/runners/errors/task-runner-restart-loop-error.ts rename to packages/cli/src/task-runners/errors/task-runner-restart-loop-error.ts diff --git a/packages/cli/src/runners/errors/task-runner-timeout.error.ts b/packages/cli/src/task-runners/errors/task-runner-timeout.error.ts similarity index 100% rename from packages/cli/src/runners/errors/task-runner-timeout.error.ts rename to packages/cli/src/task-runners/errors/task-runner-timeout.error.ts diff --git a/packages/cli/src/runners/forward-to-logger.ts b/packages/cli/src/task-runners/forward-to-logger.ts similarity index 100% rename from packages/cli/src/runners/forward-to-logger.ts rename to packages/cli/src/task-runners/forward-to-logger.ts diff --git a/packages/cli/src/runners/internal-task-runner-disconnect-analyzer.ts b/packages/cli/src/task-runners/internal-task-runner-disconnect-analyzer.ts similarity index 95% rename from packages/cli/src/runners/internal-task-runner-disconnect-analyzer.ts rename to packages/cli/src/task-runners/internal-task-runner-disconnect-analyzer.ts index 26d8de5683..a84682e521 100644 --- a/packages/cli/src/runners/internal-task-runner-disconnect-analyzer.ts +++ b/packages/cli/src/task-runners/internal-task-runner-disconnect-analyzer.ts @@ -1,12 +1,12 @@ import { TaskRunnersConfig } from '@n8n/config'; -import { Service } from 'typedi'; +import { Service } from '@n8n/di'; import { DefaultTaskRunnerDisconnectAnalyzer } from './default-task-runner-disconnect-analyzer'; import { TaskRunnerOomError } from './errors/task-runner-oom-error'; -import type { DisconnectErrorOptions } from './runner-types'; import { SlidingWindowSignal } from './sliding-window-signal'; import type { ExitReason, TaskRunnerProcessEventMap } from './task-runner-process'; import { TaskRunnerProcess } from './task-runner-process'; +import type { DisconnectErrorOptions } from './task-runner-types'; /** * Analyzes the disconnect reason of a task runner process to provide a more diff --git a/packages/cli/src/runners/node-process-oom-detector.ts b/packages/cli/src/task-runners/node-process-oom-detector.ts similarity index 100% rename from packages/cli/src/runners/node-process-oom-detector.ts rename to packages/cli/src/task-runners/node-process-oom-detector.ts diff --git a/packages/cli/src/runners/sliding-window-signal.ts b/packages/cli/src/task-runners/sliding-window-signal.ts similarity index 100% rename from packages/cli/src/runners/sliding-window-signal.ts rename to packages/cli/src/task-runners/sliding-window-signal.ts diff --git a/packages/cli/src/runners/task-broker.service.ts b/packages/cli/src/task-runners/task-broker.service.ts similarity index 98% rename from packages/cli/src/runners/task-broker.service.ts rename to packages/cli/src/task-runners/task-broker.service.ts index e52992d38e..42e0d3cd25 100644 --- a/packages/cli/src/runners/task-broker.service.ts +++ b/packages/cli/src/task-runners/task-broker.service.ts @@ -1,21 +1,21 @@ import { TaskRunnersConfig } from '@n8n/config'; +import { Service } from '@n8n/di'; import type { BrokerMessage, RequesterMessage, RunnerMessage, TaskResultData, } from '@n8n/task-runner'; +import { Logger } from 'n8n-core'; import { ApplicationError } from 'n8n-workflow'; import { nanoid } from 'nanoid'; -import { Service } from 'typedi'; import config from '@/config'; import { Time } from '@/constants'; -import { Logger } from '@/logging/logger.service'; import { TaskDeferredError, TaskRejectError } from './errors'; import { TaskRunnerTimeoutError } from './errors/task-runner-timeout.error'; -import { RunnerLifecycleEvents } from './runner-lifecycle-events'; +import { TaskRunnerLifecycleEvents } from './task-runner-lifecycle-events'; export interface TaskRunner { id: string; @@ -89,7 +89,7 @@ export class TaskBroker { constructor( private readonly logger: Logger, private readonly taskRunnersConfig: TaskRunnersConfig, - private readonly runnerLifecycleEvents: RunnerLifecycleEvents, + private readonly taskRunnerLifecycleEvents: TaskRunnerLifecycleEvents, ) { if (this.taskRunnersConfig.taskTimeout <= 0) { throw new ApplicationError('Task timeout must be greater than 0'); @@ -460,7 +460,7 @@ export class TaskBroker { if (!task) return; if (this.taskRunnersConfig.mode === 'internal') { - this.runnerLifecycleEvents.emit('runner:timed-out-during-task'); + this.taskRunnerLifecycleEvents.emit('runner:timed-out-during-task'); } else if (this.taskRunnersConfig.mode === 'external') { await this.messageRunner(task.runnerId, { type: 'broker:taskcancel', diff --git a/packages/cli/src/runners/task-managers/__tests__/data-request-response-builder.test.ts b/packages/cli/src/task-runners/task-managers/__tests__/data-request-response-builder.test.ts similarity index 100% rename from packages/cli/src/runners/task-managers/__tests__/data-request-response-builder.test.ts rename to packages/cli/src/task-runners/task-managers/__tests__/data-request-response-builder.test.ts diff --git a/packages/cli/src/runners/task-managers/__tests__/data-request-response-stripper.test.ts b/packages/cli/src/task-runners/task-managers/__tests__/data-request-response-stripper.test.ts similarity index 100% rename from packages/cli/src/runners/task-managers/__tests__/data-request-response-stripper.test.ts rename to packages/cli/src/task-runners/task-managers/__tests__/data-request-response-stripper.test.ts diff --git a/packages/cli/src/runners/task-managers/__tests__/task-manager.test.ts b/packages/cli/src/task-runners/task-managers/__tests__/task-manager.test.ts similarity index 91% rename from packages/cli/src/runners/task-managers/__tests__/task-manager.test.ts rename to packages/cli/src/task-runners/task-managers/__tests__/task-manager.test.ts index 84584e05df..3066f25cfd 100644 --- a/packages/cli/src/runners/task-managers/__tests__/task-manager.test.ts +++ b/packages/cli/src/task-runners/task-managers/__tests__/task-manager.test.ts @@ -2,10 +2,10 @@ import { mock } from 'jest-mock-extended'; import { get, set } from 'lodash'; import type { NodeTypes } from '@/node-types'; -import type { Task } from '@/runners/task-managers/task-manager'; -import { TaskManager } from '@/runners/task-managers/task-manager'; +import type { Task } from '@/task-runners/task-managers/task-requester'; +import { TaskRequester } from '@/task-runners/task-managers/task-requester'; -class TestTaskManager extends TaskManager { +class TestTaskRequester extends TaskRequester { sentMessages: unknown[] = []; sendMessage(message: unknown) { @@ -13,12 +13,12 @@ class TestTaskManager extends TaskManager { } } -describe('TaskManager', () => { - let instance: TestTaskManager; +describe('TaskRequester', () => { + let instance: TestTaskRequester; const mockNodeTypes = mock(); beforeEach(() => { - instance = new TestTaskManager(mockNodeTypes); + instance = new TestTaskRequester(mockNodeTypes); }); describe('handleRpc', () => { diff --git a/packages/cli/src/runners/task-managers/data-request-response-builder.ts b/packages/cli/src/task-runners/task-managers/data-request-response-builder.ts similarity index 100% rename from packages/cli/src/runners/task-managers/data-request-response-builder.ts rename to packages/cli/src/task-runners/task-managers/data-request-response-builder.ts diff --git a/packages/cli/src/runners/task-managers/data-request-response-stripper.ts b/packages/cli/src/task-runners/task-managers/data-request-response-stripper.ts similarity index 100% rename from packages/cli/src/runners/task-managers/data-request-response-stripper.ts rename to packages/cli/src/task-runners/task-managers/data-request-response-stripper.ts diff --git a/packages/cli/src/runners/task-managers/local-task-manager.ts b/packages/cli/src/task-runners/task-managers/local-task-requester.ts similarity index 78% rename from packages/cli/src/runners/task-managers/local-task-manager.ts rename to packages/cli/src/task-runners/task-managers/local-task-requester.ts index 7d898aaebe..1de959936b 100644 --- a/packages/cli/src/runners/task-managers/local-task-manager.ts +++ b/packages/cli/src/task-runners/task-managers/local-task-requester.ts @@ -1,17 +1,17 @@ +import { Container, Service } from '@n8n/di'; import type { RequesterMessage } from '@n8n/task-runner'; -import Container, { Service } from 'typedi'; import { NodeTypes } from '@/node-types'; -import { TaskManager } from './task-manager'; +import { TaskRequester } from './task-requester'; import type { RequesterMessageCallback } from '../task-broker.service'; import { TaskBroker } from '../task-broker.service'; @Service() -export class LocalTaskManager extends TaskManager { +export class LocalTaskRequester extends TaskRequester { taskBroker: TaskBroker; - id: string = 'single-main'; + id = 'local-task-requester'; constructor(nodeTypes: NodeTypes) { super(nodeTypes); diff --git a/packages/cli/src/runners/task-managers/task-manager.ts b/packages/cli/src/task-runners/task-managers/task-requester.ts similarity index 99% rename from packages/cli/src/runners/task-managers/task-manager.ts rename to packages/cli/src/task-runners/task-managers/task-requester.ts index 44193f9377..8124df8e13 100644 --- a/packages/cli/src/runners/task-managers/task-manager.ts +++ b/packages/cli/src/task-runners/task-managers/task-requester.ts @@ -1,3 +1,4 @@ +import { Service } from '@n8n/di'; import type { TaskResultData, RequesterMessage, BrokerMessage, TaskData } from '@n8n/task-runner'; import { AVAILABLE_RPC_METHODS } from '@n8n/task-runner'; import { isSerializedBuffer, toBuffer } from 'n8n-core'; @@ -18,7 +19,6 @@ import type { Result, } from 'n8n-workflow'; import { nanoid } from 'nanoid'; -import { Service } from 'typedi'; import { NodeTypes } from '@/node-types'; @@ -49,7 +49,7 @@ interface ExecuteFunctionObject { } @Service() -export abstract class TaskManager { +export abstract class TaskRequester { requestAcceptRejects: Map = new Map(); taskAcceptRejects: Map = new Map(); diff --git a/packages/cli/src/task-runners/task-runner-lifecycle-events.ts b/packages/cli/src/task-runners/task-runner-lifecycle-events.ts new file mode 100644 index 0000000000..f269caeb64 --- /dev/null +++ b/packages/cli/src/task-runners/task-runner-lifecycle-events.ts @@ -0,0 +1,11 @@ +import { Service } from '@n8n/di'; + +import { TypedEmitter } from '@/typed-emitter'; + +type TaskRunnerLifecycleEventMap = { + 'runner:failed-heartbeat-check': never; + 'runner:timed-out-during-task': never; +}; + +@Service() +export class TaskRunnerLifecycleEvents extends TypedEmitter {} diff --git a/packages/cli/src/runners/task-runner-module.ts b/packages/cli/src/task-runners/task-runner-module.ts similarity index 72% rename from packages/cli/src/runners/task-runner-module.ts rename to packages/cli/src/task-runners/task-runner-module.ts index 434daa066a..bcc54f653f 100644 --- a/packages/cli/src/runners/task-runner-module.ts +++ b/packages/cli/src/task-runners/task-runner-module.ts @@ -1,19 +1,18 @@ import { TaskRunnersConfig } from '@n8n/config'; -import { ErrorReporter } from 'n8n-core'; +import { Container, Service } from '@n8n/di'; +import { ErrorReporter, Logger } from 'n8n-core'; import { sleep } from 'n8n-workflow'; import * as a from 'node:assert/strict'; -import Container, { Service } from 'typedi'; import { OnShutdown } from '@/decorators/on-shutdown'; -import { Logger } from '@/logging/logger.service'; -import type { TaskRunnerRestartLoopError } from '@/runners/errors/task-runner-restart-loop-error'; -import type { TaskRunnerProcess } from '@/runners/task-runner-process'; -import { TaskRunnerProcessRestartLoopDetector } from '@/runners/task-runner-process-restart-loop-detector'; +import type { TaskRunnerRestartLoopError } from '@/task-runners/errors/task-runner-restart-loop-error'; +import type { TaskRunnerProcess } from '@/task-runners/task-runner-process'; +import { TaskRunnerProcessRestartLoopDetector } from '@/task-runners/task-runner-process-restart-loop-detector'; import { MissingAuthTokenError } from './errors/missing-auth-token.error'; -import { TaskRunnerWsServer } from './runner-ws-server'; -import type { LocalTaskManager } from './task-managers/local-task-manager'; +import type { LocalTaskRequester } from './task-managers/local-task-requester'; import type { TaskRunnerServer } from './task-runner-server'; +import { TaskRunnerWsServer } from './task-runner-ws-server'; /** * Module responsible for loading and starting task runner. Task runner can be @@ -26,7 +25,7 @@ export class TaskRunnerModule { private taskRunnerWsServer: TaskRunnerWsServer | undefined; - private taskManager: LocalTaskManager | undefined; + private taskRequester: LocalTaskRequester | undefined; private taskRunnerProcess: TaskRunnerProcess | undefined; @@ -47,7 +46,7 @@ export class TaskRunnerModule { if (mode === 'external' && !authToken) throw new MissingAuthTokenError(); - await this.loadTaskManager(); + await this.loadTaskRequester(); await this.loadTaskRunnerServer(); if (mode === 'internal') { @@ -74,17 +73,19 @@ export class TaskRunnerModule { await Promise.all([stopRunnerProcessTask, stopRunnerServerTask]); } - private async loadTaskManager() { - const { TaskManager } = await import('@/runners/task-managers/task-manager'); - const { LocalTaskManager } = await import('@/runners/task-managers/local-task-manager'); - this.taskManager = Container.get(LocalTaskManager); - Container.set(TaskManager, this.taskManager); + private async loadTaskRequester() { + const { TaskRequester } = await import('@/task-runners/task-managers/task-requester'); + const { LocalTaskRequester } = await import( + '@/task-runners/task-managers/local-task-requester' + ); + this.taskRequester = Container.get(LocalTaskRequester); + Container.set(TaskRequester, this.taskRequester); } private async loadTaskRunnerServer() { // These are imported dynamically because we need to set the task manager // instance before importing them - const { TaskRunnerServer } = await import('@/runners/task-runner-server'); + const { TaskRunnerServer } = await import('@/task-runners/task-runner-server'); this.taskRunnerHttpServer = Container.get(TaskRunnerServer); this.taskRunnerWsServer = Container.get(TaskRunnerWsServer); @@ -94,7 +95,7 @@ export class TaskRunnerModule { private async startInternalTaskRunner() { a.ok(this.taskRunnerWsServer, 'Task Runner WS Server not loaded'); - const { TaskRunnerProcess } = await import('@/runners/task-runner-process'); + const { TaskRunnerProcess } = await import('@/task-runners/task-runner-process'); this.taskRunnerProcess = Container.get(TaskRunnerProcess); this.taskRunnerProcessRestartLoopDetector = new TaskRunnerProcessRestartLoopDetector( this.taskRunnerProcess, @@ -107,7 +108,7 @@ export class TaskRunnerModule { await this.taskRunnerProcess.start(); const { InternalTaskRunnerDisconnectAnalyzer } = await import( - '@/runners/internal-task-runner-disconnect-analyzer' + '@/task-runners/internal-task-runner-disconnect-analyzer' ); this.taskRunnerWsServer.setDisconnectAnalyzer( Container.get(InternalTaskRunnerDisconnectAnalyzer), diff --git a/packages/cli/src/runners/task-runner-process-restart-loop-detector.ts b/packages/cli/src/task-runners/task-runner-process-restart-loop-detector.ts similarity index 90% rename from packages/cli/src/runners/task-runner-process-restart-loop-detector.ts rename to packages/cli/src/task-runners/task-runner-process-restart-loop-detector.ts index 5431cde195..f816c97c00 100644 --- a/packages/cli/src/runners/task-runner-process-restart-loop-detector.ts +++ b/packages/cli/src/task-runners/task-runner-process-restart-loop-detector.ts @@ -1,6 +1,6 @@ import { Time } from '@/constants'; -import { TaskRunnerRestartLoopError } from '@/runners/errors/task-runner-restart-loop-error'; -import type { TaskRunnerProcess } from '@/runners/task-runner-process'; +import { TaskRunnerRestartLoopError } from '@/task-runners/errors/task-runner-restart-loop-error'; +import type { TaskRunnerProcess } from '@/task-runners/task-runner-process'; import { TypedEmitter } from '@/typed-emitter'; const MAX_RESTARTS = 5; diff --git a/packages/cli/src/runners/task-runner-process.ts b/packages/cli/src/task-runners/task-runner-process.ts similarity index 95% rename from packages/cli/src/runners/task-runner-process.ts rename to packages/cli/src/task-runners/task-runner-process.ts index 2716383f17..5129ae98b1 100644 --- a/packages/cli/src/runners/task-runner-process.ts +++ b/packages/cli/src/task-runners/task-runner-process.ts @@ -1,16 +1,16 @@ import { TaskRunnersConfig } from '@n8n/config'; +import { Service } from '@n8n/di'; +import { Logger } from 'n8n-core'; import * as a from 'node:assert/strict'; import { spawn } from 'node:child_process'; import * as process from 'node:process'; -import { Service } from 'typedi'; import { OnShutdown } from '@/decorators/on-shutdown'; -import { Logger } from '@/logging/logger.service'; import { TaskRunnerAuthService } from './auth/task-runner-auth.service'; import { forwardToLogger } from './forward-to-logger'; import { NodeProcessOomDetector } from './node-process-oom-detector'; -import { RunnerLifecycleEvents } from './runner-lifecycle-events'; +import { TaskRunnerLifecycleEvents } from './task-runner-lifecycle-events'; import { TypedEmitter } from '../typed-emitter'; type ChildProcess = ReturnType; @@ -68,7 +68,7 @@ export class TaskRunnerProcess extends TypedEmitter { logger: Logger, private readonly runnerConfig: TaskRunnersConfig, private readonly authService: TaskRunnerAuthService, - private readonly runnerLifecycleEvents: RunnerLifecycleEvents, + private readonly runnerLifecycleEvents: TaskRunnerLifecycleEvents, ) { super(); diff --git a/packages/cli/src/runners/task-runner-server.ts b/packages/cli/src/task-runners/task-runner-server.ts similarity index 95% rename from packages/cli/src/runners/task-runner-server.ts rename to packages/cli/src/task-runners/task-runner-server.ts index 2b1f481b0e..6e68c4fb32 100644 --- a/packages/cli/src/runners/task-runner-server.ts +++ b/packages/cli/src/task-runners/task-runner-server.ts @@ -1,24 +1,24 @@ import { GlobalConfig } from '@n8n/config'; +import { Service } from '@n8n/di'; import compression from 'compression'; import express from 'express'; +import { Logger } from 'n8n-core'; import * as a from 'node:assert/strict'; import { randomBytes } from 'node:crypto'; import { ServerResponse, type Server, createServer as createHttpServer } from 'node:http'; import type { AddressInfo, Socket } from 'node:net'; import { parse as parseUrl } from 'node:url'; -import { Service } from 'typedi'; import { Server as WSServer } from 'ws'; import { inTest } from '@/constants'; -import { Logger } from '@/logging/logger.service'; import { bodyParser, rawBodyReader } from '@/middlewares'; import { send } from '@/response-helper'; -import { TaskRunnerAuthController } from '@/runners/auth/task-runner-auth.controller'; +import { TaskRunnerAuthController } from '@/task-runners/auth/task-runner-auth.controller'; import type { TaskRunnerServerInitRequest, TaskRunnerServerInitResponse, -} from '@/runners/runner-types'; -import { TaskRunnerWsServer } from '@/runners/runner-ws-server'; +} from '@/task-runners/task-runner-types'; +import { TaskRunnerWsServer } from '@/task-runners/task-runner-ws-server'; /** * Task Runner HTTP & WS server diff --git a/packages/cli/src/runners/runner-types.ts b/packages/cli/src/task-runners/task-runner-types.ts similarity index 100% rename from packages/cli/src/runners/runner-types.ts rename to packages/cli/src/task-runners/task-runner-types.ts diff --git a/packages/cli/src/runners/runner-ws-server.ts b/packages/cli/src/task-runners/task-runner-ws-server.ts similarity index 95% rename from packages/cli/src/runners/runner-ws-server.ts rename to packages/cli/src/task-runners/task-runner-ws-server.ts index 8ea3a7edbe..dbec7cef3e 100644 --- a/packages/cli/src/runners/runner-ws-server.ts +++ b/packages/cli/src/task-runners/task-runner-ws-server.ts @@ -1,21 +1,21 @@ import { TaskRunnersConfig } from '@n8n/config'; +import { Service } from '@n8n/di'; import type { BrokerMessage, RunnerMessage } from '@n8n/task-runner'; +import { Logger } from 'n8n-core'; import { ApplicationError, jsonStringify } from 'n8n-workflow'; -import { Service } from 'typedi'; import type WebSocket from 'ws'; import { Time, WsStatusCodes } from '@/constants'; -import { Logger } from '@/logging/logger.service'; import { DefaultTaskRunnerDisconnectAnalyzer } from './default-task-runner-disconnect-analyzer'; -import { RunnerLifecycleEvents } from './runner-lifecycle-events'; +import { TaskBroker, type MessageCallback, type TaskRunner } from './task-broker.service'; +import { TaskRunnerLifecycleEvents } from './task-runner-lifecycle-events'; import type { DisconnectAnalyzer, DisconnectReason, TaskRunnerServerInitRequest, TaskRunnerServerInitResponse, -} from './runner-types'; -import { TaskBroker, type MessageCallback, type TaskRunner } from './task-broker.service'; +} from './task-runner-types'; function heartbeat(this: WebSocket) { this.isAlive = true; @@ -34,7 +34,7 @@ export class TaskRunnerWsServer { private readonly taskBroker: TaskBroker, private disconnectAnalyzer: DefaultTaskRunnerDisconnectAnalyzer, private readonly taskTunnersConfig: TaskRunnersConfig, - private readonly runnerLifecycleEvents: RunnerLifecycleEvents, + private readonly runnerLifecycleEvents: TaskRunnerLifecycleEvents, ) {} start() { diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index a8d39d898e..5b16656713 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -1,9 +1,9 @@ import { GlobalConfig } from '@n8n/config'; +import { Container, Service } from '@n8n/di'; import type RudderStack from '@rudderstack/rudder-sdk-node'; import axios from 'axios'; -import { InstanceSettings } from 'n8n-core'; +import { InstanceSettings, Logger } from 'n8n-core'; import type { ITelemetryTrackProperties } from 'n8n-workflow'; -import { Container, Service } from 'typedi'; import { LOWEST_SHUTDOWN_PRIORITY, N8N_VERSION } from '@/constants'; import { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository'; @@ -13,10 +13,9 @@ import { WorkflowRepository } from '@/databases/repositories/workflow.repository import { OnShutdown } from '@/decorators/on-shutdown'; import type { IExecutionTrackProperties } from '@/interfaces'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import { PostHogClient } from '@/posthog'; -import { SourceControlPreferencesService } from '../environments/source-control/source-control-preferences.service.ee'; +import { SourceControlPreferencesService } from '../environments.ee/source-control/source-control-preferences.service.ee'; type ExecutionTrackDataKey = 'manual_error' | 'manual_success' | 'prod_error' | 'prod_success'; diff --git a/packages/cli/src/user-management/email/node-mailer.ts b/packages/cli/src/user-management/email/node-mailer.ts index a35ab77318..ac555155d2 100644 --- a/packages/cli/src/user-management/email/node-mailer.ts +++ b/packages/cli/src/user-management/email/node-mailer.ts @@ -1,13 +1,11 @@ import { GlobalConfig } from '@n8n/config'; +import { Service } from '@n8n/di'; import { pick } from 'lodash'; -import { ErrorReporter } from 'n8n-core'; +import { ErrorReporter, Logger } from 'n8n-core'; import path from 'node:path'; import type { Transporter } from 'nodemailer'; import { createTransport } from 'nodemailer'; import type SMTPConnection from 'nodemailer/lib/smtp-connection'; -import { Service } from 'typedi'; - -import { Logger } from '@/logging/logger.service'; import type { MailData, SendEmailResult } from './interfaces'; diff --git a/packages/cli/src/user-management/email/user-management-mailer.ts b/packages/cli/src/user-management/email/user-management-mailer.ts index 3acddad185..53320c61e1 100644 --- a/packages/cli/src/user-management/email/user-management-mailer.ts +++ b/packages/cli/src/user-management/email/user-management-mailer.ts @@ -1,9 +1,10 @@ import { GlobalConfig } from '@n8n/config'; +import { Container, Service } from '@n8n/di'; import { existsSync } from 'fs'; import { readFile } from 'fs/promises'; import Handlebars from 'handlebars'; +import { Logger } from 'n8n-core'; import { join as pathJoin } from 'path'; -import { Container, Service } from 'typedi'; import { inTest } from '@/constants'; import type { User } from '@/databases/entities/user'; @@ -11,7 +12,6 @@ import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import { UserRepository } from '@/databases/repositories/user.repository'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { EventService } from '@/events/event.service'; -import { Logger } from '@/logging/logger.service'; import { UrlService } from '@/services/url.service'; import { toError } from '@/utils'; diff --git a/packages/cli/src/user-management/permission-checker.ts b/packages/cli/src/user-management/permission-checker.ts index c93d2acf91..72ac867061 100644 --- a/packages/cli/src/user-management/permission-checker.ts +++ b/packages/cli/src/user-management/permission-checker.ts @@ -1,10 +1,10 @@ +import { Service } from '@n8n/di'; import type { INode } from 'n8n-workflow'; import { CredentialAccessError, NodeOperationError } from 'n8n-workflow'; -import { Service } from 'typedi'; import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; import { OwnershipService } from '@/services/ownership.service'; -import { ProjectService } from '@/services/project.service'; +import { ProjectService } from '@/services/project.service.ee'; @Service() export class PermissionChecker { diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index 700f74f9d0..d701707a11 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -58,10 +58,6 @@ export function isStringArray(value: unknown): value is string[] { export const isIntegerString = (value: string) => /^\d+$/.test(value); -export function isObjectLiteral(item: unknown): item is { [key: string]: unknown } { - return typeof item === 'object' && item !== null && !Array.isArray(item); -} - export function removeTrailingSlash(path: string) { return path.endsWith('/') ? path.slice(0, -1) : path; } diff --git a/packages/cli/src/wait-tracker.ts b/packages/cli/src/wait-tracker.ts index f42905ace1..d3c9d54c3a 100644 --- a/packages/cli/src/wait-tracker.ts +++ b/packages/cli/src/wait-tracker.ts @@ -1,10 +1,9 @@ -import { InstanceSettings } from 'n8n-core'; +import { Service } from '@n8n/di'; +import { InstanceSettings, Logger } from 'n8n-core'; import { ApplicationError, type IWorkflowExecutionDataProcess } from 'n8n-workflow'; -import { Service } from 'typedi'; import { ActiveExecutions } from '@/active-executions'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; -import { Logger } from '@/logging/logger.service'; import { OrchestrationService } from '@/services/orchestration.service'; import { OwnershipService } from '@/services/ownership.service'; import { WorkflowRunner } from '@/workflow-runner'; diff --git a/packages/cli/src/webhooks/live-webhooks.ts b/packages/cli/src/webhooks/live-webhooks.ts index 6d6fc9161d..440e8254dc 100644 --- a/packages/cli/src/webhooks/live-webhooks.ts +++ b/packages/cli/src/webhooks/live-webhooks.ts @@ -1,12 +1,12 @@ +import { Service } from '@n8n/di'; import type { Response } from 'express'; +import { Logger } from 'n8n-core'; import { Workflow, CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow'; import type { INode, IWebhookData, IHttpRequestMethods } from 'n8n-workflow'; -import { Service } from 'typedi'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { WebhookNotFoundError } from '@/errors/response-errors/webhook-not-found.error'; -import { Logger } from '@/logging/logger.service'; import { NodeTypes } from '@/node-types'; import * as WebhookHelpers from '@/webhooks/webhook-helpers'; import { WebhookService } from '@/webhooks/webhook.service'; diff --git a/packages/cli/src/webhooks/test-webhook-registrations.service.ts b/packages/cli/src/webhooks/test-webhook-registrations.service.ts index e25b3102db..565608c583 100644 --- a/packages/cli/src/webhooks/test-webhook-registrations.service.ts +++ b/packages/cli/src/webhooks/test-webhook-registrations.service.ts @@ -1,6 +1,6 @@ +import { Service } from '@n8n/di'; import { InstanceSettings } from 'n8n-core'; import type { IWebhookData } from 'n8n-workflow'; -import { Service } from 'typedi'; import { TEST_WEBHOOK_TIMEOUT, TEST_WEBHOOK_TIMEOUT_BUFFER } from '@/constants'; import type { IWorkflowDb } from '@/interfaces'; diff --git a/packages/cli/src/webhooks/test-webhooks.ts b/packages/cli/src/webhooks/test-webhooks.ts index ad642a17c3..61d29d7b79 100644 --- a/packages/cli/src/webhooks/test-webhooks.ts +++ b/packages/cli/src/webhooks/test-webhooks.ts @@ -1,3 +1,4 @@ +import { Service } from '@n8n/di'; import type express from 'express'; import { InstanceSettings } from 'n8n-core'; import { WebhookPathTakenError, Workflow } from 'n8n-workflow'; @@ -7,7 +8,6 @@ import type { IHttpRequestMethods, IRunData, } from 'n8n-workflow'; -import { Service } from 'typedi'; import { TEST_WEBHOOK_TIMEOUT } from '@/constants'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; @@ -142,8 +142,7 @@ export class TestWebhooks implements IWebhookManager { // Inform editor-ui that webhook got received if (pushRef !== undefined) { this.push.send( - 'testWebhookReceived', - { workflowId: webhook?.workflowId, executionId }, + { type: 'testWebhookReceived', data: { workflowId: webhook?.workflowId, executionId } }, pushRef, ); } @@ -155,11 +154,7 @@ export class TestWebhooks implements IWebhookManager { * the webhook. If so, after the test webhook has been successfully executed, * the handler process commands the creator process to clear its test webhooks. */ - if ( - this.instanceSettings.isMultiMain && - pushRef && - !this.push.getBackend().hasPushRef(pushRef) - ) { + if (this.instanceSettings.isMultiMain && pushRef && !this.push.hasPushRef(pushRef)) { void this.publisher.publishCommand({ command: 'clear-test-webhooks', payload: { webhookKey: key, workflowEntity, pushRef }, @@ -354,7 +349,7 @@ export class TestWebhooks implements IWebhookManager { if (pushRef !== undefined) { try { - this.push.send('testWebhookDeleted', { workflowId }, pushRef); + this.push.send({ type: 'testWebhookDeleted', data: { workflowId } }, pushRef); } catch { // Could not inform editor, probably is not connected anymore. So simply go on. } diff --git a/packages/cli/src/webhooks/waiting-forms.ts b/packages/cli/src/webhooks/waiting-forms.ts index cd2de74ed0..93294d24d0 100644 --- a/packages/cli/src/webhooks/waiting-forms.ts +++ b/packages/cli/src/webhooks/waiting-forms.ts @@ -1,8 +1,8 @@ +import { Service } from '@n8n/di'; import axios from 'axios'; import type express from 'express'; import type { IRunData } from 'n8n-workflow'; import { FORM_NODE_TYPE, sleep, Workflow } from 'n8n-workflow'; -import { Service } from 'typedi'; import { ConflictError } from '@/errors/response-errors/conflict.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; diff --git a/packages/cli/src/webhooks/waiting-webhooks.ts b/packages/cli/src/webhooks/waiting-webhooks.ts index 6355709189..90413b433d 100644 --- a/packages/cli/src/webhooks/waiting-webhooks.ts +++ b/packages/cli/src/webhooks/waiting-webhooks.ts @@ -1,4 +1,6 @@ +import { Service } from '@n8n/di'; import type express from 'express'; +import { Logger } from 'n8n-core'; import { FORM_NODE_TYPE, type INodes, @@ -7,13 +9,11 @@ import { WAIT_NODE_TYPE, Workflow, } from 'n8n-workflow'; -import { Service } from 'typedi'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { ConflictError } from '@/errors/response-errors/conflict.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import type { IExecutionResponse, IWorkflowDb } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { NodeTypes } from '@/node-types'; import * as WebhookHelpers from '@/webhooks/webhook-helpers'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; diff --git a/packages/cli/src/webhooks/webhook-helpers.ts b/packages/cli/src/webhooks/webhook-helpers.ts index 6657089881..1711d18056 100644 --- a/packages/cli/src/webhooks/webhook-helpers.ts +++ b/packages/cli/src/webhooks/webhook-helpers.ts @@ -7,9 +7,10 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/restrict-template-expressions */ import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import type express from 'express'; import get from 'lodash/get'; -import { BinaryDataService, ErrorReporter } from 'n8n-core'; +import { BinaryDataService, ErrorReporter, Logger } from 'n8n-core'; import type { IBinaryData, IBinaryKeyData, @@ -37,16 +38,16 @@ import { FORM_NODE_TYPE, NodeOperationError, } from 'n8n-workflow'; +import assert from 'node:assert'; import { finished } from 'stream/promises'; -import { Container } from 'typedi'; import { ActiveExecutions } from '@/active-executions'; +import config from '@/config'; import type { Project } from '@/databases/entities/project'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; import type { IWorkflowDb } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { parseBody } from '@/middlewares'; import { OwnershipService } from '@/services/ownership.service'; import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; @@ -532,6 +533,15 @@ export async function executeWebhook( }); } + if ( + config.getEnv('executions.mode') === 'queue' && + process.env.OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS === 'true' && + runData.executionMode === 'manual' + ) { + assert(runData.executionData); + runData.executionData.isTestWebhook = true; + } + // Start now to run the workflow executionId = await Container.get(WorkflowRunner).run( runData, diff --git a/packages/cli/src/webhooks/webhook-server.ts b/packages/cli/src/webhooks/webhook-server.ts index 263375325b..648dcd7bfb 100644 --- a/packages/cli/src/webhooks/webhook-server.ts +++ b/packages/cli/src/webhooks/webhook-server.ts @@ -1,4 +1,4 @@ -import { Service } from 'typedi'; +import { Service } from '@n8n/di'; import { AbstractServer } from '@/abstract-server'; diff --git a/packages/cli/src/webhooks/webhook.service.ts b/packages/cli/src/webhooks/webhook.service.ts index 80b12b04cd..1a3dbf37cb 100644 --- a/packages/cli/src/webhooks/webhook.service.ts +++ b/packages/cli/src/webhooks/webhook.service.ts @@ -1,4 +1,5 @@ -import { HookContext, WebhookContext } from 'n8n-core'; +import { Service } from '@n8n/di'; +import { HookContext, WebhookContext, Logger } from 'n8n-core'; import { ApplicationError, Node, NodeHelpers } from 'n8n-workflow'; import type { IHttpRequestMethods, @@ -12,11 +13,9 @@ import type { WorkflowActivateMode, WorkflowExecuteMode, } from 'n8n-workflow'; -import { Service } from 'typedi'; import type { WebhookEntity } from '@/databases/entities/webhook-entity'; import { WebhookRepository } from '@/databases/repositories/webhook.repository'; -import { Logger } from '@/logging/logger.service'; import { NodeTypes } from '@/node-types'; import { CacheService } from '@/services/cache/cache.service'; diff --git a/packages/cli/src/workflow-execute-additional-data.ts b/packages/cli/src/workflow-execute-additional-data.ts index a97bb3d3fa..e350086f9d 100644 --- a/packages/cli/src/workflow-execute-additional-data.ts +++ b/packages/cli/src/workflow-execute-additional-data.ts @@ -2,10 +2,17 @@ /* eslint-disable @typescript-eslint/no-use-before-define */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import type { PushType } from '@n8n/api-types'; +import type { PushMessage, PushType } from '@n8n/api-types'; import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import { stringify } from 'flatted'; -import { ErrorReporter, WorkflowExecute } from 'n8n-core'; +import { + ErrorReporter, + Logger, + InstanceSettings, + WorkflowExecute, + isObjectLiteral, +} from 'n8n-core'; import { ApplicationError, NodeOperationError, Workflow, WorkflowHooks } from 'n8n-workflow'; import type { IDataObject, @@ -33,7 +40,6 @@ import type { ExecuteWorkflowData, RelatedExecution, } from 'n8n-workflow'; -import { Container } from 'typedi'; import { ActiveExecutions } from '@/active-executions'; import config from '@/config'; @@ -45,7 +51,7 @@ import type { IWorkflowErrorData, UpdateExecutionPayload } from '@/interfaces'; import { NodeTypes } from '@/node-types'; import { Push } from '@/push'; import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; -import { findSubworkflowStart, isObjectLiteral, isWorkflowIdValid } from '@/utils'; +import { findSubworkflowStart, isWorkflowIdValid } from '@/utils'; import * as WorkflowHelpers from '@/workflow-helpers'; import { WorkflowRepository } from './databases/repositories/workflow.repository'; @@ -58,12 +64,11 @@ import { updateExistingExecution, } from './execution-lifecycle-hooks/shared/shared-hook-functions'; import { toSaveSettings } from './execution-lifecycle-hooks/to-save-settings'; -import { Logger } from './logging/logger.service'; -import { TaskManager } from './runners/task-managers/task-manager'; -import { SecretsHelper } from './secrets-helpers'; +import { SecretsHelper } from './secrets-helpers.ee'; import { OwnershipService } from './services/ownership.service'; import { UrlService } from './services/url.service'; import { SubworkflowPolicyChecker } from './subworkflows/subworkflow-policy-checker.service'; +import { TaskRequester } from './task-runners/task-managers/task-requester'; import { PermissionChecker } from './user-management/permission-checker'; import { WorkflowExecutionService } from './workflows/workflow-execution.service'; import { WorkflowStaticDataService } from './workflows/workflow-static-data.service'; @@ -262,7 +267,7 @@ function hookFunctionsPush(): IWorkflowExecuteHooks { workflowId: this.workflowData.id, }); - pushInstance.send('nodeExecuteBefore', { executionId, nodeName }, pushRef); + pushInstance.send({ type: 'nodeExecuteBefore', data: { executionId, nodeName } }, pushRef); }, ], nodeExecuteAfter: [ @@ -279,7 +284,10 @@ function hookFunctionsPush(): IWorkflowExecuteHooks { workflowId: this.workflowData.id, }); - pushInstance.send('nodeExecuteAfter', { executionId, nodeName, data }, pushRef); + pushInstance.send( + { type: 'nodeExecuteAfter', data: { executionId, nodeName, data } }, + pushRef, + ); }, ], workflowExecuteBefore: [ @@ -296,17 +304,19 @@ function hookFunctionsPush(): IWorkflowExecuteHooks { return; } pushInstance.send( - 'executionStarted', { - executionId, - mode: this.mode, - startedAt: new Date(), - retryOf: this.retryOf, - workflowId, - workflowName, - flattedRunData: data?.resultData.runData - ? stringify(data.resultData.runData) - : stringify({}), + type: 'executionStarted', + data: { + executionId, + mode: this.mode, + startedAt: new Date(), + retryOf: this.retryOf, + workflowId, + workflowName, + flattedRunData: data?.resultData.runData + ? stringify(data.resultData.runData) + : stringify({}), + }, }, pushRef, ); @@ -326,12 +336,11 @@ function hookFunctionsPush(): IWorkflowExecuteHooks { const { status } = fullRunData; if (status === 'waiting') { - pushInstance.send('executionWaiting', { executionId }, pushRef); + pushInstance.send({ type: 'executionWaiting', data: { executionId } }, pushRef); } else { const rawData = stringify(fullRunData.data); pushInstance.send( - 'executionFinished', - { executionId, workflowId, status, rawData }, + { type: 'executionFinished', data: { executionId, workflowId, status, rawData } }, pushRef, ); } @@ -974,7 +983,7 @@ export function sendDataToUI(type: PushType, data: IDataObject | IDataObject[]) // Push data to session which started workflow try { const pushInstance = Container.get(Push); - pushInstance.send(type, data, pushRef); + pushInstance.send({ type, data } as PushMessage, pushRef); } catch (error) { const logger = Container.get(Logger); logger.warn(`There was a problem sending message to UI: ${error.message}`); @@ -1012,7 +1021,7 @@ export async function getBase( setExecutionStatus, variables, secretsHelpers: Container.get(SecretsHelper), - async startAgentJob( + async startRunnerTask( additionalData: IWorkflowExecuteAdditionalData, jobType: string, settings: unknown, @@ -1030,7 +1039,7 @@ export async function getBase( envProviderState: EnvProviderState, executeData?: IExecuteData, ) { - return await Container.get(TaskManager).startTask( + return await Container.get(TaskRequester).startTask( additionalData, jobType, settings, @@ -1073,8 +1082,7 @@ function getWorkflowHooksIntegrated( } /** - * Returns WorkflowHooks instance for running integrated workflows - * (Workflows which get started inside of another workflow) + * Returns WorkflowHooks instance for worker in scaling mode. */ export function getWorkflowHooksWorkerExecuter( mode: WorkflowExecuteMode, @@ -1090,6 +1098,17 @@ export function getWorkflowHooksWorkerExecuter( hooks.push.apply(hookFunctions[key], preExecuteFunctions[key]); } + if (mode === 'manual' && Container.get(InstanceSettings).isWorker) { + const pushHooks = hookFunctionsPush(); + for (const key of Object.keys(pushHooks)) { + if (hookFunctions[key] === undefined) { + hookFunctions[key] = []; + } + // eslint-disable-next-line prefer-spread + hookFunctions[key].push.apply(hookFunctions[key], pushHooks[key]); + } + } + return new WorkflowHooks(hookFunctions, mode, executionId, workflowData, optionalParameters); } diff --git a/packages/cli/src/workflow-helpers.ts b/packages/cli/src/workflow-helpers.ts index addae4e290..36946dd77e 100644 --- a/packages/cli/src/workflow-helpers.ts +++ b/packages/cli/src/workflow-helpers.ts @@ -1,3 +1,4 @@ +import { Container } from '@n8n/di'; import type { IDataObject, INode, @@ -9,12 +10,11 @@ import type { WorkflowOperationError, NodeOperationError, } from 'n8n-workflow'; -import { Container } from 'typedi'; import { v4 as uuid } from 'uuid'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; -import { VariablesService } from '@/environments/variables/variables.service.ee'; +import { VariablesService } from '@/environments.ee/variables/variables.service.ee'; export function generateFailedExecutionFromError( mode: WorkflowExecuteMode, diff --git a/packages/cli/src/workflow-runner.ts b/packages/cli/src/workflow-runner.ts index 973d512e62..a5ffb728d6 100644 --- a/packages/cli/src/workflow-runner.ts +++ b/packages/cli/src/workflow-runner.ts @@ -2,7 +2,8 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-shadow */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { ErrorReporter, InstanceSettings, WorkflowExecute } from 'n8n-core'; +import { Container, Service } from '@n8n/di'; +import { ErrorReporter, InstanceSettings, Logger, WorkflowExecute } from 'n8n-core'; import type { ExecutionError, IDeferredPromise, @@ -15,13 +16,11 @@ import type { } from 'n8n-workflow'; import { ExecutionCancelledError, Workflow } from 'n8n-workflow'; import PCancelable from 'p-cancelable'; -import { Container, Service } from 'typedi'; import { ActiveExecutions } from '@/active-executions'; import config from '@/config'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { ExternalHooks } from '@/external-hooks'; -import { Logger } from '@/logging/logger.service'; import { NodeTypes } from '@/node-types'; import type { ScalingService } from '@/scaling/scaling.service'; import type { Job, JobData } from '@/scaling/scaling.types'; @@ -67,10 +66,15 @@ export class WorkflowRunner { // // FIXME: This is a quick fix. The proper fix would be to not remove // the execution from the active executions while it's still running. - if (error instanceof ExecutionNotFoundError) { + if ( + error instanceof ExecutionNotFoundError || + error instanceof ExecutionCancelledError || + error.message.includes('cancelled') + ) { return; } + this.logger.error(`Problem with execution ${executionId}: ${error.message}. Aborting.`); this.errorReporter.error(error, { executionId }); const isQueueMode = config.getEnv('executions.mode') === 'queue'; @@ -78,7 +82,7 @@ export class WorkflowRunner { // in queue mode, first do a sanity run for the edge case that the execution was not marked as stalled // by Bull even though it executed successfully, see https://github.com/OptimalBits/bull/issues/1415 - if (isQueueMode && executionMode !== 'manual') { + if (isQueueMode) { const executionWithoutData = await this.executionRepository.findSingleExecution(executionId, { includeData: false, }); @@ -149,9 +153,13 @@ export class WorkflowRunner { this.activeExecutions.attachResponsePromise(executionId, responsePromise); } - if (this.executionsMode === 'queue' && data.executionMode !== 'manual') { - // Do not run "manual" executions in bull because sending events to the - // frontend would not be possible + // @TODO: Reduce to true branch once feature is stable + const shouldEnqueue = + process.env.OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS === 'true' + ? this.executionsMode === 'queue' + : this.executionsMode === 'queue' && data.executionMode !== 'manual'; + + if (shouldEnqueue) { await this.enqueueExecution(executionId, data, loadStaticData, realtime); } else { await this.runMainProcess(executionId, data, loadStaticData, restartExecutionId); @@ -345,6 +353,7 @@ export class WorkflowRunner { const jobData: JobData = { executionId, loadStaticData: !!loadStaticData, + pushRef: data.pushRef, }; if (!this.scalingService) { @@ -414,7 +423,6 @@ export class WorkflowRunner { data.workflowData, { retryOf: data.retryOf ? data.retryOf.toString() : undefined }, ); - this.logger.error(`Problem with execution ${executionId}: ${error.message}. Aborting.`); await this.processError(error, new Date(), data.executionMode, executionId, hooks); reject(error); diff --git a/packages/cli/src/workflows/__tests__/workflows.controller.test.ts b/packages/cli/src/workflows/__tests__/workflows.controller.test.ts new file mode 100644 index 0000000000..4168d487bf --- /dev/null +++ b/packages/cli/src/workflows/__tests__/workflows.controller.test.ts @@ -0,0 +1,72 @@ +import type { ImportWorkflowFromUrlDto } from '@n8n/api-types/src/dto/workflows/import-workflow-from-url.dto'; +import axios from 'axios'; +import type { Response } from 'express'; +import { mock } from 'jest-mock-extended'; + +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import type { AuthenticatedRequest } from '@/requests'; + +import { WorkflowsController } from '../workflows.controller'; + +jest.mock('axios'); + +describe('WorkflowsController', () => { + const controller = Object.create(WorkflowsController.prototype); + const axiosMock = axios.get as jest.Mock; + const req = mock(); + const res = mock(); + + describe('getFromUrl', () => { + describe('should return workflow data', () => { + it('when the URL points to a valid JSON file', async () => { + const mockWorkflowData = { + nodes: [], + connections: {}, + }; + + axiosMock.mockResolvedValue({ data: mockWorkflowData }); + + const query: ImportWorkflowFromUrlDto = { url: 'https://example.com/workflow.json' }; + const result = await controller.getFromUrl(req, res, query); + + expect(result).toEqual(mockWorkflowData); + expect(axiosMock).toHaveBeenCalledWith(query.url); + }); + }); + + describe('should throw a BadRequestError', () => { + const query: ImportWorkflowFromUrlDto = { url: 'https://example.com/invalid.json' }; + + it('when the URL does not point to a valid JSON file', async () => { + axiosMock.mockRejectedValue(new Error('Network Error')); + + await expect(controller.getFromUrl(req, res, query)).rejects.toThrow(BadRequestError); + expect(axiosMock).toHaveBeenCalledWith(query.url); + }); + + it('when the data is not a valid n8n workflow JSON', async () => { + const invalidWorkflowData = { + nodes: 'not an array', + connections: 'not an object', + }; + + axiosMock.mockResolvedValue({ data: invalidWorkflowData }); + + await expect(controller.getFromUrl(req, res, query)).rejects.toThrow(BadRequestError); + expect(axiosMock).toHaveBeenCalledWith(query.url); + }); + + it('when the data is missing required fields', async () => { + const incompleteWorkflowData = { + nodes: [], + // Missing connections field + }; + + axiosMock.mockResolvedValue({ data: incompleteWorkflowData }); + + await expect(controller.getFromUrl(req, res, query)).rejects.toThrow(BadRequestError); + expect(axiosMock).toHaveBeenCalledWith(query.url); + }); + }); + }); +}); diff --git a/packages/cli/src/workflows/workflow-execution.service.ts b/packages/cli/src/workflows/workflow-execution.service.ts index 27b673c245..3b384f4c53 100644 --- a/packages/cli/src/workflows/workflow-execution.service.ts +++ b/packages/cli/src/workflows/workflow-execution.service.ts @@ -1,5 +1,6 @@ import { GlobalConfig } from '@n8n/config'; -import { ErrorReporter } from 'n8n-core'; +import { Service } from '@n8n/di'; +import { ErrorReporter, Logger } from 'n8n-core'; import type { IDeferredPromise, IExecuteData, @@ -13,14 +14,13 @@ import type { IWorkflowExecutionDataProcess, } from 'n8n-workflow'; import { SubworkflowOperationError, Workflow } from 'n8n-workflow'; -import { Service } from 'typedi'; +import config from '@/config'; import type { Project } from '@/databases/entities/project'; import type { User } from '@/databases/entities/user'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import type { CreateExecutionPayload, IWorkflowDb, IWorkflowErrorData } from '@/interfaces'; -import { Logger } from '@/logging/logger.service'; import { NodeTypes } from '@/node-types'; import { SubworkflowPolicyChecker } from '@/subworkflows/subworkflow-policy-checker.service'; import { TestWebhooks } from '@/webhooks/test-webhooks'; @@ -147,6 +147,35 @@ export class WorkflowExecutionService { triggerToStartFrom, }; + /** + * Historically, manual executions in scaling mode ran in the main process, + * so some execution details were never persisted in the database. + * + * Currently, manual executions in scaling mode are offloaded to workers, + * so we persist all details to give workers full access to them. + */ + if ( + config.getEnv('executions.mode') === 'queue' && + process.env.OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS === 'true' + ) { + data.executionData = { + startData: { + startNodes, + destinationNode, + }, + resultData: { + pinData, + runData, + }, + manualData: { + userId: data.userId, + partialExecutionVersion: data.partialExecutionVersion, + dirtyNodeNames, + triggerToStartFrom, + }, + }; + } + const hasRunData = (node: INode) => runData !== undefined && !!runData[node.name]; if (pinnedTrigger && !hasRunData(pinnedTrigger)) { diff --git a/packages/cli/src/workflows/workflow-history/__tests__/workflow-history-helper.ee.test.ts b/packages/cli/src/workflows/workflow-history.ee/__tests__/workflow-history-helper.ee.test.ts similarity index 97% rename from packages/cli/src/workflows/workflow-history/__tests__/workflow-history-helper.ee.test.ts rename to packages/cli/src/workflows/workflow-history.ee/__tests__/workflow-history-helper.ee.test.ts index ce3927f730..70e00d2c6d 100644 --- a/packages/cli/src/workflows/workflow-history/__tests__/workflow-history-helper.ee.test.ts +++ b/packages/cli/src/workflows/workflow-history.ee/__tests__/workflow-history-helper.ee.test.ts @@ -1,6 +1,6 @@ import config from '@/config'; import { License } from '@/license'; -import { getWorkflowHistoryPruneTime } from '@/workflows/workflow-history/workflow-history-helper.ee'; +import { getWorkflowHistoryPruneTime } from '@/workflows/workflow-history.ee/workflow-history-helper.ee'; import { mockInstance } from '@test/mocking'; let licensePruneTime = -1; diff --git a/packages/cli/src/workflows/workflow-history/__tests__/workflow-history.service.ee.test.ts b/packages/cli/src/workflows/workflow-history.ee/__tests__/workflow-history.service.ee.test.ts similarity index 96% rename from packages/cli/src/workflows/workflow-history/__tests__/workflow-history.service.ee.test.ts rename to packages/cli/src/workflows/workflow-history.ee/__tests__/workflow-history.service.ee.test.ts index a2a48587f0..b80b38eb9e 100644 --- a/packages/cli/src/workflows/workflow-history/__tests__/workflow-history.service.ee.test.ts +++ b/packages/cli/src/workflows/workflow-history.ee/__tests__/workflow-history.service.ee.test.ts @@ -3,7 +3,7 @@ import { mockClear } from 'jest-mock-extended'; import { User } from '@/databases/entities/user'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; import { WorkflowHistoryRepository } from '@/databases/repositories/workflow-history.repository'; -import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service.ee'; +import { WorkflowHistoryService } from '@/workflows/workflow-history.ee/workflow-history.service.ee'; import { mockInstance, mockLogger } from '@test/mocking'; import { getWorkflow } from '@test-integration/workflow'; @@ -24,7 +24,7 @@ const testUser = Object.assign(new User(), { }); let isWorkflowHistoryEnabled = true; -jest.mock('@/workflows/workflow-history/workflow-history-helper.ee', () => { +jest.mock('@/workflows/workflow-history.ee/workflow-history-helper.ee', () => { return { isWorkflowHistoryEnabled: jest.fn(() => isWorkflowHistoryEnabled), }; diff --git a/packages/cli/src/workflows/workflow-history/workflow-history-helper.ee.ts b/packages/cli/src/workflows/workflow-history.ee/workflow-history-helper.ee.ts similarity index 96% rename from packages/cli/src/workflows/workflow-history/workflow-history-helper.ee.ts rename to packages/cli/src/workflows/workflow-history.ee/workflow-history-helper.ee.ts index 11363a45bc..c8b06851e7 100644 --- a/packages/cli/src/workflows/workflow-history/workflow-history-helper.ee.ts +++ b/packages/cli/src/workflows/workflow-history.ee/workflow-history-helper.ee.ts @@ -1,4 +1,4 @@ -import Container from 'typedi'; +import { Container } from '@n8n/di'; import config from '@/config'; import { License } from '@/license'; diff --git a/packages/cli/src/workflows/workflow-history/workflow-history-manager.ee.ts b/packages/cli/src/workflows/workflow-history.ee/workflow-history-manager.ee.ts similarity index 96% rename from packages/cli/src/workflows/workflow-history/workflow-history-manager.ee.ts rename to packages/cli/src/workflows/workflow-history.ee/workflow-history-manager.ee.ts index f3a25bfb16..b320c144f6 100644 --- a/packages/cli/src/workflows/workflow-history/workflow-history-manager.ee.ts +++ b/packages/cli/src/workflows/workflow-history.ee/workflow-history-manager.ee.ts @@ -1,5 +1,5 @@ +import { Service } from '@n8n/di'; import { DateTime } from 'luxon'; -import { Service } from 'typedi'; import { Time } from '@/constants'; import { WorkflowHistoryRepository } from '@/databases/repositories/workflow-history.repository'; diff --git a/packages/cli/src/workflows/workflow-history/workflow-history.controller.ee.ts b/packages/cli/src/workflows/workflow-history.ee/workflow-history.controller.ee.ts similarity index 100% rename from packages/cli/src/workflows/workflow-history/workflow-history.controller.ee.ts rename to packages/cli/src/workflows/workflow-history.ee/workflow-history.controller.ee.ts diff --git a/packages/cli/src/workflows/workflow-history/workflow-history.service.ee.ts b/packages/cli/src/workflows/workflow-history.ee/workflow-history.service.ee.ts similarity index 97% rename from packages/cli/src/workflows/workflow-history/workflow-history.service.ee.ts rename to packages/cli/src/workflows/workflow-history.ee/workflow-history.service.ee.ts index 3b171e3422..05256ec7a6 100644 --- a/packages/cli/src/workflows/workflow-history/workflow-history.service.ee.ts +++ b/packages/cli/src/workflows/workflow-history.ee/workflow-history.service.ee.ts @@ -1,5 +1,6 @@ +import { Service } from '@n8n/di'; +import { Logger } from 'n8n-core'; import { ensureError } from 'n8n-workflow'; -import { Service } from 'typedi'; import type { User } from '@/databases/entities/user'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; @@ -8,7 +9,6 @@ import { SharedWorkflowRepository } from '@/databases/repositories/shared-workfl import { WorkflowHistoryRepository } from '@/databases/repositories/workflow-history.repository'; import { SharedWorkflowNotFoundError } from '@/errors/shared-workflow-not-found.error'; import { WorkflowHistoryVersionNotFoundError } from '@/errors/workflow-history-version-not-found.error'; -import { Logger } from '@/logging/logger.service'; import { isWorkflowHistoryEnabled } from './workflow-history-helper.ee'; diff --git a/packages/cli/src/workflows/workflow-sharing.service.ts b/packages/cli/src/workflows/workflow-sharing.service.ts index 280c553a5e..220c9adaf9 100644 --- a/packages/cli/src/workflows/workflow-sharing.service.ts +++ b/packages/cli/src/workflows/workflow-sharing.service.ts @@ -1,7 +1,7 @@ +import { Service } from '@n8n/di'; import type { Scope } from '@n8n/permissions'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In } from '@n8n/typeorm'; -import { Service } from 'typedi'; import type { ProjectRole } from '@/databases/entities/project-relation'; import type { WorkflowSharingRole } from '@/databases/entities/shared-workflow'; diff --git a/packages/cli/src/workflows/workflow-static-data.service.ts b/packages/cli/src/workflows/workflow-static-data.service.ts index 3e5159dc9a..5ef9a52480 100644 --- a/packages/cli/src/workflows/workflow-static-data.service.ts +++ b/packages/cli/src/workflows/workflow-static-data.service.ts @@ -1,10 +1,9 @@ import { GlobalConfig } from '@n8n/config'; -import { ErrorReporter } from 'n8n-core'; +import { Service } from '@n8n/di'; +import { ErrorReporter, Logger } from 'n8n-core'; import type { IDataObject, Workflow } from 'n8n-workflow'; -import { Service } from 'typedi'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; -import { Logger } from '@/logging/logger.service'; import { isWorkflowIdValid } from '@/utils'; @Service() diff --git a/packages/cli/src/workflows/workflow.request.ts b/packages/cli/src/workflows/workflow.request.ts index 4098384abb..47fd2cdb93 100644 --- a/packages/cli/src/workflows/workflow.request.ts +++ b/packages/cli/src/workflows/workflow.request.ts @@ -69,6 +69,4 @@ export declare namespace WorkflowRequest { {}, { destinationProjectId: string } >; - - type FromUrl = AuthenticatedRequest<{}, {}, {}, { url?: string }>; } diff --git a/packages/cli/src/workflows/workflow.service.ee.ts b/packages/cli/src/workflows/workflow.service.ee.ts index 90a8af90b1..8122e909c4 100644 --- a/packages/cli/src/workflows/workflow.service.ee.ts +++ b/packages/cli/src/workflows/workflow.service.ee.ts @@ -1,8 +1,9 @@ +import { Service } from '@n8n/di'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In, type EntityManager } from '@n8n/typeorm'; import omit from 'lodash/omit'; +import { Logger } from 'n8n-core'; import { ApplicationError, NodeOperationError, WorkflowActivationError } from 'n8n-workflow'; -import { Service } from 'typedi'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { CredentialsService } from '@/credentials/credentials.service'; @@ -17,9 +18,8 @@ import { WorkflowRepository } from '@/databases/repositories/workflow.repository import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { TransferWorkflowError } from '@/errors/response-errors/transfer-workflow.error'; -import { Logger } from '@/logging/logger.service'; import { OwnershipService } from '@/services/ownership.service'; -import { ProjectService } from '@/services/project.service'; +import { ProjectService } from '@/services/project.service.ee'; import type { WorkflowWithSharingsAndCredentials, diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index 7220e1a640..2141e79ed5 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -1,3 +1,4 @@ +import { Service } from '@n8n/di'; import type { Scope } from '@n8n/permissions'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import type { EntityManager } from '@n8n/typeorm'; @@ -5,9 +6,8 @@ import type { EntityManager } from '@n8n/typeorm'; import { In } from '@n8n/typeorm'; import omit from 'lodash/omit'; import pick from 'lodash/pick'; -import { BinaryDataService } from 'n8n-core'; +import { BinaryDataService, Logger } from 'n8n-core'; import { NodeApiError } from 'n8n-workflow'; -import { Service } from 'typedi'; import { v4 as uuid } from 'uuid'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; @@ -24,16 +24,15 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { EventService } from '@/events/event.service'; import { ExternalHooks } from '@/external-hooks'; import { validateEntity } from '@/generic-helpers'; -import { Logger } from '@/logging/logger.service'; import { hasSharing, type ListQuery } from '@/requests'; import { OrchestrationService } from '@/services/orchestration.service'; import { OwnershipService } from '@/services/ownership.service'; -import { ProjectService } from '@/services/project.service'; +import { ProjectService } from '@/services/project.service.ee'; import { RoleService } from '@/services/role.service'; import { TagService } from '@/services/tag.service'; import * as WorkflowHelpers from '@/workflow-helpers'; -import { WorkflowHistoryService } from './workflow-history/workflow-history.service.ee'; +import { WorkflowHistoryService } from './workflow-history.ee/workflow-history.service.ee'; import { WorkflowSharingService } from './workflow-sharing.service'; @Service() diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index 24765b422a..865b38450e 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -1,8 +1,10 @@ +import { ImportWorkflowFromUrlDto } from '@n8n/api-types'; import { GlobalConfig } from '@n8n/config'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In, type FindOptionsRelations } from '@n8n/typeorm'; import axios from 'axios'; import express from 'express'; +import { Logger } from 'n8n-core'; import { ApplicationError } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; import { z } from 'zod'; @@ -17,7 +19,7 @@ import { SharedWorkflowRepository } from '@/databases/repositories/shared-workfl import { TagRepository } from '@/databases/repositories/tag.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import * as Db from '@/db'; -import { Delete, Get, Patch, Post, ProjectScope, Put, RestController } from '@/decorators'; +import { Delete, Get, Patch, Post, ProjectScope, Put, Query, RestController } from '@/decorators'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; @@ -27,18 +29,18 @@ import { ExternalHooks } from '@/external-hooks'; import { validateEntity } from '@/generic-helpers'; import type { IWorkflowResponse } from '@/interfaces'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import { listQueryMiddleware } from '@/middlewares'; +import { AuthenticatedRequest } from '@/requests'; import * as ResponseHelper from '@/response-helper'; import { NamingService } from '@/services/naming.service'; -import { ProjectService } from '@/services/project.service'; +import { ProjectService } from '@/services/project.service.ee'; import { TagService } from '@/services/tag.service'; import { UserManagementMailer } from '@/user-management/email'; import * as utils from '@/utils'; import * as WorkflowHelpers from '@/workflow-helpers'; import { WorkflowExecutionService } from './workflow-execution.service'; -import { WorkflowHistoryService } from './workflow-history/workflow-history.service.ee'; +import { WorkflowHistoryService } from './workflow-history.ee/workflow-history.service.ee'; import { WorkflowRequest } from './workflow.request'; import { WorkflowService } from './workflow.service'; import { EnterpriseWorkflowService } from './workflow.service.ee'; @@ -215,18 +217,14 @@ export class WorkflowsController { } @Get('/from-url') - async getFromUrl(req: WorkflowRequest.FromUrl) { - if (req.query.url === undefined) { - throw new BadRequestError('The parameter "url" is missing!'); - } - if (!/^http[s]?:\/\/.*\.json$/i.exec(req.query.url)) { - throw new BadRequestError( - 'The parameter "url" is not valid! It does not seem to be a URL pointing to a n8n workflow JSON file.', - ); - } + async getFromUrl( + _req: AuthenticatedRequest, + _res: express.Response, + @Query query: ImportWorkflowFromUrlDto, + ) { let workflowData: IWorkflowResponse | undefined; try { - const { data } = await axios.get(req.query.url); + const { data } = await axios.get(query.url); workflowData = data; } catch (error) { throw new BadRequestError('The URL does not point to valid JSON file!'); diff --git a/packages/cli/test/integration/active-workflow-manager.test.ts b/packages/cli/test/integration/active-workflow-manager.test.ts index a3e4f657f2..3c98c2a4f1 100644 --- a/packages/cli/test/integration/active-workflow-manager.test.ts +++ b/packages/cli/test/integration/active-workflow-manager.test.ts @@ -1,7 +1,8 @@ +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; +import { Logger } from 'n8n-core'; import { NodeApiError, Workflow } from 'n8n-workflow'; import type { IWebhookData, WorkflowActivateMode } from 'n8n-workflow'; -import { Container } from 'typedi'; import { ActiveExecutions } from '@/active-executions'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; @@ -9,10 +10,9 @@ import type { WebhookEntity } from '@/databases/entities/webhook-entity'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import { ExecutionService } from '@/executions/execution.service'; import { ExternalHooks } from '@/external-hooks'; -import { Logger } from '@/logging/logger.service'; import { NodeTypes } from '@/node-types'; import { Push } from '@/push'; -import { SecretsHelper } from '@/secrets-helpers'; +import { SecretsHelper } from '@/secrets-helpers.ee'; import * as WebhookHelpers from '@/webhooks/webhook-helpers'; import { WebhookService } from '@/webhooks/webhook.service'; import * as AdditionalData from '@/workflow-execute-additional-data'; diff --git a/packages/cli/test/integration/ai/ai.api.test.ts b/packages/cli/test/integration/ai/ai.api.test.ts new file mode 100644 index 0000000000..721f2296ed --- /dev/null +++ b/packages/cli/test/integration/ai/ai.api.test.ts @@ -0,0 +1,99 @@ +import { Container } from '@n8n/di'; +import { randomUUID } from 'crypto'; +import { mock } from 'jest-mock-extended'; + +import { FREE_AI_CREDITS_CREDENTIAL_NAME, OPEN_AI_API_CREDENTIAL_TYPE } from '@/constants'; +import type { Project } from '@/databases/entities/project'; +import type { User } from '@/databases/entities/user'; +import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; +import { UserRepository } from '@/databases/repositories/user.repository'; +import { AiService } from '@/services/ai.service'; + +import { createOwner } from '../shared/db/users'; +import * as testDb from '../shared/test-db'; +import type { SuperAgentTest } from '../shared/types'; +import { setupTestServer } from '../shared/utils'; + +const createAiCreditsResponse = { + apiKey: randomUUID(), + url: 'https://api.openai.com', +}; + +Container.set( + AiService, + mock({ + createFreeAiCredits: async () => createAiCreditsResponse, + }), +); + +const testServer = setupTestServer({ endpointGroups: ['ai'] }); + +let owner: User; +let ownerPersonalProject: Project; + +let authOwnerAgent: SuperAgentTest; + +beforeEach(async () => { + await testDb.truncate(['SharedCredentials', 'Credentials']); + + owner = await createOwner(); + + ownerPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + owner.id, + ); + + authOwnerAgent = testServer.authAgentFor(owner); +}); + +describe('POST /ai/free-credits', () => { + test('should create OpenAI managed credential', async () => { + // Act + const response = await authOwnerAgent.post('/ai/free-credits').send({ + projectId: ownerPersonalProject.id, + }); + + // Assert + + expect(response.statusCode).toBe(200); + + const { id, name, type, data: encryptedData, scopes } = response.body.data; + + expect(name).toBe(FREE_AI_CREDITS_CREDENTIAL_NAME); + expect(type).toBe(OPEN_AI_API_CREDENTIAL_TYPE); + expect(encryptedData).not.toBe(createAiCreditsResponse); + + expect(scopes).toEqual( + [ + 'credential:create', + 'credential:delete', + 'credential:list', + 'credential:move', + 'credential:read', + 'credential:share', + 'credential:update', + ].sort(), + ); + + const credential = await Container.get(CredentialsRepository).findOneByOrFail({ id }); + + expect(credential.name).toBe(FREE_AI_CREDITS_CREDENTIAL_NAME); + expect(credential.type).toBe(OPEN_AI_API_CREDENTIAL_TYPE); + expect(credential.data).not.toBe(createAiCreditsResponse); + expect(credential.isManaged).toBe(true); + + const sharedCredential = await Container.get(SharedCredentialsRepository).findOneOrFail({ + relations: { project: true, credentials: true }, + where: { credentialsId: credential.id }, + }); + + expect(sharedCredential.project.id).toBe(ownerPersonalProject.id); + expect(sharedCredential.credentials.name).toBe(FREE_AI_CREDITS_CREDENTIAL_NAME); + expect(sharedCredential.credentials.isManaged).toBe(true); + + const user = await Container.get(UserRepository).findOneOrFail({ where: { id: owner.id } }); + + expect(user.settings?.userClaimedAiCredits).toBe(true); + }); +}); diff --git a/packages/cli/test/integration/api-keys.api.test.ts b/packages/cli/test/integration/api-keys.api.test.ts index f577e0cf78..14050b543a 100644 --- a/packages/cli/test/integration/api-keys.api.test.ts +++ b/packages/cli/test/integration/api-keys.api.test.ts @@ -1,5 +1,5 @@ import { GlobalConfig } from '@n8n/config'; -import { Container } from 'typedi'; +import { Container } from '@n8n/di'; import type { ApiKey } from '@/databases/entities/api-key'; import type { User } from '@/databases/entities/user'; diff --git a/packages/cli/test/integration/auth.api.test.ts b/packages/cli/test/integration/auth.api.test.ts index 6c1ddc5892..7fd3f50eac 100644 --- a/packages/cli/test/integration/auth.api.test.ts +++ b/packages/cli/test/integration/auth.api.test.ts @@ -1,4 +1,4 @@ -import { Container } from 'typedi'; +import { Container } from '@n8n/di'; import validator from 'validator'; import config from '@/config'; @@ -147,6 +147,21 @@ describe('POST /login', () => { const response = await testServer.authAgentFor(ownerUser).get('/login'); expect(response.statusCode).toBe(200); }); + + test('should fail on invalid email in the payload', async () => { + const response = await testServer.authlessAgent.post('/login').send({ + email: 'invalid-email', + password: ownerPassword, + }); + + expect(response.statusCode).toBe(400); + expect(response.body).toEqual({ + validation: 'email', + code: 'invalid_string', + message: 'Invalid email', + path: ['email'], + }); + }); }); describe('GET /login', () => { diff --git a/packages/cli/test/integration/collaboration/collaboration.service.test.ts b/packages/cli/test/integration/collaboration/collaboration.service.test.ts index df5f901f28..33ebb9ee31 100644 --- a/packages/cli/test/integration/collaboration/collaboration.service.test.ts +++ b/packages/cli/test/integration/collaboration/collaboration.service.test.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; -import Container from 'typedi'; import type { WorkflowClosedMessage, @@ -16,7 +16,7 @@ import { createWorkflow, shareWorkflowWithUsers } from '@test-integration/db/wor import * as testDb from '@test-integration/test-db'; describe('CollaborationService', () => { - mockInstance(Push, new Push(mock(), mock())); + mockInstance(Push, new Push(mock(), mock(), mock())); let pushService: Push; let collaborationService: CollaborationService; let owner: User; @@ -78,37 +78,41 @@ describe('CollaborationService', () => { // Assert expect(sendToUsersSpy).toHaveBeenNthCalledWith( 1, - 'collaboratorsChanged', { - collaborators: [ - { - lastSeen: expect.any(String), - user: owner.toIUser(), - }, - ], - workflowId: workflow.id, + type: 'collaboratorsChanged', + data: { + collaborators: [ + { + lastSeen: expect.any(String), + user: owner.toIUser(), + }, + ], + workflowId: workflow.id, + }, }, [owner.id], ); expect(sendToUsersSpy).toHaveBeenNthCalledWith( 2, - 'collaboratorsChanged', { - collaborators: expect.arrayContaining([ - expect.objectContaining({ - lastSeen: expect.any(String), - user: expect.objectContaining({ - id: owner.id, + type: 'collaboratorsChanged', + data: { + collaborators: expect.arrayContaining([ + expect.objectContaining({ + lastSeen: expect.any(String), + user: expect.objectContaining({ + id: owner.id, + }), }), - }), - expect.objectContaining({ - lastSeen: expect.any(String), - user: expect.objectContaining({ - id: memberWithAccess.id, + expect.objectContaining({ + lastSeen: expect.any(String), + user: expect.objectContaining({ + id: memberWithAccess.id, + }), }), - }), - ]), - workflowId: workflow.id, + ]), + workflowId: workflow.id, + }, }, [owner.id, memberWithAccess.id], ); @@ -151,17 +155,19 @@ describe('CollaborationService', () => { // Assert expect(sendToUsersSpy).toHaveBeenCalledWith( - 'collaboratorsChanged', { - collaborators: expect.arrayContaining([ - expect.objectContaining({ - lastSeen: expect.any(String), - user: expect.objectContaining({ - id: memberWithAccess.id, + type: 'collaboratorsChanged', + data: { + collaborators: expect.arrayContaining([ + expect.objectContaining({ + lastSeen: expect.any(String), + user: expect.objectContaining({ + id: memberWithAccess.id, + }), }), - }), - ]), - workflowId: workflow.id, + ]), + workflowId: workflow.id, + }, }, [memberWithAccess.id], ); diff --git a/packages/cli/test/integration/commands/ldap/reset.test.ts b/packages/cli/test/integration/commands/ldap/reset.test.ts index ef0ab2c0d6..11051b9d98 100644 --- a/packages/cli/test/integration/commands/ldap/reset.test.ts +++ b/packages/cli/test/integration/commands/ldap/reset.test.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import { EntityNotFoundError } from '@n8n/typeorm'; -import { Container } from 'typedi'; import { v4 as uuid } from 'uuid'; import { Reset } from '@/commands/ldap/reset'; @@ -7,8 +7,8 @@ import { CredentialsRepository } from '@/databases/repositories/credentials.repo import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; -import { getLdapSynchronizations, saveLdapSynchronization } from '@/ldap/helpers.ee'; -import { LdapService } from '@/ldap/ldap.service.ee'; +import { getLdapSynchronizations, saveLdapSynchronization } from '@/ldap.ee/helpers.ee'; +import { LdapService } from '@/ldap.ee/ldap.service.ee'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { Push } from '@/push'; import { Telemetry } from '@/telemetry'; diff --git a/packages/cli/test/integration/commands/license.cmd.test.ts b/packages/cli/test/integration/commands/license.cmd.test.ts index 363d2feae6..d35c4fdc4a 100644 --- a/packages/cli/test/integration/commands/license.cmd.test.ts +++ b/packages/cli/test/integration/commands/license.cmd.test.ts @@ -1,4 +1,4 @@ -import { Container } from 'typedi'; +import { Container } from '@n8n/di'; import { ClearLicenseCommand } from '@/commands/license/clear'; import { SETTINGS_LICENSE_CERT_KEY } from '@/constants'; diff --git a/packages/cli/test/integration/commands/reset.cmd.test.ts b/packages/cli/test/integration/commands/reset.cmd.test.ts index 58e8e344e5..19b301f596 100644 --- a/packages/cli/test/integration/commands/reset.cmd.test.ts +++ b/packages/cli/test/integration/commands/reset.cmd.test.ts @@ -1,4 +1,4 @@ -import { Container } from 'typedi'; +import { Container } from '@n8n/di'; import { Reset } from '@/commands/user-management/reset'; import { CredentialsEntity } from '@/databases/entities/credentials-entity'; diff --git a/packages/cli/test/integration/commands/worker.cmd.test.ts b/packages/cli/test/integration/commands/worker.cmd.test.ts index e17a8d2279..8b9cebe854 100644 --- a/packages/cli/test/integration/commands/worker.cmd.test.ts +++ b/packages/cli/test/integration/commands/worker.cmd.test.ts @@ -1,24 +1,24 @@ process.argv[2] = 'worker'; import { TaskRunnersConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import { BinaryDataService } from 'n8n-core'; -import Container from 'typedi'; import { Worker } from '@/commands/worker'; import config from '@/config'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import { LogStreamingEventRelay } from '@/events/relays/log-streaming.event-relay'; import { ExternalHooks } from '@/external-hooks'; -import { ExternalSecretsManager } from '@/external-secrets/external-secrets-manager.ee'; +import { ExternalSecretsManager } from '@/external-secrets.ee/external-secrets-manager.ee'; import { License } from '@/license'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { Push } from '@/push'; -import { TaskRunnerProcess } from '@/runners/task-runner-process'; -import { TaskRunnerServer } from '@/runners/task-runner-server'; import { Publisher } from '@/scaling/pubsub/publisher.service'; import { Subscriber } from '@/scaling/pubsub/subscriber.service'; import { ScalingService } from '@/scaling/scaling.service'; import { OrchestrationService } from '@/services/orchestration.service'; +import { TaskRunnerProcess } from '@/task-runners/task-runner-process'; +import { TaskRunnerServer } from '@/task-runners/task-runner-server'; import { Telemetry } from '@/telemetry'; import { setupTestCommand } from '@test-integration/utils/test-command'; diff --git a/packages/cli/test/integration/controllers/dynamic-node-parameters.controller.test.ts b/packages/cli/test/integration/controllers/dynamic-node-parameters.controller.test.ts index 8f7436fc75..82e97a11f6 100644 --- a/packages/cli/test/integration/controllers/dynamic-node-parameters.controller.test.ts +++ b/packages/cli/test/integration/controllers/dynamic-node-parameters.controller.test.ts @@ -3,16 +3,21 @@ import type { INodeListSearchResult, IWorkflowExecuteAdditionalData, ResourceMapperFields, + NodeParameterValueType, } from 'n8n-workflow'; import { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service'; import * as AdditionalData from '@/workflow-execute-additional-data'; +import { mockInstance } from '@test/mocking'; import { createOwner } from '../shared/db/users'; import type { SuperAgentTest } from '../shared/types'; import { setupTestServer } from '../shared/utils'; describe('DynamicNodeParametersController', () => { + const additionalData = mock(); + const service = mockInstance(DynamicNodeParametersService); + const testServer = setupTestServer({ endpointGroups: ['dynamic-node-parameters'] }); let ownerAgent: SuperAgentTest; @@ -21,62 +26,171 @@ describe('DynamicNodeParametersController', () => { ownerAgent = testServer.authAgentFor(owner); }); + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(AdditionalData, 'getBase').mockResolvedValue(additionalData); + }); + const commonRequestParams = { credentials: {}, currentNodeParameters: {}, - nodeTypeAndVersion: {}, + nodeTypeAndVersion: { name: 'TestNode', version: 1 }, path: 'path', - methodName: 'methodName', }; describe('POST /dynamic-node-parameters/options', () => { - jest.spyOn(AdditionalData, 'getBase').mockResolvedValue(mock()); - it('should take params via body', async () => { - jest - .spyOn(DynamicNodeParametersService.prototype, 'getOptionsViaMethodName') - .mockResolvedValue([]); + service.getOptionsViaMethodName.mockResolvedValue([]); await ownerAgent .post('/dynamic-node-parameters/options') .send({ ...commonRequestParams, - loadOptions: {}, + methodName: 'testMethod', }) .expect(200); }); + + it('should take params with loadOptions', async () => { + const expectedResult = [{ name: 'Test Option', value: 'test' }]; + service.getOptionsViaLoadOptions.mockResolvedValue(expectedResult); + + const response = await ownerAgent + .post('/dynamic-node-parameters/options') + .send({ + ...commonRequestParams, + loadOptions: { type: 'test' }, + }) + .expect(200); + + expect(response.body).toEqual({ data: expectedResult }); + }); + + it('should return empty array when no method or loadOptions provided', async () => { + const response = await ownerAgent + .post('/dynamic-node-parameters/options') + .send({ + ...commonRequestParams, + }) + .expect(200); + + expect(response.body).toEqual({ data: [] }); + }); }); describe('POST /dynamic-node-parameters/resource-locator-results', () => { - it('should take params via body', async () => { - jest - .spyOn(DynamicNodeParametersService.prototype, 'getResourceLocatorResults') - .mockResolvedValue(mock()); + it('should return resource locator results', async () => { + const expectedResult: INodeListSearchResult = { results: [] }; + service.getResourceLocatorResults.mockResolvedValue(expectedResult); + + const response = await ownerAgent + .post('/dynamic-node-parameters/resource-locator-results') + .send({ + ...commonRequestParams, + methodName: 'testMethod', + filter: 'testFilter', + paginationToken: 'testToken', + }) + .expect(200); + + expect(response.body).toEqual({ data: expectedResult }); + }); + + it('should handle resource locator results without pagination', async () => { + const mockResults = mock(); + service.getResourceLocatorResults.mockResolvedValue(mockResults); await ownerAgent .post('/dynamic-node-parameters/resource-locator-results') .send({ + methodName: 'testMethod', ...commonRequestParams, - filter: 'filter', - paginationToken: 'paginationToken', }) .expect(200); }); + + it('should return a 400 if methodName is not defined', async () => { + await ownerAgent + .post('/dynamic-node-parameters/resource-locator-results') + .send(commonRequestParams) + .expect(400); + }); }); describe('POST /dynamic-node-parameters/resource-mapper-fields', () => { - it('should take params via body', async () => { - jest - .spyOn(DynamicNodeParametersService.prototype, 'getResourceMappingFields') - .mockResolvedValue(mock()); + it('should return resource mapper fields', async () => { + const expectedResult: ResourceMapperFields = { fields: [] }; + service.getResourceMappingFields.mockResolvedValue(expectedResult); - await ownerAgent + const response = await ownerAgent .post('/dynamic-node-parameters/resource-mapper-fields') .send({ ...commonRequestParams, - loadOptions: 'loadOptions', + methodName: 'testMethod', + loadOptions: 'testLoadOptions', }) .expect(200); + + expect(response.body).toEqual({ data: expectedResult }); + }); + + it('should return a 400 if methodName is not defined', async () => { + await ownerAgent + .post('/dynamic-node-parameters/resource-mapper-fields') + .send(commonRequestParams) + .expect(400); + }); + }); + + describe('POST /dynamic-node-parameters/local-resource-mapper-fields', () => { + it('should return local resource mapper fields', async () => { + const expectedResult: ResourceMapperFields = { fields: [] }; + service.getLocalResourceMappingFields.mockResolvedValue(expectedResult); + + const response = await ownerAgent + .post('/dynamic-node-parameters/local-resource-mapper-fields') + .send({ + ...commonRequestParams, + methodName: 'testMethod', + }) + .expect(200); + + expect(response.body).toEqual({ data: expectedResult }); + }); + + it('should return a 400 if methodName is not defined', async () => { + await ownerAgent + .post('/dynamic-node-parameters/local-resource-mapper-fields') + .send(commonRequestParams) + .expect(400); + }); + }); + + describe('POST /dynamic-node-parameters/action-result', () => { + it('should return action result with handler', async () => { + const expectedResult: NodeParameterValueType = { test: true }; + service.getActionResult.mockResolvedValue(expectedResult); + + const response = await ownerAgent + .post('/dynamic-node-parameters/action-result') + .send({ + ...commonRequestParams, + handler: 'testHandler', + payload: { someData: 'test' }, + }) + .expect(200); + + expect(response.body).toEqual({ data: expectedResult }); + }); + + it('should return a 400 if handler is not defined', async () => { + await ownerAgent + .post('/dynamic-node-parameters/action-result') + .send({ + ...commonRequestParams, + payload: { someData: 'test' }, + }) + .expect(400); }); }); }); diff --git a/packages/cli/test/integration/controllers/invitation/invitation.controller.integration.test.ts b/packages/cli/test/integration/controllers/invitation/invitation.controller.integration.test.ts index f1e8e2a1b9..64ef210ab9 100644 --- a/packages/cli/test/integration/controllers/invitation/invitation.controller.integration.test.ts +++ b/packages/cli/test/integration/controllers/invitation/invitation.controller.integration.test.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import { Not } from '@n8n/typeorm'; -import Container from 'typedi'; import type { User } from '@/databases/entities/user'; import { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository'; diff --git a/packages/cli/test/integration/controllers/oauth/oauth2.api.test.ts b/packages/cli/test/integration/controllers/oauth/oauth2.api.test.ts index 59a853cee1..f20f9df550 100644 --- a/packages/cli/test/integration/controllers/oauth/oauth2.api.test.ts +++ b/packages/cli/test/integration/controllers/oauth/oauth2.api.test.ts @@ -1,7 +1,7 @@ +import { Container } from '@n8n/di'; import { response as Response } from 'express'; import nock from 'nock'; import { parse as parseQs } from 'querystring'; -import { Container } from 'typedi'; import { OAuth2CredentialController } from '@/controllers/oauth/oauth2-credential.controller'; import { CredentialsHelper } from '@/credentials-helper'; diff --git a/packages/cli/test/integration/credentials-helper.test.ts b/packages/cli/test/integration/credentials-helper.test.ts index b3cb15555b..7b8522589a 100644 --- a/packages/cli/test/integration/credentials-helper.test.ts +++ b/packages/cli/test/integration/credentials-helper.test.ts @@ -1,4 +1,4 @@ -import Container from 'typedi'; +import { Container } from '@n8n/di'; import { CredentialsHelper } from '@/credentials-helper'; import type { User } from '@/databases/entities/user'; diff --git a/packages/cli/test/integration/credentials/credentials.api.ee.test.ts b/packages/cli/test/integration/credentials/credentials.api.ee.test.ts index 5428cafbd4..039791eaf2 100644 --- a/packages/cli/test/integration/credentials/credentials.api.ee.test.ts +++ b/packages/cli/test/integration/credentials/credentials.api.ee.test.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import { In } from '@n8n/typeorm'; -import { Container } from 'typedi'; import config from '@/config'; import type { Project } from '@/databases/entities/project'; @@ -8,7 +8,7 @@ import type { User } from '@/databases/entities/user'; import { ProjectRepository } from '@/databases/repositories/project.repository'; import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; import type { ListQuery } from '@/requests'; -import { ProjectService } from '@/services/project.service'; +import { ProjectService } from '@/services/project.service.ee'; import { UserManagementMailer } from '@/user-management/email'; import { createWorkflow, shareWorkflowWithUsers } from '@test-integration/db/workflows'; @@ -540,6 +540,7 @@ describe('GET /credentials/:id', () => { id: ownerPersonalProject.id, name: owner.createPersonalProjectName(), type: ownerPersonalProject.type, + icon: null, }); expect(firstCredential.sharedWithProjects).toHaveLength(0); @@ -629,17 +630,20 @@ describe('GET /credentials/:id', () => { homeProject: { id: member1PersonalProject.id, name: member1.createPersonalProjectName(), + icon: null, type: 'personal', }, sharedWithProjects: expect.arrayContaining([ { id: member2PersonalProject.id, name: member2.createPersonalProjectName(), + icon: null, type: member2PersonalProject.type, }, { id: member3PersonalProject.id, name: member3.createPersonalProjectName(), + icon: null, type: member3PersonalProject.type, }, ]), diff --git a/packages/cli/test/integration/credentials/credentials.api.test.ts b/packages/cli/test/integration/credentials/credentials.api.test.ts index 5f40850e59..9cfe3260ba 100644 --- a/packages/cli/test/integration/credentials/credentials.api.test.ts +++ b/packages/cli/test/integration/credentials/credentials.api.test.ts @@ -1,8 +1,8 @@ import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import type { Scope } from '@sentry/node'; import { Credentials } from 'n8n-core'; import { randomString } from 'n8n-workflow'; -import { Container } from 'typedi'; import type { Project } from '@/databases/entities/project'; import type { User } from '@/databases/entities/user'; @@ -87,6 +87,7 @@ describe('GET /credentials', () => { validateMainCredentialData(credential); expect('data' in credential).toBe(false); expect(savedCredentialsIds).toContain(credential.id); + expect('isManaged' in credential).toBe(true); }); }); @@ -225,6 +226,161 @@ describe('GET /credentials', () => { } }); + test('should return data when ?includeData=true', async () => { + // ARRANGE + const [actor, otherMember] = await createManyUsers(2, { + role: 'global:member', + }); + + const teamProjectViewer = await createTeamProject(undefined); + await linkUserToProject(actor, teamProjectViewer, 'project:viewer'); + const teamProjectEditor = await createTeamProject(undefined); + await linkUserToProject(actor, teamProjectEditor, 'project:editor'); + + const [ + // should have data + ownedCredential, + // should not have + sharedCredential, + // should not have data + teamCredentialAsViewer, + // should have data + teamCredentialAsEditor, + ] = await Promise.all([ + saveCredential(randomCredentialPayload(), { user: actor, role: 'credential:owner' }), + saveCredential(randomCredentialPayload(), { user: otherMember, role: 'credential:owner' }), + saveCredential(randomCredentialPayload(), { + project: teamProjectViewer, + role: 'credential:owner', + }), + saveCredential(randomCredentialPayload(), { + project: teamProjectEditor, + role: 'credential:owner', + }), + ]); + await shareCredentialWithUsers(sharedCredential, [actor]); + + // ACT + const response = await testServer + .authAgentFor(actor) + .get('/credentials') + .query({ includeData: true }); + + // ASSERT + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(4); + + const creds = response.body.data as Array; + const ownedCred = creds.find((c) => c.id === ownedCredential.id)!; + const sharedCred = creds.find((c) => c.id === sharedCredential.id)!; + const teamCredAsViewer = creds.find((c) => c.id === teamCredentialAsViewer.id)!; + const teamCredAsEditor = creds.find((c) => c.id === teamCredentialAsEditor.id)!; + + expect(ownedCred.id).toBe(ownedCredential.id); + expect(ownedCred.data).toBeDefined(); + expect(ownedCred.scopes).toEqual( + [ + 'credential:move', + 'credential:read', + 'credential:update', + 'credential:share', + 'credential:delete', + ].sort(), + ); + + expect(sharedCred.id).toBe(sharedCredential.id); + expect(sharedCred.data).not.toBeDefined(); + expect(sharedCred.scopes).toEqual(['credential:read'].sort()); + + expect(teamCredAsViewer.id).toBe(teamCredentialAsViewer.id); + expect(teamCredAsViewer.data).not.toBeDefined(); + expect(teamCredAsViewer.scopes).toEqual(['credential:read'].sort()); + + expect(teamCredAsEditor.id).toBe(teamCredentialAsEditor.id); + expect(teamCredAsEditor.data).toBeDefined(); + expect(teamCredAsEditor.scopes).toEqual( + ['credential:read', 'credential:update', 'credential:delete'].sort(), + ); + }); + + test('should return data when ?includeData=true for owners', async () => { + // ARRANGE + const teamProjectViewer = await createTeamProject(undefined); + + const [ + // should have data + ownedCredential, + // should have data + sharedCredential, + // should have data + teamCredentialAsViewer, + ] = await Promise.all([ + saveCredential(randomCredentialPayload(), { user: owner, role: 'credential:owner' }), + saveCredential(randomCredentialPayload(), { user: member, role: 'credential:owner' }), + saveCredential(randomCredentialPayload(), { + project: teamProjectViewer, + role: 'credential:owner', + }), + ]); + + // ACT + const response = await testServer + .authAgentFor(owner) + .get('/credentials') + .query({ includeData: true }); + + // ASSERT + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(3); + + const creds = response.body.data as Array; + const ownedCred = creds.find((c) => c.id === ownedCredential.id)!; + const sharedCred = creds.find((c) => c.id === sharedCredential.id)!; + const teamCredAsViewer = creds.find((c) => c.id === teamCredentialAsViewer.id)!; + + expect(ownedCred.id).toBe(ownedCredential.id); + expect(ownedCred.data).toBeDefined(); + expect(ownedCred.scopes).toEqual( + [ + 'credential:move', + 'credential:read', + 'credential:update', + 'credential:share', + 'credential:delete', + 'credential:create', + 'credential:list', + ].sort(), + ); + + expect(sharedCred.id).toBe(sharedCredential.id); + expect(sharedCred.data).toBeDefined(); + expect(sharedCred.scopes).toEqual( + [ + 'credential:move', + 'credential:read', + 'credential:update', + 'credential:share', + 'credential:delete', + 'credential:create', + 'credential:list', + ].sort(), + ); + + expect(teamCredAsViewer.id).toBe(teamCredentialAsViewer.id); + expect(teamCredAsViewer.data).toBeDefined(); + expect(teamCredAsViewer.scopes).toEqual( + [ + 'credential:move', + 'credential:read', + 'credential:update', + 'credential:share', + 'credential:delete', + 'credential:create', + 'credential:list', + ].sort(), + ); + }); + describe('should return', () => { test('all credentials for owner', async () => { const { id: id1 } = await saveCredential(payload(), { @@ -1035,6 +1191,19 @@ describe('PATCH /credentials/:id', () => { expect(response.statusCode).toBe(403); }); + + test('should fail with a 400 is credential is managed', async () => { + const { id } = await saveCredential(randomCredentialPayload({ isManaged: true }), { + user: owner, + role: 'credential:owner', + }); + + const response = await authOwnerAgent + .patch(`/credentials/${id}`) + .send(randomCredentialPayload()); + + expect(response.statusCode).toBe(400); + }); }); describe('GET /credentials/new', () => { @@ -1188,10 +1357,11 @@ const INVALID_PAYLOADS = [ ]; function validateMainCredentialData(credential: ListQuery.Credentials.WithOwnedByAndSharedWith) { - const { name, type, sharedWithProjects, homeProject } = credential; + const { name, type, sharedWithProjects, homeProject, isManaged } = credential; expect(typeof name).toBe('string'); expect(typeof type).toBe('string'); + expect(typeof isManaged).toBe('boolean'); if (sharedWithProjects) { expect(Array.isArray(sharedWithProjects)).toBe(true); diff --git a/packages/cli/test/integration/credentials/credentials.service.test.ts b/packages/cli/test/integration/credentials/credentials.service.test.ts index ecfafada3f..9638228183 100644 --- a/packages/cli/test/integration/credentials/credentials.service.test.ts +++ b/packages/cli/test/integration/credentials/credentials.service.test.ts @@ -1,4 +1,4 @@ -import Container from 'typedi'; +import { Container } from '@n8n/di'; import { CredentialsService } from '@/credentials/credentials.service'; import type { CredentialsEntity } from '@/databases/entities/credentials-entity'; diff --git a/packages/cli/test/integration/cta.service.test.ts b/packages/cli/test/integration/cta.service.test.ts index 68c1f24c5f..366b86e3ec 100644 --- a/packages/cli/test/integration/cta.service.test.ts +++ b/packages/cli/test/integration/cta.service.test.ts @@ -1,4 +1,4 @@ -import Container from 'typedi'; +import { Container } from '@n8n/di'; import type { User } from '@/databases/entities/user'; import { StatisticsNames } from '@/databases/entities/workflow-statistics'; diff --git a/packages/cli/test/integration/database/repositories/execution.repository.test.ts b/packages/cli/test/integration/database/repositories/execution.repository.test.ts index 1b50415686..cb368b511c 100644 --- a/packages/cli/test/integration/database/repositories/execution.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/execution.repository.test.ts @@ -1,5 +1,5 @@ import { GlobalConfig } from '@n8n/config'; -import Container from 'typedi'; +import { Container } from '@n8n/di'; import { ExecutionDataRepository } from '@/databases/repositories/execution-data.repository'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; diff --git a/packages/cli/test/integration/database/repositories/project.repository.test.ts b/packages/cli/test/integration/database/repositories/project.repository.test.ts index cc494fd542..334929e4e7 100644 --- a/packages/cli/test/integration/database/repositories/project.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/project.repository.test.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import { EntityNotFoundError } from '@n8n/typeorm'; -import Container from 'typedi'; import { AuthIdentity } from '@/databases/entities/auth-identity'; import { ProjectRepository } from '@/databases/repositories/project.repository'; diff --git a/packages/cli/test/integration/database/repositories/workflow.repository.test.ts b/packages/cli/test/integration/database/repositories/workflow.repository.test.ts index acff2cbce9..63b87df7e8 100644 --- a/packages/cli/test/integration/database/repositories/workflow.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/workflow.repository.test.ts @@ -1,4 +1,4 @@ -import Container from 'typedi'; +import { Container } from '@n8n/di'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; diff --git a/packages/cli/test/integration/debug.controller.test.ts b/packages/cli/test/integration/debug.controller.test.ts index 8ab58bd1a0..7ca483a912 100644 --- a/packages/cli/test/integration/debug.controller.test.ts +++ b/packages/cli/test/integration/debug.controller.test.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import { InstanceSettings } from 'n8n-core'; -import Container from 'typedi'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; diff --git a/packages/cli/test/integration/environments/source-control-import.service.test.ts b/packages/cli/test/integration/environments/source-control-import.service.test.ts index 4d2a3d668a..6835d18f58 100644 --- a/packages/cli/test/integration/environments/source-control-import.service.test.ts +++ b/packages/cli/test/integration/environments/source-control-import.service.test.ts @@ -1,17 +1,17 @@ +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import { Cipher } from 'n8n-core'; import type { InstanceSettings } from 'n8n-core'; import * as utils from 'n8n-workflow'; import { nanoid } from 'nanoid'; import fsp from 'node:fs/promises'; -import Container from 'typedi'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository'; import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; -import { SourceControlImportService } from '@/environments/source-control/source-control-import.service.ee'; -import type { ExportableCredential } from '@/environments/source-control/types/exportable-credential'; -import type { SourceControlledFile } from '@/environments/source-control/types/source-controlled-file'; +import { SourceControlImportService } from '@/environments.ee/source-control/source-control-import.service.ee'; +import type { ExportableCredential } from '@/environments.ee/source-control/types/exportable-credential'; +import type { SourceControlledFile } from '@/environments.ee/source-control/types/source-controlled-file'; import { mockInstance } from '../../shared/mocking'; import { saveCredential } from '../shared/db/credentials'; diff --git a/packages/cli/test/integration/environments/source-control.test.ts b/packages/cli/test/integration/environments/source-control.test.ts index f983b899aa..11a7ad8a2b 100644 --- a/packages/cli/test/integration/environments/source-control.test.ts +++ b/packages/cli/test/integration/environments/source-control.test.ts @@ -1,10 +1,10 @@ -import { Container } from 'typedi'; +import { Container } from '@n8n/di'; import config from '@/config'; import type { User } from '@/databases/entities/user'; -import { SourceControlPreferencesService } from '@/environments/source-control/source-control-preferences.service.ee'; -import { SourceControlService } from '@/environments/source-control/source-control.service.ee'; -import type { SourceControlledFile } from '@/environments/source-control/types/source-controlled-file'; +import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee'; +import { SourceControlService } from '@/environments.ee/source-control/source-control.service.ee'; +import type { SourceControlledFile } from '@/environments.ee/source-control/types/source-controlled-file'; import { Telemetry } from '@/telemetry'; import { mockInstance } from '@test/mocking'; diff --git a/packages/cli/test/integration/evaluation/metrics.api.test.ts b/packages/cli/test/integration/evaluation/metrics.api.test.ts index d10bb106a5..996fd96148 100644 --- a/packages/cli/test/integration/evaluation/metrics.api.test.ts +++ b/packages/cli/test/integration/evaluation/metrics.api.test.ts @@ -1,4 +1,4 @@ -import { Container } from 'typedi'; +import { Container } from '@n8n/di'; import type { TestDefinition } from '@/databases/entities/test-definition.ee'; import type { User } from '@/databases/entities/user'; diff --git a/packages/cli/test/integration/evaluation/test-definitions.api.test.ts b/packages/cli/test/integration/evaluation/test-definitions.api.test.ts index fe977fbfd3..ad359f8731 100644 --- a/packages/cli/test/integration/evaluation/test-definitions.api.test.ts +++ b/packages/cli/test/integration/evaluation/test-definitions.api.test.ts @@ -1,11 +1,11 @@ +import { Container } from '@n8n/di'; import { mockInstance } from 'n8n-core/test/utils'; -import { Container } from 'typedi'; import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee'; import type { User } from '@/databases/entities/user'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import { TestDefinitionRepository } from '@/databases/repositories/test-definition.repository.ee'; -import { TestRunnerService } from '@/evaluation/test-runner/test-runner.service.ee'; +import { TestRunnerService } from '@/evaluation.ee/test-runner/test-runner.service.ee'; import { createAnnotationTags } from '@test-integration/db/executions'; import { createUserShell } from './../shared/db/users'; diff --git a/packages/cli/test/integration/evaluation/test-runs.api.test.ts b/packages/cli/test/integration/evaluation/test-runs.api.test.ts index b485b6a35a..def5b95ffe 100644 --- a/packages/cli/test/integration/evaluation/test-runs.api.test.ts +++ b/packages/cli/test/integration/evaluation/test-runs.api.test.ts @@ -1,5 +1,5 @@ import { mockInstance } from 'n8n-core/test/utils'; -import { Container } from 'typedi'; +import { Container } from '@n8n/di'; import type { TestDefinition } from '@/databases/entities/test-definition.ee'; import type { User } from '@/databases/entities/user'; diff --git a/packages/cli/test/integration/eventbus.ee.test.ts b/packages/cli/test/integration/eventbus.ee.test.ts index c2b6a7f23c..0be15b6124 100644 --- a/packages/cli/test/integration/eventbus.ee.test.ts +++ b/packages/cli/test/integration/eventbus.ee.test.ts @@ -1,3 +1,4 @@ +import { Container } from '@n8n/di'; import axios from 'axios'; import type { MessageEventBusDestinationSentryOptions, @@ -10,7 +11,6 @@ import { defaultMessageEventBusDestinationWebhookOptions, } from 'n8n-workflow'; import syslog from 'syslog-client'; -import { Container } from 'typedi'; import { v4 as uuid } from 'uuid'; import type { User } from '@/databases/entities/user'; diff --git a/packages/cli/test/integration/execution.service.integration.test.ts b/packages/cli/test/integration/execution.service.integration.test.ts index 27163b3bea..e1de129d7c 100644 --- a/packages/cli/test/integration/execution.service.integration.test.ts +++ b/packages/cli/test/integration/execution.service.integration.test.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; -import Container from 'typedi'; import { ExecutionMetadataRepository } from '@/databases/repositories/execution-metadata.repository'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; diff --git a/packages/cli/test/integration/external-secrets/external-secrets.api.test.ts b/packages/cli/test/integration/external-secrets/external-secrets.api.test.ts index c36340108e..095555b72b 100644 --- a/packages/cli/test/integration/external-secrets/external-secrets.api.test.ts +++ b/packages/cli/test/integration/external-secrets/external-secrets.api.test.ts @@ -1,14 +1,14 @@ +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import { Cipher } from 'n8n-core'; import { jsonParse, type IDataObject } from 'n8n-workflow'; -import { Container } from 'typedi'; import config from '@/config'; import { CREDENTIAL_BLANKING_VALUE } from '@/constants'; import { SettingsRepository } from '@/databases/repositories/settings.repository'; import type { EventService } from '@/events/event.service'; -import { ExternalSecretsManager } from '@/external-secrets/external-secrets-manager.ee'; -import { ExternalSecretsProviders } from '@/external-secrets/external-secrets-providers.ee'; +import { ExternalSecretsManager } from '@/external-secrets.ee/external-secrets-manager.ee'; +import { ExternalSecretsProviders } from '@/external-secrets.ee/external-secrets-providers.ee'; import type { ExternalSecretsSettings, SecretsProviderState } from '@/interfaces'; import { License } from '@/license'; diff --git a/packages/cli/test/integration/import.service.test.ts b/packages/cli/test/integration/import.service.test.ts index 39cadde676..0c40d868ce 100644 --- a/packages/cli/test/integration/import.service.test.ts +++ b/packages/cli/test/integration/import.service.test.ts @@ -1,6 +1,6 @@ +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import type { INode } from 'n8n-workflow'; -import Container from 'typedi'; import { v4 as uuid } from 'uuid'; import type { Project } from '@/databases/entities/project'; diff --git a/packages/cli/test/integration/ldap/ldap.api.test.ts b/packages/cli/test/integration/ldap/ldap.api.test.ts index 17573f49f5..9a4c214f10 100644 --- a/packages/cli/test/integration/ldap/ldap.api.test.ts +++ b/packages/cli/test/integration/ldap/ldap.api.test.ts @@ -1,16 +1,19 @@ +import { Container } from '@n8n/di'; import { Not } from '@n8n/typeorm'; import type { Entry as LdapUser } from 'ldapts'; import { Cipher } from 'n8n-core'; -import { Container } from 'typedi'; import config from '@/config'; import type { User } from '@/databases/entities/user'; import { AuthProviderSyncHistoryRepository } from '@/databases/repositories/auth-provider-sync-history.repository'; import { UserRepository } from '@/databases/repositories/user.repository'; -import { LDAP_DEFAULT_CONFIGURATION } from '@/ldap/constants'; -import { saveLdapSynchronization } from '@/ldap/helpers.ee'; -import { LdapService } from '@/ldap/ldap.service.ee'; -import { getCurrentAuthenticationMethod, setCurrentAuthenticationMethod } from '@/sso/sso-helpers'; +import { LDAP_DEFAULT_CONFIGURATION } from '@/ldap.ee/constants'; +import { saveLdapSynchronization } from '@/ldap.ee/helpers.ee'; +import { LdapService } from '@/ldap.ee/ldap.service.ee'; +import { + getCurrentAuthenticationMethod, + setCurrentAuthenticationMethod, +} from '@/sso.ee/sso-helpers'; import { randomEmail, randomName, uniqueId } from './../shared/random'; import { getPersonalProject } from '../shared/db/projects'; diff --git a/packages/cli/test/integration/license-metrics.repository.test.ts b/packages/cli/test/integration/license-metrics.repository.test.ts index 5396b4681f..298c7d67fd 100644 --- a/packages/cli/test/integration/license-metrics.repository.test.ts +++ b/packages/cli/test/integration/license-metrics.repository.test.ts @@ -1,4 +1,4 @@ -import Container from 'typedi'; +import { Container } from '@n8n/di'; import { StatisticsNames } from '@/databases/entities/workflow-statistics'; import { LicenseMetricsRepository } from '@/databases/repositories/license-metrics.repository'; diff --git a/packages/cli/test/integration/me.api.test.ts b/packages/cli/test/integration/me.api.test.ts index a29f158a32..25cbe20b64 100644 --- a/packages/cli/test/integration/me.api.test.ts +++ b/packages/cli/test/integration/me.api.test.ts @@ -1,6 +1,6 @@ import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import type { IPersonalizationSurveyAnswersV4 } from 'n8n-workflow'; -import { Container } from 'typedi'; import validator from 'validator'; import type { User } from '@/databases/entities/user'; diff --git a/packages/cli/test/integration/mfa/mfa.api.test.ts b/packages/cli/test/integration/mfa/mfa.api.test.ts index 498be7abd5..935ede4c17 100644 --- a/packages/cli/test/integration/mfa/mfa.api.test.ts +++ b/packages/cli/test/integration/mfa/mfa.api.test.ts @@ -1,5 +1,5 @@ -import { randomInt, randomString } from 'n8n-workflow'; -import Container from 'typedi'; +import { Container } from '@n8n/di'; +import { randomString } from 'n8n-workflow'; import { AuthService } from '@/auth/auth.service'; import config from '@/config'; @@ -239,7 +239,7 @@ describe('Change password with MFA enabled', () => { .send({ password: newPassword, token: resetPasswordToken, - mfaCode: randomInt(10), + mfaCode: randomString(10), }) .expect(404); }); diff --git a/packages/cli/test/integration/owner.api.test.ts b/packages/cli/test/integration/owner.api.test.ts index fe5284ffbe..13f660688e 100644 --- a/packages/cli/test/integration/owner.api.test.ts +++ b/packages/cli/test/integration/owner.api.test.ts @@ -1,4 +1,4 @@ -import { Container } from 'typedi'; +import { Container } from '@n8n/di'; import validator from 'validator'; import config from '@/config'; diff --git a/packages/cli/test/integration/password-reset.api.test.ts b/packages/cli/test/integration/password-reset.api.test.ts index 89d66c3f21..826b4ddef9 100644 --- a/packages/cli/test/integration/password-reset.api.test.ts +++ b/packages/cli/test/integration/password-reset.api.test.ts @@ -1,7 +1,7 @@ +import { Container } from '@n8n/di'; import { compare } from 'bcryptjs'; import { mock } from 'jest-mock-extended'; import { randomString } from 'n8n-workflow'; -import { Container } from 'typedi'; import { v4 as uuid } from 'uuid'; import { AuthService } from '@/auth/auth.service'; @@ -12,7 +12,7 @@ import { ExternalHooks } from '@/external-hooks'; import { License } from '@/license'; import { JwtService } from '@/services/jwt.service'; import { PasswordUtility } from '@/services/password.utility'; -import { setCurrentAuthenticationMethod } from '@/sso/sso-helpers'; +import { setCurrentAuthenticationMethod } from '@/sso.ee/sso-helpers'; import { UserManagementMailer } from '@/user-management/email'; import { createUser } from './shared/db/users'; diff --git a/packages/cli/test/integration/permission-checker.test.ts b/packages/cli/test/integration/permission-checker.test.ts index 1a0963b685..71dd77b503 100644 --- a/packages/cli/test/integration/permission-checker.test.ts +++ b/packages/cli/test/integration/permission-checker.test.ts @@ -1,6 +1,6 @@ +import { Container } from '@n8n/di'; import type { INode } from 'n8n-workflow'; import { randomInt } from 'n8n-workflow'; -import { Container } from 'typedi'; import { v4 as uuid } from 'uuid'; import type { Project } from '@/databases/entities/project'; diff --git a/packages/cli/test/integration/project.api.test.ts b/packages/cli/test/integration/project.api.test.ts index 14707ad764..a75aad9566 100644 --- a/packages/cli/test/integration/project.api.test.ts +++ b/packages/cli/test/integration/project.api.test.ts @@ -1,6 +1,6 @@ +import { Container } from '@n8n/di'; import type { Scope } from '@n8n/permissions'; import { EntityNotFoundError } from '@n8n/typeorm'; -import Container from 'typedi'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; import type { Project } from '@/databases/entities/project'; diff --git a/packages/cli/test/integration/project.service.integration.test.ts b/packages/cli/test/integration/project.service.integration.test.ts index 5d425d17ee..b2210b5210 100644 --- a/packages/cli/test/integration/project.service.integration.test.ts +++ b/packages/cli/test/integration/project.service.integration.test.ts @@ -1,7 +1,7 @@ -import Container from 'typedi'; +import { Container } from '@n8n/di'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; -import { ProjectService } from '@/services/project.service'; +import { ProjectService } from '@/services/project.service.ee'; import { linkUserToProject, createTeamProject } from './shared/db/projects'; import { createUser } from './shared/db/users'; diff --git a/packages/cli/test/integration/prometheus-metrics.test.ts b/packages/cli/test/integration/prometheus-metrics.test.ts index 2a13d2f091..c5accedbc6 100644 --- a/packages/cli/test/integration/prometheus-metrics.test.ts +++ b/packages/cli/test/integration/prometheus-metrics.test.ts @@ -1,7 +1,7 @@ import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import { parse as semverParse } from 'semver'; import request, { type Response } from 'supertest'; -import { Container } from 'typedi'; import config from '@/config'; import { N8N_VERSION } from '@/constants'; diff --git a/packages/cli/test/integration/pruning.service.test.ts b/packages/cli/test/integration/pruning.service.test.ts index 4f34048a1a..3a97350f79 100644 --- a/packages/cli/test/integration/pruning.service.test.ts +++ b/packages/cli/test/integration/pruning.service.test.ts @@ -1,8 +1,8 @@ import { ExecutionsConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import { BinaryDataService, InstanceSettings } from 'n8n-core'; import type { ExecutionStatus } from 'n8n-workflow'; -import Container from 'typedi'; import { Time } from '@/constants'; import type { ExecutionEntity } from '@/databases/entities/execution-entity'; @@ -21,7 +21,7 @@ import { mockInstance, mockLogger } from '../shared/mocking'; describe('softDeleteOnPruningCycle()', () => { let pruningService: PruningService; - const instanceSettings = new InstanceSettings(mock()); + const instanceSettings = Container.get(InstanceSettings); instanceSettings.markAsLeader(); const now = new Date(); diff --git a/packages/cli/test/integration/public-api/credentials.test.ts b/packages/cli/test/integration/public-api/credentials.test.ts index 953a6e1c95..c6e4dc5c12 100644 --- a/packages/cli/test/integration/public-api/credentials.test.ts +++ b/packages/cli/test/integration/public-api/credentials.test.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import { randomString } from 'n8n-workflow'; -import { Container } from 'typedi'; import type { User } from '@/databases/entities/user'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; diff --git a/packages/cli/test/integration/public-api/projects.test.ts b/packages/cli/test/integration/public-api/projects.test.ts index f815d9d07b..4283382558 100644 --- a/packages/cli/test/integration/public-api/projects.test.ts +++ b/packages/cli/test/integration/public-api/projects.test.ts @@ -131,6 +131,7 @@ describe('Projects in Public API', () => { expect(response.status).toBe(201); expect(response.body).toEqual({ name: 'some-project', + icon: null, type: 'team', id: expect.any(String), createdAt: expect.any(String), diff --git a/packages/cli/test/integration/public-api/tags.test.ts b/packages/cli/test/integration/public-api/tags.test.ts index c2e25cc3f6..2be18285f4 100644 --- a/packages/cli/test/integration/public-api/tags.test.ts +++ b/packages/cli/test/integration/public-api/tags.test.ts @@ -1,4 +1,4 @@ -import { Container } from 'typedi'; +import { Container } from '@n8n/di'; import type { User } from '@/databases/entities/user'; import { TagRepository } from '@/databases/repositories/tag.repository'; diff --git a/packages/cli/test/integration/public-api/workflows.test.ts b/packages/cli/test/integration/public-api/workflows.test.ts index 28f9d444da..943d33bc35 100644 --- a/packages/cli/test/integration/public-api/workflows.test.ts +++ b/packages/cli/test/integration/public-api/workflows.test.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import type { INode } from 'n8n-workflow'; -import { Container } from 'typedi'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; import config from '@/config'; @@ -11,7 +11,7 @@ import { ProjectRepository } from '@/databases/repositories/project.repository'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; import { WorkflowHistoryRepository } from '@/databases/repositories/workflow-history.repository'; import { ExecutionService } from '@/executions/execution.service'; -import { ProjectService } from '@/services/project.service'; +import { ProjectService } from '@/services/project.service.ee'; import { Telemetry } from '@/telemetry'; import { createTeamProject } from '@test-integration/db/projects'; diff --git a/packages/cli/test/integration/role.api.test.ts b/packages/cli/test/integration/role.api.test.ts index ad961c2565..85888347cc 100644 --- a/packages/cli/test/integration/role.api.test.ts +++ b/packages/cli/test/integration/role.api.test.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import type { Scope } from '@n8n/permissions'; -import { Container } from 'typedi'; import type { ProjectRole } from '@/databases/entities/project-relation'; import type { CredentialSharingRole } from '@/databases/entities/shared-credentials'; diff --git a/packages/cli/test/integration/runners/task-runner-module.external.test.ts b/packages/cli/test/integration/runners/task-runner-module.external.test.ts index bb61dae6d4..d111854db6 100644 --- a/packages/cli/test/integration/runners/task-runner-module.external.test.ts +++ b/packages/cli/test/integration/runners/task-runner-module.external.test.ts @@ -1,12 +1,12 @@ import { TaskRunnersConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; -import Container from 'typedi'; -import { MissingAuthTokenError } from '@/runners/errors/missing-auth-token.error'; -import { TaskRunnerModule } from '@/runners/task-runner-module'; +import { MissingAuthTokenError } from '@/task-runners/errors/missing-auth-token.error'; +import { TaskRunnerModule } from '@/task-runners/task-runner-module'; -import { DefaultTaskRunnerDisconnectAnalyzer } from '../../../src/runners/default-task-runner-disconnect-analyzer'; -import { TaskRunnerWsServer } from '../../../src/runners/runner-ws-server'; +import { DefaultTaskRunnerDisconnectAnalyzer } from '../../../src/task-runners/default-task-runner-disconnect-analyzer'; +import { TaskRunnerWsServer } from '../../../src/task-runners/task-runner-ws-server'; describe('TaskRunnerModule in external mode', () => { const runnerConfig = Container.get(TaskRunnersConfig); diff --git a/packages/cli/test/integration/runners/task-runner-module.internal.test.ts b/packages/cli/test/integration/runners/task-runner-module.internal.test.ts index db195001a7..3c373e9614 100644 --- a/packages/cli/test/integration/runners/task-runner-module.internal.test.ts +++ b/packages/cli/test/integration/runners/task-runner-module.internal.test.ts @@ -1,10 +1,10 @@ import { TaskRunnersConfig } from '@n8n/config'; -import Container from 'typedi'; +import { Container } from '@n8n/di'; -import { TaskRunnerModule } from '@/runners/task-runner-module'; +import { TaskRunnerModule } from '@/task-runners/task-runner-module'; -import { InternalTaskRunnerDisconnectAnalyzer } from '../../../src/runners/internal-task-runner-disconnect-analyzer'; -import { TaskRunnerWsServer } from '../../../src/runners/runner-ws-server'; +import { InternalTaskRunnerDisconnectAnalyzer } from '../../../src/task-runners/internal-task-runner-disconnect-analyzer'; +import { TaskRunnerWsServer } from '../../../src/task-runners/task-runner-ws-server'; describe('TaskRunnerModule in internal mode', () => { const runnerConfig = Container.get(TaskRunnersConfig); diff --git a/packages/cli/test/integration/runners/task-runner-process.test.ts b/packages/cli/test/integration/runners/task-runner-process.test.ts index b21ef68640..bd6dae00b0 100644 --- a/packages/cli/test/integration/runners/task-runner-process.test.ts +++ b/packages/cli/test/integration/runners/task-runner-process.test.ts @@ -1,9 +1,9 @@ -import Container from 'typedi'; +import { Container } from '@n8n/di'; -import { TaskRunnerWsServer } from '@/runners/runner-ws-server'; -import { TaskBroker } from '@/runners/task-broker.service'; -import { TaskRunnerProcess } from '@/runners/task-runner-process'; -import { TaskRunnerProcessRestartLoopDetector } from '@/runners/task-runner-process-restart-loop-detector'; +import { TaskBroker } from '@/task-runners/task-broker.service'; +import { TaskRunnerProcess } from '@/task-runners/task-runner-process'; +import { TaskRunnerProcessRestartLoopDetector } from '@/task-runners/task-runner-process-restart-loop-detector'; +import { TaskRunnerWsServer } from '@/task-runners/task-runner-ws-server'; import { retryUntil } from '@test-integration/retry-until'; import { setupBrokerTestServer } from '@test-integration/utils/task-broker-test-server'; diff --git a/packages/cli/test/integration/saml/saml-helpers.test.ts b/packages/cli/test/integration/saml/saml-helpers.test.ts index 6ac48ee93b..87d020248c 100644 --- a/packages/cli/test/integration/saml/saml-helpers.test.ts +++ b/packages/cli/test/integration/saml/saml-helpers.test.ts @@ -1,5 +1,5 @@ -import * as helpers from '@/sso/saml/saml-helpers'; -import type { SamlUserAttributes } from '@/sso/saml/types/saml-user-attributes'; +import * as helpers from '@/sso.ee/saml/saml-helpers'; +import type { SamlUserAttributes } from '@/sso.ee/saml/types'; import { getPersonalProject } from '../shared/db/projects'; import * as testDb from '../shared/test-db'; diff --git a/packages/cli/test/integration/saml/saml.api.test.ts b/packages/cli/test/integration/saml/saml.api.test.ts index d30d57356a..7737444c6b 100644 --- a/packages/cli/test/integration/saml/saml.api.test.ts +++ b/packages/cli/test/integration/saml/saml.api.test.ts @@ -1,6 +1,9 @@ import type { User } from '@/databases/entities/user'; -import { setSamlLoginEnabled } from '@/sso/saml/saml-helpers'; -import { getCurrentAuthenticationMethod, setCurrentAuthenticationMethod } from '@/sso/sso-helpers'; +import { setSamlLoginEnabled } from '@/sso.ee/saml/saml-helpers'; +import { + getCurrentAuthenticationMethod, + setCurrentAuthenticationMethod, +} from '@/sso.ee/sso-helpers'; import { sampleConfig } from './sample-metadata'; import { createOwner, createUser } from '../shared/db/users'; @@ -31,6 +34,8 @@ beforeAll(async () => { authMemberAgent = testServer.authAgentFor(someUser); }); +beforeEach(async () => await enableSaml(false)); + describe('Instance owner', () => { describe('PATCH /me', () => { test('should succeed with valid inputs', async () => { @@ -86,6 +91,17 @@ describe('Instance owner', () => { .expect(200); expect(getCurrentAuthenticationMethod()).toBe('saml'); }); + + test('should return 400 on invalid config', async () => { + await authOwnerAgent + .post('/sso/saml/config') + .send({ + ...sampleConfig, + loginBinding: 'invalid', + }) + .expect(400); + expect(getCurrentAuthenticationMethod()).toBe('email'); + }); }); describe('POST /sso/saml/config/toggle', () => { diff --git a/packages/cli/test/integration/saml/sample-metadata.ts b/packages/cli/test/integration/saml/sample-metadata.ts index fd7968c2fb..528a3f158f 100644 --- a/packages/cli/test/integration/saml/sample-metadata.ts +++ b/packages/cli/test/integration/saml/sample-metadata.ts @@ -1,7 +1,8 @@ +import type { SamlPreferences } from '@n8n/api-types'; export const sampleMetadata = '\n\n\n\n\n\n\n\n\n\nd/0TlU9d7qi9oQxDwjsZi69RMCiheKmcjJ7W0fRCHlM=\n\n\num+M46ZJmOhK1vGm6ZTIOY926ZN8pkMClyVprLs0NAWH3sEO11rZZZkcAnSuWrLR\n8BcrwpKRU6qE4zrZBWfh+/Fqp180OvUa7vUDpxuZFJZhv7dSldfLgAdFX2VHctBo\n77hdLmrmJuWv/u6Gzsie/J8/2D0U0OwDGwfsOLLW3rjrfea5opcaAxY+0Rh+2zzk\nzIxVBqtSnSKxAJtkOpCDzbtnQIO0meB0ZvO7ssxwSFjBbHs34TRj1S3GFgCZXzl5\naXDi7AoWEs1YPviRNb368OrD3aljFBK0gzjullFter0rzp2TzSzZilkxaZmhupJe\n388cIDBKJPUmkxumafWXxJIOMfktUTnciUl4kz0OfDQ0J5m5NaDrmvYU8g/2A0+P\nVRI88N9n0GcT9cDvzTCEDSBFefOVpvuQkue+ZYLpZ8bJJS0ykunkcNiXLbGlBlCS\nje3Od78eNjwzG/WYmHsf9ajmBezBrUmzvdJx+SmfGRZplu86z9NrOQMliKcU4/T6\nOGEwz0pRcvhMJLn+MNR2DPzX6YHnPZ0neyiUqnIkzt0fU4q1QNdcyqSTfRQlZjkx\ndbdLsEFALxcNRv8vFaAbsQpxPuFNlfZeyAWQ/MLoBG1rUiEl06I9REMN6KM7CTog\n5i926hP4LLsIki45Ob83glFOrIoj/3nAw2jbd2Crl+E=\n\n\nMIIFUzCCAzugAwIBAgIRAJ1peD6pO0pygujUcWb85QswDQYJKoZIhvcNAQELBQAw\nHTEbMBkGA1UEAwwSYXV0aGVudGlrIDIwMjMuMi4yMB4XDTIzMDIyNzEzMTQ0MFoX\nDTI0MDIyODEzMTQ0MFowVjEqMCgGA1UEAwwhYXV0aGVudGlrIFNlbGYtc2lnbmVk\nIENlcnRpZmljYXRlMRIwEAYDVQQKDAlhdXRoZW50aWsxFDASBgNVBAsMC1NlbGYt\nc2lnbmVkMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA3thve9UWPL09\nouGwUPlCxfrBDDKmDdvoMc3eahfuop2tSP38EvdBcnPCVYTtu2hhHNqN/QtoyAZc\nTvwD8oDjwiYxdO6VbNjMZAnMD4W84l2niGnG7ATy/niNcZoge4xy+OmCJKXsolbs\nXT+hQGQ2oiUDnbX8QwMQCMN8FBF+EvYoHXKvRjmjO75DHyHY9JP05HZTO3lycVLW\nGrIq4oJfp60PN/0z5tbpk/Tyst21o4lcESAM4fkmndonPmoKMr7q9g+CFYRT+As6\niB+L38J44YNWs0Qm42tHAlveinBRuLLMi+eMC2L0sckvyJKB1qHG+bKl7jVXNDJg\n5KWKEHdM4CBg3dJkign+12EO205ruLYSBydZErAb2NKd2htgYs/zGHSgb3LhQ3vE\nuHiTIcq828PWmVM7l3B8CJ+ZyPLixywT0pKgkb8lrDqzXIffRljCYMT2pIR4FNuy\n+CzXMYm+N30qVO8h9+cl3YRSHpFBk9KJ0/+HQp1k6ELnaYW+LryS8Jr1uPxhwyMq\nGu+4bxCF8JfZncojMhlQghXCQUvOaboNlBWv5jtsoZ9mN266V1EJpnF064UimQ1f\noN1O4l4292NvkChcmiQf2YDE5PrMWm10gQg401oulE9o91OsxLRmyw/qZTJvA06K\ngVamNLfhN/St/CVfl8q6ldgoHmWaxY8CAwEAAaNVMFMwUQYDVR0RAQH/BEcwRYJD\nT1BRVVpWNW1qdWFvQ01hdEVvenU5ajNoUnlhU0UyQThaTjd4WlZqUy5zZWxmLXNp\nZ25lZC5nb2F1dGhlbnRpay5pbzANBgkqhkiG9w0BAQsFAAOCAgEAwaQtK4s2DnJx\njg6i6BSo/rhNg7ClXgnOyF79T7JO3gexVjzboY2UTi1ut/DEII01PI0qgQ62+q9l\nTloWd1SpxPOrOVeu2uVgTK0LkGb63q355iJ2myfhFYYPPprNDzvUhnX8cVY979Ma\niqAOCJW7irlHAH2bLAujanRdlcgFtmoe5lZ+qnS5iOUmp5tehPsDJGlPZ3nCWJcR\nQHDLLSOp3TvR5no8nj0cWxUWnNeaGoJy1GsJlGapLXS5pUKpxVg9GeEcQxjBkFgM\nLWrkWBsQDvC5+GlmHgSkdRvuYBlB6CRK2eGY7G06v7ZRPhf82LvEFRBwzJvGdM0g\n491OTTJquTN2wyq45UlJK4anMYrUbpi8p8MOW7IUw6a+SvZyJab9gNoLTUzA6Mlz\nQP9bPrEALpwNhmHsmD09zNyYiNfpkpLJog96wPscx4b+gsg+5PcilET8qvth6VYD\nup8TdsonPvDPH0oyo66SAYoyOgAeB+BHTicjtVt+UnrhXYj92BHDXfmfdTzA8QcY\n7reLPIOQVk1zV24cwySiLh4F2Hr8z8V1wMRVNVHcezMsVBvCzxQ15XlMq9X2wBuj\nfED93dXJVs+WuzbpTIoXvHHT3zWnzykX8hVbrj9ddzF8TuJW4NYis0cH5SLzvtPj\n7EzvuRaQc7pNrduO1pTKoPAy+2SLgqo=\n\n\nMIIFUzCCAzugAwIBAgIRAJ1peD6pO0pygujUcWb85QswDQYJKoZIhvcNAQELBQAwHTEbMBkGA1UEAwwSYXV0aGVudGlrIDIwMjMuMi4yMB4XDTIzMDIyNzEzMTQ0MFoXDTI0MDIyODEzMTQ0MFowVjEqMCgGA1UEAwwhYXV0aGVudGlrIFNlbGYtc2lnbmVkIENlcnRpZmljYXRlMRIwEAYDVQQKDAlhdXRoZW50aWsxFDASBgNVBAsMC1NlbGYtc2lnbmVkMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA3thve9UWPL09ouGwUPlCxfrBDDKmDdvoMc3eahfuop2tSP38EvdBcnPCVYTtu2hhHNqN/QtoyAZcTvwD8oDjwiYxdO6VbNjMZAnMD4W84l2niGnG7ATy/niNcZoge4xy+OmCJKXsolbsXT+hQGQ2oiUDnbX8QwMQCMN8FBF+EvYoHXKvRjmjO75DHyHY9JP05HZTO3lycVLWGrIq4oJfp60PN/0z5tbpk/Tyst21o4lcESAM4fkmndonPmoKMr7q9g+CFYRT+As6iB+L38J44YNWs0Qm42tHAlveinBRuLLMi+eMC2L0sckvyJKB1qHG+bKl7jVXNDJg5KWKEHdM4CBg3dJkign+12EO205ruLYSBydZErAb2NKd2htgYs/zGHSgb3LhQ3vEuHiTIcq828PWmVM7l3B8CJ+ZyPLixywT0pKgkb8lrDqzXIffRljCYMT2pIR4FNuy+CzXMYm+N30qVO8h9+cl3YRSHpFBk9KJ0/+HQp1k6ELnaYW+LryS8Jr1uPxhwyMqGu+4bxCF8JfZncojMhlQghXCQUvOaboNlBWv5jtsoZ9mN266V1EJpnF064UimQ1foN1O4l4292NvkChcmiQf2YDE5PrMWm10gQg401oulE9o91OsxLRmyw/qZTJvA06KgVamNLfhN/St/CVfl8q6ldgoHmWaxY8CAwEAAaNVMFMwUQYDVR0RAQH/BEcwRYJDT1BRVVpWNW1qdWFvQ01hdEVvenU5ajNoUnlhU0UyQThaTjd4WlZqUy5zZWxmLXNpZ25lZC5nb2F1dGhlbnRpay5pbzANBgkqhkiG9w0BAQsFAAOCAgEAwaQtK4s2DnJxjg6i6BSo/rhNg7ClXgnOyF79T7JO3gexVjzboY2UTi1ut/DEII01PI0qgQ62+q9lTloWd1SpxPOrOVeu2uVgTK0LkGb63q355iJ2myfhFYYPPprNDzvUhnX8cVY979MaiqAOCJW7irlHAH2bLAujanRdlcgFtmoe5lZ+qnS5iOUmp5tehPsDJGlPZ3nCWJcRQHDLLSOp3TvR5no8nj0cWxUWnNeaGoJy1GsJlGapLXS5pUKpxVg9GeEcQxjBkFgMLWrkWBsQDvC5+GlmHgSkdRvuYBlB6CRK2eGY7G06v7ZRPhf82LvEFRBwzJvGdM0g491OTTJquTN2wyq45UlJK4anMYrUbpi8p8MOW7IUw6a+SvZyJab9gNoLTUzA6MlzQP9bPrEALpwNhmHsmD09zNyYiNfpkpLJog96wPscx4b+gsg+5PcilET8qvth6VYDup8TdsonPvDPH0oyo66SAYoyOgAeB+BHTicjtVt+UnrhXYj92BHDXfmfdTzA8QcY7reLPIOQVk1zV24cwySiLh4F2Hr8z8V1wMRVNVHcezMsVBvCzxQ15XlMq9X2wBujfED93dXJVs+WuzbpTIoXvHHT3zWnzykX8hVbrj9ddzF8TuJW4NYis0cH5SLzvtPj7EzvuRaQc7pNrduO1pTKoPAy+2SLgqo=urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddressurn:oasis:names:tc:SAML:2.0:nameid-format:persistenturn:oasis:names:tc:SAML:2.0:nameid-format:X509SubjectNameurn:oasis:names:tc:SAML:2.0:nameid-format:transient'; -export const sampleConfig = { +export const sampleConfig: SamlPreferences = { mapping: { email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', firstName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/firstname', @@ -25,6 +26,5 @@ export const sampleConfig = { action: 'after', }, }, - entityID: 'https://n8n-tunnel.localhost.dev/rest/sso/saml/metadata', - returnUrl: 'https://n8n-tunnel.localhost.dev/rest/sso/saml/acs', + relayState: '', }; diff --git a/packages/cli/test/integration/security-audit/credentials-risk-reporter.test.ts b/packages/cli/test/integration/security-audit/credentials-risk-reporter.test.ts index b5b4c122df..ba38f04bd6 100644 --- a/packages/cli/test/integration/security-audit/credentials-risk-reporter.test.ts +++ b/packages/cli/test/integration/security-audit/credentials-risk-reporter.test.ts @@ -1,6 +1,6 @@ import type { SecurityConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; -import Container from 'typedi'; import { v4 as uuid } from 'uuid'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; diff --git a/packages/cli/test/integration/security-audit/database-risk-reporter.test.ts b/packages/cli/test/integration/security-audit/database-risk-reporter.test.ts index 3aef57396b..67927426b3 100644 --- a/packages/cli/test/integration/security-audit/database-risk-reporter.test.ts +++ b/packages/cli/test/integration/security-audit/database-risk-reporter.test.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; -import Container from 'typedi'; import { v4 as uuid } from 'uuid'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; diff --git a/packages/cli/test/integration/security-audit/filesystem-risk-reporter.test.ts b/packages/cli/test/integration/security-audit/filesystem-risk-reporter.test.ts index ceb306935f..85ab2e6ec4 100644 --- a/packages/cli/test/integration/security-audit/filesystem-risk-reporter.test.ts +++ b/packages/cli/test/integration/security-audit/filesystem-risk-reporter.test.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; -import Container from 'typedi'; import { v4 as uuid } from 'uuid'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; diff --git a/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts b/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts index 58a2a2c9a8..ddf3fce556 100644 --- a/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts +++ b/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts @@ -1,7 +1,7 @@ import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import { NodeConnectionType } from 'n8n-workflow'; -import Container from 'typedi'; import { v4 as uuid } from 'uuid'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; diff --git a/packages/cli/test/integration/security-audit/nodes-risk-reporter.test.ts b/packages/cli/test/integration/security-audit/nodes-risk-reporter.test.ts index c1fb198b69..461685ec5c 100644 --- a/packages/cli/test/integration/security-audit/nodes-risk-reporter.test.ts +++ b/packages/cli/test/integration/security-audit/nodes-risk-reporter.test.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; -import { Container } from 'typedi'; import { v4 as uuid } from 'uuid'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; diff --git a/packages/cli/test/integration/security-audit/utils.ts b/packages/cli/test/integration/security-audit/utils.ts index d74bcc9d87..d153db28eb 100644 --- a/packages/cli/test/integration/security-audit/utils.ts +++ b/packages/cli/test/integration/security-audit/utils.ts @@ -1,6 +1,6 @@ import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import nock from 'nock'; -import Container from 'typedi'; import { v4 as uuid } from 'uuid'; import * as constants from '@/constants'; diff --git a/packages/cli/test/integration/services/execution-metadata.service.test.ts b/packages/cli/test/integration/services/execution-metadata.service.test.ts index b69c78b5fe..7288c4b98f 100644 --- a/packages/cli/test/integration/services/execution-metadata.service.test.ts +++ b/packages/cli/test/integration/services/execution-metadata.service.test.ts @@ -1,4 +1,4 @@ -import Container from 'typedi'; +import { Container } from '@n8n/di'; import { ExecutionMetadataRepository } from '@/databases/repositories/execution-metadata.repository'; import { ExecutionMetadataService } from '@/services/execution-metadata.service'; diff --git a/packages/cli/test/integration/services/project.service.test.ts b/packages/cli/test/integration/services/project.service.test.ts index 85393a4013..bf83c6159c 100644 --- a/packages/cli/test/integration/services/project.service.test.ts +++ b/packages/cli/test/integration/services/project.service.test.ts @@ -1,10 +1,10 @@ +import { Container } from '@n8n/di'; import type { Scope } from '@n8n/permissions'; -import Container from 'typedi'; import type { ProjectRole } from '@/databases/entities/project-relation'; import { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository'; -import { ProjectService } from '@/services/project.service'; +import { ProjectService } from '@/services/project.service.ee'; import { createMember } from '../shared/db/users'; import * as testDb from '../shared/test-db'; diff --git a/packages/cli/test/integration/services/workflow-static-data.service.test.ts b/packages/cli/test/integration/services/workflow-static-data.service.test.ts index c6ab9301ce..8cb2cbc1cc 100644 --- a/packages/cli/test/integration/services/workflow-static-data.service.test.ts +++ b/packages/cli/test/integration/services/workflow-static-data.service.test.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import { Workflow } from 'n8n-workflow'; -import Container from 'typedi'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { NodeTypes } from '@/node-types'; diff --git a/packages/cli/test/integration/shared/constants.ts b/packages/cli/test/integration/shared/constants.ts index 5fffacbd11..377a0e45a4 100644 --- a/packages/cli/test/integration/shared/constants.ts +++ b/packages/cli/test/integration/shared/constants.ts @@ -1,5 +1,5 @@ import { GlobalConfig } from '@n8n/config'; -import Container from 'typedi'; +import { Container } from '@n8n/di'; export const REST_PATH_SEGMENT = Container.get(GlobalConfig).endpoints.rest; diff --git a/packages/cli/test/integration/shared/db/credentials.ts b/packages/cli/test/integration/shared/db/credentials.ts index 5593b7d55a..08d99354f2 100644 --- a/packages/cli/test/integration/shared/db/credentials.ts +++ b/packages/cli/test/integration/shared/db/credentials.ts @@ -1,4 +1,4 @@ -import { Container } from 'typedi'; +import { Container } from '@n8n/di'; import { CredentialsEntity } from '@/databases/entities/credentials-entity'; import type { Project } from '@/databases/entities/project'; diff --git a/packages/cli/test/integration/shared/db/executions.ts b/packages/cli/test/integration/shared/db/executions.ts index 4dd0b4fa76..9af7ad22ed 100644 --- a/packages/cli/test/integration/shared/db/executions.ts +++ b/packages/cli/test/integration/shared/db/executions.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import type { AnnotationVote } from 'n8n-workflow'; -import Container from 'typedi'; import type { ExecutionData } from '@/databases/entities/execution-data'; import type { ExecutionEntity } from '@/databases/entities/execution-entity'; diff --git a/packages/cli/test/integration/shared/db/projects.ts b/packages/cli/test/integration/shared/db/projects.ts index 93310d5a99..6ca0fcfad2 100644 --- a/packages/cli/test/integration/shared/db/projects.ts +++ b/packages/cli/test/integration/shared/db/projects.ts @@ -1,4 +1,4 @@ -import Container from 'typedi'; +import { Container } from '@n8n/di'; import type { Project } from '@/databases/entities/project'; import type { ProjectRelation, ProjectRole } from '@/databases/entities/project-relation'; diff --git a/packages/cli/test/integration/shared/db/tags.ts b/packages/cli/test/integration/shared/db/tags.ts index b9d943b938..af5638f902 100644 --- a/packages/cli/test/integration/shared/db/tags.ts +++ b/packages/cli/test/integration/shared/db/tags.ts @@ -1,4 +1,4 @@ -import Container from 'typedi'; +import { Container } from '@n8n/di'; import type { TagEntity } from '@/databases/entities/tag-entity'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; diff --git a/packages/cli/test/integration/shared/db/users.ts b/packages/cli/test/integration/shared/db/users.ts index 64c4d8ad85..bb4332f9de 100644 --- a/packages/cli/test/integration/shared/db/users.ts +++ b/packages/cli/test/integration/shared/db/users.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import { hash } from 'bcryptjs'; -import Container from 'typedi'; import { AuthIdentity } from '@/databases/entities/auth-identity'; import { type GlobalRole, type User } from '@/databases/entities/user'; diff --git a/packages/cli/test/integration/shared/db/variables.ts b/packages/cli/test/integration/shared/db/variables.ts index 582d3ccc1b..2ff052e7c0 100644 --- a/packages/cli/test/integration/shared/db/variables.ts +++ b/packages/cli/test/integration/shared/db/variables.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import { randomString } from 'n8n-workflow'; -import Container from 'typedi'; import { VariablesRepository } from '@/databases/repositories/variables.repository'; import { generateNanoId } from '@/databases/utils/generators'; diff --git a/packages/cli/test/integration/shared/db/workflow-history.ts b/packages/cli/test/integration/shared/db/workflow-history.ts index 31d7e50cf5..ff7a2d5bec 100644 --- a/packages/cli/test/integration/shared/db/workflow-history.ts +++ b/packages/cli/test/integration/shared/db/workflow-history.ts @@ -1,4 +1,4 @@ -import Container from 'typedi'; +import { Container } from '@n8n/di'; import { v4 as uuid } from 'uuid'; import type { WorkflowHistory } from '@/databases/entities/workflow-history'; diff --git a/packages/cli/test/integration/shared/db/workflow-statistics.ts b/packages/cli/test/integration/shared/db/workflow-statistics.ts index 842ce6518a..0eee830816 100644 --- a/packages/cli/test/integration/shared/db/workflow-statistics.ts +++ b/packages/cli/test/integration/shared/db/workflow-statistics.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import type { Workflow } from 'n8n-workflow'; -import Container from 'typedi'; import { StatisticsNames, type WorkflowStatistics } from '@/databases/entities/workflow-statistics'; import { WorkflowStatisticsRepository } from '@/databases/repositories/workflow-statistics.repository'; diff --git a/packages/cli/test/integration/shared/db/workflows.ts b/packages/cli/test/integration/shared/db/workflows.ts index 5c86f1dc35..dfd272b396 100644 --- a/packages/cli/test/integration/shared/db/workflows.ts +++ b/packages/cli/test/integration/shared/db/workflows.ts @@ -1,6 +1,6 @@ +import { Container } from '@n8n/di'; import type { DeepPartial } from '@n8n/typeorm'; import { NodeConnectionType } from 'n8n-workflow'; -import Container from 'typedi'; import { v4 as uuid } from 'uuid'; import { Project } from '@/databases/entities/project'; @@ -40,6 +40,7 @@ export function newWorkflow(attributes: Partial = {}): WorkflowE ], connections: connections ?? {}, versionId: versionId ?? uuid(), + settings: {}, ...attributes, }); diff --git a/packages/cli/test/integration/shared/ldap.ts b/packages/cli/test/integration/shared/ldap.ts index 3f48cf2e3c..9cf37e6b62 100644 --- a/packages/cli/test/integration/shared/ldap.ts +++ b/packages/cli/test/integration/shared/ldap.ts @@ -1,9 +1,9 @@ +import { Container } from '@n8n/di'; import { jsonParse } from 'n8n-workflow'; -import Container from 'typedi'; import { SettingsRepository } from '@/databases/repositories/settings.repository'; -import { LDAP_DEFAULT_CONFIGURATION, LDAP_FEATURE_NAME } from '@/ldap/constants'; -import type { LdapConfig } from '@/ldap/types'; +import { LDAP_DEFAULT_CONFIGURATION, LDAP_FEATURE_NAME } from '@/ldap.ee/constants'; +import type { LdapConfig } from '@/ldap.ee/types'; export const defaultLdapConfig = { ...LDAP_DEFAULT_CONFIGURATION, diff --git a/packages/cli/test/integration/shared/random.ts b/packages/cli/test/integration/shared/random.ts index a51fae3a05..e556c4f512 100644 --- a/packages/cli/test/integration/shared/random.ts +++ b/packages/cli/test/integration/shared/random.ts @@ -37,10 +37,13 @@ const randomTopLevelDomain = () => chooseRandomly(POPULAR_TOP_LEVEL_DOMAINS); export const randomName = () => randomString(4, 8).toLowerCase(); -export const randomCredentialPayload = (): CredentialPayload => ({ +export const randomCredentialPayload = ({ + isManaged = false, +}: { isManaged?: boolean } = {}): CredentialPayload => ({ name: randomName(), type: randomName(), data: { accessToken: randomString(6, 16) }, + isManaged, }); export const uniqueId = () => uuid(); diff --git a/packages/cli/test/integration/shared/test-db.ts b/packages/cli/test/integration/shared/test-db.ts index 4cfb131fb2..052d383c27 100644 --- a/packages/cli/test/integration/shared/test-db.ts +++ b/packages/cli/test/integration/shared/test-db.ts @@ -1,10 +1,10 @@ import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import type { DataSourceOptions, Repository } from '@n8n/typeorm'; import { DataSource as Connection } from '@n8n/typeorm'; import { kebabCase } from 'lodash'; import type { Class } from 'n8n-core'; import { randomString } from 'n8n-workflow'; -import { Container } from 'typedi'; import { getOptionOverrides } from '@/databases/config'; import * as Db from '@/db'; diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index 2afe6ec328..2a789e4f00 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -42,7 +42,8 @@ type EndpointGroup = | 'role' | 'dynamic-node-parameters' | 'apiKeys' - | 'evaluation'; + | 'evaluation' + | 'ai'; export interface SetupProps { endpointGroups?: EndpointGroup[]; @@ -68,6 +69,7 @@ export type CredentialPayload = { name: string; type: string; data: ICredentialDataDecryptedObject; + isManaged?: boolean; }; export type SaveCredentialFunction = ( diff --git a/packages/cli/test/integration/shared/utils/community-nodes.ts b/packages/cli/test/integration/shared/utils/community-nodes.ts index d29a9361be..033d86c709 100644 --- a/packages/cli/test/integration/shared/utils/community-nodes.ts +++ b/packages/cli/test/integration/shared/utils/community-nodes.ts @@ -1,4 +1,4 @@ -import Container from 'typedi'; +import { Container } from '@n8n/di'; import { NODE_PACKAGE_PREFIX } from '@/constants'; import { InstalledPackages } from '@/databases/entities/installed-packages'; diff --git a/packages/cli/test/integration/shared/utils/index.ts b/packages/cli/test/integration/shared/utils/index.ts index ba99e1ca07..f43b102bd3 100644 --- a/packages/cli/test/integration/shared/utils/index.ts +++ b/packages/cli/test/integration/shared/utils/index.ts @@ -1,3 +1,4 @@ +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import { BinaryDataService, @@ -13,7 +14,6 @@ import { Set } from 'n8n-nodes-base/nodes/Set/Set.node'; import { Start } from 'n8n-nodes-base/nodes/Start/Start.node'; import type { INodeTypeData, INode } from 'n8n-workflow'; import type request from 'supertest'; -import { Container } from 'typedi'; import { v4 as uuid } from 'uuid'; import config from '@/config'; diff --git a/packages/cli/test/integration/shared/utils/task-broker-test-server.ts b/packages/cli/test/integration/shared/utils/task-broker-test-server.ts index 9363fc089e..63a96678a2 100644 --- a/packages/cli/test/integration/shared/utils/task-broker-test-server.ts +++ b/packages/cli/test/integration/shared/utils/task-broker-test-server.ts @@ -1,9 +1,9 @@ import { TaskRunnersConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import request from 'supertest'; import type TestAgent from 'supertest/lib/agent'; -import Container from 'typedi'; -import { TaskRunnerServer } from '@/runners/task-runner-server'; +import { TaskRunnerServer } from '@/task-runners/task-runner-server'; export interface TestTaskBrokerServer { server: TaskRunnerServer; diff --git a/packages/cli/test/integration/shared/utils/test-server.ts b/packages/cli/test/integration/shared/utils/test-server.ts index ef0588b8d7..aab78ef33e 100644 --- a/packages/cli/test/integration/shared/utils/test-server.ts +++ b/packages/cli/test/integration/shared/utils/test-server.ts @@ -1,8 +1,9 @@ +import { Container } from '@n8n/di'; import cookieParser from 'cookie-parser'; import express from 'express'; +import { Logger } from 'n8n-core'; import type superagent from 'superagent'; import request from 'supertest'; -import { Container } from 'typedi'; import { URL } from 'url'; import { AuthService } from '@/auth/auth.service'; @@ -11,7 +12,6 @@ import { AUTH_COOKIE_NAME } from '@/constants'; import type { User } from '@/databases/entities/user'; import { ControllerRegistry } from '@/decorators'; import { License } from '@/license'; -import { Logger } from '@/logging/logger.service'; import { rawBodyReader, bodyParser } from '@/middlewares'; import { PostHogClient } from '@/posthog'; import { Push } from '@/push'; @@ -171,7 +171,7 @@ export const setupTestServer = ({ break; case 'variables': - await import('@/environments/variables/variables.controller.ee'); + await import('@/environments.ee/variables/variables.controller.ee'); break; case 'license': @@ -202,20 +202,22 @@ export const setupTestServer = ({ break; case 'ldap': - const { LdapService } = await import('@/ldap/ldap.service.ee'); - await import('@/ldap/ldap.controller.ee'); + const { LdapService } = await import('@/ldap.ee/ldap.service.ee'); + await import('@/ldap.ee/ldap.controller.ee'); testServer.license.enable('feat:ldap'); await Container.get(LdapService).init(); break; case 'saml': - const { setSamlLoginEnabled } = await import('@/sso/saml/saml-helpers'); - await import('@/sso/saml/routes/saml.controller.ee'); + const { SamlService } = await import('@/sso.ee/saml/saml.service.ee'); + await Container.get(SamlService).init(); + await import('@/sso.ee/saml/routes/saml.controller.ee'); + const { setSamlLoginEnabled } = await import('@/sso.ee/saml/saml-helpers'); await setSamlLoginEnabled(true); break; case 'sourceControl': - await import('@/environments/source-control/source-control.controller.ee'); + await import('@/environments.ee/source-control/source-control.controller.ee'); break; case 'community-packages': @@ -247,11 +249,11 @@ export const setupTestServer = ({ break; case 'externalSecrets': - await import('@/external-secrets/external-secrets.controller.ee'); + await import('@/external-secrets.ee/external-secrets.controller.ee'); break; case 'workflowHistory': - await import('@/workflows/workflow-history/workflow-history.controller.ee'); + await import('@/workflows/workflow-history.ee/workflow-history.controller.ee'); break; case 'binaryData': @@ -279,10 +281,13 @@ export const setupTestServer = ({ break; case 'evaluation': - await import('@/evaluation/metrics.controller'); - await import('@/evaluation/test-definitions.controller.ee'); - await import('@/evaluation/test-runs.controller.ee'); + await import('@/evaluation.ee/metrics.controller'); + await import('@/evaluation.ee/test-definitions.controller.ee'); + await import('@/evaluation.ee/test-runs.controller.ee'); break; + + case 'ai': + await import('@/controllers/ai.controller'); } } diff --git a/packages/cli/test/integration/tags.api.test.ts b/packages/cli/test/integration/tags.api.test.ts index e3acc2a650..5d9a724f78 100644 --- a/packages/cli/test/integration/tags.api.test.ts +++ b/packages/cli/test/integration/tags.api.test.ts @@ -1,4 +1,4 @@ -import { Container } from 'typedi'; +import { Container } from '@n8n/di'; import { TagRepository } from '@/databases/repositories/tag.repository'; diff --git a/packages/cli/test/integration/user.repository.test.ts b/packages/cli/test/integration/user.repository.test.ts index 4de084ba39..1e5ce846e0 100644 --- a/packages/cli/test/integration/user.repository.test.ts +++ b/packages/cli/test/integration/user.repository.test.ts @@ -1,4 +1,4 @@ -import Container from 'typedi'; +import { Container } from '@n8n/di'; import { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository'; import { UserRepository } from '@/databases/repositories/user.repository'; diff --git a/packages/cli/test/integration/users.api.test.ts b/packages/cli/test/integration/users.api.test.ts index 9ecacdec0c..01fc50a17f 100644 --- a/packages/cli/test/integration/users.api.test.ts +++ b/packages/cli/test/integration/users.api.test.ts @@ -1,4 +1,4 @@ -import Container from 'typedi'; +import { Container } from '@n8n/di'; import { v4 as uuid } from 'uuid'; import { RESPONSE_ERROR_MESSAGES } from '@/constants'; diff --git a/packages/cli/test/integration/variables.test.ts b/packages/cli/test/integration/variables.test.ts index 7dd8d00aae..cb47fb8a61 100644 --- a/packages/cli/test/integration/variables.test.ts +++ b/packages/cli/test/integration/variables.test.ts @@ -1,9 +1,9 @@ -import { Container } from 'typedi'; +import { Container } from '@n8n/di'; import type { Variables } from '@/databases/entities/variables'; import { VariablesRepository } from '@/databases/repositories/variables.repository'; import { generateNanoId } from '@/databases/utils/generators'; -import { VariablesService } from '@/environments/variables/variables.service.ee'; +import { VariablesService } from '@/environments.ee/variables/variables.service.ee'; import { CacheService } from '@/services/cache/cache.service'; import { createOwner, createUser } from './shared/db/users'; diff --git a/packages/cli/test/integration/webhooks.test.ts b/packages/cli/test/integration/webhooks.test.ts index 7d7b5105cb..0895f3caa8 100644 --- a/packages/cli/test/integration/webhooks.test.ts +++ b/packages/cli/test/integration/webhooks.test.ts @@ -1,8 +1,8 @@ import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import { agent as testAgent } from 'supertest'; import type SuperAgentTest from 'supertest/lib/agent'; -import Container from 'typedi'; import { ExternalHooks } from '@/external-hooks'; import { LiveWebhooks } from '@/webhooks/live-webhooks'; diff --git a/packages/cli/test/integration/workflow-history-manager.test.ts b/packages/cli/test/integration/workflow-history-manager.test.ts index 825da9fcbf..12dd037357 100644 --- a/packages/cli/test/integration/workflow-history-manager.test.ts +++ b/packages/cli/test/integration/workflow-history-manager.test.ts @@ -1,11 +1,11 @@ +import { Container } from '@n8n/di'; import { In } from '@n8n/typeorm'; import { DateTime } from 'luxon'; -import Container from 'typedi'; import config from '@/config'; import { WorkflowHistoryRepository } from '@/databases/repositories/workflow-history.repository'; import { License } from '@/license'; -import { WorkflowHistoryManager } from '@/workflows/workflow-history/workflow-history-manager.ee'; +import { WorkflowHistoryManager } from '@/workflows/workflow-history.ee/workflow-history-manager.ee'; import { createManyWorkflowHistoryItems } from './shared/db/workflow-history'; import { createWorkflow } from './shared/db/workflows'; diff --git a/packages/cli/test/integration/workflow-tag-mapping.repository.integration.test.ts b/packages/cli/test/integration/workflow-tag-mapping.repository.integration.test.ts index 563764de7f..c01adbc51a 100644 --- a/packages/cli/test/integration/workflow-tag-mapping.repository.integration.test.ts +++ b/packages/cli/test/integration/workflow-tag-mapping.repository.integration.test.ts @@ -1,4 +1,4 @@ -import Container from 'typedi'; +import { Container } from '@n8n/di'; import { TagRepository } from '@/databases/repositories/tag.repository'; import { WorkflowTagMappingRepository } from '@/databases/repositories/workflow-tag-mapping.repository'; diff --git a/packages/cli/test/integration/workflows/workflow-sharing.service.test.ts b/packages/cli/test/integration/workflows/workflow-sharing.service.test.ts index 3730d0db6c..bfd068e2a0 100644 --- a/packages/cli/test/integration/workflows/workflow-sharing.service.test.ts +++ b/packages/cli/test/integration/workflows/workflow-sharing.service.test.ts @@ -1,8 +1,8 @@ -import Container from 'typedi'; +import { Container } from '@n8n/di'; import type { User } from '@/databases/entities/user'; import { License } from '@/license'; -import { ProjectService } from '@/services/project.service'; +import { ProjectService } from '@/services/project.service.ee'; import { WorkflowSharingService } from '@/workflows/workflow-sharing.service'; import { createUser } from '../shared/db/users'; diff --git a/packages/cli/test/integration/workflows/workflow.service.ee.test.ts b/packages/cli/test/integration/workflows/workflow.service.ee.test.ts index 5fbab55840..0bb272d0c6 100644 --- a/packages/cli/test/integration/workflows/workflow.service.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflow.service.ee.test.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; -import Container from 'typedi'; import { CredentialsEntity } from '@/databases/entities/credentials-entity'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; diff --git a/packages/cli/test/integration/workflows/workflow.service.test.ts b/packages/cli/test/integration/workflows/workflow.service.test.ts index 759dbc54c8..8e2c76c981 100644 --- a/packages/cli/test/integration/workflows/workflow.service.test.ts +++ b/packages/cli/test/integration/workflows/workflow.service.test.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; -import Container from 'typedi'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; diff --git a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts index 2002843bfe..d376500484 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts @@ -1,5 +1,5 @@ +import { Container } from '@n8n/di'; import { ApplicationError, WorkflowActivationError, type INode } from 'n8n-workflow'; -import Container from 'typedi'; import { v4 as uuid } from 'uuid'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index fb28918509..e69e172f97 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -1,6 +1,6 @@ +import { Container } from '@n8n/di'; import type { Scope } from '@n8n/permissions'; import type { INode, IPinData } from 'n8n-workflow'; -import Container from 'typedi'; import { v4 as uuid } from 'uuid'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; @@ -12,7 +12,7 @@ import { WorkflowHistoryRepository } from '@/databases/repositories/workflow-his import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { License } from '@/license'; import type { ListQuery } from '@/requests'; -import { ProjectService } from '@/services/project.service'; +import { ProjectService } from '@/services/project.service.ee'; import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee'; import { mockInstance } from '../../shared/mocking'; @@ -441,6 +441,7 @@ describe('GET /workflows', () => { homeProject: { id: ownerPersonalProject.id, name: owner.createPersonalProjectName(), + icon: null, type: ownerPersonalProject.type, }, sharedWithProjects: [], @@ -456,6 +457,7 @@ describe('GET /workflows', () => { homeProject: { id: ownerPersonalProject.id, name: owner.createPersonalProjectName(), + icon: null, type: ownerPersonalProject.type, }, sharedWithProjects: [], @@ -833,6 +835,7 @@ describe('GET /workflows', () => { homeProject: { id: ownerPersonalProject.id, name: owner.createPersonalProjectName(), + icon: null, type: ownerPersonalProject.type, }, sharedWithProjects: [], @@ -842,6 +845,7 @@ describe('GET /workflows', () => { homeProject: { id: ownerPersonalProject.id, name: owner.createPersonalProjectName(), + icon: null, type: ownerPersonalProject.type, }, sharedWithProjects: [], diff --git a/packages/cli/test/shared/mocking.ts b/packages/cli/test/shared/mocking.ts index 129acb585c..9fa9852f98 100644 --- a/packages/cli/test/shared/mocking.ts +++ b/packages/cli/test/shared/mocking.ts @@ -1,10 +1,9 @@ +import { Container } from '@n8n/di'; import { DataSource, EntityManager, type EntityMetadata } from '@n8n/typeorm'; import { mock } from 'jest-mock-extended'; import type { Class } from 'n8n-core'; +import type { Logger } from 'n8n-core'; import type { DeepPartial } from 'ts-essentials'; -import { Container } from 'typedi'; - -import type { Logger } from '@/logging/logger.service'; export const mockInstance = ( serviceClass: Class, diff --git a/packages/cli/test/teardown.ts b/packages/cli/test/teardown.ts index 1c40bf2df6..4d6339fde9 100644 --- a/packages/cli/test/teardown.ts +++ b/packages/cli/test/teardown.ts @@ -1,7 +1,7 @@ import 'tsconfig-paths/register'; import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import { DataSource as Connection } from '@n8n/typeorm'; -import { Container } from 'typedi'; import { getBootstrapDBOptions, testDbPrefix } from './integration/shared/test-db'; diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index d145ba0c63..a427433aa6 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -15,13 +15,14 @@ "strict": false, "useUnknownInCatchVariables": false }, - "include": ["src/**/*.ts", "test/**/*.ts", "src/sso/saml/saml-schema-metadata-2.0.xsd"], + "include": ["src/**/*.ts", "test/**/*.ts", "src/sso.ee/saml/saml-schema-metadata-2.0.xsd"], "references": [ { "path": "../workflow/tsconfig.build.json" }, { "path": "../core/tsconfig.build.json" }, { "path": "../@n8n/api-types/tsconfig.build.json" }, { "path": "../@n8n/client-oauth2/tsconfig.build.json" }, { "path": "../@n8n/config/tsconfig.build.json" }, + { "path": "../@n8n/di/tsconfig.build.json" }, { "path": "../@n8n/permissions/tsconfig.build.json" } ] } diff --git a/packages/core/package.json b/packages/core/package.json index 26b41800d9..3b51aacf06 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -39,9 +39,11 @@ "@langchain/core": "catalog:", "@n8n/client-oauth2": "workspace:*", "@n8n/config": "workspace:*", + "@n8n/di": "workspace:*", "@sentry/node": "catalog:", "aws4": "1.11.0", "axios": "catalog:", + "callsites": "catalog:", "chardet": "2.0.0", "concat-stream": "2.0.0", "cron": "3.1.7", @@ -56,11 +58,12 @@ "nanoid": "catalog:", "oauth-1.0a": "2.2.6", "p-cancelable": "2.1.1", + "picocolors": "catalog:", "pretty-bytes": "5.6.0", "qs": "6.11.0", "ssh2": "1.15.0", - "typedi": "catalog:", "uuid": "catalog:", + "winston": "3.14.2", "xml2js": "catalog:", "zod": "catalog:" } diff --git a/packages/core/src/ActiveWorkflows.ts b/packages/core/src/ActiveWorkflows.ts index b7604f9778..abd544956a 100644 --- a/packages/core/src/ActiveWorkflows.ts +++ b/packages/core/src/ActiveWorkflows.ts @@ -1,3 +1,4 @@ +import { Service } from '@n8n/di'; import type { IGetExecutePollFunctions, IGetExecuteTriggerFunctions, @@ -11,22 +12,22 @@ import type { } from 'n8n-workflow'; import { ApplicationError, - LoggerProxy as Logger, toCronExpression, TriggerCloseError, WorkflowActivationError, WorkflowDeactivationError, } from 'n8n-workflow'; -import { Service } from 'typedi'; import { ErrorReporter } from './error-reporter'; import type { IWorkflowData } from './Interfaces'; +import { Logger } from './logging/logger'; import { ScheduledTaskManager } from './ScheduledTaskManager'; import { TriggersAndPollers } from './TriggersAndPollers'; @Service() export class ActiveWorkflows { constructor( + private readonly logger: Logger, private readonly scheduledTaskManager: ScheduledTaskManager, private readonly triggersAndPollers: TriggersAndPollers, private readonly errorReporter: ErrorReporter, @@ -71,16 +72,13 @@ export class ActiveWorkflows { getTriggerFunctions: IGetExecuteTriggerFunctions, getPollFunctions: IGetExecutePollFunctions, ) { - this.activeWorkflows[workflowId] = {}; const triggerNodes = workflow.getTriggerNodes(); - let triggerResponse: ITriggerResponse | undefined; - - this.activeWorkflows[workflowId].triggerResponses = []; + const triggerResponses: ITriggerResponse[] = []; for (const triggerNode of triggerNodes) { try { - triggerResponse = await this.triggersAndPollers.runTrigger( + const triggerResponse = await this.triggersAndPollers.runTrigger( workflow, triggerNode, getTriggerFunctions, @@ -89,10 +87,7 @@ export class ActiveWorkflows { activation, ); if (triggerResponse !== undefined) { - // If a response was given save it - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - this.activeWorkflows[workflowId].triggerResponses!.push(triggerResponse); + triggerResponses.push(triggerResponse); } } catch (e) { const error = e instanceof Error ? e : new Error(`${e}`); @@ -104,6 +99,8 @@ export class ActiveWorkflows { } } + this.activeWorkflows[workflowId] = { triggerResponses }; + const pollingNodes = workflow.getPollNodes(); if (pollingNodes.length === 0) return; @@ -119,6 +116,11 @@ export class ActiveWorkflows { activation, ); } catch (e) { + // Do not mark this workflow as active if there are no triggerResponses, and any polling activation failed + if (triggerResponses.length === 0) { + delete this.activeWorkflows[workflowId]; + } + const error = e instanceof Error ? e : new Error(`${e}`); throw new WorkflowActivationError( @@ -132,7 +134,7 @@ export class ActiveWorkflows { /** * Activates polling for the given node */ - async activatePolling( + private async activatePolling( node: INode, workflow: Workflow, additionalData: IWorkflowExecuteAdditionalData, @@ -150,7 +152,7 @@ export class ActiveWorkflows { const cronTimes = (pollTimes.item || []).map(toCronExpression); // The trigger function to execute when the cron-time got reached const executeTrigger = async (testingTrigger = false) => { - Logger.debug(`Polling trigger initiated for workflow "${workflow.name}"`, { + this.logger.debug(`Polling trigger initiated for workflow "${workflow.name}"`, { workflowName: workflow.name, workflowId: workflow.id, }); @@ -192,7 +194,7 @@ export class ActiveWorkflows { */ async remove(workflowId: string) { if (!this.isActive(workflowId)) { - Logger.warn(`Cannot deactivate already inactive workflow ID "${workflowId}"`); + this.logger.warn(`Cannot deactivate already inactive workflow ID "${workflowId}"`); return false; } @@ -221,7 +223,7 @@ export class ActiveWorkflows { await response.closeFunction(); } catch (e) { if (e instanceof TriggerCloseError) { - Logger.error( + this.logger.error( `There was a problem calling "closeFunction" on "${e.node.name}" in workflow "${workflowId}"`, ); this.errorReporter.error(e, { extra: { workflowId } }); diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index 556a3176de..5876ae075e 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -1,9 +1,9 @@ +import { Container, Service } from '@n8n/di'; import { BINARY_ENCODING } from 'n8n-workflow'; import type { INodeExecutionData, IBinaryData } from 'n8n-workflow'; import { readFile, stat } from 'node:fs/promises'; import prettyBytes from 'pretty-bytes'; import type { Readable } from 'stream'; -import Container, { Service } from 'typedi'; import type { BinaryData } from './types'; import { areConfigModes, binaryToBuffer } from './utils'; diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts index 65827d4ef0..5a2ab8ef55 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ts @@ -1,6 +1,6 @@ +import { Service } from '@n8n/di'; import fs from 'node:fs/promises'; import type { Readable } from 'node:stream'; -import { Service } from 'typedi'; import { v4 as uuid } from 'uuid'; import type { BinaryData } from './types'; diff --git a/packages/core/src/Cipher.ts b/packages/core/src/Cipher.ts index d9ed93ddb6..812c8f452a 100644 --- a/packages/core/src/Cipher.ts +++ b/packages/core/src/Cipher.ts @@ -1,5 +1,5 @@ +import { Service } from '@n8n/di'; import { createHash, createCipheriv, createDecipheriv, randomBytes } from 'crypto'; -import { Service } from 'typedi'; import { InstanceSettings } from './InstanceSettings'; diff --git a/packages/core/src/Constants.ts b/packages/core/src/Constants.ts index ceaf77566a..82a39b07cd 100644 --- a/packages/core/src/Constants.ts +++ b/packages/core/src/Constants.ts @@ -1,6 +1,10 @@ import type { INodeProperties } from 'n8n-workflow'; import { cronNodeOptions } from 'n8n-workflow'; +const { NODE_ENV } = process.env; +export const inProduction = NODE_ENV === 'production'; +export const inDevelopment = !NODE_ENV || NODE_ENV === 'development'; + export const CUSTOM_EXTENSION_ENV = 'N8N_CUSTOM_EXTENSIONS'; export const PLACEHOLDER_EMPTY_EXECUTION_ID = '__UNKNOWN__'; export const PLACEHOLDER_EMPTY_WORKFLOW_ID = '__EMPTY__'; diff --git a/packages/core/src/CreateNodeAsTool.ts b/packages/core/src/CreateNodeAsTool.ts index a84c564210..da34b377df 100644 --- a/packages/core/src/CreateNodeAsTool.ts +++ b/packages/core/src/CreateNodeAsTool.ts @@ -17,6 +17,9 @@ type ParserOptions = { handleToolInvocation: (toolArgs: IDataObject) => Promise; }; +// This file is temporarily duplicated in `packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/FromAIParser.ts` +// Please apply any changes in both files + /** * AIParametersParser * diff --git a/packages/core/src/Credentials.ts b/packages/core/src/Credentials.ts index da6deb742c..f5f8eb834f 100644 --- a/packages/core/src/Credentials.ts +++ b/packages/core/src/Credentials.ts @@ -1,6 +1,6 @@ +import { Container } from '@n8n/di'; import type { ICredentialDataDecryptedObject, ICredentialsEncrypted } from 'n8n-workflow'; import { ApplicationError, ICredentials, jsonParse } from 'n8n-workflow'; -import { Container } from 'typedi'; import { Cipher } from './Cipher'; diff --git a/packages/core/src/DirectoryLoader.ts b/packages/core/src/DirectoryLoader.ts index cd223da2dd..26dace0fd1 100644 --- a/packages/core/src/DirectoryLoader.ts +++ b/packages/core/src/DirectoryLoader.ts @@ -1,3 +1,4 @@ +import { Container } from '@n8n/di'; import glob from 'fast-glob'; import uniqBy from 'lodash/uniqBy'; import type { @@ -15,16 +16,13 @@ import type { IVersionedNodeType, KnownNodesAndCredentials, } from 'n8n-workflow'; -import { - ApplicationError, - LoggerProxy as Logger, - applyDeclarativeNodeOptionParameters, - jsonParse, -} from 'n8n-workflow'; +import { ApplicationError, applyDeclarativeNodeOptionParameters, jsonParse } from 'n8n-workflow'; import { readFileSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import * as path from 'path'; +import { Logger } from '@/logging/logger'; + import { loadClassInIsolation } from './ClassLoader'; import { commonCORSParameters, commonPollingParameters, CUSTOM_NODES_CATEGORY } from './Constants'; import { UnrecognizedCredentialTypeError } from './errors/unrecognized-credential-type.error'; @@ -78,6 +76,8 @@ export abstract class DirectoryLoader { readonly nodesByCredential: Record = {}; + protected readonly logger = Container.get(Logger); + constructor( readonly directory: string, protected readonly excludeNodes: string[] = [], @@ -336,7 +336,7 @@ export abstract class DirectoryLoader { node.description.codex = codex; } catch { - Logger.debug(`No codex available for: ${node.description.name}`); + this.logger.debug(`No codex available for: ${node.description.name}`); if (isCustom) { node.description.codex = { @@ -454,7 +454,7 @@ export class PackageDirectoryLoader extends DirectoryLoader { this.inferSupportedNodes(); - Logger.debug(`Loaded all credentials and nodes from ${this.packageName}`, { + this.logger.debug(`Loaded all credentials and nodes from ${this.packageName}`, { credentials: credentials?.length ?? 0, nodes: nodes?.length ?? 0, }); @@ -550,7 +550,7 @@ export class LazyPackageDirectoryLoader extends PackageDirectoryLoader { ); } - Logger.debug(`Lazy-loading nodes and credentials from ${this.packageJson.name}`, { + this.logger.debug(`Lazy-loading nodes and credentials from ${this.packageJson.name}`, { nodes: this.types.nodes?.length ?? 0, credentials: this.types.credentials?.length ?? 0, }); @@ -559,7 +559,7 @@ export class LazyPackageDirectoryLoader extends PackageDirectoryLoader { return; // We can load nodes and credentials lazily now } catch { - Logger.debug("Can't enable lazy-loading"); + this.logger.debug("Can't enable lazy-loading"); await super.loadAll(); } } diff --git a/packages/core/src/InstanceSettings.ts b/packages/core/src/InstanceSettings.ts index f611e034b3..d06f68fc12 100644 --- a/packages/core/src/InstanceSettings.ts +++ b/packages/core/src/InstanceSettings.ts @@ -1,9 +1,11 @@ +import { Service } from '@n8n/di'; import { createHash, randomBytes } from 'crypto'; import { ApplicationError, jsonParse, ALPHABET, toResult } from 'n8n-workflow'; import { customAlphabet } from 'nanoid'; import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import path from 'path'; -import { Service } from 'typedi'; + +import { Logger } from '@/logging/logger'; import { Memoized } from './decorators'; import { InstanceSettingsConfig } from './InstanceSettingsConfig'; @@ -28,13 +30,11 @@ const inTest = process.env.NODE_ENV === 'test'; @Service() export class InstanceSettings { - private readonly userHome = this.getUserHome(); - /** The path to the n8n folder in which all n8n related data gets saved */ - readonly n8nFolder = path.join(this.userHome, '.n8n'); + readonly n8nFolder = this.config.n8nFolder; /** The path to the folder where all generated static assets are copied to */ - readonly staticCacheDir = path.join(this.userHome, '.cache/n8n/public'); + readonly staticCacheDir = path.join(this.config.userHome, '.cache/n8n/public'); /** The path to the folder containing custom nodes and credentials */ readonly customExtensionDir = path.join(this.n8nFolder, 'custom'); @@ -58,7 +58,10 @@ export class InstanceSettings { readonly instanceType: InstanceType; - constructor(private readonly config: InstanceSettingsConfig) { + constructor( + private readonly config: InstanceSettingsConfig, + private readonly logger: Logger, + ) { const command = process.argv[2]; this.instanceType = ['webhook', 'worker'].includes(command) ? (command as InstanceType) @@ -110,6 +113,10 @@ export class InstanceSettings { return !this.isMultiMain; } + get isWorker() { + return this.instanceType === 'worker'; + } + get isLeader() { return this.instanceRole === 'leader'; } @@ -154,15 +161,6 @@ export class InstanceSettings { this.save({ ...this.settings, ...newSettings }); } - /** - * The home folder path of the user. - * If none can be found it falls back to the current working directory - */ - private getUserHome() { - const homeVarName = process.platform === 'win32' ? 'USERPROFILE' : 'HOME'; - 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. @@ -198,7 +196,9 @@ export class InstanceSettings { this.save(settings); if (!inTest && !process.env.N8N_ENCRYPTION_KEY) { - console.info(`No encryption key found - Auto-generated and saved to: ${this.settingsFile}`); + this.logger.info( + `No encryption key found - Auto-generated and saved to: ${this.settingsFile}`, + ); } this.ensureSettingsFilePermissions(); @@ -260,11 +260,11 @@ export class InstanceSettings { const permissionsResult = toResult(() => { const stats = statSync(this.settingsFile); - return stats.mode & 0o777; + return stats?.mode & 0o777; }); // If we can't determine the permissions, log a warning and skip the check if (!permissionsResult.ok) { - console.warn( + this.logger.warn( `Could not ensure settings file permissions: ${permissionsResult.error.message}. To skip this check, set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false.`, ); return; @@ -277,7 +277,7 @@ export class InstanceSettings { // If the permissions are incorrect and the flag is not set, log a warning if (!this.enforceSettingsFilePermissions.isSet) { - console.warn( + this.logger.warn( `Permissions 0${permissionsResult.result.toString(8)} for n8n settings file ${this.settingsFile} are too wide. This is ignored for now, but in the future n8n will attempt to change the permissions automatically. To automatically enforce correct permissions now set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true (recommended), or turn this check off set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false.`, ); // The default is false so we skip the enforcement for now @@ -285,7 +285,7 @@ export class InstanceSettings { } if (this.enforceSettingsFilePermissions.enforce) { - console.warn( + this.logger.warn( `Permissions 0${permissionsResult.result.toString(8)} for n8n settings file ${this.settingsFile} are too wide. Changing permissions to 0600..`, ); const chmodResult = toResult(() => chmodSync(this.settingsFile, 0o600)); @@ -293,7 +293,7 @@ export class InstanceSettings { // Some filesystems don't support permissions. In this case we log the // error and ignore it. We might want to prevent the app startup in the // future in this case. - console.warn( + this.logger.warn( `Could not enforce settings file permissions: ${chmodResult.error.message}. To skip this check, set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false.`, ); } diff --git a/packages/core/src/InstanceSettingsConfig.ts b/packages/core/src/InstanceSettingsConfig.ts index 60baf8b80f..dd28472a05 100644 --- a/packages/core/src/InstanceSettingsConfig.ts +++ b/packages/core/src/InstanceSettingsConfig.ts @@ -1,4 +1,5 @@ import { Config, Env } from '@n8n/config'; +import path from 'node:path'; @Config export class InstanceSettingsConfig { @@ -9,4 +10,19 @@ export class InstanceSettingsConfig { */ @Env('N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS') enforceSettingsFilePermissions: boolean = false; + + /** + * The home folder path of the user. + * If none can be found it falls back to the current working directory + */ + readonly userHome: string; + + readonly n8nFolder: string; + + constructor() { + const homeVarName = process.platform === 'win32' ? 'USERPROFILE' : 'HOME'; + this.userHome = process.env.N8N_USER_FOLDER ?? process.env[homeVarName] ?? process.cwd(); + + this.n8nFolder = path.join(this.userHome, '.n8n'); + } } diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 38679513de..9504ad1c78 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -13,6 +13,7 @@ import type { OAuth2CredentialData, } from '@n8n/client-oauth2'; import { ClientOAuth2 } from '@n8n/client-oauth2'; +import { Container } from '@n8n/di'; import type { AxiosError, AxiosHeaders, AxiosRequestConfig, AxiosResponse } from 'axios'; import axios from 'axios'; import chardet from 'chardet'; @@ -31,7 +32,6 @@ import pick from 'lodash/pick'; import { extension, lookup } from 'mime-types'; import type { BinaryHelperFunctions, - CloseFunction, FileSystemHelperFunctions, GenericValue, IAdditionalCredentialOptions, @@ -47,7 +47,6 @@ import type { IN8nHttpResponse, INode, INodeExecutionData, - INodeInputConfiguration, IOAuth2Options, IPairedItemData, IPollFunctions, @@ -76,12 +75,8 @@ import type { ICheckProcessedContextData, WebhookType, SchedulingFunctions, - SupplyData, - AINodeConnectionType, } from 'n8n-workflow'; import { - NodeConnectionType, - LoggerProxy as Logger, NodeApiError, NodeHelpers, NodeOperationError, @@ -99,9 +94,10 @@ import clientOAuth1 from 'oauth-1.0a'; import path from 'path'; import { stringify } from 'qs'; import { Readable } from 'stream'; -import Container from 'typedi'; import url, { URL, URLSearchParams } from 'url'; +import { Logger } from '@/logging/logger'; + import { BinaryDataService } from './BinaryData/BinaryData.service'; import type { BinaryData } from './BinaryData/types'; import { binaryToBuffer } from './BinaryData/utils'; @@ -114,12 +110,11 @@ import { UM_EMAIL_TEMPLATES_INVITE, UM_EMAIL_TEMPLATES_PWRESET, } from './Constants'; -import { createNodeAsTool } from './CreateNodeAsTool'; import { DataDeduplicationService } from './data-deduplication-service'; import { InstanceSettings } from './InstanceSettings'; import type { IResponseError } from './Interfaces'; // eslint-disable-next-line import/no-cycle -import { PollContext, SupplyDataContext, TriggerContext } from './node-execution-context'; +import { PollContext, TriggerContext } from './node-execution-context'; import { ScheduledTaskManager } from './ScheduledTaskManager'; import { SSHClientsManager } from './SSHClientsManager'; @@ -207,7 +202,7 @@ async function generateContentLengthHeader(config: AxiosRequestConfig) { 'content-length': length, }; } catch (error) { - Logger.error('Unable to calculate form data length', { error }); + Container.get(Logger).error('Unable to calculate form data length', { error }); } } @@ -798,7 +793,7 @@ export async function proxyRequestToAxios( error.config = error.request = undefined; error.options = pick(config ?? {}, ['url', 'method', 'data', 'headers']); if (response) { - Logger.debug('Request proxied to Axios failed', { status: response.status }); + Container.get(Logger).debug('Request proxied to Axios failed', { status: response.status }); let responseData = response.data; if (Buffer.isBuffer(responseData) || responseData instanceof Readable) { @@ -1412,7 +1407,7 @@ export async function requestOAuth2( if (isN8nRequest) { return await this.helpers.httpRequest(newRequestOptions).catch(async (error: AxiosError) => { if (error.response?.status === 401) { - Logger.debug( + this.logger.debug( `OAuth2 token for "${credentialsType}" used by node "${node.name}" expired. Should revalidate.`, ); const tokenRefreshOptions: IDataObject = {}; @@ -1431,7 +1426,7 @@ export async function requestOAuth2( let newToken; - Logger.debug( + this.logger.debug( `OAuth2 token for "${credentialsType}" used by node "${node.name}" has been renewed.`, ); // if it's OAuth2 with client credentials grant type, get a new token @@ -1442,7 +1437,7 @@ export async function requestOAuth2( newToken = await token.refresh(tokenRefreshOptions as unknown as ClientOAuth2Options); } - Logger.debug( + this.logger.debug( `OAuth2 token for "${credentialsType}" used by node "${node.name}" has been renewed.`, ); @@ -1505,7 +1500,7 @@ export async function requestOAuth2( Authorization: '', }; } - Logger.debug( + this.logger.debug( `OAuth2 token for "${credentialsType}" used by node "${node.name}" expired. Should revalidate.`, ); @@ -1518,7 +1513,7 @@ export async function requestOAuth2( } else { newToken = await token.refresh(tokenRefreshOptions as unknown as ClientOAuth2Options); } - Logger.debug( + this.logger.debug( `OAuth2 token for "${credentialsType}" used by node "${node.name}" has been renewed.`, ); @@ -1540,7 +1535,7 @@ export async function requestOAuth2( credentials as unknown as ICredentialDataDecryptedObject, ); - Logger.debug( + this.logger.debug( `OAuth2 token for "${credentialsType}" used by node "${node.name}" has been saved to database successfully.`, ); @@ -2013,185 +2008,6 @@ export function getWebhookDescription( return undefined; } -export async function getInputConnectionData( - this: IAllExecuteFunctions, - workflow: Workflow, - runExecutionData: IRunExecutionData, - parentRunIndex: number, - connectionInputData: INodeExecutionData[], - parentInputData: ITaskDataConnections, - additionalData: IWorkflowExecuteAdditionalData, - executeData: IExecuteData, - mode: WorkflowExecuteMode, - closeFunctions: CloseFunction[], - connectionType: AINodeConnectionType, - itemIndex: number, - abortSignal?: AbortSignal, -): Promise { - const parentNode = this.getNode(); - const parentNodeType = workflow.nodeTypes.getByNameAndVersion( - parentNode.type, - parentNode.typeVersion, - ); - - const inputs = NodeHelpers.getNodeInputs(workflow, parentNode, parentNodeType.description); - - let inputConfiguration = inputs.find((input) => { - if (typeof input === 'string') { - return input === connectionType; - } - return input.type === connectionType; - }); - - if (inputConfiguration === undefined) { - throw new ApplicationError('Node does not have input of type', { - extra: { nodeName: parentNode.name, connectionType }, - }); - } - - if (typeof inputConfiguration === 'string') { - inputConfiguration = { - type: inputConfiguration, - } as INodeInputConfiguration; - } - - const connectedNodes = workflow - .getParentNodes(parentNode.name, connectionType, 1) - .map((nodeName) => workflow.getNode(nodeName) as INode) - .filter((connectedNode) => connectedNode.disabled !== true); - - if (connectedNodes.length === 0) { - if (inputConfiguration.required) { - throw new NodeOperationError( - parentNode, - `A ${inputConfiguration?.displayName ?? connectionType} sub-node must be connected and enabled`, - ); - } - return inputConfiguration.maxConnections === 1 ? undefined : []; - } - - if ( - inputConfiguration.maxConnections !== undefined && - connectedNodes.length > inputConfiguration.maxConnections - ) { - throw new NodeOperationError( - parentNode, - `Only ${inputConfiguration.maxConnections} ${connectionType} sub-nodes are/is allowed to be connected`, - ); - } - - const nodes: SupplyData[] = []; - for (const connectedNode of connectedNodes) { - const connectedNodeType = workflow.nodeTypes.getByNameAndVersion( - connectedNode.type, - connectedNode.typeVersion, - ); - const contextFactory = (runIndex: number, inputData: ITaskDataConnections) => - new SupplyDataContext( - workflow, - connectedNode, - additionalData, - mode, - runExecutionData, - runIndex, - connectionInputData, - inputData, - connectionType, - executeData, - closeFunctions, - abortSignal, - ); - - if (!connectedNodeType.supplyData) { - if (connectedNodeType.description.outputs.includes(NodeConnectionType.AiTool)) { - /** - * This keeps track of how many times this specific AI tool node has been invoked. - * It is incremented on every invocation of the tool to keep the output of each invocation separate from each other. - */ - let toolRunIndex = 0; - const supplyData = createNodeAsTool({ - node: connectedNode, - nodeType: connectedNodeType, - handleToolInvocation: async (toolArgs) => { - const runIndex = toolRunIndex++; - const context = contextFactory(runIndex, {}); - context.addInputData(NodeConnectionType.AiTool, [[{ json: toolArgs }]]); - - try { - // Execute the sub-node with the proxied context - const result = await connectedNodeType.execute?.call( - context as unknown as IExecuteFunctions, - ); - - // Process and map the results - const mappedResults = result?.[0]?.flatMap((item) => item.json); - - // Add output data to the context - context.addOutputData(NodeConnectionType.AiTool, runIndex, [ - [{ json: { response: mappedResults } }], - ]); - - // Return the stringified results - return JSON.stringify(mappedResults); - } catch (error) { - const nodeError = new NodeOperationError(connectedNode, error as Error); - context.addOutputData(NodeConnectionType.AiTool, runIndex, nodeError); - return 'Error during node execution: ' + nodeError.description; - } - }, - }); - nodes.push(supplyData); - } else { - throw new ApplicationError('Node does not have a `supplyData` method defined', { - extra: { nodeName: connectedNode.name }, - }); - } - } else { - const context = contextFactory(parentRunIndex, parentInputData); - try { - const supplyData = await connectedNodeType.supplyData.call(context, itemIndex); - if (supplyData.closeFunction) { - closeFunctions.push(supplyData.closeFunction); - } - nodes.push(supplyData); - } catch (error) { - // Propagate errors from sub-nodes - if (error.functionality === 'configuration-node') throw error; - if (!(error instanceof ExecutionBaseError)) { - error = new NodeOperationError(connectedNode, error, { - itemIndex, - }); - } - - let currentNodeRunIndex = 0; - if (runExecutionData.resultData.runData.hasOwnProperty(parentNode.name)) { - currentNodeRunIndex = runExecutionData.resultData.runData[parentNode.name].length; - } - - // Display the error on the node which is causing it - await context.addExecutionDataFunctions( - 'input', - error, - connectionType, - parentNode.name, - currentNodeRunIndex, - ); - - // Display on the calling node which node has the error - throw new NodeOperationError(connectedNode, `Error in sub-node ${connectedNode.name}`, { - itemIndex, - functionality: 'configuration-node', - description: error.message, - }); - } - } - } - - return inputConfiguration.maxConnections === 1 - ? (nodes || [])[0]?.response - : nodes.map((node) => node.response); -} - export const getRequestHelperFunctions = ( workflow: Workflow, node: INode, @@ -2254,7 +2070,7 @@ export const getRequestHelperFunctions = ( const runIndex = 0; - const additionalKeys = { + const additionalKeys: IWorkflowDataProxyAdditionalKeys = { $request: requestOptions, $response: {} as IN8nHttpFullResponse, $version: node.typeVersion, @@ -2379,7 +2195,7 @@ export const getRequestHelperFunctions = ( responseData.push(tempResponseData); additionalKeys.$response = newResponse; - additionalKeys.$pageCount = additionalKeys.$pageCount + 1; + additionalKeys.$pageCount = (additionalKeys.$pageCount ?? 0) + 1; const maxRequests = getResolvedValue( paginationOptions.maxRequests, @@ -2747,6 +2563,7 @@ export function getExecuteTriggerFunctions( export function getCredentialTestFunctions(): ICredentialTestFunctions { return { + logger: Container.get(Logger), helpers: { ...getSSHTunnelFunctions(), request: async (uriOrObject: string | object, options?: object) => { diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 0e4d8463df..e1b98c6f50 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -1,11 +1,13 @@ +import { Service } from '@n8n/di'; import { sign } from 'aws4'; import type { Request as Aws4Options, Credentials as Aws4Credentials } from 'aws4'; import axios from 'axios'; import type { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig, Method } from 'axios'; -import { ApplicationError, LoggerProxy as Logger } from 'n8n-workflow'; +import { ApplicationError } from 'n8n-workflow'; import { createHash } from 'node:crypto'; import type { Readable } from 'stream'; -import { Service } from 'typedi'; + +import { Logger } from '@/logging/logger'; import type { Bucket, @@ -30,7 +32,7 @@ export class ObjectStoreService { private isReadOnly = false; - private logger = Logger; + constructor(private readonly logger: Logger) {} async init(host: string, bucket: Bucket, credentials: ConfigSchemaCredentials) { this.host = host; diff --git a/packages/core/src/SSHClientsManager.ts b/packages/core/src/SSHClientsManager.ts index 17046a26eb..a29c5facc7 100644 --- a/packages/core/src/SSHClientsManager.ts +++ b/packages/core/src/SSHClientsManager.ts @@ -1,7 +1,7 @@ +import { Service } from '@n8n/di'; import type { SSHCredentials } from 'n8n-workflow'; import { createHash } from 'node:crypto'; import { Client, type ConnectConfig } from 'ssh2'; -import { Service } from 'typedi'; @Service() export class SSHClientsManager { diff --git a/packages/core/src/ScheduledTaskManager.ts b/packages/core/src/ScheduledTaskManager.ts index 00396903a5..0c33f9872c 100644 --- a/packages/core/src/ScheduledTaskManager.ts +++ b/packages/core/src/ScheduledTaskManager.ts @@ -1,6 +1,6 @@ +import { Service } from '@n8n/di'; import { CronJob } from 'cron'; import type { CronExpression, Workflow } from 'n8n-workflow'; -import { Service } from 'typedi'; import { InstanceSettings } from './InstanceSettings'; diff --git a/packages/core/src/SerializedBuffer.ts b/packages/core/src/SerializedBuffer.ts index 48395049b9..d6ea874c7a 100644 --- a/packages/core/src/SerializedBuffer.ts +++ b/packages/core/src/SerializedBuffer.ts @@ -1,3 +1,5 @@ +import { isObjectLiteral } from '@/utils'; + /** A nodejs Buffer gone through JSON.stringify */ export type SerializedBuffer = { type: 'Buffer'; @@ -9,10 +11,6 @@ export function toBuffer(serializedBuffer: SerializedBuffer): Buffer { return Buffer.from(serializedBuffer.data); } -function isObjectLiteral(item: unknown): item is { [key: string]: unknown } { - return typeof item === 'object' && item !== null && !Array.isArray(item); -} - export function isSerializedBuffer(candidate: unknown): candidate is SerializedBuffer { return ( isObjectLiteral(candidate) && diff --git a/packages/core/src/TriggersAndPollers.ts b/packages/core/src/TriggersAndPollers.ts index b77926e136..681d50641a 100644 --- a/packages/core/src/TriggersAndPollers.ts +++ b/packages/core/src/TriggersAndPollers.ts @@ -1,3 +1,4 @@ +import { Service } from '@n8n/di'; import { ApplicationError } from 'n8n-workflow'; import type { Workflow, @@ -13,7 +14,6 @@ import type { IExecuteResponsePromiseData, IRun, } from 'n8n-workflow'; -import { Service } from 'typedi'; @Service() export class TriggersAndPollers { diff --git a/packages/core/src/WorkflowExecute.ts b/packages/core/src/WorkflowExecute.ts index 252694fd1f..3817fe32b2 100644 --- a/packages/core/src/WorkflowExecute.ts +++ b/packages/core/src/WorkflowExecute.ts @@ -2,6 +2,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ +import { Container } from '@n8n/di'; import * as assert from 'assert/strict'; import { setMaxListeners } from 'events'; import { omit } from 'lodash'; @@ -53,7 +54,6 @@ import { Node, } from 'n8n-workflow'; import PCancelable from 'p-cancelable'; -import Container from 'typedi'; import { ErrorReporter } from './error-reporter'; import { ExecuteContext, PollContext } from './node-execution-context'; @@ -417,6 +417,17 @@ export class WorkflowExecute { return await this.additionalData.hooks.executeHookFunctions(hookName, parameters); } + /** + * Merges temporary execution metadata into the final runData structure. + * During workflow execution, metadata is collected in a temporary location + * (executionData.metadata). This method moves that metadata to its final + * location in the resultData.runData for each node. + * + * @remarks + * - Metadata from multiple runs is preserved using run indices + * - Existing metadata in runData is preserved and merged with new metadata + * - If no metadata exists, the operation is a no-op + */ moveNodeMetadata(): void { const metadata = get(this.runExecutionData, 'executionData.metadata'); @@ -437,14 +448,27 @@ export class WorkflowExecute { } /** - * Checks the incoming connection does not receive any data + * Checks if all incoming connections to a node are empty (have no data). + * This is used to determine if a node should be executed or skipped. + * + * @param runData - The execution data from all nodes in the workflow + * @param inputConnections - Array of connections to check + * @param runIndex - Index of the current execution run (nodes can execute multiple times) + * + * @returns `true` if all connections are empty (no data), `false` if any connection has data + * + * @remarks + * A connection is considered empty when: + * - The source node doesn't exist in runData + * - The source node's data is undefined + * - The source node's output array is empty + * - The specified output index contains no items */ incomingConnectionIsEmpty( runData: IRunData, inputConnections: IConnection[], runIndex: number, ): boolean { - // for (const inputConnection of workflow.connectionsByDestinationNode[nodeToAdd].main[0]) { for (const inputConnection of inputConnections) { const nodeIncomingData = get(runData, [ inputConnection.node, @@ -460,24 +484,29 @@ export class WorkflowExecute { return true; } + /** + * Prepares the waiting execution data structure for a node that needs to wait for data before it can execute. + * This function initializes arrays to store data and metadata for each connection of the node. + * + * @param nodeName - The name of the node to prepare waiting execution for + * @param numberOfConnections - Number of input connections the node has + * @param runIndex - The index of the current run (for nodes that may run multiple times) + */ prepareWaitingToExecution(nodeName: string, numberOfConnections: number, runIndex: number) { - if (!this.runExecutionData.executionData!.waitingExecutionSource) { - this.runExecutionData.executionData!.waitingExecutionSource = {}; - } + const executionData = this.runExecutionData.executionData!; - this.runExecutionData.executionData!.waitingExecution[nodeName][runIndex] = { - main: [], - }; - this.runExecutionData.executionData!.waitingExecutionSource[nodeName][runIndex] = { - main: [], - }; + executionData.waitingExecution ??= {}; + executionData.waitingExecutionSource ??= {}; + + const nodeWaiting = (executionData.waitingExecution[nodeName] ??= []); + const nodeWaitingSource = (executionData.waitingExecutionSource[nodeName] ??= []); + + nodeWaiting[runIndex] = { main: [] }; + nodeWaitingSource[runIndex] = { main: [] }; for (let i = 0; i < numberOfConnections; i++) { - this.runExecutionData.executionData!.waitingExecution[nodeName][runIndex].main.push(null); - - this.runExecutionData.executionData!.waitingExecutionSource[nodeName][runIndex].main.push( - null, - ); + nodeWaiting[runIndex].main.push(null); + nodeWaitingSource[runIndex].main.push(null); } } @@ -1197,7 +1226,7 @@ export class WorkflowExecute { }); if (workflowIssues !== null) { throw new WorkflowOperationError( - 'The workflow has issues and can for that reason not be executed. Please fix them first.', + 'The workflow has issues and cannot be executed for that reason. Please fix them first.', ); } @@ -1489,119 +1518,7 @@ export class WorkflowExecute { } if (nodeSuccessData && executionData.node.onError === 'continueErrorOutput') { - // If errorOutput is activated check all the output items for error data. - // If any is found, route them to the last output as that will be the - // error output. - - const nodeType = workflow.nodeTypes.getByNameAndVersion( - executionData.node.type, - executionData.node.typeVersion, - ); - const outputs = NodeHelpers.getNodeOutputs( - workflow, - executionData.node, - nodeType.description, - ); - const outputTypes = NodeHelpers.getConnectionTypes(outputs); - const mainOutputTypes = outputTypes.filter( - (output) => output === NodeConnectionType.Main, - ); - - const errorItems: INodeExecutionData[] = []; - const closeFunctions: CloseFunction[] = []; - // Create a WorkflowDataProxy instance that we can get the data of the - // item which did error - const executeFunctions = new ExecuteContext( - workflow, - executionData.node, - this.additionalData, - this.mode, - this.runExecutionData, - runIndex, - [], - executionData.data, - executionData, - closeFunctions, - this.abortController.signal, - ); - - const dataProxy = executeFunctions.getWorkflowDataProxy(0); - - // Loop over all outputs except the error output as it would not contain data by default - for ( - let outputIndex = 0; - outputIndex < mainOutputTypes.length - 1; - outputIndex++ - ) { - const successItems: INodeExecutionData[] = []; - const items = nodeSuccessData[outputIndex]?.length - ? nodeSuccessData[outputIndex] - : []; - - while (items.length) { - const item = items.shift(); - if (item === undefined) { - continue; - } - - let errorData: GenericValue | undefined; - if (item.error) { - errorData = item.error; - 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) { - const pairedItemData = - item.pairedItem && typeof item.pairedItem === 'object' - ? Array.isArray(item.pairedItem) - ? item.pairedItem[0] - : item.pairedItem - : undefined; - - if (executionData!.source === null || pairedItemData === undefined) { - // Source data is missing for some reason so we can not figure out the item - errorItems.push(item); - } else { - const pairedItemInputIndex = pairedItemData.input || 0; - - const sourceData = - executionData!.source[NodeConnectionType.Main][pairedItemInputIndex]; - - const constPairedItem = dataProxy.$getPairedItem( - sourceData!.previousNode, - sourceData, - pairedItemData, - ); - - if (constPairedItem === null) { - errorItems.push(item); - } else { - errorItems.push({ - ...item, - json: { - ...constPairedItem.json, - ...item.json, - }, - }); - } - } - } else { - successItems.push(item); - } - } - - nodeSuccessData[outputIndex] = successItems; - } - - nodeSuccessData[mainOutputTypes.length - 1] = errorItems; + this.handleNodeErrorOutput(workflow, executionData, nodeSuccessData, runIndex); } if (runNodeData.closeFunction) { @@ -1616,53 +1533,7 @@ export class WorkflowExecute { workflowId: workflow.id, }); - if (nodeSuccessData?.length) { - // Check if the output data contains pairedItem data and if not try - // to automatically fix it - - const isSingleInputAndOutput = - executionData.data.main.length === 1 && executionData.data.main[0]?.length === 1; - - const isSameNumberOfItems = - nodeSuccessData.length === 1 && - executionData.data.main.length === 1 && - executionData.data.main[0]?.length === nodeSuccessData[0].length; - - checkOutputData: for (const outputData of nodeSuccessData) { - if (outputData === null) { - continue; - } - for (const [index, item] of outputData.entries()) { - if (item.pairedItem === undefined) { - // The pairedItem data is missing, so check if it can get automatically fixed - if (isSingleInputAndOutput) { - // The node has one input and one incoming item, so we know - // that all items must originate from that single - item.pairedItem = { - item: 0, - }; - } else if (isSameNumberOfItems) { - // The number of oncoming and outcoming items is identical so we can - // make the reasonable assumption that each of the input items - // is the origin of the corresponding output items - item.pairedItem = { - item: index, - }; - } else { - // In all other cases autofixing is not possible - break checkOutputData; - } - } - } - } - } - - if (nodeSuccessData === undefined) { - // Node did not get executed - nodeSuccessData = null; - } else { - this.runExecutionData.resultData.lastNodeExecuted = executionData.node.name; - } + nodeSuccessData = this.assignPairedItems(nodeSuccessData, executionData); if (nodeSuccessData === null || nodeSuccessData[0][0] === undefined) { if (executionData.node.alwaysOutputData === true) { @@ -2175,6 +2046,27 @@ export class WorkflowExecute { }); } + /** + * Processes the final state of a workflow execution and prepares the execution result. + * This method handles different completion scenarios: success, waiting, error, and canceled states. + * It also manages cleanup tasks like static data updates and trigger deactivation. + * + * @param startedAt - The timestamp when the workflow execution started + * @param workflow - The workflow being executed + * @param executionError - Optional error that occurred during execution + * @param closeFunction - Optional promise that handles cleanup of triggers/webhooks + * + * @returns A promise that resolves to the complete workflow execution data (IRun) + * + * @remarks + * The function performs these tasks in order: + * 1. Generates full execution data + * 2. Sets appropriate status based on execution outcome + * 3. Handles any static data changes + * 4. Moves node metadata to its final location + * 5. Executes the 'workflowExecuteAfter' hook + * 6. Performs cleanup via closeFunction if provided + */ async processSuccessExecution( startedAt: Date, workflow: Workflow, @@ -2240,15 +2132,187 @@ export class WorkflowExecute { } getFullRunData(startedAt: Date): IRun { - const fullRunData: IRun = { + return { data: this.runExecutionData, mode: this.mode, startedAt, stoppedAt: new Date(), status: this.status, }; + } - return fullRunData; + handleNodeErrorOutput( + workflow: Workflow, + executionData: IExecuteData, + nodeSuccessData: INodeExecutionData[][], + runIndex: number, + ): void { + const nodeType = workflow.nodeTypes.getByNameAndVersion( + executionData.node.type, + executionData.node.typeVersion, + ); + const outputs = NodeHelpers.getNodeOutputs(workflow, executionData.node, nodeType.description); + const outputTypes = NodeHelpers.getConnectionTypes(outputs); + const mainOutputTypes = outputTypes.filter((output) => output === NodeConnectionType.Main); + + const errorItems: INodeExecutionData[] = []; + const closeFunctions: CloseFunction[] = []; + // Create a WorkflowDataProxy instance that we can get the data of the + // item which did error + const executeFunctions = new ExecuteContext( + workflow, + executionData.node, + this.additionalData, + this.mode, + this.runExecutionData, + runIndex, + [], + executionData.data, + executionData, + closeFunctions, + this.abortController.signal, + ); + + const dataProxy = executeFunctions.getWorkflowDataProxy(0); + + // Loop over all outputs except the error output as it would not contain data by default + for (let outputIndex = 0; outputIndex < mainOutputTypes.length - 1; outputIndex++) { + const successItems: INodeExecutionData[] = []; + const items = nodeSuccessData[outputIndex]?.length ? nodeSuccessData[outputIndex] : []; + + while (items.length) { + const item = items.shift(); + if (item === undefined) { + continue; + } + + let errorData: GenericValue | undefined; + if (item.error) { + errorData = item.error; + 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) { + const pairedItemData = + item.pairedItem && typeof item.pairedItem === 'object' + ? Array.isArray(item.pairedItem) + ? item.pairedItem[0] + : item.pairedItem + : undefined; + + if (executionData.source === null || pairedItemData === undefined) { + // Source data is missing for some reason so we can not figure out the item + errorItems.push(item); + } else { + const pairedItemInputIndex = pairedItemData.input || 0; + + const sourceData = executionData.source[NodeConnectionType.Main][pairedItemInputIndex]; + + const constPairedItem = dataProxy.$getPairedItem( + sourceData!.previousNode, + sourceData, + pairedItemData, + ); + + if (constPairedItem === null) { + errorItems.push(item); + } else { + errorItems.push({ + ...item, + json: { + ...constPairedItem.json, + ...item.json, + }, + }); + } + } + } else { + successItems.push(item); + } + } + + nodeSuccessData[outputIndex] = successItems; + } + + nodeSuccessData[mainOutputTypes.length - 1] = errorItems; + } + + /** + * Assigns pairedItem information to node output items by matching them with input items. + * PairedItem data is used to track which output items were derived from which input items. + * + * @param nodeSuccessData - The output data from a node execution + * @param executionData - The execution data containing input information + * + * @returns The node output data with pairedItem information assigned where possible + * + * @remarks + * Auto-assignment of pairedItem happens in two scenarios: + * 1. Single input/output: When node has exactly one input item and produces output(s), + * all outputs are marked as derived from that single input (item: 0) + * 2. Matching items count: When number of input and output items match exactly, + * each output item is paired with the input item at the same index + * + * In all other cases, if pairedItem is missing, it remains undefined as automatic + * assignment cannot be done reliably. + */ + assignPairedItems( + nodeSuccessData: INodeExecutionData[][] | null | undefined, + executionData: IExecuteData, + ) { + if (nodeSuccessData?.length) { + // Check if the output data contains pairedItem data and if not try + // to automatically fix it + + const isSingleInputAndOutput = + executionData.data.main.length === 1 && executionData.data.main[0]?.length === 1; + + const isSameNumberOfItems = + nodeSuccessData.length === 1 && + executionData.data.main.length === 1 && + executionData.data.main[0]?.length === nodeSuccessData[0].length; + + checkOutputData: for (const outputData of nodeSuccessData) { + if (outputData === null) { + continue; + } + for (const [index, item] of outputData.entries()) { + if (item.pairedItem === undefined) { + // The pairedItem data is missing, so check if it can get automatically fixed + if (isSingleInputAndOutput) { + // The node has one input and one incoming item, so we know + // that all items must originate from that single + item.pairedItem = { + item: 0, + }; + } else if (isSameNumberOfItems) { + // The number of oncoming and outcoming items is identical so we can + // make the reasonable assumption that each of the input items + // is the origin of the corresponding output items + item.pairedItem = { + item: index, + }; + } else { + // In all other cases autofixing is not possible + break checkOutputData; + } + } + } + } + } + + if (nodeSuccessData === undefined) { + // Node did not get executed + nodeSuccessData = null; + } else { + this.runExecutionData.resultData.lastNodeExecuted = executionData.node.name; + } + + return nodeSuccessData; } private get isCancelled() { diff --git a/packages/core/src/__tests__/ActiveWorkflows.test.ts b/packages/core/src/__tests__/ActiveWorkflows.test.ts new file mode 100644 index 0000000000..410b4779ba --- /dev/null +++ b/packages/core/src/__tests__/ActiveWorkflows.test.ts @@ -0,0 +1,295 @@ +import { mock } from 'jest-mock-extended'; +import type { + IGetExecuteTriggerFunctions, + INode, + ITriggerResponse, + IWorkflowExecuteAdditionalData, + Workflow, + WorkflowActivateMode, + WorkflowExecuteMode, + TriggerTime, + CronExpression, +} from 'n8n-workflow'; +import { LoggerProxy, TriggerCloseError, WorkflowActivationError } from 'n8n-workflow'; + +import { ActiveWorkflows } from '@/ActiveWorkflows'; +import type { ErrorReporter } from '@/error-reporter'; +import type { PollContext } from '@/node-execution-context'; +import type { ScheduledTaskManager } from '@/ScheduledTaskManager'; +import type { TriggersAndPollers } from '@/TriggersAndPollers'; + +describe('ActiveWorkflows', () => { + const workflowId = 'test-workflow-id'; + const workflow = mock(); + const additionalData = mock(); + const mode: WorkflowExecuteMode = 'trigger'; + const activation: WorkflowActivateMode = 'init'; + + const getTriggerFunctions = jest.fn() as IGetExecuteTriggerFunctions; + const triggerResponse = mock(); + + const pollFunctions = mock(); + const getPollFunctions = jest.fn(); + + LoggerProxy.init(mock()); + const scheduledTaskManager = mock(); + const triggersAndPollers = mock(); + const errorReporter = mock(); + const triggerNode = mock(); + const pollNode = mock(); + + let activeWorkflows: ActiveWorkflows; + + beforeEach(() => { + jest.clearAllMocks(); + activeWorkflows = new ActiveWorkflows( + mock(), + scheduledTaskManager, + triggersAndPollers, + errorReporter, + ); + }); + + type PollTimes = { item: TriggerTime[] }; + type TestOptions = { + triggerNodes?: INode[]; + pollNodes?: INode[]; + triggerError?: Error; + pollError?: Error; + pollTimes?: PollTimes; + }; + + const addWorkflow = async ({ + triggerNodes = [], + pollNodes = [], + triggerError, + pollError, + pollTimes = { item: [{ mode: 'everyMinute' }] }, + }: TestOptions) => { + workflow.getTriggerNodes.mockReturnValue(triggerNodes); + workflow.getPollNodes.mockReturnValue(pollNodes); + pollFunctions.getNodeParameter.calledWith('pollTimes').mockReturnValue(pollTimes); + + if (triggerError) { + triggersAndPollers.runTrigger.mockRejectedValueOnce(triggerError); + } else { + triggersAndPollers.runTrigger.mockResolvedValue(triggerResponse); + } + + if (pollError) { + triggersAndPollers.runPoll.mockRejectedValueOnce(pollError); + } else { + getPollFunctions.mockReturnValue(pollFunctions); + } + + return await activeWorkflows.add( + workflowId, + workflow, + additionalData, + mode, + activation, + getTriggerFunctions, + getPollFunctions, + ); + }; + + describe('add()', () => { + describe('should activate workflow', () => { + it('with trigger nodes', async () => { + await addWorkflow({ triggerNodes: [triggerNode] }); + + expect(activeWorkflows.isActive(workflowId)).toBe(true); + expect(workflow.getTriggerNodes).toHaveBeenCalled(); + expect(triggersAndPollers.runTrigger).toHaveBeenCalledWith( + workflow, + triggerNode, + getTriggerFunctions, + additionalData, + mode, + activation, + ); + }); + + it('with polling nodes', async () => { + await addWorkflow({ pollNodes: [pollNode] }); + + expect(activeWorkflows.isActive(workflowId)).toBe(true); + expect(workflow.getPollNodes).toHaveBeenCalled(); + expect(scheduledTaskManager.registerCron).toHaveBeenCalled(); + }); + + it('with both trigger and polling nodes', async () => { + await addWorkflow({ triggerNodes: [triggerNode], pollNodes: [pollNode] }); + + expect(activeWorkflows.isActive(workflowId)).toBe(true); + expect(workflow.getTriggerNodes).toHaveBeenCalled(); + expect(workflow.getPollNodes).toHaveBeenCalled(); + expect(triggersAndPollers.runTrigger).toHaveBeenCalledWith( + workflow, + triggerNode, + getTriggerFunctions, + additionalData, + mode, + activation, + ); + expect(scheduledTaskManager.registerCron).toHaveBeenCalled(); + expect(triggersAndPollers.runPoll).toHaveBeenCalledWith(workflow, pollNode, pollFunctions); + }); + }); + + describe('should throw error', () => { + it('if trigger activation fails', async () => { + const error = new Error('Trigger activation failed'); + await expect( + addWorkflow({ triggerNodes: [triggerNode], triggerError: error }), + ).rejects.toThrow(WorkflowActivationError); + expect(activeWorkflows.isActive(workflowId)).toBe(false); + }); + + it('if polling activation fails', async () => { + const error = new Error('Failed to activate polling'); + await expect(addWorkflow({ pollNodes: [pollNode], pollError: error })).rejects.toThrow( + WorkflowActivationError, + ); + expect(activeWorkflows.isActive(workflowId)).toBe(false); + }); + + it('if the polling interval is too short', async () => { + const pollTimes: PollTimes = { + item: [ + { + mode: 'custom', + cronExpression: '* * * * *' as CronExpression, + }, + ], + }; + + await expect(addWorkflow({ pollNodes: [pollNode], pollTimes })).rejects.toThrow( + 'The polling interval is too short. It has to be at least a minute.', + ); + + expect(scheduledTaskManager.registerCron).not.toHaveBeenCalled(); + }); + }); + + describe('should handle polling errors', () => { + it('should throw error when poll fails during initial testing', async () => { + const error = new Error('Poll function failed'); + + await expect(addWorkflow({ pollNodes: [pollNode], pollError: error })).rejects.toThrow( + WorkflowActivationError, + ); + + expect(triggersAndPollers.runPoll).toHaveBeenCalledWith(workflow, pollNode, pollFunctions); + expect(pollFunctions.__emit).not.toHaveBeenCalled(); + expect(pollFunctions.__emitError).not.toHaveBeenCalled(); + }); + + it('should emit error when poll fails during regular polling', async () => { + const error = new Error('Poll function failed'); + triggersAndPollers.runPoll + .mockResolvedValueOnce(null) // Succeed on first call (testing) + .mockRejectedValueOnce(error); // Fail on second call (regular polling) + + await addWorkflow({ pollNodes: [pollNode] }); + + // Get the executeTrigger function that was registered + const registerCronCall = scheduledTaskManager.registerCron.mock.calls[0]; + const executeTrigger = registerCronCall[2] as () => Promise; + + // Execute the trigger function to simulate a regular poll + await executeTrigger(); + + expect(triggersAndPollers.runPoll).toHaveBeenCalledTimes(2); + expect(pollFunctions.__emit).not.toHaveBeenCalled(); + expect(pollFunctions.__emitError).toHaveBeenCalledWith(error); + }); + }); + }); + + describe('remove()', () => { + const setupForRemoval = async () => { + await addWorkflow({ triggerNodes: [triggerNode] }); + return await activeWorkflows.remove(workflowId); + }; + + it('should remove an active workflow', async () => { + const result = await setupForRemoval(); + + expect(result).toBe(true); + expect(activeWorkflows.isActive(workflowId)).toBe(false); + expect(scheduledTaskManager.deregisterCrons).toHaveBeenCalledWith(workflowId); + expect(triggerResponse.closeFunction).toHaveBeenCalled(); + }); + + it('should return false when removing non-existent workflow', async () => { + const result = await activeWorkflows.remove('non-existent'); + + expect(result).toBe(false); + expect(scheduledTaskManager.deregisterCrons).not.toHaveBeenCalled(); + }); + + it('should handle TriggerCloseError when closing trigger', async () => { + const triggerCloseError = new TriggerCloseError(triggerNode, { level: 'warning' }); + (triggerResponse.closeFunction as jest.Mock).mockRejectedValueOnce(triggerCloseError); + + const result = await setupForRemoval(); + + expect(result).toBe(true); + expect(activeWorkflows.isActive(workflowId)).toBe(false); + expect(triggerResponse.closeFunction).toHaveBeenCalled(); + expect(errorReporter.error).toHaveBeenCalledWith(triggerCloseError, { + extra: { workflowId }, + }); + }); + + it('should throw WorkflowDeactivationError when closeFunction throws regular error', async () => { + const error = new Error('Close function failed'); + (triggerResponse.closeFunction as jest.Mock).mockRejectedValueOnce(error); + + await addWorkflow({ triggerNodes: [triggerNode] }); + + await expect(activeWorkflows.remove(workflowId)).rejects.toThrow( + `Failed to deactivate trigger of workflow ID "${workflowId}": "Close function failed"`, + ); + + expect(triggerResponse.closeFunction).toHaveBeenCalled(); + expect(errorReporter.error).not.toHaveBeenCalled(); + }); + }); + + describe('get() and isActive()', () => { + it('should return workflow data for active workflow', async () => { + await addWorkflow({ triggerNodes: [triggerNode] }); + + expect(activeWorkflows.isActive(workflowId)).toBe(true); + expect(activeWorkflows.get(workflowId)).toBeDefined(); + }); + + it('should return undefined for non-active workflow', () => { + expect(activeWorkflows.isActive('non-existent')).toBe(false); + expect(activeWorkflows.get('non-existent')).toBeUndefined(); + }); + }); + + describe('allActiveWorkflows()', () => { + it('should return all active workflow IDs', async () => { + await addWorkflow({ triggerNodes: [triggerNode] }); + + const activeIds = activeWorkflows.allActiveWorkflows(); + + expect(activeIds).toEqual([workflowId]); + }); + }); + + describe('removeAllTriggerAndPollerBasedWorkflows()', () => { + it('should remove all active workflows', async () => { + await addWorkflow({ triggerNodes: [triggerNode] }); + + await activeWorkflows.removeAllTriggerAndPollerBasedWorkflows(); + + expect(activeWorkflows.allActiveWorkflows()).toEqual([]); + expect(scheduledTaskManager.deregisterCrons).toHaveBeenCalledWith(workflowId); + }); + }); +}); diff --git a/packages/core/src/__tests__/utils.test.ts b/packages/core/src/__tests__/utils.test.ts new file mode 100644 index 0000000000..a8532ed589 --- /dev/null +++ b/packages/core/src/__tests__/utils.test.ts @@ -0,0 +1,35 @@ +import { isObjectLiteral } from '@/utils'; + +describe('isObjectLiteral', () => { + test.each([ + ['empty object literal', {}, true], + ['object with properties', { foo: 'bar', num: 123 }, true], + ['nested object literal', { nested: { foo: 'bar' } }, true], + ['object with symbol key', { [Symbol.for('foo')]: 'bar' }, true], + ['null', null, false], + ['empty array', [], false], + ['array with values', [1, 2, 3], false], + ['number', 42, false], + ['string', 'string', false], + ['boolean', true, false], + ['undefined', undefined, false], + ['Date object', new Date(), false], + ['RegExp object', new RegExp(''), false], + ['Map object', new Map(), false], + ['Set object', new Set(), false], + ['arrow function', () => {}, false], + ['regular function', function () {}, false], + ['class instance', new (class TestClass {})(), false], + ['object with custom prototype', Object.create({ customMethod: () => {} }), true], + ['Object.create(null)', Object.create(null), false], + ['Buffer', Buffer.from('test'), false], + ['Serialized Buffer', Buffer.from('test').toJSON(), true], + ['Promise', new Promise(() => {}), false], + ])('should return %s for %s', (_, input, expected) => { + expect(isObjectLiteral(input)).toBe(expected); + }); + + it('should return false for Error objects', () => { + expect(isObjectLiteral(new Error())).toBe(false); + }); +}); diff --git a/packages/core/src/error-reporter.ts b/packages/core/src/error-reporter.ts index b6fc936daa..0bc0f6058e 100644 --- a/packages/core/src/error-reporter.ts +++ b/packages/core/src/error-reporter.ts @@ -1,12 +1,13 @@ +import { Service } from '@n8n/di'; import type { NodeOptions } from '@sentry/node'; import { close } from '@sentry/node'; import type { ErrorEvent, EventHint } from '@sentry/types'; import { AxiosError } from 'axios'; -import { ApplicationError, LoggerProxy, type ReportingOptions } from 'n8n-workflow'; +import { ApplicationError, ExecutionCancelledError, type ReportingOptions } from 'n8n-workflow'; import { createHash } from 'node:crypto'; -import { Service } from 'typedi'; import type { InstanceType } from './InstanceSettings'; +import { Logger } from './logging/logger'; @Service() export class ErrorReporter { @@ -15,7 +16,7 @@ export class ErrorReporter { private report: (error: Error | string, options?: ReportingOptions) => void; - constructor() { + constructor(private readonly logger: Logger) { // eslint-disable-next-line @typescript-eslint/unbound-method this.report = this.defaultReport; } @@ -28,9 +29,12 @@ export class ErrorReporter { const context = executionId ? ` (execution ${executionId})` : ''; do { - const msg = [e.message + context, e.stack ? `\n${e.stack}\n` : ''].join(''); + const msg = [ + e.message + context, + e instanceof ApplicationError && e.level === 'error' && e.stack ? `\n${e.stack}\n` : '', + ].join(''); const meta = e instanceof ApplicationError ? e.extra : undefined; - LoggerProxy.error(msg, meta); + this.logger.error(msg, meta); e = e.cause as Error; } while (e); } @@ -142,6 +146,7 @@ export class ErrorReporter { } error(e: unknown, options?: ReportingOptions) { + if (e instanceof ExecutionCancelledError) return; const toReport = this.wrap(e); if (toReport) this.report(toReport, options); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1fc9d77399..7abbd9ad9a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,6 +11,7 @@ export * from './Credentials'; export * from './DirectoryLoader'; export * from './Interfaces'; export { InstanceSettings, InstanceType } from './InstanceSettings'; +export { Logger } from './logging/logger'; export * from './NodeExecuteFunctions'; export * from './RoutingNode'; export * from './WorkflowExecute'; @@ -25,3 +26,4 @@ export * from './node-execution-context'; export * from './PartialExecutionUtils'; export { ErrorReporter } from './error-reporter'; export * from './SerializedBuffer'; +export { isObjectLiteral } from './utils'; diff --git a/packages/cli/src/logging/__tests__/logger.service.test.ts b/packages/core/src/logging/__tests__/logger.test.ts similarity index 93% rename from packages/cli/src/logging/__tests__/logger.service.test.ts rename to packages/core/src/logging/__tests__/logger.test.ts index 2ffbf2120e..d34eaf250a 100644 --- a/packages/cli/src/logging/__tests__/logger.service.test.ts +++ b/packages/core/src/logging/__tests__/logger.test.ts @@ -5,10 +5,11 @@ jest.mock('n8n-workflow', () => ({ import type { GlobalConfig } from '@n8n/config'; import { mock } from 'jest-mock-extended'; -import type { InstanceSettings } from 'n8n-core'; import { LoggerProxy } from 'n8n-workflow'; -import { Logger } from '@/logging/logger.service'; +import type { InstanceSettingsConfig } from '@/InstanceSettingsConfig'; + +import { Logger } from '../logger'; describe('Logger', () => { beforeEach(() => { @@ -25,13 +26,13 @@ describe('Logger', () => { }); test('if root, should initialize `LoggerProxy` with instance', () => { - const logger = new Logger(globalConfig, mock(), { isRoot: true }); + const logger = new Logger(globalConfig, mock(), { isRoot: true }); expect(LoggerProxy.init).toHaveBeenCalledWith(logger); }); test('if scoped, should not initialize `LoggerProxy`', () => { - new Logger(globalConfig, mock(), { isRoot: false }); + new Logger(globalConfig, mock(), { isRoot: false }); expect(LoggerProxy.init).not.toHaveBeenCalled(); }); @@ -47,7 +48,7 @@ describe('Logger', () => { }, }); - const logger = new Logger(globalConfig, mock()); + const logger = new Logger(globalConfig, mock()); const { transports } = logger.getInternalLogger(); @@ -72,7 +73,7 @@ describe('Logger', () => { }, }); - const logger = new Logger(globalConfig, mock({ n8nFolder: '/tmp' })); + const logger = new Logger(globalConfig, mock({ n8nFolder: '/tmp' })); const { transports } = logger.getInternalLogger(); @@ -94,7 +95,7 @@ describe('Logger', () => { }, }); - const logger = new Logger(globalConfig, mock()); + const logger = new Logger(globalConfig, mock()); const internalLogger = logger.getInternalLogger(); @@ -113,7 +114,7 @@ describe('Logger', () => { }, }); - const logger = new Logger(globalConfig, mock()); + const logger = new Logger(globalConfig, mock()); const internalLogger = logger.getInternalLogger(); @@ -132,7 +133,7 @@ describe('Logger', () => { }, }); - const logger = new Logger(globalConfig, mock()); + const logger = new Logger(globalConfig, mock()); const internalLogger = logger.getInternalLogger(); @@ -151,7 +152,7 @@ describe('Logger', () => { }, }); - const logger = new Logger(globalConfig, mock()); + const logger = new Logger(globalConfig, mock()); const internalLogger = logger.getInternalLogger(); @@ -170,7 +171,7 @@ describe('Logger', () => { }, }); - const logger = new Logger(globalConfig, mock()); + const logger = new Logger(globalConfig, mock()); const internalLogger = logger.getInternalLogger(); diff --git a/packages/cli/src/logging/logger.service.ts b/packages/core/src/logging/logger.ts similarity index 77% rename from packages/cli/src/logging/logger.service.ts rename to packages/core/src/logging/logger.ts index 46471c0611..8115f93c86 100644 --- a/packages/cli/src/logging/logger.service.ts +++ b/packages/core/src/logging/logger.ts @@ -1,22 +1,27 @@ import type { LogScope } from '@n8n/config'; import { GlobalConfig } from '@n8n/config'; +import { Service } from '@n8n/di'; import callsites from 'callsites'; import type { TransformableInfo } from 'logform'; -import { InstanceSettings } from 'n8n-core'; import { LoggerProxy, LOG_LEVELS } from 'n8n-workflow'; +import type { + Logger as LoggerType, + LogLocationMetadata, + LogLevel, + LogMetadata, +} from 'n8n-workflow'; import path, { basename } from 'node:path'; import pc from 'picocolors'; -import { Service } from 'typedi'; import winston from 'winston'; -import { inDevelopment, inProduction } from '@/constants'; +import { inDevelopment, inProduction } from '@/Constants'; +import { InstanceSettingsConfig } from '@/InstanceSettingsConfig'; import { isObjectLiteral } from '@/utils'; -import { noOp } from './constants'; -import type { LogLocationMetadata, LogLevel, LogMetadata } from './types'; +const noOp = () => {}; @Service() -export class Logger { +export class Logger implements LoggerType { private internalLogger: winston.Logger; private readonly level: LogLevel; @@ -27,9 +32,12 @@ export class Logger { return this.scopes.size > 0; } + /** https://no-color.org/ */ + private readonly noColor = process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== ''; + constructor( private readonly globalConfig: GlobalConfig, - private readonly instanceSettings: InstanceSettings, + private readonly instanceSettingsConfig: InstanceSettingsConfig, { isRoot }: { isRoot?: boolean } = { isRoot: true }, ) { this.level = this.globalConfig.logging.level; @@ -50,6 +58,8 @@ export class Logger { if (outputs.includes('file')) this.setFileTransport(); this.scopes = new Set(scopes); + } else { + this.scopes = new Set(); } if (isRoot) LoggerProxy.init(this); @@ -62,7 +72,9 @@ export class Logger { /** Create a logger that injects the given scopes into its log metadata. */ scoped(scopes: LogScope | LogScope[]) { scopes = Array.isArray(scopes) ? scopes : [scopes]; - const scopedLogger = new Logger(this.globalConfig, this.instanceSettings, { isRoot: false }); + const scopedLogger = new Logger(this.globalConfig, this.instanceSettingsConfig, { + isRoot: false, + }); const childLogger = this.internalLogger.child({ scopes }); scopedLogger.setInternalLogger(childLogger); @@ -108,10 +120,10 @@ export class Logger { } private scopeFilter() { - return winston.format((info: TransformableInfo & { metadata: LogMetadata }) => { + return winston.format((info: TransformableInfo) => { if (!this.isScopingEnabled) return info; - const { scopes } = info.metadata; + const { scopes } = (info as unknown as { metadata: LogMetadata }).metadata; const shouldIncludeScope = scopes && scopes?.length > 0 && scopes.some((s) => this.scopes.has(s)); @@ -120,18 +132,22 @@ export class Logger { })(); } + private color() { + return this.noColor ? winston.format.uncolorize() : winston.format.colorize({ all: true }); + } + private debugDevConsoleFormat() { return winston.format.combine( winston.format.metadata(), winston.format.timestamp({ format: () => this.devTsFormat() }), - winston.format.colorize({ all: true }), + this.color(), this.scopeFilter(), - winston.format.printf(({ level: _level, message, timestamp, metadata: _metadata }) => { - const SEPARATOR = ' '.repeat(3); - const LOG_LEVEL_COLUMN_WIDTH = 15; // 5 columns + ANSI color codes - const level = _level.toLowerCase().padEnd(LOG_LEVEL_COLUMN_WIDTH, ' '); - const metadata = this.toPrintable(_metadata); - return [timestamp, level, message + ' ' + pc.dim(metadata)].join(SEPARATOR); + winston.format.printf(({ level: rawLevel, message, timestamp, metadata: rawMetadata }) => { + const separator = ' '.repeat(3); + const logLevelColumnWidth = this.noColor ? 5 : 15; // when colorizing, account for ANSI color codes + const level = rawLevel.toLowerCase().padEnd(logLevelColumnWidth, ' '); + const metadata = this.toPrintable(rawMetadata); + return [timestamp, level, message + ' ' + pc.dim(metadata)].join(separator); }), ); } @@ -140,10 +156,11 @@ export class Logger { return winston.format.combine( winston.format.metadata(), winston.format.timestamp(), + this.color(), this.scopeFilter(), - winston.format.printf(({ level, message, timestamp, metadata }) => { - const _metadata = this.toPrintable(metadata); - return `${timestamp} | ${level.padEnd(5)} | ${message}${_metadata ? ' ' + _metadata : ''}`; + winston.format.printf(({ level, message, timestamp, metadata: rawMetadata }) => { + const metadata = this.toPrintable(rawMetadata); + return `${timestamp} | ${level.padEnd(5)} | ${message}${metadata ? ' ' + metadata : ''}`; }), ); } @@ -180,7 +197,7 @@ export class Logger { ); const filename = path.join( - this.instanceSettings.n8nFolder, + this.instanceSettingsConfig.n8nFolder, this.globalConfig.logging.file.location, ); diff --git a/packages/core/src/node-execution-context/__tests__/node-execution-context.test.ts b/packages/core/src/node-execution-context/__tests__/node-execution-context.test.ts index 0231873984..a0a368df4c 100644 --- a/packages/core/src/node-execution-context/__tests__/node-execution-context.test.ts +++ b/packages/core/src/node-execution-context/__tests__/node-execution-context.test.ts @@ -1,12 +1,16 @@ +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import type { + Expression, INode, + INodeType, + INodeTypes, INodeExecutionData, IWorkflowExecuteAdditionalData, Workflow, WorkflowExecuteMode, } from 'n8n-workflow'; -import { Container } from 'typedi'; +import { NodeConnectionType } from 'n8n-workflow'; import { InstanceSettings } from '@/InstanceSettings'; @@ -18,23 +22,29 @@ describe('NodeExecutionContext', () => { const instanceSettings = mock({ instanceId: 'abc123' }); Container.set(InstanceSettings, instanceSettings); + const node = mock(); + const nodeType = mock({ description: mock() }); + const nodeTypes = mock(); + const expression = mock(); const workflow = mock({ id: '123', name: 'Test Workflow', active: true, - nodeTypes: mock(), + nodeTypes, timezone: 'UTC', + expression, }); - const node = mock(); let additionalData = mock({ credentialsHelper: mock(), }); const mode: WorkflowExecuteMode = 'manual'; - const testContext = new TestContext(workflow, node, additionalData, mode); + let testContext: TestContext; beforeEach(() => { jest.clearAllMocks(); + testContext = new TestContext(workflow, node, additionalData, mode); + nodeTypes.getByNameAndVersion.mockReturnValue(nodeType); }); describe('getNode', () => { @@ -106,9 +116,9 @@ describe('NodeExecutionContext', () => { }); describe('getKnownNodeTypes', () => { - it('should call getKnownTypes method of workflow.nodeTypes', () => { + it('should call getKnownTypes method of nodeTypes', () => { testContext.getKnownNodeTypes(); - expect(workflow.nodeTypes.getKnownTypes).toHaveBeenCalled(); + expect(nodeTypes.getKnownTypes).toHaveBeenCalled(); }); }); @@ -165,4 +175,164 @@ describe('NodeExecutionContext', () => { expect(result).toEqual([outputData]); }); }); + + describe('getNodeInputs', () => { + it('should return static inputs array when inputs is an array', () => { + nodeType.description.inputs = [NodeConnectionType.Main, NodeConnectionType.AiLanguageModel]; + + const result = testContext.getNodeInputs(); + + expect(result).toEqual([ + { type: NodeConnectionType.Main }, + { type: NodeConnectionType.AiLanguageModel }, + ]); + }); + + it('should return input objects when inputs contains configurations', () => { + nodeType.description.inputs = [ + { type: NodeConnectionType.Main }, + { type: NodeConnectionType.AiLanguageModel, required: true }, + ]; + + const result = testContext.getNodeInputs(); + + expect(result).toEqual([ + { type: NodeConnectionType.Main }, + { type: NodeConnectionType.AiLanguageModel, required: true }, + ]); + }); + + it('should evaluate dynamic inputs when inputs is a function', () => { + const inputsExpressions = '={{ ["main", "ai_languageModel"] }}'; + nodeType.description.inputs = inputsExpressions; + expression.getSimpleParameterValue.mockReturnValue([ + NodeConnectionType.Main, + NodeConnectionType.AiLanguageModel, + ]); + + const result = testContext.getNodeInputs(); + + expect(result).toEqual([ + { type: NodeConnectionType.Main }, + { type: NodeConnectionType.AiLanguageModel }, + ]); + expect(expression.getSimpleParameterValue).toHaveBeenCalledWith( + node, + inputsExpressions, + 'internal', + {}, + ); + }); + }); + + describe('getNodeOutputs', () => { + it('should return static outputs array when outputs is an array', () => { + nodeType.description.outputs = [NodeConnectionType.Main, NodeConnectionType.AiLanguageModel]; + + const result = testContext.getNodeOutputs(); + + expect(result).toEqual([ + { type: NodeConnectionType.Main }, + { type: NodeConnectionType.AiLanguageModel }, + ]); + }); + + it('should return output objects when outputs contains configurations', () => { + nodeType.description.outputs = [ + { type: NodeConnectionType.Main }, + { type: NodeConnectionType.AiLanguageModel, required: true }, + ]; + + const result = testContext.getNodeOutputs(); + + expect(result).toEqual([ + { type: NodeConnectionType.Main }, + { type: NodeConnectionType.AiLanguageModel, required: true }, + ]); + }); + + it('should evaluate dynamic outputs when outputs is a function', () => { + const outputsExpressions = '={{ ["main", "ai_languageModel"] }}'; + nodeType.description.outputs = outputsExpressions; + expression.getSimpleParameterValue.mockReturnValue([ + NodeConnectionType.Main, + NodeConnectionType.AiLanguageModel, + ]); + + const result = testContext.getNodeOutputs(); + + expect(result).toEqual([ + { type: NodeConnectionType.Main }, + { type: NodeConnectionType.AiLanguageModel }, + ]); + expect(expression.getSimpleParameterValue).toHaveBeenCalledWith( + node, + outputsExpressions, + 'internal', + {}, + ); + }); + + it('should add error output when node has continueOnFail error handling', () => { + const nodeWithError = mock({ onError: 'continueErrorOutput' }); + const contextWithError = new TestContext(workflow, nodeWithError, additionalData, mode); + nodeType.description.outputs = [NodeConnectionType.Main]; + + const result = contextWithError.getNodeOutputs(); + + expect(result).toEqual([ + { type: NodeConnectionType.Main, displayName: 'Success' }, + { type: NodeConnectionType.Main, displayName: 'Error', category: 'error' }, + ]); + }); + }); + + describe('getConnectedNodes', () => { + it('should return connected nodes of given type', () => { + const node1 = mock({ name: 'Node 1', type: 'test', disabled: false }); + const node2 = mock({ name: 'Node 2', type: 'test', disabled: false }); + + workflow.getParentNodes.mockReturnValue(['Node 1', 'Node 2']); + workflow.getNode.mockImplementation((name) => { + if (name === 'Node 1') return node1; + if (name === 'Node 2') return node2; + return null; + }); + + const result = testContext.getConnectedNodes(NodeConnectionType.Main); + + expect(result).toEqual([node1, node2]); + expect(workflow.getParentNodes).toHaveBeenCalledWith(node.name, NodeConnectionType.Main, 1); + }); + + it('should filter out disabled nodes', () => { + const node1 = mock({ name: 'Node 1', type: 'test', disabled: false }); + const node2 = mock({ name: 'Node 2', type: 'test', disabled: true }); + + workflow.getParentNodes.mockReturnValue(['Node 1', 'Node 2']); + workflow.getNode.mockImplementation((name) => { + if (name === 'Node 1') return node1; + if (name === 'Node 2') return node2; + return null; + }); + + const result = testContext.getConnectedNodes(NodeConnectionType.Main); + + expect(result).toEqual([node1]); + }); + + it('should filter out non-existent nodes', () => { + const node1 = mock({ name: 'Node 1', type: 'test', disabled: false }); + + workflow.getParentNodes.mockReturnValue(['Node 1', 'NonExistent']); + workflow.getNode.mockImplementation((name) => { + if (name === 'Node 1') return node1; + return null; + }); + + const result = testContext.getConnectedNodes(NodeConnectionType.Main); + + expect(result).toEqual([node1]); + }); + }); }); diff --git a/packages/core/src/node-execution-context/__tests__/shared-tests.ts b/packages/core/src/node-execution-context/__tests__/shared-tests.ts index 9992507bdd..8b1d23930b 100644 --- a/packages/core/src/node-execution-context/__tests__/shared-tests.ts +++ b/packages/core/src/node-execution-context/__tests__/shared-tests.ts @@ -1,3 +1,4 @@ +import { Container } from '@n8n/di'; import { captor, mock, type MockProxy } from 'jest-mock-extended'; import type { IRunExecutionData, @@ -15,7 +16,6 @@ import type { IExecuteWorkflowInfo, } from 'n8n-workflow'; import { ApplicationError, NodeHelpers, WAIT_INDEFINITELY } from 'n8n-workflow'; -import Container from 'typedi'; import { BinaryDataService } from '@/BinaryData/BinaryData.service'; diff --git a/packages/core/src/node-execution-context/base-execute-context.ts b/packages/core/src/node-execution-context/base-execute-context.ts index 8ecc658579..0416870344 100644 --- a/packages/core/src/node-execution-context/base-execute-context.ts +++ b/packages/core/src/node-execution-context/base-execute-context.ts @@ -1,3 +1,4 @@ +import { Container } from '@n8n/di'; import { get } from 'lodash'; import type { Workflow, @@ -16,8 +17,6 @@ import type { ITaskMetadata, ContextType, IContextObject, - INodeInputConfiguration, - INodeOutputConfiguration, IWorkflowDataProxyData, ISourceData, AiEvent, @@ -29,7 +28,6 @@ import { WAIT_INDEFINITELY, WorkflowDataProxy, } from 'n8n-workflow'; -import { Container } from 'typedi'; import { BinaryDataService } from '@/BinaryData/BinaryData.service'; @@ -161,26 +159,6 @@ export class BaseExecuteContext extends NodeExecutionContext { return allItems; } - getNodeInputs(): INodeInputConfiguration[] { - const nodeType = this.workflow.nodeTypes.getByNameAndVersion( - this.node.type, - this.node.typeVersion, - ); - return NodeHelpers.getNodeInputs(this.workflow, this.node, nodeType.description).map((input) => - typeof input === 'string' ? { type: input } : input, - ); - } - - getNodeOutputs(): INodeOutputConfiguration[] { - const nodeType = this.workflow.nodeTypes.getByNameAndVersion( - this.node.type, - this.node.typeVersion, - ); - return NodeHelpers.getNodeOutputs(this.workflow, this.node, nodeType.description).map( - (output) => (typeof output === 'string' ? { type: output } : output), - ); - } - getInputSourceData(inputIndex = 0, connectionType = NodeConnectionType.Main): ISourceData { if (this.executeData?.source === null) { // Should never happen as n8n sets it automatically diff --git a/packages/core/src/node-execution-context/execute-context.ts b/packages/core/src/node-execution-context/execute-context.ts index d563881bea..089c3f500a 100644 --- a/packages/core/src/node-execution-context/execute-context.ts +++ b/packages/core/src/node-execution-context/execute-context.ts @@ -28,7 +28,6 @@ import { copyInputItems, normalizeItems, constructExecutionMetaData, - getInputConnectionData, assertBinaryData, getBinaryDataBuffer, copyBinaryFile, @@ -41,6 +40,7 @@ import { } from '@/NodeExecuteFunctions'; import { BaseExecuteContext } from './base-execute-context'; +import { getInputConnectionData } from './utils/getInputConnectionData'; export class ExecuteContext extends BaseExecuteContext implements IExecuteFunctions { readonly helpers: IExecuteFunctions['helpers']; @@ -131,7 +131,7 @@ export class ExecuteContext extends BaseExecuteContext implements IExecuteFuncti settings: unknown, itemIndex: number, ): Promise> { - return await this.additionalData.startAgentJob( + return await this.additionalData.startRunnerTask( this.additionalData, jobType, settings, diff --git a/packages/core/src/node-execution-context/index.ts b/packages/core/src/node-execution-context/index.ts index 00c90266db..c3bcebbd44 100644 --- a/packages/core/src/node-execution-context/index.ts +++ b/packages/core/src/node-execution-context/index.ts @@ -3,9 +3,11 @@ export { ExecuteContext } from './execute-context'; export { ExecuteSingleContext } from './execute-single-context'; export { HookContext } from './hook-context'; export { LoadOptionsContext } from './load-options-context'; +export { LocalLoadOptionsContext } from './local-load-options-context'; export { PollContext } from './poll-context'; +// eslint-disable-next-line import/no-cycle export { SupplyDataContext } from './supply-data-context'; export { TriggerContext } from './trigger-context'; export { WebhookContext } from './webhook-context'; -export { getAdditionalKeys } from './utils'; +export { getAdditionalKeys } from './utils/getAdditionalKeys'; diff --git a/packages/core/src/node-execution-context/local-load-options-context.ts b/packages/core/src/node-execution-context/local-load-options-context.ts new file mode 100644 index 0000000000..39456ff966 --- /dev/null +++ b/packages/core/src/node-execution-context/local-load-options-context.ts @@ -0,0 +1,70 @@ +import lodash from 'lodash'; +import { ApplicationError, Workflow } from 'n8n-workflow'; +import type { + INodeParameterResourceLocator, + IWorkflowExecuteAdditionalData, + NodeParameterValueType, + ILocalLoadOptionsFunctions, + IWorkflowLoader, + IWorkflowNodeContext, + INodeTypes, +} from 'n8n-workflow'; + +import { LoadWorkflowNodeContext } from './workflow-node-context'; + +export class LocalLoadOptionsContext implements ILocalLoadOptionsFunctions { + constructor( + private nodeTypes: INodeTypes, + private additionalData: IWorkflowExecuteAdditionalData, + private path: string, + private workflowLoader: IWorkflowLoader, + ) {} + + async getWorkflowNodeContext(nodeType: string): Promise { + const { value: workflowId } = this.getCurrentNodeParameter( + 'workflowId', + ) as INodeParameterResourceLocator; + + if (typeof workflowId !== 'string' || !workflowId) { + throw new ApplicationError(`No workflowId parameter defined on node of type "${nodeType}"!`); + } + + const dbWorkflow = await this.workflowLoader.get(workflowId); + + const selectedWorkflowNode = dbWorkflow.nodes.find((node) => node.type === nodeType); + + if (selectedWorkflowNode) { + const selectedSingleNodeWorkflow = new Workflow({ + nodes: [selectedWorkflowNode], + connections: {}, + active: false, + nodeTypes: this.nodeTypes, + }); + + const workflowAdditionalData = { + ...this.additionalData, + currentNodeParameters: selectedWorkflowNode.parameters, + }; + + return new LoadWorkflowNodeContext( + selectedSingleNodeWorkflow, + selectedWorkflowNode, + workflowAdditionalData, + ); + } + + return null; + } + + getCurrentNodeParameter(parameterPath: string): NodeParameterValueType | object | undefined { + const nodeParameters = this.additionalData.currentNodeParameters; + + if (parameterPath.startsWith('&')) { + parameterPath = `${this.path.split('.').slice(1, -1).join('.')}.${parameterPath.slice(1)}`; + } + + const returnData = lodash.get(nodeParameters, parameterPath); + + return returnData; + } +} diff --git a/packages/core/src/node-execution-context/node-execution-context.ts b/packages/core/src/node-execution-context/node-execution-context.ts index 158b06d02e..45a205637c 100644 --- a/packages/core/src/node-execution-context/node-execution-context.ts +++ b/packages/core/src/node-execution-context/node-execution-context.ts @@ -1,3 +1,4 @@ +import { Container } from '@n8n/di'; import { get } from 'lodash'; import type { FunctionsBase, @@ -9,8 +10,11 @@ import type { INodeCredentialDescription, INodeCredentialsDetails, INodeExecutionData, + INodeInputConfiguration, + INodeOutputConfiguration, IRunExecutionData, IWorkflowExecuteAdditionalData, + NodeConnectionType, NodeParameterValueType, NodeTypeAndVersion, Workflow, @@ -20,22 +24,20 @@ import { ApplicationError, deepCopy, ExpressionError, - LoggerProxy, NodeHelpers, NodeOperationError, } from 'n8n-workflow'; -import { Container } from 'typedi'; import { HTTP_REQUEST_NODE_TYPE, HTTP_REQUEST_TOOL_NODE_TYPE } from '@/Constants'; +import { Memoized } from '@/decorators'; import { extractValue } from '@/ExtractValue'; import { InstanceSettings } from '@/InstanceSettings'; +import { Logger } from '@/logging/logger'; -import { - cleanupParameterData, - ensureType, - getAdditionalKeys, - validateValueAgainstSchema, -} from './utils'; +import { cleanupParameterData } from './utils/cleanupParameterData'; +import { ensureType } from './utils/ensureType'; +import { getAdditionalKeys } from './utils/getAdditionalKeys'; +import { validateValueAgainstSchema } from './utils/validateValueAgainstSchema'; export abstract class NodeExecutionContext implements Omit { protected readonly instanceSettings = Container.get(InstanceSettings); @@ -51,8 +53,9 @@ export abstract class NodeExecutionContext implements Omit (typeof input === 'string' ? { type: input } : input), + ); + } + + getNodeInputs(): INodeInputConfiguration[] { + return this.nodeInputs; + } + + @Memoized + get nodeOutputs() { + return NodeHelpers.getNodeOutputs(this.workflow, this.node, this.nodeType.description).map( + (output) => (typeof output === 'string' ? { type: output } : output), + ); + } + + getConnectedNodes(connectionType: NodeConnectionType): INode[] { + return this.workflow + .getParentNodes(this.node.name, connectionType, 1) + .map((nodeName) => this.workflow.getNode(nodeName)) + .filter((node) => !!node) + .filter((node) => node.disabled !== true); + } + + getNodeOutputs(): INodeOutputConfiguration[] { + return this.nodeOutputs; + } + getKnownNodeTypes() { return this.workflow.nodeTypes.getKnownTypes(); } @@ -260,6 +299,7 @@ export abstract class NodeExecutionContext implements Omit cleanupParameterData(value as NodeParameterValueType)); - return; - } - - if (typeof inputData === 'object') { - Object.keys(inputData).forEach((key) => { - const value = (inputData as INodeParameters)[key]; - if (typeof value === 'object') { - if (DateTime.isDateTime(value)) { - // Is a special luxon date so convert to string - (inputData as INodeParameters)[key] = value.toString(); - } else { - cleanupParameterData(value); - } - } - }); - } -} - -const validateResourceMapperValue = ( - parameterName: string, - paramValues: { [key: string]: unknown }, - node: INode, - skipRequiredCheck = false, -): ExtendedValidationResult => { - const result: ExtendedValidationResult = { valid: true, newValue: paramValues }; - const paramNameParts = parameterName.split('.'); - if (paramNameParts.length !== 2) { - return result; - } - const resourceMapperParamName = paramNameParts[0]; - const resourceMapperField = node.parameters[resourceMapperParamName]; - if (!resourceMapperField || !isResourceMapperValue(resourceMapperField)) { - return result; - } - const schema = resourceMapperField.schema; - const paramValueNames = Object.keys(paramValues); - for (let i = 0; i < paramValueNames.length; i++) { - const key = paramValueNames[i]; - const resolvedValue = paramValues[key]; - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call - const schemaEntry = schema.find((s) => s.id === key); - - if ( - !skipRequiredCheck && - schemaEntry?.required === true && - schemaEntry.type !== 'boolean' && - !resolvedValue - ) { - return { - valid: false, - errorMessage: `The value "${String(key)}" is required but not set`, - fieldName: key, - }; - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (schemaEntry?.type) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const validationResult = validateFieldType(key, resolvedValue, schemaEntry.type, { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - valueOptions: schemaEntry.options, - }); - if (!validationResult.valid) { - return { ...validationResult, fieldName: key }; - } else { - // If it's valid, set the casted value - paramValues[key] = validationResult.newValue; - } - } - } - return result; -}; - -const validateCollection = ( - node: INode, - runIndex: number, - itemIndex: number, - propertyDescription: INodeProperties, - parameterPath: string[], - validationResult: ExtendedValidationResult, -): ExtendedValidationResult => { - let nestedDescriptions: INodeProperties[] | undefined; - - if (propertyDescription.type === 'fixedCollection') { - nestedDescriptions = (propertyDescription.options as INodePropertyCollection[]).find( - (entry) => entry.name === parameterPath[1], - )?.values; - } - - if (propertyDescription.type === 'collection') { - nestedDescriptions = propertyDescription.options as INodeProperties[]; - } - - if (!nestedDescriptions) { - return validationResult; - } - - const validationMap: { - [key: string]: { type: FieldType; displayName: string; options?: INodePropertyOptions[] }; - } = {}; - - for (const prop of nestedDescriptions) { - if (!prop.validateType || prop.ignoreValidationDuringExecution) continue; - - validationMap[prop.name] = { - type: prop.validateType, - displayName: prop.displayName, - options: - prop.validateType === 'options' ? (prop.options as INodePropertyOptions[]) : undefined, - }; - } - - if (!Object.keys(validationMap).length) { - return validationResult; - } - - if (validationResult.valid) { - for (const value of Array.isArray(validationResult.newValue) - ? (validationResult.newValue as IDataObject[]) - : [validationResult.newValue as IDataObject]) { - for (const key of Object.keys(value)) { - if (!validationMap[key]) continue; - - const fieldValidationResult = validateFieldType(key, value[key], validationMap[key].type, { - valueOptions: validationMap[key].options, - }); - - if (!fieldValidationResult.valid) { - throw new ExpressionError( - `Invalid input for field '${validationMap[key].displayName}' inside '${propertyDescription.displayName}' in [item ${itemIndex}]`, - { - description: fieldValidationResult.errorMessage, - runIndex, - itemIndex, - nodeCause: node.name, - }, - ); - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - value[key] = fieldValidationResult.newValue; - } - } - } - - return validationResult; -}; - -export const validateValueAgainstSchema = ( - node: INode, - nodeType: INodeType, - parameterValue: string | number | boolean | object | null | undefined, - parameterName: string, - runIndex: number, - itemIndex: number, -) => { - const parameterPath = parameterName.split('.'); - - const propertyDescription = nodeType.description.properties.find( - (prop) => - parameterPath[0] === prop.name && NodeHelpers.displayParameter(node.parameters, prop, node), - ); - - if (!propertyDescription) { - return parameterValue; - } - - let validationResult: ExtendedValidationResult = { valid: true, newValue: parameterValue }; - - if ( - parameterPath.length === 1 && - propertyDescription.validateType && - !propertyDescription.ignoreValidationDuringExecution - ) { - validationResult = validateFieldType( - parameterName, - parameterValue, - propertyDescription.validateType, - ); - } else if ( - propertyDescription.type === 'resourceMapper' && - parameterPath[1] === 'value' && - typeof parameterValue === 'object' - ) { - validationResult = validateResourceMapperValue( - parameterName, - parameterValue as { [key: string]: unknown }, - node, - propertyDescription.typeOptions?.resourceMapper?.mode !== 'add', - ); - } else if (['fixedCollection', 'collection'].includes(propertyDescription.type)) { - validationResult = validateCollection( - node, - runIndex, - itemIndex, - propertyDescription, - parameterPath, - validationResult, - ); - } - - if (!validationResult.valid) { - throw new ExpressionError( - `Invalid input for '${ - validationResult.fieldName - ? String(validationResult.fieldName) - : propertyDescription.displayName - }' [item ${itemIndex}]`, - { - description: validationResult.errorMessage, - runIndex, - itemIndex, - nodeCause: node.name, - }, - ); - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return validationResult.newValue; -}; - -export function ensureType( - toType: EnsureTypeOptions, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - parameterValue: any, - parameterName: string, - errorOptions?: { itemIndex?: number; runIndex?: number; nodeCause?: string }, -): string | number | boolean | object { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - let returnData = parameterValue; - - if (returnData === null) { - throw new ExpressionError(`Parameter '${parameterName}' must not be null`, errorOptions); - } - - if (returnData === undefined) { - throw new ExpressionError( - `Parameter '${parameterName}' could not be 'undefined'`, - errorOptions, - ); - } - - if (['object', 'array', 'json'].includes(toType)) { - if (typeof returnData !== 'object') { - // if value is not an object and is string try to parse it, else throw an error - if (typeof returnData === 'string' && returnData.length) { - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const parsedValue = JSON.parse(returnData); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - returnData = parsedValue; - } catch (error) { - throw new ExpressionError(`Parameter '${parameterName}' could not be parsed`, { - ...errorOptions, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - description: error.message, - }); - } - } else { - throw new ExpressionError( - `Parameter '${parameterName}' must be an ${toType}, but we got '${String(parameterValue)}'`, - errorOptions, - ); - } - } else if (toType === 'json') { - // value is an object, make sure it is valid JSON - try { - JSON.stringify(returnData); - } catch (error) { - throw new ExpressionError(`Parameter '${parameterName}' is not valid JSON`, { - ...errorOptions, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - description: error.message, - }); - } - } - - if (toType === 'array' && !Array.isArray(returnData)) { - // value is not an array, but has to be - throw new ExpressionError( - `Parameter '${parameterName}' must be an array, but we got object`, - errorOptions, - ); - } - } - - try { - if (toType === 'string') { - if (typeof returnData === 'object') { - returnData = JSON.stringify(returnData); - } else { - returnData = String(returnData); - } - } - - if (toType === 'number') { - returnData = Number(returnData); - if (Number.isNaN(returnData)) { - throw new ExpressionError( - `Parameter '${parameterName}' must be a number, but we got '${parameterValue}'`, - errorOptions, - ); - } - } - - if (toType === 'boolean') { - returnData = Boolean(returnData); - } - } catch (error) { - if (error instanceof ExpressionError) throw error; - - throw new ExpressionError(`Parameter '${parameterName}' could not be converted to ${toType}`, { - ...errorOptions, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - description: error.message, - }); - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return returnData; -} - -/** Returns the additional keys for Expressions and Function-Nodes */ -export function getAdditionalKeys( - additionalData: IWorkflowExecuteAdditionalData, - mode: WorkflowExecuteMode, - runExecutionData: IRunExecutionData | null, - options?: { secretsEnabled?: boolean }, -): IWorkflowDataProxyAdditionalKeys { - const executionId = additionalData.executionId ?? PLACEHOLDER_EMPTY_EXECUTION_ID; - const resumeUrl = `${additionalData.webhookWaitingBaseUrl}/${executionId}`; - const resumeFormUrl = `${additionalData.formWaitingBaseUrl}/${executionId}`; - return { - $execution: { - id: executionId, - mode: mode === 'manual' ? 'test' : 'production', - resumeUrl, - resumeFormUrl, - customData: runExecutionData - ? { - set(key: string, value: string): void { - try { - setWorkflowExecutionMetadata(runExecutionData, key, value); - } catch (e) { - if (mode === 'manual') { - throw e; - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access - LoggerProxy.debug(e.message); - } - }, - setAll(obj: Record): void { - try { - setAllWorkflowExecutionMetadata(runExecutionData, obj); - } catch (e) { - if (mode === 'manual') { - throw e; - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access - LoggerProxy.debug(e.message); - } - }, - get(key: string): string { - return getWorkflowExecutionMetadata(runExecutionData, key); - }, - getAll(): Record { - return getAllWorkflowExecutionMetadata(runExecutionData); - }, - } - : undefined, - }, - $vars: additionalData.variables, - $secrets: options?.secretsEnabled ? getSecretsProxy(additionalData) : undefined, - - // deprecated - $executionId: executionId, - $resumeWebhookUrl: resumeUrl, - }; -} diff --git a/packages/core/src/node-execution-context/utils/__tests__/cleanupParameterData.test.ts b/packages/core/src/node-execution-context/utils/__tests__/cleanupParameterData.test.ts new file mode 100644 index 0000000000..47913669b6 --- /dev/null +++ b/packages/core/src/node-execution-context/utils/__tests__/cleanupParameterData.test.ts @@ -0,0 +1,38 @@ +import toPlainObject from 'lodash/toPlainObject'; +import { DateTime } from 'luxon'; +import type { NodeParameterValue } from 'n8n-workflow'; + +import { cleanupParameterData } from '../cleanupParameterData'; + +describe('cleanupParameterData', () => { + it('should stringify Luxon dates in-place', () => { + const input = { x: 1, y: DateTime.now() as unknown as NodeParameterValue }; + expect(typeof input.y).toBe('object'); + cleanupParameterData(input); + expect(typeof input.y).toBe('string'); + }); + + it('should stringify plain Luxon dates in-place', () => { + const input = { + x: 1, + y: toPlainObject(DateTime.now()), + }; + expect(typeof input.y).toBe('object'); + cleanupParameterData(input); + expect(typeof input.y).toBe('string'); + }); + + it('should handle objects with nameless constructors', () => { + const input = { x: 1, y: { constructor: {} } as NodeParameterValue }; + expect(typeof input.y).toBe('object'); + cleanupParameterData(input); + expect(typeof input.y).toBe('object'); + }); + + it('should handle objects without a constructor', () => { + const input = { x: 1, y: { constructor: undefined } as unknown as NodeParameterValue }; + expect(typeof input.y).toBe('object'); + cleanupParameterData(input); + expect(typeof input.y).toBe('object'); + }); +}); diff --git a/packages/core/src/node-execution-context/utils/__tests__/ensureType.test.ts b/packages/core/src/node-execution-context/utils/__tests__/ensureType.test.ts new file mode 100644 index 0000000000..1637d988c9 --- /dev/null +++ b/packages/core/src/node-execution-context/utils/__tests__/ensureType.test.ts @@ -0,0 +1,80 @@ +import { ExpressionError } from 'n8n-workflow'; + +import { ensureType } from '../ensureType'; + +describe('ensureType', () => { + it('throws error for null value', () => { + expect(() => ensureType('string', null, 'myParam')).toThrowError( + new ExpressionError("Parameter 'myParam' must not be null"), + ); + }); + + it('throws error for undefined value', () => { + expect(() => ensureType('string', undefined, 'myParam')).toThrowError( + new ExpressionError("Parameter 'myParam' could not be 'undefined'"), + ); + }); + + it('returns string value without modification', () => { + const value = 'hello'; + const expectedValue = value; + const result = ensureType('string', value, 'myParam'); + expect(result).toBe(expectedValue); + }); + + it('returns number value without modification', () => { + const value = 42; + const expectedValue = value; + const result = ensureType('number', value, 'myParam'); + expect(result).toBe(expectedValue); + }); + + it('returns boolean value without modification', () => { + const value = true; + const expectedValue = value; + const result = ensureType('boolean', value, 'myParam'); + expect(result).toBe(expectedValue); + }); + + it('converts object to string if toType is string', () => { + const value = { name: 'John' }; + const expectedValue = JSON.stringify(value); + const result = ensureType('string', value, 'myParam'); + expect(result).toBe(expectedValue); + }); + + it('converts string to number if toType is number', () => { + const value = '10'; + const expectedValue = 10; + const result = ensureType('number', value, 'myParam'); + expect(result).toBe(expectedValue); + }); + + it('throws error for invalid conversion to number', () => { + const value = 'invalid'; + expect(() => ensureType('number', value, 'myParam')).toThrowError( + new ExpressionError("Parameter 'myParam' must be a number, but we got 'invalid'"), + ); + }); + + it('parses valid JSON string to object if toType is object', () => { + const value = '{"name": "Alice"}'; + const expectedValue = JSON.parse(value); + const result = ensureType('object', value, 'myParam'); + expect(result).toEqual(expectedValue); + }); + + it('throws error for invalid JSON string to object conversion', () => { + const value = 'invalid_json'; + expect(() => ensureType('object', value, 'myParam')).toThrowError( + new ExpressionError("Parameter 'myParam' could not be parsed"), + ); + }); + + it('throws error for non-array value if toType is array', () => { + const value = { name: 'Alice' }; + expect(() => ensureType('array', value, 'myParam')).toThrowError( + new ExpressionError("Parameter 'myParam' must be an array, but we got object"), + ); + }); +}); diff --git a/packages/core/src/node-execution-context/utils/__tests__/getAdditionalKeys.test.ts b/packages/core/src/node-execution-context/utils/__tests__/getAdditionalKeys.test.ts new file mode 100644 index 0000000000..6ac1fbdc07 --- /dev/null +++ b/packages/core/src/node-execution-context/utils/__tests__/getAdditionalKeys.test.ts @@ -0,0 +1,146 @@ +import { mock } from 'jest-mock-extended'; +import { LoggerProxy } from 'n8n-workflow'; +import type { + IDataObject, + IRunExecutionData, + IWorkflowExecuteAdditionalData, + SecretsHelpersBase, +} from 'n8n-workflow'; + +import { PLACEHOLDER_EMPTY_EXECUTION_ID } from '@/Constants'; + +import { getAdditionalKeys } from '../getAdditionalKeys'; + +describe('getAdditionalKeys', () => { + const secretsHelpers = mock(); + const additionalData = mock({ + executionId: '123', + webhookWaitingBaseUrl: 'https://webhook.test', + formWaitingBaseUrl: 'https://form.test', + variables: { testVar: 'value' }, + secretsHelpers, + }); + + const runExecutionData = mock({ + resultData: { + runData: {}, + metadata: {}, + }, + }); + + beforeAll(() => { + LoggerProxy.init(mock()); + secretsHelpers.hasProvider.mockReturnValue(true); + secretsHelpers.hasSecret.mockReturnValue(true); + secretsHelpers.getSecret.mockReturnValue('secret-value'); + secretsHelpers.listSecrets.mockReturnValue(['secret1']); + secretsHelpers.listProviders.mockReturnValue(['provider1']); + }); + + it('should use placeholder execution ID when none provided', () => { + const noIdData = { ...additionalData, executionId: undefined }; + const result = getAdditionalKeys(noIdData, 'manual', null); + + expect(result.$execution?.id).toBe(PLACEHOLDER_EMPTY_EXECUTION_ID); + }); + + it('should return production mode when not manual', () => { + const result = getAdditionalKeys(additionalData, 'internal', null); + + expect(result.$execution?.mode).toBe('production'); + }); + + it('should include customData methods when runExecutionData is provided', () => { + const result = getAdditionalKeys(additionalData, 'manual', runExecutionData); + + expect(result.$execution?.customData).toBeDefined(); + expect(typeof result.$execution?.customData?.set).toBe('function'); + expect(typeof result.$execution?.customData?.setAll).toBe('function'); + expect(typeof result.$execution?.customData?.get).toBe('function'); + expect(typeof result.$execution?.customData?.getAll).toBe('function'); + }); + + it('should handle customData operations correctly', () => { + const result = getAdditionalKeys(additionalData, 'manual', runExecutionData); + const customData = result.$execution?.customData; + + customData?.set('testKey', 'testValue'); + expect(customData?.get('testKey')).toBe('testValue'); + + customData?.setAll({ key1: 'value1', key2: 'value2' }); + const allData = customData?.getAll(); + expect(allData).toEqual({ + testKey: 'testValue', + key1: 'value1', + key2: 'value2', + }); + }); + + it('should include secrets when enabled', () => { + const result = getAdditionalKeys(additionalData, 'manual', null, { secretsEnabled: true }); + + expect(result.$secrets).toBeDefined(); + expect((result.$secrets?.provider1 as IDataObject).secret1).toEqual('secret-value'); + }); + + it('should not include secrets when disabled', () => { + const result = getAdditionalKeys(additionalData, 'manual', null, { secretsEnabled: false }); + + expect(result.$secrets).toBeUndefined(); + }); + + it('should throw errors in manual mode', () => { + const result = getAdditionalKeys(additionalData, 'manual', runExecutionData); + + expect(() => { + result.$execution?.customData?.set('invalid*key', 'value'); + }).toThrow(); + }); + + it('should correctly set resume URLs', () => { + const result = getAdditionalKeys(additionalData, 'manual', null); + + expect(result.$execution?.resumeUrl).toBe('https://webhook.test/123'); + expect(result.$execution?.resumeFormUrl).toBe('https://form.test/123'); + expect(result.$resumeWebhookUrl).toBe('https://webhook.test/123'); // Test deprecated property + }); + + it('should return test mode when manual', () => { + const result = getAdditionalKeys(additionalData, 'manual', null); + + expect(result.$execution?.mode).toBe('test'); + }); + + it('should return variables from additionalData', () => { + const result = getAdditionalKeys(additionalData, 'manual', null); + expect(result.$vars?.testVar).toEqual('value'); + }); + + it('should handle errors in non-manual mode without throwing', () => { + const result = getAdditionalKeys(additionalData, 'internal', runExecutionData); + const customData = result.$execution?.customData; + + expect(() => { + customData?.set('invalid*key', 'value'); + }).not.toThrow(); + }); + + it('should return undefined customData when runExecutionData is null', () => { + const result = getAdditionalKeys(additionalData, 'manual', null); + + expect(result.$execution?.customData).toBeUndefined(); + }); + + it('should respect metadata KV limit', () => { + const result = getAdditionalKeys(additionalData, 'manual', runExecutionData); + const customData = result.$execution?.customData; + + // Add 11 key-value pairs (exceeding the limit of 10) + for (let i = 0; i < 11; i++) { + customData?.set(`key${i}`, `value${i}`); + } + + const allData = customData?.getAll() ?? {}; + expect(Object.keys(allData)).toHaveLength(10); + }); +}); diff --git a/packages/core/src/node-execution-context/utils/__tests__/getInputConnectionData.test.ts b/packages/core/src/node-execution-context/utils/__tests__/getInputConnectionData.test.ts new file mode 100644 index 0000000000..4e634a196e --- /dev/null +++ b/packages/core/src/node-execution-context/utils/__tests__/getInputConnectionData.test.ts @@ -0,0 +1,366 @@ +import type { Tool } from '@langchain/core/tools'; +import { mock } from 'jest-mock-extended'; +import type { + INode, + ITaskDataConnections, + IRunExecutionData, + INodeExecutionData, + IExecuteData, + IWorkflowExecuteAdditionalData, + Workflow, + INodeType, + INodeTypes, +} from 'n8n-workflow'; +import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; + +import { ExecuteContext } from '../../execute-context'; + +describe('getInputConnectionData', () => { + const agentNode = mock({ + name: 'Test Agent', + type: 'test.agent', + parameters: {}, + }); + const agentNodeType = mock({ + description: { + inputs: [], + }, + }); + const nodeTypes = mock(); + const workflow = mock({ + id: 'test-workflow', + active: false, + nodeTypes, + }); + const runExecutionData = mock({ + resultData: { runData: {} }, + }); + const connectionInputData = [] as INodeExecutionData[]; + const inputData = {} as ITaskDataConnections; + const executeData = {} as IExecuteData; + + const hooks = mock>(); + const additionalData = mock({ hooks }); + + let executeContext: ExecuteContext; + + beforeEach(() => { + jest.clearAllMocks(); + + executeContext = new ExecuteContext( + workflow, + agentNode, + additionalData, + 'internal', + runExecutionData, + 0, + connectionInputData, + inputData, + executeData, + [], + ); + + jest.spyOn(executeContext, 'getNode').mockReturnValue(agentNode); + nodeTypes.getByNameAndVersion + .calledWith(agentNode.type, expect.anything()) + .mockReturnValue(agentNodeType); + }); + + describe.each([ + NodeConnectionType.AiAgent, + NodeConnectionType.AiChain, + NodeConnectionType.AiDocument, + NodeConnectionType.AiEmbedding, + NodeConnectionType.AiLanguageModel, + NodeConnectionType.AiMemory, + NodeConnectionType.AiOutputParser, + NodeConnectionType.AiRetriever, + NodeConnectionType.AiTextSplitter, + NodeConnectionType.AiVectorStore, + ] as const)('%s', (connectionType) => { + const response = mock(); + const node = mock({ + name: 'First Node', + type: 'test.type', + disabled: false, + }); + const secondNode = mock({ name: 'Second Node', disabled: false }); + const supplyData = jest.fn().mockResolvedValue({ response }); + const nodeType = mock({ supplyData }); + + beforeEach(() => { + nodeTypes.getByNameAndVersion + .calledWith(node.type, expect.anything()) + .mockReturnValue(nodeType); + workflow.getParentNodes + .calledWith(agentNode.name, connectionType) + .mockReturnValue([node.name]); + workflow.getNode.calledWith(node.name).mockReturnValue(node); + workflow.getNode.calledWith(secondNode.name).mockReturnValue(secondNode); + }); + + it('should throw when no inputs are defined', async () => { + agentNodeType.description.inputs = []; + + await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow( + 'Node does not have input of type', + ); + expect(supplyData).not.toHaveBeenCalled(); + }); + + it('should return undefined when no nodes are connected and input is not required', async () => { + agentNodeType.description.inputs = [ + { + type: connectionType, + maxConnections: 1, + required: false, + }, + ]; + workflow.getParentNodes.mockReturnValueOnce([]); + + const result = await executeContext.getInputConnectionData(connectionType, 0); + expect(result).toBeUndefined(); + expect(supplyData).not.toHaveBeenCalled(); + }); + + it('should throw when too many nodes are connected', async () => { + agentNodeType.description.inputs = [ + { + type: connectionType, + maxConnections: 1, + required: true, + }, + ]; + workflow.getParentNodes.mockReturnValueOnce([node.name, secondNode.name]); + + await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow( + `Only 1 ${connectionType} sub-nodes are/is allowed to be connected`, + ); + expect(supplyData).not.toHaveBeenCalled(); + }); + + it('should throw when required node is not connected', async () => { + agentNodeType.description.inputs = [ + { + type: connectionType, + required: true, + }, + ]; + workflow.getParentNodes.mockReturnValueOnce([]); + + await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow( + 'must be connected and enabled', + ); + expect(supplyData).not.toHaveBeenCalled(); + }); + + it('should handle disabled nodes', async () => { + agentNodeType.description.inputs = [ + { + type: connectionType, + required: true, + }, + ]; + + const disabledNode = mock({ + name: 'Disabled Node', + type: 'test.type', + disabled: true, + }); + workflow.getParentNodes.mockReturnValueOnce([disabledNode.name]); + workflow.getNode.calledWith(disabledNode.name).mockReturnValue(disabledNode); + + await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow( + 'must be connected and enabled', + ); + expect(supplyData).not.toHaveBeenCalled(); + }); + + it('should handle node execution errors', async () => { + agentNodeType.description.inputs = [ + { + type: connectionType, + required: true, + }, + ]; + + supplyData.mockRejectedValueOnce(new Error('supplyData error')); + + await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow( + `Error in sub-node ${node.name}`, + ); + expect(supplyData).toHaveBeenCalled(); + }); + + it('should propagate configuration errors', async () => { + agentNodeType.description.inputs = [ + { + type: connectionType, + required: true, + }, + ]; + + const configError = new NodeOperationError(node, 'Config Error in node', { + functionality: 'configuration-node', + }); + supplyData.mockRejectedValueOnce(configError); + + await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow( + configError.message, + ); + expect(nodeType.supplyData).toHaveBeenCalled(); + }); + + it('should handle close functions', async () => { + agentNodeType.description.inputs = [ + { + type: connectionType, + maxConnections: 1, + required: true, + }, + ]; + + const closeFunction = jest.fn(); + supplyData.mockResolvedValueOnce({ response, closeFunction }); + + const result = await executeContext.getInputConnectionData(connectionType, 0); + expect(result).toBe(response); + expect(supplyData).toHaveBeenCalled(); + // @ts-expect-error private property + expect(executeContext.closeFunctions).toContain(closeFunction); + }); + }); + + describe(NodeConnectionType.AiTool, () => { + const mockTool = mock(); + const toolNode = mock({ + name: 'Test Tool', + type: 'test.tool', + disabled: false, + }); + const supplyData = jest.fn().mockResolvedValue({ response: mockTool }); + const toolNodeType = mock({ supplyData }); + + const secondToolNode = mock({ name: 'test.secondTool', disabled: false }); + const secondMockTool = mock(); + const secondToolNodeType = mock({ + supplyData: jest.fn().mockResolvedValue({ response: secondMockTool }), + }); + + beforeEach(() => { + nodeTypes.getByNameAndVersion + .calledWith(toolNode.type, expect.anything()) + .mockReturnValue(toolNodeType); + workflow.getParentNodes + .calledWith(agentNode.name, NodeConnectionType.AiTool) + .mockReturnValue([toolNode.name]); + workflow.getNode.calledWith(toolNode.name).mockReturnValue(toolNode); + workflow.getNode.calledWith(secondToolNode.name).mockReturnValue(secondToolNode); + }); + + it('should return empty array when no tools are connected and input is not required', async () => { + agentNodeType.description.inputs = [ + { + type: NodeConnectionType.AiTool, + required: false, + }, + ]; + workflow.getParentNodes.mockReturnValueOnce([]); + + const result = await executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0); + expect(result).toEqual([]); + expect(supplyData).not.toHaveBeenCalled(); + }); + + it('should throw when required tool node is not connected', async () => { + agentNodeType.description.inputs = [ + { + type: NodeConnectionType.AiTool, + required: true, + }, + ]; + workflow.getParentNodes.mockReturnValueOnce([]); + + await expect( + executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0), + ).rejects.toThrow('must be connected and enabled'); + expect(supplyData).not.toHaveBeenCalled(); + }); + + it('should handle disabled tool nodes', async () => { + const disabledToolNode = mock({ + name: 'Disabled Tool', + type: 'test.tool', + disabled: true, + }); + + agentNodeType.description.inputs = [ + { + type: NodeConnectionType.AiTool, + required: true, + }, + ]; + + workflow.getParentNodes + .calledWith(agentNode.name, NodeConnectionType.AiTool) + .mockReturnValue([disabledToolNode.name]); + workflow.getNode.calledWith(disabledToolNode.name).mockReturnValue(disabledToolNode); + + await expect( + executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0), + ).rejects.toThrow('must be connected and enabled'); + expect(supplyData).not.toHaveBeenCalled(); + }); + + it('should handle multiple connected tools', async () => { + agentNodeType.description.inputs = [ + { + type: NodeConnectionType.AiTool, + required: true, + }, + ]; + + nodeTypes.getByNameAndVersion + .calledWith(secondToolNode.type, expect.anything()) + .mockReturnValue(secondToolNodeType); + + workflow.getParentNodes + .calledWith(agentNode.name, NodeConnectionType.AiTool) + .mockReturnValue([toolNode.name, secondToolNode.name]); + + const result = await executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0); + expect(result).toEqual([mockTool, secondMockTool]); + expect(supplyData).toHaveBeenCalled(); + expect(secondToolNodeType.supplyData).toHaveBeenCalled(); + }); + + it('should handle tool execution errors', async () => { + supplyData.mockRejectedValueOnce(new Error('Tool execution error')); + + agentNodeType.description.inputs = [ + { + type: NodeConnectionType.AiTool, + required: true, + }, + ]; + + await expect( + executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0), + ).rejects.toThrow(`Error in sub-node ${toolNode.name}`); + expect(supplyData).toHaveBeenCalled(); + }); + + it('should return the tool when there are no issues', async () => { + agentNodeType.description.inputs = [ + { + type: NodeConnectionType.AiTool, + required: true, + }, + ]; + + const result = await executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0); + expect(result).toEqual([mockTool]); + expect(supplyData).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/src/node-execution-context/__tests__/utils.test.ts b/packages/core/src/node-execution-context/utils/__tests__/validateValueAgainstSchema.test.ts similarity index 59% rename from packages/core/src/node-execution-context/__tests__/utils.test.ts rename to packages/core/src/node-execution-context/utils/__tests__/validateValueAgainstSchema.test.ts index 1871af4c0d..e09299a457 100644 --- a/packages/core/src/node-execution-context/__tests__/utils.test.ts +++ b/packages/core/src/node-execution-context/utils/__tests__/validateValueAgainstSchema.test.ts @@ -1,119 +1,6 @@ -import toPlainObject from 'lodash/toPlainObject'; -import { DateTime } from 'luxon'; -import type { IDataObject, INode, INodeType, NodeParameterValue } from 'n8n-workflow'; -import { ExpressionError } from 'n8n-workflow'; +import type { IDataObject, INode, INodeType } from 'n8n-workflow'; -import { cleanupParameterData, ensureType, validateValueAgainstSchema } from '../utils'; - -describe('cleanupParameterData', () => { - it('should stringify Luxon dates in-place', () => { - const input = { x: 1, y: DateTime.now() as unknown as NodeParameterValue }; - expect(typeof input.y).toBe('object'); - cleanupParameterData(input); - expect(typeof input.y).toBe('string'); - }); - - it('should stringify plain Luxon dates in-place', () => { - const input = { - x: 1, - y: toPlainObject(DateTime.now()), - }; - expect(typeof input.y).toBe('object'); - cleanupParameterData(input); - expect(typeof input.y).toBe('string'); - }); - - it('should handle objects with nameless constructors', () => { - const input = { x: 1, y: { constructor: {} } as NodeParameterValue }; - expect(typeof input.y).toBe('object'); - cleanupParameterData(input); - expect(typeof input.y).toBe('object'); - }); - - it('should handle objects without a constructor', () => { - const input = { x: 1, y: { constructor: undefined } as unknown as NodeParameterValue }; - expect(typeof input.y).toBe('object'); - cleanupParameterData(input); - expect(typeof input.y).toBe('object'); - }); -}); - -describe('ensureType', () => { - it('throws error for null value', () => { - expect(() => ensureType('string', null, 'myParam')).toThrowError( - new ExpressionError("Parameter 'myParam' must not be null"), - ); - }); - - it('throws error for undefined value', () => { - expect(() => ensureType('string', undefined, 'myParam')).toThrowError( - new ExpressionError("Parameter 'myParam' could not be 'undefined'"), - ); - }); - - it('returns string value without modification', () => { - const value = 'hello'; - const expectedValue = value; - const result = ensureType('string', value, 'myParam'); - expect(result).toBe(expectedValue); - }); - - it('returns number value without modification', () => { - const value = 42; - const expectedValue = value; - const result = ensureType('number', value, 'myParam'); - expect(result).toBe(expectedValue); - }); - - it('returns boolean value without modification', () => { - const value = true; - const expectedValue = value; - const result = ensureType('boolean', value, 'myParam'); - expect(result).toBe(expectedValue); - }); - - it('converts object to string if toType is string', () => { - const value = { name: 'John' }; - const expectedValue = JSON.stringify(value); - const result = ensureType('string', value, 'myParam'); - expect(result).toBe(expectedValue); - }); - - it('converts string to number if toType is number', () => { - const value = '10'; - const expectedValue = 10; - const result = ensureType('number', value, 'myParam'); - expect(result).toBe(expectedValue); - }); - - it('throws error for invalid conversion to number', () => { - const value = 'invalid'; - expect(() => ensureType('number', value, 'myParam')).toThrowError( - new ExpressionError("Parameter 'myParam' must be a number, but we got 'invalid'"), - ); - }); - - it('parses valid JSON string to object if toType is object', () => { - const value = '{"name": "Alice"}'; - const expectedValue = JSON.parse(value); - const result = ensureType('object', value, 'myParam'); - expect(result).toEqual(expectedValue); - }); - - it('throws error for invalid JSON string to object conversion', () => { - const value = 'invalid_json'; - expect(() => ensureType('object', value, 'myParam')).toThrowError( - new ExpressionError("Parameter 'myParam' could not be parsed"), - ); - }); - - it('throws error for non-array value if toType is array', () => { - const value = { name: 'Alice' }; - expect(() => ensureType('array', value, 'myParam')).toThrowError( - new ExpressionError("Parameter 'myParam' must be an array, but we got object"), - ); - }); -}); +import { validateValueAgainstSchema } from '../validateValueAgainstSchema'; describe('validateValueAgainstSchema', () => { test('should validate fixedCollection values parameter', () => { diff --git a/packages/core/src/node-execution-context/utils/cleanupParameterData.ts b/packages/core/src/node-execution-context/utils/cleanupParameterData.ts new file mode 100644 index 0000000000..59f27874ff --- /dev/null +++ b/packages/core/src/node-execution-context/utils/cleanupParameterData.ts @@ -0,0 +1,31 @@ +import { DateTime } from 'luxon'; +import type { INodeParameters, NodeParameterValueType } from 'n8n-workflow'; + +/** + * Clean up parameter data to make sure that only valid data gets returned + * INFO: Currently only converts Luxon Dates as we know for sure it will not be breaking + */ +export function cleanupParameterData(inputData: NodeParameterValueType): void { + if (typeof inputData !== 'object' || inputData === null) { + return; + } + + if (Array.isArray(inputData)) { + inputData.forEach((value) => cleanupParameterData(value as NodeParameterValueType)); + return; + } + + if (typeof inputData === 'object') { + Object.keys(inputData).forEach((key) => { + const value = (inputData as INodeParameters)[key]; + if (typeof value === 'object') { + if (DateTime.isDateTime(value)) { + // Is a special luxon date so convert to string + (inputData as INodeParameters)[key] = value.toString(); + } else { + cleanupParameterData(value); + } + } + }); + } +} diff --git a/packages/core/src/node-execution-context/utils/ensureType.ts b/packages/core/src/node-execution-context/utils/ensureType.ts new file mode 100644 index 0000000000..4869c73ba4 --- /dev/null +++ b/packages/core/src/node-execution-context/utils/ensureType.ts @@ -0,0 +1,103 @@ +import type { EnsureTypeOptions } from 'n8n-workflow'; +import { ExpressionError } from 'n8n-workflow'; + +export function ensureType( + toType: EnsureTypeOptions, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parameterValue: any, + parameterName: string, + errorOptions?: { itemIndex?: number; runIndex?: number; nodeCause?: string }, +): string | number | boolean | object { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + let returnData = parameterValue; + + if (returnData === null) { + throw new ExpressionError(`Parameter '${parameterName}' must not be null`, errorOptions); + } + + if (returnData === undefined) { + throw new ExpressionError( + `Parameter '${parameterName}' could not be 'undefined'`, + errorOptions, + ); + } + + if (['object', 'array', 'json'].includes(toType)) { + if (typeof returnData !== 'object') { + // if value is not an object and is string try to parse it, else throw an error + if (typeof returnData === 'string' && returnData.length) { + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const parsedValue = JSON.parse(returnData); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + returnData = parsedValue; + } catch (error) { + throw new ExpressionError(`Parameter '${parameterName}' could not be parsed`, { + ...errorOptions, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + description: error.message, + }); + } + } else { + throw new ExpressionError( + `Parameter '${parameterName}' must be an ${toType}, but we got '${String(parameterValue)}'`, + errorOptions, + ); + } + } else if (toType === 'json') { + // value is an object, make sure it is valid JSON + try { + JSON.stringify(returnData); + } catch (error) { + throw new ExpressionError(`Parameter '${parameterName}' is not valid JSON`, { + ...errorOptions, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + description: error.message, + }); + } + } + + if (toType === 'array' && !Array.isArray(returnData)) { + // value is not an array, but has to be + throw new ExpressionError( + `Parameter '${parameterName}' must be an array, but we got object`, + errorOptions, + ); + } + } + + try { + if (toType === 'string') { + if (typeof returnData === 'object') { + returnData = JSON.stringify(returnData); + } else { + returnData = String(returnData); + } + } + + if (toType === 'number') { + returnData = Number(returnData); + if (Number.isNaN(returnData)) { + throw new ExpressionError( + `Parameter '${parameterName}' must be a number, but we got '${parameterValue}'`, + errorOptions, + ); + } + } + + if (toType === 'boolean') { + returnData = Boolean(returnData); + } + } catch (error) { + if (error instanceof ExpressionError) throw error; + + throw new ExpressionError(`Parameter '${parameterName}' could not be converted to ${toType}`, { + ...errorOptions, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + description: error.message, + }); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return returnData; +} diff --git a/packages/core/src/node-execution-context/utils/getAdditionalKeys.ts b/packages/core/src/node-execution-context/utils/getAdditionalKeys.ts new file mode 100644 index 0000000000..28bf3b89f6 --- /dev/null +++ b/packages/core/src/node-execution-context/utils/getAdditionalKeys.ts @@ -0,0 +1,74 @@ +import type { + IRunExecutionData, + IWorkflowDataProxyAdditionalKeys, + IWorkflowExecuteAdditionalData, + WorkflowExecuteMode, +} from 'n8n-workflow'; +import { LoggerProxy } from 'n8n-workflow'; + +import { PLACEHOLDER_EMPTY_EXECUTION_ID } from '@/Constants'; +import { + setWorkflowExecutionMetadata, + setAllWorkflowExecutionMetadata, + getWorkflowExecutionMetadata, + getAllWorkflowExecutionMetadata, +} from '@/ExecutionMetadata'; +import { getSecretsProxy } from '@/Secrets'; + +/** Returns the additional keys for Expressions and Function-Nodes */ +export function getAdditionalKeys( + additionalData: IWorkflowExecuteAdditionalData, + mode: WorkflowExecuteMode, + runExecutionData: IRunExecutionData | null, + options?: { secretsEnabled?: boolean }, +): IWorkflowDataProxyAdditionalKeys { + const executionId = additionalData.executionId ?? PLACEHOLDER_EMPTY_EXECUTION_ID; + const resumeUrl = `${additionalData.webhookWaitingBaseUrl}/${executionId}`; + const resumeFormUrl = `${additionalData.formWaitingBaseUrl}/${executionId}`; + return { + $execution: { + id: executionId, + mode: mode === 'manual' ? 'test' : 'production', + resumeUrl, + resumeFormUrl, + customData: runExecutionData + ? { + set(key: string, value: string): void { + try { + setWorkflowExecutionMetadata(runExecutionData, key, value); + } catch (e) { + if (mode === 'manual') { + throw e; + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access + LoggerProxy.debug(e.message); + } + }, + setAll(obj: Record): void { + try { + setAllWorkflowExecutionMetadata(runExecutionData, obj); + } catch (e) { + if (mode === 'manual') { + throw e; + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access + LoggerProxy.debug(e.message); + } + }, + get(key: string): string { + return getWorkflowExecutionMetadata(runExecutionData, key); + }, + getAll(): Record { + return getAllWorkflowExecutionMetadata(runExecutionData); + }, + } + : undefined, + }, + $vars: additionalData.variables, + $secrets: options?.secretsEnabled ? getSecretsProxy(additionalData) : undefined, + + // deprecated + $executionId: executionId, + $resumeWebhookUrl: resumeUrl, + }; +} diff --git a/packages/core/src/node-execution-context/utils/getInputConnectionData.ts b/packages/core/src/node-execution-context/utils/getInputConnectionData.ts new file mode 100644 index 0000000000..231f672d65 --- /dev/null +++ b/packages/core/src/node-execution-context/utils/getInputConnectionData.ts @@ -0,0 +1,184 @@ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import type { + CloseFunction, + IExecuteData, + IExecuteFunctions, + INodeExecutionData, + IRunExecutionData, + ITaskDataConnections, + IWorkflowExecuteAdditionalData, + Workflow, + WorkflowExecuteMode, + SupplyData, + AINodeConnectionType, +} from 'n8n-workflow'; +import { + NodeConnectionType, + NodeOperationError, + ExecutionBaseError, + ApplicationError, +} from 'n8n-workflow'; + +import { createNodeAsTool } from '@/CreateNodeAsTool'; +// eslint-disable-next-line import/no-cycle +import { SupplyDataContext } from '@/node-execution-context'; +import type { ExecuteContext, WebhookContext } from '@/node-execution-context'; + +export async function getInputConnectionData( + this: ExecuteContext | WebhookContext | SupplyDataContext, + workflow: Workflow, + runExecutionData: IRunExecutionData, + parentRunIndex: number, + connectionInputData: INodeExecutionData[], + parentInputData: ITaskDataConnections, + additionalData: IWorkflowExecuteAdditionalData, + executeData: IExecuteData, + mode: WorkflowExecuteMode, + closeFunctions: CloseFunction[], + connectionType: AINodeConnectionType, + itemIndex: number, + abortSignal?: AbortSignal, +): Promise { + const parentNode = this.getNode(); + + const inputConfiguration = this.nodeInputs.find((input) => input.type === connectionType); + if (inputConfiguration === undefined) { + throw new ApplicationError('Node does not have input of type', { + extra: { nodeName: parentNode.name, connectionType }, + }); + } + + const connectedNodes = this.getConnectedNodes(connectionType); + if (connectedNodes.length === 0) { + if (inputConfiguration.required) { + throw new NodeOperationError( + parentNode, + `A ${inputConfiguration?.displayName ?? connectionType} sub-node must be connected and enabled`, + ); + } + return inputConfiguration.maxConnections === 1 ? undefined : []; + } + + if ( + inputConfiguration.maxConnections !== undefined && + connectedNodes.length > inputConfiguration.maxConnections + ) { + throw new NodeOperationError( + parentNode, + `Only ${inputConfiguration.maxConnections} ${connectionType} sub-nodes are/is allowed to be connected`, + ); + } + + const nodes: SupplyData[] = []; + for (const connectedNode of connectedNodes) { + const connectedNodeType = workflow.nodeTypes.getByNameAndVersion( + connectedNode.type, + connectedNode.typeVersion, + ); + const contextFactory = (runIndex: number, inputData: ITaskDataConnections) => + new SupplyDataContext( + workflow, + connectedNode, + additionalData, + mode, + runExecutionData, + runIndex, + connectionInputData, + inputData, + connectionType, + executeData, + closeFunctions, + abortSignal, + ); + + if (!connectedNodeType.supplyData) { + if (connectedNodeType.description.outputs.includes(NodeConnectionType.AiTool)) { + /** + * This keeps track of how many times this specific AI tool node has been invoked. + * It is incremented on every invocation of the tool to keep the output of each invocation separate from each other. + */ + let toolRunIndex = 0; + const supplyData = createNodeAsTool({ + node: connectedNode, + nodeType: connectedNodeType, + handleToolInvocation: async (toolArgs) => { + const runIndex = toolRunIndex++; + const context = contextFactory(runIndex, {}); + context.addInputData(NodeConnectionType.AiTool, [[{ json: toolArgs }]]); + + try { + // Execute the sub-node with the proxied context + const result = await connectedNodeType.execute?.call( + context as unknown as IExecuteFunctions, + ); + + // Process and map the results + const mappedResults = result?.[0]?.flatMap((item) => item.json); + + // Add output data to the context + context.addOutputData(NodeConnectionType.AiTool, runIndex, [ + [{ json: { response: mappedResults } }], + ]); + + // Return the stringified results + return JSON.stringify(mappedResults); + } catch (error) { + const nodeError = new NodeOperationError(connectedNode, error as Error); + context.addOutputData(NodeConnectionType.AiTool, runIndex, nodeError); + return 'Error during node execution: ' + nodeError.description; + } + }, + }); + nodes.push(supplyData); + } else { + throw new ApplicationError('Node does not have a `supplyData` method defined', { + extra: { nodeName: connectedNode.name }, + }); + } + } else { + const context = contextFactory(parentRunIndex, parentInputData); + try { + const supplyData = await connectedNodeType.supplyData.call(context, itemIndex); + if (supplyData.closeFunction) { + closeFunctions.push(supplyData.closeFunction); + } + nodes.push(supplyData); + } catch (error) { + // Propagate errors from sub-nodes + if (error instanceof ExecutionBaseError) { + if (error.functionality === 'configuration-node') throw error; + } else { + error = new NodeOperationError(connectedNode, error, { + itemIndex, + }); + } + + let currentNodeRunIndex = 0; + if (runExecutionData.resultData.runData.hasOwnProperty(parentNode.name)) { + currentNodeRunIndex = runExecutionData.resultData.runData[parentNode.name].length; + } + + // Display the error on the node which is causing it + await context.addExecutionDataFunctions( + 'input', + error, + connectionType, + parentNode.name, + currentNodeRunIndex, + ); + + // Display on the calling node which node has the error + throw new NodeOperationError(connectedNode, `Error in sub-node ${connectedNode.name}`, { + itemIndex, + functionality: 'configuration-node', + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + description: error.message, + }); + } + } + } + + return inputConfiguration.maxConnections === 1 + ? (nodes || [])[0]?.response + : nodes.map((node) => node.response); +} diff --git a/packages/core/src/node-execution-context/utils/validateValueAgainstSchema.ts b/packages/core/src/node-execution-context/utils/validateValueAgainstSchema.ts new file mode 100644 index 0000000000..adac8c3a78 --- /dev/null +++ b/packages/core/src/node-execution-context/utils/validateValueAgainstSchema.ts @@ -0,0 +1,222 @@ +import type { + FieldType, + IDataObject, + INode, + INodeProperties, + INodePropertyCollection, + INodePropertyOptions, + INodeType, +} from 'n8n-workflow'; +import { + ExpressionError, + isResourceMapperValue, + NodeHelpers, + validateFieldType, +} from 'n8n-workflow'; + +import type { ExtendedValidationResult } from '@/Interfaces'; + +const validateResourceMapperValue = ( + parameterName: string, + paramValues: { [key: string]: unknown }, + node: INode, + skipRequiredCheck = false, +): ExtendedValidationResult => { + const result: ExtendedValidationResult = { valid: true, newValue: paramValues }; + const paramNameParts = parameterName.split('.'); + if (paramNameParts.length !== 2) { + return result; + } + const resourceMapperParamName = paramNameParts[0]; + const resourceMapperField = node.parameters[resourceMapperParamName]; + if (!resourceMapperField || !isResourceMapperValue(resourceMapperField)) { + return result; + } + const schema = resourceMapperField.schema; + const paramValueNames = Object.keys(paramValues); + for (let i = 0; i < paramValueNames.length; i++) { + const key = paramValueNames[i]; + const resolvedValue = paramValues[key]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call + const schemaEntry = schema.find((s) => s.id === key); + + if ( + !skipRequiredCheck && + schemaEntry?.required === true && + schemaEntry.type !== 'boolean' && + !resolvedValue + ) { + return { + valid: false, + errorMessage: `The value "${String(key)}" is required but not set`, + fieldName: key, + }; + } + + if (schemaEntry?.type) { + const validationResult = validateFieldType(key, resolvedValue, schemaEntry.type, { + valueOptions: schemaEntry.options, + strict: !resourceMapperField.attemptToConvertTypes, + parseStrings: !!resourceMapperField.convertFieldsToString, + }); + + if (!validationResult.valid) { + if (!resourceMapperField.ignoreTypeMismatchErrors) { + return { ...validationResult, fieldName: key }; + } else { + paramValues[key] = resolvedValue; + } + } else { + // If it's valid, set the casted value + paramValues[key] = validationResult.newValue; + } + } + } + return result; +}; + +const validateCollection = ( + node: INode, + runIndex: number, + itemIndex: number, + propertyDescription: INodeProperties, + parameterPath: string[], + validationResult: ExtendedValidationResult, +): ExtendedValidationResult => { + let nestedDescriptions: INodeProperties[] | undefined; + + if (propertyDescription.type === 'fixedCollection') { + nestedDescriptions = (propertyDescription.options as INodePropertyCollection[]).find( + (entry) => entry.name === parameterPath[1], + )?.values; + } + + if (propertyDescription.type === 'collection') { + nestedDescriptions = propertyDescription.options as INodeProperties[]; + } + + if (!nestedDescriptions) { + return validationResult; + } + + const validationMap: { + [key: string]: { type: FieldType; displayName: string; options?: INodePropertyOptions[] }; + } = {}; + + for (const prop of nestedDescriptions) { + if (!prop.validateType || prop.ignoreValidationDuringExecution) continue; + + validationMap[prop.name] = { + type: prop.validateType, + displayName: prop.displayName, + options: + prop.validateType === 'options' ? (prop.options as INodePropertyOptions[]) : undefined, + }; + } + + if (!Object.keys(validationMap).length) { + return validationResult; + } + + if (validationResult.valid) { + for (const value of Array.isArray(validationResult.newValue) + ? (validationResult.newValue as IDataObject[]) + : [validationResult.newValue as IDataObject]) { + for (const key of Object.keys(value)) { + if (!validationMap[key]) continue; + + const fieldValidationResult = validateFieldType(key, value[key], validationMap[key].type, { + valueOptions: validationMap[key].options, + }); + + if (!fieldValidationResult.valid) { + throw new ExpressionError( + `Invalid input for field '${validationMap[key].displayName}' inside '${propertyDescription.displayName}' in [item ${itemIndex}]`, + { + description: fieldValidationResult.errorMessage, + runIndex, + itemIndex, + nodeCause: node.name, + }, + ); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + value[key] = fieldValidationResult.newValue; + } + } + } + + return validationResult; +}; + +export const validateValueAgainstSchema = ( + node: INode, + nodeType: INodeType, + parameterValue: string | number | boolean | object | null | undefined, + parameterName: string, + runIndex: number, + itemIndex: number, +) => { + const parameterPath = parameterName.split('.'); + + const propertyDescription = nodeType.description.properties.find( + (prop) => + parameterPath[0] === prop.name && NodeHelpers.displayParameter(node.parameters, prop, node), + ); + + if (!propertyDescription) { + return parameterValue; + } + + let validationResult: ExtendedValidationResult = { valid: true, newValue: parameterValue }; + + if ( + parameterPath.length === 1 && + propertyDescription.validateType && + !propertyDescription.ignoreValidationDuringExecution + ) { + validationResult = validateFieldType( + parameterName, + parameterValue, + propertyDescription.validateType, + ); + } else if ( + propertyDescription.type === 'resourceMapper' && + parameterPath[1] === 'value' && + typeof parameterValue === 'object' + ) { + validationResult = validateResourceMapperValue( + parameterName, + parameterValue as { [key: string]: unknown }, + node, + propertyDescription.typeOptions?.resourceMapper?.mode !== 'add', + ); + } else if (['fixedCollection', 'collection'].includes(propertyDescription.type)) { + validationResult = validateCollection( + node, + runIndex, + itemIndex, + propertyDescription, + parameterPath, + validationResult, + ); + } + + if (!validationResult.valid) { + throw new ExpressionError( + `Invalid input for '${ + validationResult.fieldName + ? String(validationResult.fieldName) + : propertyDescription.displayName + }' [item ${itemIndex}]`, + { + description: validationResult.errorMessage, + runIndex, + itemIndex, + nodeCause: node.name, + }, + ); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return validationResult.newValue; +}; diff --git a/packages/core/src/node-execution-context/webhook-context.ts b/packages/core/src/node-execution-context/webhook-context.ts index 04d1df5e40..9d131a4103 100644 --- a/packages/core/src/node-execution-context/webhook-context.ts +++ b/packages/core/src/node-execution-context/webhook-context.ts @@ -22,13 +22,13 @@ import { ApplicationError, createDeferredPromise } from 'n8n-workflow'; import { copyBinaryFile, getBinaryHelperFunctions, - getInputConnectionData, getNodeWebhookUrl, getRequestHelperFunctions, returnJsonArray, } from '@/NodeExecuteFunctions'; import { NodeExecutionContext } from './node-execution-context'; +import { getInputConnectionData } from './utils/getInputConnectionData'; export class WebhookContext extends NodeExecutionContext implements IWebhookFunctions { readonly helpers: IWebhookFunctions['helpers']; diff --git a/packages/core/src/node-execution-context/workflow-node-context.ts b/packages/core/src/node-execution-context/workflow-node-context.ts new file mode 100644 index 0000000000..18de159e4b --- /dev/null +++ b/packages/core/src/node-execution-context/workflow-node-context.ts @@ -0,0 +1,36 @@ +import type { + IGetNodeParameterOptions, + INode, + IWorkflowExecuteAdditionalData, + Workflow, + IWorkflowNodeContext, +} from 'n8n-workflow'; + +import { NodeExecutionContext } from './node-execution-context'; + +export class LoadWorkflowNodeContext extends NodeExecutionContext implements IWorkflowNodeContext { + // Note that this differs from and does not shadow the function with the + // same name in `NodeExecutionContext`, as it has the `itemIndex` parameter + readonly getNodeParameter: IWorkflowNodeContext['getNodeParameter']; + + constructor(workflow: Workflow, node: INode, additionalData: IWorkflowExecuteAdditionalData) { + super(workflow, node, additionalData, 'internal'); + { + // We need to cast due to the overloaded IWorkflowNodeContext::getNodeParameter function + // Which would require us to replicate all overload return types, as TypeScript offers + // no convenient solution to refer to a set of overloads. + this.getNodeParameter = (( + parameterName: string, + itemIndex: number, + fallbackValue?: unknown, + options?: IGetNodeParameterOptions, + ) => + this._getNodeParameter( + parameterName, + itemIndex, + fallbackValue, + options, + )) as IWorkflowNodeContext['getNodeParameter']; + } + } +} diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts new file mode 100644 index 0000000000..c0d29c83dc --- /dev/null +++ b/packages/core/src/utils.ts @@ -0,0 +1,18 @@ +type ObjectLiteral = { [key: string | symbol]: unknown }; + +/** + * Checks if the provided value is a plain object literal (not null, not an array, not a class instance, and not a primitive). + * This function serves as a type guard. + * + * @param candidate - The value to check + * @returns {boolean} True if the value is an object literal, false otherwise + */ +export function isObjectLiteral(candidate: unknown): candidate is ObjectLiteral { + return ( + typeof candidate === 'object' && + candidate !== null && + !Array.isArray(candidate) && + // eslint-disable-next-line @typescript-eslint/ban-types + (Object.getPrototypeOf(candidate) as Object)?.constructor?.name === 'Object' + ); +} diff --git a/packages/core/test/Cipher.test.ts b/packages/core/test/Cipher.test.ts index e3dfa609fa..7f6bcdedf3 100644 --- a/packages/core/test/Cipher.test.ts +++ b/packages/core/test/Cipher.test.ts @@ -1,4 +1,4 @@ -import Container from 'typedi'; +import { Container } from '@n8n/di'; import { Cipher } from '@/Cipher'; import { InstanceSettings } from '@/InstanceSettings'; diff --git a/packages/core/test/Credentials.test.ts b/packages/core/test/Credentials.test.ts index fa7be59267..bf6ba29ca5 100644 --- a/packages/core/test/Credentials.test.ts +++ b/packages/core/test/Credentials.test.ts @@ -1,6 +1,6 @@ +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import type { CredentialInformation } from 'n8n-workflow'; -import { Container } from 'typedi'; import { Cipher } from '@/Cipher'; import { Credentials } from '@/Credentials'; diff --git a/packages/core/test/InstanceSettings.test.ts b/packages/core/test/InstanceSettings.test.ts index b6a9cf63c4..1fe96d3490 100644 --- a/packages/core/test/InstanceSettings.test.ts +++ b/packages/core/test/InstanceSettings.test.ts @@ -4,6 +4,9 @@ import * as fs from 'node:fs'; import { InstanceSettings } from '@/InstanceSettings'; import { InstanceSettingsConfig } from '@/InstanceSettingsConfig'; +import { Logger } from '@/logging/logger'; + +import { mockInstance } from './utils'; describe('InstanceSettings', () => { const userFolder = '/test'; @@ -11,12 +14,16 @@ describe('InstanceSettings', () => { const settingsFile = `${userFolder}/.n8n/config`; const mockFs = mock(fs); + const logger = mockInstance(Logger); const createInstanceSettings = (opts?: Partial) => - new InstanceSettings({ - ...new InstanceSettingsConfig(), - ...opts, - }); + new InstanceSettings( + { + ...new InstanceSettingsConfig(), + ...opts, + }, + logger, + ); beforeEach(() => { jest.resetAllMocks(); @@ -203,7 +210,7 @@ describe('InstanceSettings', () => { mockFs.readFileSync .calledWith(settingsFile) .mockReturnValue(JSON.stringify({ encryptionKey: 'test_key' })); - settings = new InstanceSettings(mock()); + settings = createInstanceSettings(); }); it('should return true if /.dockerenv exists', () => { diff --git a/packages/core/test/NodeExecuteFunctions.test.ts b/packages/core/test/NodeExecuteFunctions.test.ts index b1b6e96577..703e3f9bef 100644 --- a/packages/core/test/NodeExecuteFunctions.test.ts +++ b/packages/core/test/NodeExecuteFunctions.test.ts @@ -1,3 +1,4 @@ +import { Container } from '@n8n/di'; import FormData from 'form-data'; import { mkdtempSync, readFileSync } from 'fs'; import { IncomingMessage } from 'http'; @@ -19,7 +20,6 @@ import { tmpdir } from 'os'; import { join } from 'path'; import { Readable } from 'stream'; import type { SecureContextOptions } from 'tls'; -import Container from 'typedi'; import { BinaryDataService } from '@/BinaryData/BinaryData.service'; import { InstanceSettings } from '@/InstanceSettings'; diff --git a/packages/core/test/ObjectStore.service.test.ts b/packages/core/test/ObjectStore.service.test.ts index 77936c20f0..9899ad17fc 100644 --- a/packages/core/test/ObjectStore.service.test.ts +++ b/packages/core/test/ObjectStore.service.test.ts @@ -1,4 +1,5 @@ import axios from 'axios'; +import { mock } from 'jest-mock-extended'; import { Readable } from 'stream'; import { ObjectStoreService } from '@/ObjectStore/ObjectStore.service.ee'; @@ -25,7 +26,7 @@ const toDeletionXml = (filename: string) => ` let objectStoreService: ObjectStoreService; beforeEach(async () => { - objectStoreService = new ObjectStoreService(); + objectStoreService = new ObjectStoreService(mock()); mockAxios.request.mockResolvedValueOnce({ status: 200 }); // for checkConnection await objectStoreService.init(mockHost, mockBucket, mockCredentials); jest.restoreAllMocks(); diff --git a/packages/core/test/SerializedBuffer.test.ts b/packages/core/test/SerializedBuffer.test.ts index 4243729629..95d7213401 100644 --- a/packages/core/test/SerializedBuffer.test.ts +++ b/packages/core/test/SerializedBuffer.test.ts @@ -7,7 +7,7 @@ const validSerializedBuffer: SerializedBuffer = { data: [65, 66, 67], // Corresponds to 'ABC' in ASCII }; -describe('serializedBufferToBuffer', () => { +describe('toBuffer', () => { it('should convert a SerializedBuffer to a Buffer', () => { const buffer = toBuffer(validSerializedBuffer); expect(buffer).toBeInstanceOf(Buffer); @@ -43,7 +43,7 @@ describe('isSerializedBuffer', () => { }); }); -describe('Integration: serializedBufferToBuffer and isSerializedBuffer', () => { +describe('Integration: toBuffer and isSerializedBuffer', () => { it('should correctly validate and convert a SerializedBuffer', () => { if (isSerializedBuffer(validSerializedBuffer)) { const buffer = toBuffer(validSerializedBuffer); diff --git a/packages/core/test/TriggersAndPollers.test.ts b/packages/core/test/TriggersAndPollers.test.ts index c30a0693a6..27cc8b47d9 100644 --- a/packages/core/test/TriggersAndPollers.test.ts +++ b/packages/core/test/TriggersAndPollers.test.ts @@ -9,6 +9,8 @@ import type { INodeType, INodeTypes, ITriggerFunctions, + WorkflowHooks, + IRun, } from 'n8n-workflow'; import { TriggersAndPollers } from '@/TriggersAndPollers'; @@ -21,11 +23,13 @@ describe('TriggersAndPollers', () => { }); const nodeTypes = mock(); const workflow = mock({ nodeTypes }); + const hookFunctions = mock({ + sendResponse: [], + workflowExecuteAfter: [], + }); const additionalData = mock({ hooks: { - hookFunctions: { - sendResponse: [], - }, + hookFunctions, }, }); const triggersAndPollers = new TriggersAndPollers(); @@ -39,87 +43,80 @@ describe('TriggersAndPollers', () => { const triggerFunctions = mock(); const getTriggerFunctions = jest.fn().mockReturnValue(triggerFunctions); const triggerFn = jest.fn(); + const mockEmitData: INodeExecutionData[][] = [[{ json: { data: 'test' } }]]; + + const runTriggerHelper = async (mode: 'manual' | 'trigger' = 'trigger') => + await triggersAndPollers.runTrigger( + workflow, + node, + getTriggerFunctions, + additionalData, + mode, + 'init', + ); it('should throw error if node type does not have trigger function', async () => { - await expect( - triggersAndPollers.runTrigger( - workflow, - node, - getTriggerFunctions, - additionalData, - 'trigger', - 'init', - ), - ).rejects.toThrow(ApplicationError); + await expect(runTriggerHelper()).rejects.toThrow(ApplicationError); }); it('should call trigger function in regular mode', async () => { nodeType.trigger = triggerFn; triggerFn.mockResolvedValue({ test: true }); - const result = await triggersAndPollers.runTrigger( - workflow, - node, - getTriggerFunctions, - additionalData, - 'trigger', - 'init', - ); + const result = await runTriggerHelper(); expect(triggerFn).toHaveBeenCalled(); expect(result).toEqual({ test: true }); }); - it('should handle manual mode with promise resolution', async () => { - const mockEmitData: INodeExecutionData[][] = [[{ json: { data: 'test' } }]]; - const mockTriggerResponse = { workflowId: '123' }; + describe('manual mode', () => { + const getMockTriggerFunctions = () => getTriggerFunctions.mock.results[0]?.value; - nodeType.trigger = triggerFn; - triggerFn.mockResolvedValue(mockTriggerResponse); + beforeEach(() => { + nodeType.trigger = triggerFn; + triggerFn.mockResolvedValue({ workflowId: '123' }); + }); - const result = await triggersAndPollers.runTrigger( - workflow, - node, - getTriggerFunctions, - additionalData, - 'manual', - 'init', - ); + it('should handle promise resolution', async () => { + const result = await runTriggerHelper('manual'); - expect(result).toBeDefined(); - expect(result?.manualTriggerResponse).toBeInstanceOf(Promise); + expect(result?.manualTriggerResponse).toBeInstanceOf(Promise); + getMockTriggerFunctions()?.emit?.(mockEmitData); + }); - // Simulate emit - const mockTriggerFunctions = getTriggerFunctions.mock.results[0]?.value; - if (mockTriggerFunctions?.emit) { - mockTriggerFunctions.emit(mockEmitData); - } - }); + it('should handle error emission', async () => { + const testError = new Error('Test error'); + const result = await runTriggerHelper('manual'); - it('should handle error emission in manual mode', async () => { - const testError = new Error('Test error'); + getMockTriggerFunctions()?.emitError?.(testError); + await expect(result?.manualTriggerResponse).rejects.toThrow(testError); + }); - nodeType.trigger = triggerFn; - triggerFn.mockResolvedValue({}); + it('should handle response promise', async () => { + const responsePromise = { resolve: jest.fn(), reject: jest.fn() }; + await runTriggerHelper('manual'); - const result = await triggersAndPollers.runTrigger( - workflow, - node, - getTriggerFunctions, - additionalData, - 'manual', - 'init', - ); + getMockTriggerFunctions()?.emit?.(mockEmitData, responsePromise); - expect(result?.manualTriggerResponse).toBeInstanceOf(Promise); + expect(hookFunctions.sendResponse?.length).toBe(1); + await hookFunctions.sendResponse![0]?.({ testResponse: true }); + expect(responsePromise.resolve).toHaveBeenCalledWith({ testResponse: true }); + }); - // Simulate error - const mockTriggerFunctions = getTriggerFunctions.mock.results[0]?.value; - if (mockTriggerFunctions?.emitError) { - mockTriggerFunctions.emitError(testError); - } + it('should handle both response and done promises', async () => { + const responsePromise = { resolve: jest.fn(), reject: jest.fn() }; + const donePromise = { resolve: jest.fn(), reject: jest.fn() }; + const mockRunData = mock({ data: { resultData: { runData: {} } } }); - await expect(result?.manualTriggerResponse).rejects.toThrow(testError); + await runTriggerHelper('manual'); + getMockTriggerFunctions()?.emit?.(mockEmitData, responsePromise, donePromise); + + await hookFunctions.sendResponse![0]?.({ testResponse: true }); + expect(responsePromise.resolve).toHaveBeenCalledWith({ testResponse: true }); + + await hookFunctions.workflowExecuteAfter?.[0]?.(mockRunData, {}); + expect(donePromise.resolve).toHaveBeenCalledWith(mockRunData); + }); }); }); @@ -127,10 +124,11 @@ describe('TriggersAndPollers', () => { const pollFunctions = mock(); const pollFn = jest.fn(); + const runPollHelper = async () => + await triggersAndPollers.runPoll(workflow, node, pollFunctions); + it('should throw error if node type does not have poll function', async () => { - await expect(triggersAndPollers.runPoll(workflow, node, pollFunctions)).rejects.toThrow( - ApplicationError, - ); + await expect(runPollHelper()).rejects.toThrow(ApplicationError); }); it('should call poll function and return result', async () => { @@ -138,7 +136,7 @@ describe('TriggersAndPollers', () => { nodeType.poll = pollFn; pollFn.mockResolvedValue(mockPollResult); - const result = await triggersAndPollers.runPoll(workflow, node, pollFunctions); + const result = await runPollHelper(); expect(pollFn).toHaveBeenCalled(); expect(result).toBe(mockPollResult); @@ -148,10 +146,18 @@ describe('TriggersAndPollers', () => { nodeType.poll = pollFn; pollFn.mockResolvedValue(null); - const result = await triggersAndPollers.runPoll(workflow, node, pollFunctions); + const result = await runPollHelper(); expect(pollFn).toHaveBeenCalled(); expect(result).toBeNull(); }); + + it('should propagate errors from poll function', async () => { + nodeType.poll = pollFn; + pollFn.mockRejectedValue(new Error('Poll function failed')); + + await expect(runPollHelper()).rejects.toThrow('Poll function failed'); + expect(pollFn).toHaveBeenCalled(); + }); }); }); diff --git a/packages/core/test/WorkflowExecute.test.ts b/packages/core/test/WorkflowExecute.test.ts index 6a826a8118..6ab3afdaeb 100644 --- a/packages/core/test/WorkflowExecute.test.ts +++ b/packages/core/test/WorkflowExecute.test.ts @@ -12,8 +12,11 @@ import { mock } from 'jest-mock-extended'; import { pick } from 'lodash'; import type { + ExecutionBaseError, + IConnection, IExecuteData, INode, + INodeExecutionData, INodeType, INodeTypes, IPinData, @@ -23,10 +26,12 @@ import type { ITriggerResponse, IWorkflowExecuteAdditionalData, WorkflowTestData, + RelatedExecution, } from 'n8n-workflow'; import { ApplicationError, createDeferredPromise, + NodeConnectionType, NodeExecutionOutput, NodeHelpers, Workflow, @@ -604,4 +609,752 @@ describe('WorkflowExecute', () => { expect(triggerResponse.closeFunction).toHaveBeenCalled(); }); }); + + describe('handleNodeErrorOutput', () => { + const testNode: INode = { + id: '1', + name: 'Node1', + type: 'test.set', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }; + + const nodeType = mock({ + description: { + name: 'test', + displayName: 'test', + defaultVersion: 1, + properties: [], + inputs: [{ type: NodeConnectionType.Main }], + outputs: [ + { type: NodeConnectionType.Main }, + { type: NodeConnectionType.Main, category: 'error' }, + ], + }, + }); + + const nodeTypes = mock(); + + const workflow = new Workflow({ + id: 'test', + nodes: [testNode], + connections: {}, + active: false, + nodeTypes, + }); + + const executionData = { + node: workflow.nodes.Node1, + data: { + main: [ + [ + { + json: { data: 'test' }, + pairedItem: { item: 0, input: 0 }, + }, + ], + ], + }, + source: { + [NodeConnectionType.Main]: [ + { + previousNode: 'previousNode', + previousNodeOutput: 0, + previousNodeRun: 0, + }, + ], + }, + }; + + const runExecutionData: IRunExecutionData = { + resultData: { + runData: { + previousNode: [ + { + data: { + main: [[{ json: { someData: 'test' } }]], + }, + source: [], + startTime: 0, + executionTime: 0, + }, + ], + }, + }, + }; + + let workflowExecute: WorkflowExecute; + + beforeEach(() => { + jest.clearAllMocks(); + + nodeTypes.getByNameAndVersion.mockReturnValue(nodeType); + + workflowExecute = new WorkflowExecute(mock(), 'manual', runExecutionData); + }); + + test('should handle undefined error data input correctly', () => { + const nodeSuccessData: INodeExecutionData[][] = [ + [undefined as unknown as INodeExecutionData], + ]; + workflowExecute.handleNodeErrorOutput(workflow, executionData, nodeSuccessData, 0); + expect(nodeSuccessData[0]).toEqual([undefined]); + expect(nodeSuccessData[1]).toEqual([]); + }); + + test('should handle empty input', () => { + const nodeSuccessData: INodeExecutionData[][] = [[]]; + + workflowExecute.handleNodeErrorOutput(workflow, executionData, nodeSuccessData, 0); + + expect(nodeSuccessData[0]).toHaveLength(0); + expect(nodeSuccessData[1]).toHaveLength(0); + }); + + test('should route error items to last output', () => { + const nodeSuccessData: INodeExecutionData[][] = [ + [ + { + json: { error: 'Test error', additionalData: 'preserved' }, + pairedItem: { item: 0, input: 0 }, + }, + { + json: { regularData: 'success' }, + pairedItem: { item: 1, input: 0 }, + }, + ], + ]; + + workflowExecute.handleNodeErrorOutput(workflow, executionData, nodeSuccessData, 0); + + expect(nodeSuccessData[0]).toEqual([ + { + json: { additionalData: 'preserved', error: 'Test error' }, + pairedItem: { item: 0, input: 0 }, + }, + { json: { regularData: 'success' }, pairedItem: { item: 1, input: 0 } }, + ]); + expect(nodeSuccessData[1]).toEqual([]); + }); + + test('should handle error in json with message property', () => { + const nodeSuccessData: INodeExecutionData[][] = [ + [ + { + json: { + error: 'Error occurred', + message: 'Error details', + }, + pairedItem: { item: 0, input: 0 }, + }, + ], + ]; + + workflowExecute.handleNodeErrorOutput(workflow, executionData, nodeSuccessData, 0); + + expect(nodeSuccessData[0]).toEqual([]); + expect(nodeSuccessData[1]).toEqual([ + { + json: { + error: 'Error occurred', + message: 'Error details', + someData: 'test', + }, + pairedItem: { item: 0, input: 0 }, + }, + ]); + }); + + test('should preserve pairedItem data when routing errors', () => { + const nodeSuccessData: INodeExecutionData[][] = [ + [ + { + json: { error: 'Test error' }, + pairedItem: [ + { item: 0, input: 0 }, + { item: 1, input: 1 }, + ], + }, + ], + ]; + + workflowExecute.handleNodeErrorOutput(workflow, executionData, nodeSuccessData, 0); + + expect(nodeSuccessData[0]).toEqual([]); + expect(nodeSuccessData[1]).toEqual([ + { + json: { someData: 'test', error: 'Test error' }, + pairedItem: [ + { item: 0, input: 0 }, + { item: 1, input: 1 }, + ], + }, + ]); + }); + + test('should route multiple error items correctly', () => { + const nodeSuccessData: INodeExecutionData[][] = [ + [ + { + json: { error: 'Error 1', data: 'preserved1' }, + pairedItem: { item: 0, input: 0 }, + }, + { + json: { error: 'Error 2', data: 'preserved2' }, + pairedItem: { item: 1, input: 0 }, + }, + ], + ]; + + workflowExecute.handleNodeErrorOutput(workflow, executionData, nodeSuccessData, 0); + + expect(nodeSuccessData[1]).toEqual([]); + expect(nodeSuccessData[0]).toEqual([ + { + json: { error: 'Error 1', data: 'preserved1' }, + pairedItem: { item: 0, input: 0 }, + }, + { + json: { error: 'Error 2', data: 'preserved2' }, + pairedItem: { item: 1, input: 0 }, + }, + ]); + }); + + test('should handle complex pairedItem data correctly', () => { + const nodeSuccessData: INodeExecutionData[][] = [ + [ + { + json: { error: 'Test error' }, + pairedItem: [ + { item: 0, input: 0 }, + { item: 1, input: 1 }, + ], + }, + ], + ]; + + workflowExecute.handleNodeErrorOutput(workflow, executionData, nodeSuccessData, 0); + + expect(nodeSuccessData[0]).toEqual([]); + expect(nodeSuccessData[1]).toEqual([ + { + json: { someData: 'test', error: 'Test error' }, + pairedItem: [ + { item: 0, input: 0 }, + { item: 1, input: 1 }, + ], + }, + ]); + }); + }); + + describe('prepareWaitingToExecution', () => { + let runExecutionData: IRunExecutionData; + let workflowExecute: WorkflowExecute; + + beforeEach(() => { + runExecutionData = { + startData: {}, + resultData: { + runData: {}, + pinData: {}, + }, + executionData: { + contextData: {}, + nodeExecutionStack: [], + metadata: {}, + waitingExecution: {}, + waitingExecutionSource: {}, + }, + }; + workflowExecute = new WorkflowExecute(mock(), 'manual', runExecutionData); + }); + + test('should initialize waitingExecutionSource if undefined', () => { + runExecutionData.executionData!.waitingExecutionSource = null; + const nodeName = 'testNode'; + const numberOfConnections = 2; + const runIndex = 0; + + workflowExecute.prepareWaitingToExecution(nodeName, numberOfConnections, runIndex); + + expect(runExecutionData.executionData?.waitingExecutionSource).toBeDefined(); + }); + + test('should create arrays of correct length with null values', () => { + const nodeName = 'testNode'; + const numberOfConnections = 3; + const runIndex = 0; + runExecutionData.executionData!.waitingExecution[nodeName] = {}; + + workflowExecute.prepareWaitingToExecution(nodeName, numberOfConnections, runIndex); + + const nodeWaiting = runExecutionData.executionData!.waitingExecution[nodeName]; + const nodeWaitingSource = runExecutionData.executionData!.waitingExecutionSource![nodeName]; + + expect(nodeWaiting[runIndex].main).toHaveLength(3); + expect(nodeWaiting[runIndex].main).toEqual([null, null, null]); + expect(nodeWaitingSource[runIndex].main).toHaveLength(3); + expect(nodeWaitingSource[runIndex].main).toEqual([null, null, null]); + }); + + test('should work with zero connections', () => { + const nodeName = 'testNode'; + const numberOfConnections = 0; + const runIndex = 0; + runExecutionData.executionData!.waitingExecution[nodeName] = {}; + + workflowExecute.prepareWaitingToExecution(nodeName, numberOfConnections, runIndex); + + expect( + runExecutionData.executionData!.waitingExecution[nodeName][runIndex].main, + ).toHaveLength(0); + expect( + runExecutionData.executionData!.waitingExecutionSource![nodeName][runIndex].main, + ).toHaveLength(0); + }); + + test('should handle multiple run indices', () => { + const nodeName = 'testNode'; + const numberOfConnections = 2; + runExecutionData.executionData!.waitingExecution[nodeName] = {}; + + workflowExecute.prepareWaitingToExecution(nodeName, numberOfConnections, 0); + workflowExecute.prepareWaitingToExecution(nodeName, numberOfConnections, 1); + + const nodeWaiting = runExecutionData.executionData!.waitingExecution[nodeName]; + const nodeWaitingSource = runExecutionData.executionData!.waitingExecutionSource![nodeName]; + + expect(nodeWaiting[0].main).toHaveLength(2); + expect(nodeWaiting[1].main).toHaveLength(2); + expect(nodeWaitingSource[0].main).toHaveLength(2); + expect(nodeWaitingSource[1].main).toHaveLength(2); + }); + }); + + describe('incomingConnectionIsEmpty', () => { + let workflowExecute: WorkflowExecute; + + beforeEach(() => { + workflowExecute = new WorkflowExecute(mock(), 'manual'); + }); + + test('should return true when there are no input connections', () => { + const result = workflowExecute.incomingConnectionIsEmpty({}, [], 0); + expect(result).toBe(true); + }); + + test('should return true when all input connections have no data', () => { + const runData: IRunData = { + node1: [ + { + source: [], + data: { main: [[], []] }, + startTime: 0, + executionTime: 0, + }, + ], + }; + + const inputConnections: IConnection[] = [ + { node: 'node1', type: NodeConnectionType.Main, index: 0 }, + { node: 'node1', type: NodeConnectionType.Main, index: 1 }, + ]; + + const result = workflowExecute.incomingConnectionIsEmpty(runData, inputConnections, 0); + expect(result).toBe(true); + }); + + test('should return true when input connection node does not exist in runData', () => { + const runData: IRunData = {}; + const inputConnections: IConnection[] = [ + { node: 'nonexistentNode', type: NodeConnectionType.Main, index: 0 }, + ]; + + const result = workflowExecute.incomingConnectionIsEmpty(runData, inputConnections, 0); + expect(result).toBe(true); + }); + + test('should return false when any input connection has data', () => { + const runData: IRunData = { + node1: [ + { + source: [], + data: { + main: [[{ json: { data: 'test' } }], []], + }, + startTime: 0, + executionTime: 0, + }, + ], + }; + + const inputConnections: IConnection[] = [ + { node: 'node1', type: NodeConnectionType.Main, index: 0 }, + { node: 'node1', type: NodeConnectionType.Main, index: 1 }, + ]; + + const result = workflowExecute.incomingConnectionIsEmpty(runData, inputConnections, 0); + expect(result).toBe(false); + }); + + test('should check correct run index', () => { + const runData: IRunData = { + node1: [ + { + source: [], + data: { + main: [[]], + }, + startTime: 0, + executionTime: 0, + }, + { + source: [], + data: { + main: [[{ json: { data: 'test' } }]], + }, + startTime: 0, + executionTime: 0, + }, + ], + }; + + const inputConnections: IConnection[] = [ + { node: 'node1', type: NodeConnectionType.Main, index: 0 }, + ]; + + expect(workflowExecute.incomingConnectionIsEmpty(runData, inputConnections, 0)).toBe(true); + expect(workflowExecute.incomingConnectionIsEmpty(runData, inputConnections, 1)).toBe(false); + }); + + test('should handle undefined data in runData correctly', () => { + const runData: IRunData = { + node1: [ + { + source: [], + startTime: 0, + executionTime: 0, + }, + ], + }; + + const inputConnections: IConnection[] = [ + { node: 'node1', type: NodeConnectionType.Main, index: 0 }, + ]; + + const result = workflowExecute.incomingConnectionIsEmpty(runData, inputConnections, 0); + expect(result).toBe(true); + }); + }); + + describe('moveNodeMetadata', () => { + let runExecutionData: IRunExecutionData; + let workflowExecute: WorkflowExecute; + const parentExecution = mock(); + + beforeEach(() => { + runExecutionData = { + startData: {}, + resultData: { + runData: {}, + pinData: {}, + }, + executionData: { + contextData: {}, + nodeExecutionStack: [], + metadata: {}, + waitingExecution: {}, + waitingExecutionSource: {}, + }, + }; + workflowExecute = new WorkflowExecute(mock(), 'manual', runExecutionData); + }); + + test('should do nothing when there is no metadata', () => { + runExecutionData.resultData.runData = { + node1: [{ startTime: 0, executionTime: 0, source: [] }], + }; + + workflowExecute.moveNodeMetadata(); + + expect(runExecutionData.resultData.runData.node1[0].metadata).toBeUndefined(); + }); + + test('should merge metadata into runData for single node', () => { + runExecutionData.resultData.runData = { + node1: [{ startTime: 0, executionTime: 0, source: [] }], + }; + runExecutionData.executionData!.metadata = { + node1: [{ parentExecution }], + }; + + workflowExecute.moveNodeMetadata(); + + expect(runExecutionData.resultData.runData.node1[0].metadata).toEqual({ parentExecution }); + }); + + test('should merge metadata into runData for multiple nodes', () => { + runExecutionData.resultData.runData = { + node1: [{ startTime: 0, executionTime: 0, source: [] }], + node2: [{ startTime: 0, executionTime: 0, source: [] }], + }; + runExecutionData.executionData!.metadata = { + node1: [{ parentExecution }], + node2: [{ subExecutionsCount: 4 }], + }; + + workflowExecute.moveNodeMetadata(); + + const { runData } = runExecutionData.resultData; + expect(runData.node1[0].metadata).toEqual({ parentExecution }); + expect(runData.node2[0].metadata).toEqual({ subExecutionsCount: 4 }); + }); + + test('should preserve existing metadata when merging', () => { + runExecutionData.resultData.runData = { + node1: [ + { + startTime: 0, + executionTime: 0, + source: [], + metadata: { subExecutionsCount: 4 }, + }, + ], + }; + runExecutionData.executionData!.metadata = { + node1: [{ parentExecution }], + }; + + workflowExecute.moveNodeMetadata(); + + expect(runExecutionData.resultData.runData.node1[0].metadata).toEqual({ + parentExecution, + subExecutionsCount: 4, + }); + }); + + test('should handle multiple run indices', () => { + runExecutionData.resultData.runData = { + node1: [ + { startTime: 0, executionTime: 0, source: [] }, + { startTime: 0, executionTime: 0, source: [] }, + ], + }; + runExecutionData.executionData!.metadata = { + node1: [{ parentExecution }, { subExecutionsCount: 4 }], + }; + + workflowExecute.moveNodeMetadata(); + + const { runData } = runExecutionData.resultData; + expect(runData.node1[0].metadata).toEqual({ parentExecution }); + expect(runData.node1[1].metadata).toEqual({ subExecutionsCount: 4 }); + }); + }); + + describe('getFullRunData', () => { + afterAll(() => { + jest.useRealTimers(); + }); + + test('should return complete IRun object with all properties correctly set', () => { + const runExecutionData = mock(); + + const workflowExecute = new WorkflowExecute(mock(), 'manual', runExecutionData); + + const startedAt = new Date('2023-01-01T00:00:00.000Z'); + jest.useFakeTimers().setSystemTime(startedAt); + + const result1 = workflowExecute.getFullRunData(startedAt); + + expect(result1).toEqual({ + data: runExecutionData, + mode: 'manual', + startedAt, + stoppedAt: startedAt, + status: 'new', + }); + + const stoppedAt = new Date('2023-01-01T00:00:10.000Z'); + jest.setSystemTime(stoppedAt); + // @ts-expect-error read-only property + workflowExecute.status = 'running'; + + const result2 = workflowExecute.getFullRunData(startedAt); + + expect(result2).toEqual({ + data: runExecutionData, + mode: 'manual', + startedAt, + stoppedAt, + status: 'running', + }); + }); + }); + + describe('processSuccessExecution', () => { + const startedAt: Date = new Date('2023-01-01T00:00:00.000Z'); + const workflow = new Workflow({ + id: 'test', + nodes: [], + connections: {}, + active: false, + nodeTypes: mock(), + }); + + let runExecutionData: IRunExecutionData; + let workflowExecute: WorkflowExecute; + + beforeEach(() => { + runExecutionData = { + startData: {}, + resultData: { runData: {} }, + executionData: { + contextData: {}, + nodeExecutionStack: [], + metadata: {}, + waitingExecution: {}, + waitingExecutionSource: null, + }, + }; + workflowExecute = new WorkflowExecute(mock(), 'manual', runExecutionData); + + jest.spyOn(workflowExecute, 'executeHook').mockResolvedValue(undefined); + jest.spyOn(workflowExecute, 'moveNodeMetadata').mockImplementation(); + }); + + test('should handle different workflow completion scenarios', async () => { + // Test successful execution + const successResult = await workflowExecute.processSuccessExecution(startedAt, workflow); + expect(successResult.status).toBe('success'); + expect(successResult.finished).toBe(true); + + // Test execution with wait + runExecutionData.waitTill = new Date('2024-01-01'); + const waitResult = await workflowExecute.processSuccessExecution(startedAt, workflow); + expect(waitResult.status).toBe('waiting'); + expect(waitResult.waitTill).toEqual(runExecutionData.waitTill); + + // Test execution with error + const testError = new Error('Test error') as ExecutionBaseError; + + // Reset the status since it was changed by previous tests + // @ts-expect-error read-only property + workflowExecute.status = 'new'; + runExecutionData.waitTill = undefined; + + const errorResult = await workflowExecute.processSuccessExecution( + startedAt, + workflow, + testError, + ); + + expect(errorResult.data.resultData.error).toBeDefined(); + expect(errorResult.data.resultData.error?.message).toBe('Test error'); + + // Test canceled execution + const cancelError = new Error('Workflow execution canceled') as ExecutionBaseError; + const cancelResult = await workflowExecute.processSuccessExecution( + startedAt, + workflow, + cancelError, + ); + expect(cancelResult.data.resultData.error).toBeDefined(); + expect(cancelResult.data.resultData.error?.message).toBe('Workflow execution canceled'); + }); + + test('should handle static data, hooks, and cleanup correctly', async () => { + // Mock static data change + workflow.staticData.__dataChanged = true; + workflow.staticData.testData = 'changed'; + + // Mock cleanup function that's actually a promise + let cleanupCalled = false; + const mockCleanupPromise = new Promise((resolve) => { + setTimeout(() => { + cleanupCalled = true; + resolve(); + }, 0); + }); + + const result = await workflowExecute.processSuccessExecution( + startedAt, + workflow, + undefined, + mockCleanupPromise, + ); + + // Verify static data handling + expect(result).toBeDefined(); + expect(workflowExecute.moveNodeMetadata).toHaveBeenCalled(); + expect(workflowExecute.executeHook).toHaveBeenCalledWith('workflowExecuteAfter', [ + result, + workflow.staticData, + ]); + + // Verify cleanup was called + await mockCleanupPromise; + expect(cleanupCalled).toBe(true); + }); + }); + + describe('assignPairedItems', () => { + let workflowExecute: WorkflowExecute; + + beforeEach(() => { + workflowExecute = new WorkflowExecute(mock(), 'manual'); + }); + + test('should handle undefined node output', () => { + const result = workflowExecute.assignPairedItems( + undefined, + mock({ data: { main: [] } }), + ); + expect(result).toBeNull(); + }); + + test('should auto-fix pairedItem for single input/output scenario', () => { + const nodeOutput = [[{ json: { test: true } }]]; + const executionData = mock({ data: { main: [[{ json: { input: true } }]] } }); + + const result = workflowExecute.assignPairedItems(nodeOutput, executionData); + + expect(result?.[0][0].pairedItem).toEqual({ item: 0 }); + }); + + test('should auto-fix pairedItem when number of items match', () => { + const nodeOutput = [[{ json: { test: 1 } }, { json: { test: 2 } }]]; + const executionData = mock({ + data: { main: [[{ json: { input: 1 } }, { json: { input: 2 } }]] }, + }); + + const result = workflowExecute.assignPairedItems(nodeOutput, executionData); + + expect(result?.[0][0].pairedItem).toEqual({ item: 0 }); + expect(result?.[0][1].pairedItem).toEqual({ item: 1 }); + }); + + test('should not modify existing pairedItem data', () => { + const existingPairedItem = { item: 5, input: 2 }; + const nodeOutput = [[{ json: { test: true }, pairedItem: existingPairedItem }]]; + const executionData = mock({ data: { main: [[{ json: { input: true } }]] } }); + + const result = workflowExecute.assignPairedItems(nodeOutput, executionData); + + expect(result?.[0][0].pairedItem).toEqual(existingPairedItem); + }); + + test('should process multiple output branches correctly', () => { + const nodeOutput = [[{ json: { test: 1 } }], [{ json: { test: 2 } }]]; + const executionData = mock({ data: { main: [[{ json: { input: true } }]] } }); + + const result = workflowExecute.assignPairedItems(nodeOutput, executionData); + + expect(result?.[0][0].pairedItem).toEqual({ item: 0 }); + expect(result?.[1][0].pairedItem).toEqual({ item: 0 }); + }); + }); }); diff --git a/packages/core/test/error-reporter.test.ts b/packages/core/test/error-reporter.test.ts index 1f507ab5c0..9edc27f15c 100644 --- a/packages/core/test/error-reporter.test.ts +++ b/packages/core/test/error-reporter.test.ts @@ -1,9 +1,11 @@ import { QueryFailedError } from '@n8n/typeorm'; import type { ErrorEvent } from '@sentry/types'; import { AxiosError } from 'axios'; +import { mock } from 'jest-mock-extended'; import { ApplicationError } from 'n8n-workflow'; import { ErrorReporter } from '@/error-reporter'; +import type { Logger } from '@/logging/logger'; jest.mock('@sentry/node', () => ({ init: jest.fn(), @@ -15,7 +17,7 @@ jest.mock('@sentry/node', () => ({ jest.spyOn(process, 'on'); describe('ErrorReporter', () => { - const errorReporter = new ErrorReporter(); + const errorReporter = new ErrorReporter(mock()); const event = {} as ErrorEvent; describe('beforeSend', () => { @@ -100,4 +102,29 @@ describe('ErrorReporter', () => { expect(result).toBeNull(); }); }); + + describe('error', () => { + let error: ApplicationError; + let logger: Logger; + let errorReporter: ErrorReporter; + const metadata = undefined; + + beforeEach(() => { + error = new ApplicationError('Test error'); + logger = mock(); + errorReporter = new ErrorReporter(logger); + }); + + it('should include stack trace for error-level `ApplicationError`', () => { + error.level = 'error'; + errorReporter.error(error); + expect(logger.error).toHaveBeenCalledWith(`Test error\n${error.stack}\n`, metadata); + }); + + it('should exclude stack trace for warning-level `ApplicationError`', () => { + error.level = 'warning'; + errorReporter.error(error); + expect(logger.error).toHaveBeenCalledWith('Test error', metadata); + }); + }); }); diff --git a/packages/core/test/setup-mocks.ts b/packages/core/test/setup-mocks.ts index d2c9bc6e64..c36ff529c2 100644 --- a/packages/core/test/setup-mocks.ts +++ b/packages/core/test/setup-mocks.ts @@ -1 +1,6 @@ import 'reflect-metadata'; + +// WebCrypto Polyfill for older versions of Node.js 18 +if (!globalThis.crypto?.getRandomValues) { + globalThis.crypto = require('node:crypto').webcrypto; +} diff --git a/packages/core/test/utils.ts b/packages/core/test/utils.ts index f1ed54dd03..49db471e7e 100644 --- a/packages/core/test/utils.ts +++ b/packages/core/test/utils.ts @@ -1,8 +1,8 @@ +import type { Constructable } from '@n8n/di'; +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import { Duplex } from 'stream'; import type { DeepPartial } from 'ts-essentials'; -import type { Constructable } from 'typedi'; -import { Container } from 'typedi'; export const mockInstance = ( constructor: Constructable, diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 401bb177c4..111dd828dd 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -15,6 +15,8 @@ "include": ["src/**/*.ts", "test/**/*.ts"], "references": [ { "path": "../workflow/tsconfig.build.json" }, + { "path": "../@n8n/config/tsconfig.build.json" }, + { "path": "../@n8n/di/tsconfig.build.json" }, { "path": "../@n8n/client-oauth2/tsconfig.build.json" } ] } diff --git a/packages/design-system/package.json b/packages/design-system/package.json index d4a7e1dcec..2ecd0513ac 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -45,6 +45,7 @@ "@fortawesome/free-solid-svg-icons": "^5.15.4", "@fortawesome/vue-fontawesome": "^3.0.3", "element-plus": "2.4.3", + "is-emoji-supported": "^0.0.5", "markdown-it": "^13.0.2", "markdown-it-emoji": "^2.0.2", "markdown-it-link-attributes": "^4.0.1", @@ -55,5 +56,8 @@ "vue-boring-avatars": "^1.3.0", "vue-router": "catalog:frontend", "xss": "catalog:" + }, + "peerDependencies": { + "@vueuse/core": "*" } } diff --git a/packages/design-system/src/__tests__/setup.ts b/packages/design-system/src/__tests__/setup.ts index 981c9d5a60..5c091e2925 100644 --- a/packages/design-system/src/__tests__/setup.ts +++ b/packages/design-system/src/__tests__/setup.ts @@ -15,3 +15,8 @@ window.ResizeObserver = observe: vi.fn(), unobserve: vi.fn(), })); + +// Globally mock is-emoji-supported +vi.mock('is-emoji-supported', () => ({ + isEmojiSupported: () => true, +})); diff --git a/packages/design-system/src/components/N8nActionBox/ActionBox.vue b/packages/design-system/src/components/N8nActionBox/ActionBox.vue index a43d7a6d7e..02de38143c 100644 --- a/packages/design-system/src/components/N8nActionBox/ActionBox.vue +++ b/packages/design-system/src/components/N8nActionBox/ActionBox.vue @@ -13,6 +13,7 @@ interface ActionBoxProps { buttonText: string; buttonType: ButtonType; buttonDisabled?: boolean; + buttonIcon?: string; description: string; calloutText?: string; calloutTheme?: CalloutTheme; @@ -22,6 +23,7 @@ interface ActionBoxProps { defineOptions({ name: 'N8nActionBox' }); withDefaults(defineProps(), { calloutTheme: 'info', + buttonIcon: undefined, }); @@ -51,6 +53,7 @@ withDefaults(defineProps(), { :label="buttonText" :type="buttonType" :disabled="buttonDisabled" + :icon="buttonIcon" size="large" @click="$emit('click:button', $event)" /> diff --git a/packages/design-system/src/components/N8nButton/Button.scss b/packages/design-system/src/components/N8nButton/Button.scss index bdafef35d9..af37c8b4fe 100644 --- a/packages/design-system/src/components/N8nButton/Button.scss +++ b/packages/design-system/src/components/N8nButton/Button.scss @@ -196,7 +196,7 @@ @mixin n8n-button-danger { --button-font-color: var(--color-button-danger-font); - --button-border-color: var(--color-danger); + --button-border-color: var(--color-button-danger-border); --button-background-color: var(--color-danger); --button-hover-font-color: var(--color-button-danger-font); @@ -210,11 +210,11 @@ --button-focus-font-color: var(--color-button-danger-font); --button-focus-border-color: var(--color-danger); --button-focus-background-color: var(--color-danger); - --button-focus-outline-color: var(--color-danger-tint-1); + --button-focus-outline-color: var(--color-button-danger-focus-outline); --button-disabled-font-color: var(--color-button-danger-disabled-font); - --button-disabled-border-color: var(--color-danger-tint-1); - --button-disabled-background-color: var(--color-danger-tint-1); + --button-disabled-border-color: var(--color-button-danger-disabled-border); + --button-disabled-background-color: var(--color-button-danger-disabled-background); --button-loading-font-color: var(--color-button-danger-font); --button-loading-border-color: var(--color-danger); diff --git a/packages/design-system/src/components/N8nIconPicker/IconPicker.stories.ts b/packages/design-system/src/components/N8nIconPicker/IconPicker.stories.ts new file mode 100644 index 0000000000..b03a0cc332 --- /dev/null +++ b/packages/design-system/src/components/N8nIconPicker/IconPicker.stories.ts @@ -0,0 +1,57 @@ +import { action } from '@storybook/addon-actions'; +import type { StoryFn } from '@storybook/vue3'; + +import { TEST_ICONS } from './constants'; +import type { Icon } from './IconPicker.vue'; +import N8nIconPicker from './IconPicker.vue'; + +export default { + title: 'Atoms/Icon Picker', + component: N8nIconPicker, + argTypes: { + buttonTooltip: { + control: 'text', + }, + buttonSize: { + type: 'select', + options: ['small', 'large'], + }, + }, +}; + +function createTemplate(icon: Icon): StoryFn { + return (args, { argTypes }) => ({ + components: { N8nIconPicker }, + props: Object.keys(argTypes), + setup: () => ({ args }), + data: () => ({ + icon, + }), + template: + '
', + methods: { + onIconSelected: action('iconSelected'), + }, + }); +} + +const DefaultTemplate = createTemplate({ type: 'icon', value: 'smile' }); +export const Default = DefaultTemplate.bind({}); +Default.args = { + buttonTooltip: 'Select an icon', + availableIcons: TEST_ICONS, +}; + +const CustomTooltipTemplate = createTemplate({ type: 'icon', value: 'layer-group' }); +export const WithCustomIconAndTooltip = CustomTooltipTemplate.bind({}); +WithCustomIconAndTooltip.args = { + availableIcons: [...TEST_ICONS], + buttonTooltip: 'Select something...', +}; + +const OnlyEmojiTemplate = createTemplate({ type: 'emoji', value: '🔥' }); +export const OnlyEmojis = OnlyEmojiTemplate.bind({}); +OnlyEmojis.args = { + buttonTooltip: 'Select an emoji', + availableIcons: [], +}; diff --git a/packages/design-system/src/components/N8nIconPicker/IconPicker.test.ts b/packages/design-system/src/components/N8nIconPicker/IconPicker.test.ts new file mode 100644 index 0000000000..f3295ba5e6 --- /dev/null +++ b/packages/design-system/src/components/N8nIconPicker/IconPicker.test.ts @@ -0,0 +1,183 @@ +import userEvent from '@testing-library/user-event'; +import { fireEvent, render } from '@testing-library/vue'; +import { createRouter, createWebHistory } from 'vue-router'; + +import IconPicker from '.'; +import { TEST_ICONS } from './constants'; + +// Create a proxy handler that returns a mock icon object for any icon name +// and mock the entire icon library with the proxy +vi.mock( + '@fortawesome/free-solid-svg-icons', + () => + new Proxy( + {}, + { + get: (_target, prop) => { + return { prefix: 'fas', iconName: prop.toString().replace('fa', '').toLowerCase() }; + }, + }, + ), +); + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/icons', + name: 'icons', + redirect: '/icons', + }, + { + path: '/emojis', + name: 'emojis', + component: { template: '

emojis

' }, + }, + ], +}); + +// Component stubs +const components = { + N8nIconButton: { + template: '